mlx-bun 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -51,22 +51,48 @@ import {
51
51
  type SharedKv,
52
52
  type LoraWeights,
53
53
  } from "./gemma4-base";
54
- import { fusedGeglu, fusedGeluEnabled, fusedGeluSupported } from "./fused-geglu-kernel";
54
+ import { fusedGeglu, fusedGegluDifferentiable, fusedGeluEnabled, fusedGeluSupported, fusedGeluTraining } from "./fused-geglu-kernel";
55
55
  import { Checkpoint } from "../mlx/checkpoint";
56
56
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
57
57
 
58
58
  /** Optional gradient-checkpointing context for the training forward. When set,
59
59
  * forwardLayers wraps each layer in a Checkpoint so its interior activations
60
60
  * are recomputed in the backward pass. `byLayer` maps a layer index to that
61
- * layer's trainable LoRA weights (re-swapped inside the closure so they are
62
- * explicit checkpoint inputs — required because the autograd primals are
63
- * disposed before the recompute). `keepAlive` collects the per-layer
61
+ * layer's trainable LoRA weights, partitioned into the attention sub-block
62
+ * (q/k/v/o_proj) and the MLP sub-block (gate/up/down_proj + per-layer gate/
63
+ * projection) — re-swapped inside the closure so they are explicit checkpoint
64
+ * inputs (required because the autograd primals are disposed before the
65
+ * recompute). When `splitMlp` is set, the layer is wrapped in TWO checkpoints
66
+ * (attn sub-block + MLP sub-block) with the post-attn residual `hMid` as the
67
+ * boundary, so the backward recompute holds `max(attn,MLP)+hMid` instead of the
68
+ * whole layer (the intra-layer MLP split, lever 4). `keepAlive` collects the
64
69
  * Checkpoint objects; the trainer disposes them after value_and_grad. */
70
+ export interface LayerLoras {
71
+ attn: LoraWeights[];
72
+ mlp: LoraWeights[];
73
+ }
65
74
  export interface GradCheckpointCtx {
66
- byLayer: Map<number, LoraWeights[]>;
75
+ byLayer: Map<number, LayerLoras>;
76
+ splitMlp: boolean;
67
77
  keepAlive: Checkpoint[];
68
78
  }
69
79
 
80
+ // Shared prompt-prefix plan (lever 7, e4b — docs/design/orpo-training.md). When
81
+ // set, Attention ropes the concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)]
82
+ // sequence BLOCK-WISE — prompt at offset 0, EACH response reset to logical offset
83
+ // P — instead of the uniform cache.offset, so ONE forward over the concat is
84
+ // bit-exact with the two separate [prompt;resp] forwards (each response sees the
85
+ // prompt's RoPE positions, not shifted-by-the-other-response). The matching
86
+ // block-sparse + LOGICAL-window attention mask rides in via the prefix cache's
87
+ // makeMask (src/train/prefix-shared.ts). Set around the single forward
88
+ // (single-threaded), cleared after; null → the normal uniform-offset rope (every
89
+ // other forward is untouched).
90
+ export interface GemmaPrefixPlan { P: number; Rc: number; Rr: number }
91
+ let _gemmaPrefixPlan: GemmaPrefixPlan | null = null;
92
+ export function setGemmaPrefixPlan(p: GemmaPrefixPlan | null): void {
93
+ _gemmaPrefixPlan = p;
94
+ }
95
+
70
96
  class Attention {
71
97
  readonly isSliding: boolean;
72
98
  readonly windowSize: number;
@@ -161,6 +187,32 @@ class Attention {
161
187
  : ops.ropeDynamic(x, this.headDim, this.ropeBase, offset, this.ropeFreqs);
162
188
  }
163
189
 
190
+ /** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
191
+ * the sequence axis (axis 2 of [B,H,T,D]): prompt at offset 0, EACH response
192
+ * reset to logical offset P. RoPE is per-token, so roping each contiguous block
193
+ * at its scalar offset and concatenating == roping with per-token position-ids
194
+ * [0..P-1, P..P+Rc-1, P..P+Rr-1]. Uses this.rope per block so the layer's
195
+ * proportional/ropeFreqs + sliding-vs-full geometry applies identically to the
196
+ * two-forward path. Caller disposes the input. */
197
+ ropeBlocks(x: MlxArray, plan: GemmaPrefixPlan): MlxArray {
198
+ const { P, Rc, Rr } = plan;
199
+ const [B, H, , D] = x.shape as [number, number, number, number];
200
+ const blocks = [
201
+ { start: 0, len: P, off: 0 },
202
+ { start: P, len: Rc, off: P },
203
+ { start: P + Rc, len: Rr, off: P },
204
+ ].filter((b) => b.len > 0);
205
+ const parts = blocks.map((b) => {
206
+ const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
207
+ const r = this.rope(sl, b.off);
208
+ sl.dispose();
209
+ return r;
210
+ });
211
+ const out = ops.concatAxis(parts, 2);
212
+ for (const p of parts) p.dispose();
213
+ return out;
214
+ }
215
+
164
216
  /** Returns the attention output plus the fetched KV (for KV-shared
165
217
  * sharer layers and the speculative drafter). The SharedKv arrays are
166
218
  * owned by the caller (forwardLayers) and disposed after the pass. */
@@ -203,7 +255,9 @@ class Attention {
203
255
  const kNormed = this.kNorm!.forward(k);
204
256
  const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
205
257
  kNormed.dispose();
206
- const kRoped = this.rope(kT, offsetArr ?? offset);
258
+ const kRoped = _gemmaPrefixPlan
259
+ ? this.ropeBlocks(kT, _gemmaPrefixPlan)
260
+ : this.rope(kT, offsetArr ?? offset);
207
261
  kT.dispose();
208
262
 
209
263
  const vNormed = this.vNorm!.forward(v);
@@ -230,7 +284,9 @@ class Attention {
230
284
  }
231
285
 
232
286
  q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
233
- q = disposing(q, this.rope(q, shared.offsetArr ?? shared.offset));
287
+ q = disposing(q, _gemmaPrefixPlan
288
+ ? this.ropeBlocks(q, _gemmaPrefixPlan)
289
+ : this.rope(q, shared.offsetArr ?? shared.offset));
234
290
 
235
291
  let attn: MlxArray;
236
292
  const ta = getTrainingAttn();
@@ -273,9 +329,12 @@ class MLP {
273
329
  const u = this.up.forward(x);
274
330
  // Fused GeGLU perf kernel (opt-in, not in a compiled trace): one pass
275
331
  // instead of ~9 element-wise kernels. Off → the bit-exact spelled path.
332
+ // In training mode the differentiable wrapper (kernel forward + hand-derived
333
+ // vjp) is used so autograd can flow AND the backward recomputes the gelu
334
+ // from the primal instead of retaining the spelled-out intermediates.
276
335
  let h: MlxArray;
277
336
  if (fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(g)) {
278
- h = fusedGeglu(g, u);
337
+ h = fusedGeluTraining() ? fusedGegluDifferentiable(g, u) : fusedGeglu(g, u);
279
338
  g.dispose();
280
339
  u.dispose();
281
340
  } else {
@@ -500,6 +559,23 @@ export class DecoderLayer {
500
559
  forward(
501
560
  x: MlxArray, mask: Mask, cache: Cache | null,
502
561
  sharedIn: SharedKv | null, perLayerInput: MlxArray | null,
562
+ ): { h: MlxArray; shared: SharedKv } {
563
+ // Composed from the two sub-blocks (the intra-layer min-cut is the post-attn
564
+ // residual `hMid` [1,T,hidden]). Behaviour is identical to the spelled-out
565
+ // body; the split lets the checkpoint path rematerialize attn and MLP
566
+ // separately (peak attn+MLP → max(attn,MLP)+hMid). `forwardMlp` borrows hMid.
567
+ const { h: hMid, shared } = this.forwardAttn(x, mask, cache, sharedIn);
568
+ const h = this.forwardMlp(hMid, perLayerInput);
569
+ hMid.dispose();
570
+ return { h, shared };
571
+ }
572
+
573
+ /** Attention sub-block: inputNorm → attn → postAttnNorm → residual add.
574
+ * Returns the post-attn residual stream `h` [1,T,hidden] (the intra-layer
575
+ * boundary) and the attention's fetched/produced K/V (`shared`). `x` is
576
+ * borrowed (never disposed). The e4b donor-KV threading lives entirely here. */
577
+ forwardAttn(
578
+ x: MlxArray, mask: Mask, cache: Cache | null, sharedIn: SharedKv | null,
503
579
  ): { h: MlxArray; shared: SharedKv } {
504
580
  let h = this.inputNorm.forward(x);
505
581
  const { out, shared } = this.attn.forward(h, mask, cache, sharedIn);
@@ -507,18 +583,24 @@ export class DecoderLayer {
507
583
  h = out;
508
584
  h = disposing(h, this.postAttnNorm.forward(h));
509
585
  h = disposing(h, ops.add(x, h));
586
+ return { h, shared };
587
+ }
510
588
 
511
- const residual = h;
589
+ /** MLP sub-block: FFN (dense or MoE) + postFfNorm + residual add, then the
590
+ * e4b per-layer-input gate and layer scalar. `hMid` (the attn sub-block's
591
+ * output) is BORROWED — the caller owns/disposes it (it is the checkpoint
592
+ * boundary leaf), so this never disposes it. */
593
+ forwardMlp(hMid: MlxArray, perLayerInput: MlxArray | null): MlxArray {
512
594
  let f: MlxArray;
513
595
  if (this.router && this.experts) {
514
- // MoE: dense MLP and routed experts as parallel branches off h
596
+ // MoE: dense MLP and routed experts as parallel branches off hMid
515
597
  // (reference DecoderLayer, enable_moe path)
516
- let h1 = this.preFfNorm.forward(h);
598
+ let h1 = this.preFfNorm.forward(hMid);
517
599
  h1 = disposing(h1, this.mlp.forward(h1));
518
600
  h1 = disposing(h1, this.postFfNorm1!.forward(h1));
519
601
 
520
- const { indices, weights: topKWeights } = this.router.forward(h);
521
- let h2 = this.preFfNorm2!.forward(h);
602
+ const { indices, weights: topKWeights } = this.router.forward(hMid);
603
+ let h2 = this.preFfNorm2!.forward(hMid);
522
604
  h2 = disposing(h2, this.experts.forward(h2, indices, topKWeights));
523
605
  h2 = disposing(h2, this.postFfNorm2!.forward(h2));
524
606
  indices.dispose();
@@ -528,20 +610,25 @@ export class DecoderLayer {
528
610
  h1.dispose();
529
611
  h2.dispose();
530
612
  } else {
531
- f = this.preFfNorm.forward(h);
613
+ f = this.preFfNorm.forward(hMid);
532
614
  f = disposing(f, this.mlp.forward(f));
533
615
  }
534
616
  f = disposing(f, this.postFfNorm.forward(f));
535
- h = ops.add(residual, f);
536
- residual.dispose();
617
+ let h = ops.add(hMid, f); // hMid borrowed — NOT disposed here
537
618
  f.dispose();
538
619
 
539
- // per-layer input gating (reference gemma4_text DecoderLayer)
620
+ // per-layer input gating (reference gemma4_text DecoderLayer). gelu(gate)·pli
621
+ // is the second GeGLU site — fused (differentiable) on the training path, the
622
+ // bit-exact spelled-out gelu+mul on the inference path (unchanged).
540
623
  if (this.perLayerGate && this.perLayerProjection && this.postPerLayerNorm && perLayerInput) {
541
624
  const res2 = h;
542
625
  let gate = this.perLayerGate.forward(h);
543
- gate = disposing(gate, ops.geluApprox(gate));
544
- gate = disposing(gate, ops.mul(gate, perLayerInput));
626
+ if (fusedGeluTraining() && fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(gate)) {
627
+ gate = disposing(gate, fusedGegluDifferentiable(gate, perLayerInput));
628
+ } else {
629
+ gate = disposing(gate, ops.geluApprox(gate));
630
+ gate = disposing(gate, ops.mul(gate, perLayerInput));
631
+ }
545
632
  gate = disposing(gate, this.perLayerProjection.forward(gate));
546
633
  gate = disposing(gate, this.postPerLayerNorm.forward(gate));
547
634
  h = ops.add(res2, gate);
@@ -550,7 +637,7 @@ export class DecoderLayer {
550
637
  }
551
638
 
552
639
  if (this.layerScalar) h = disposing(h, ops.mul(h, this.layerScalar));
553
- return { h, shared };
640
+ return h;
554
641
  }
555
642
  }
556
643
 
@@ -927,7 +1014,19 @@ export class Gemma4Model {
927
1014
  layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
928
1015
  ): { h: MlxArray; shared: SharedKv | null } {
929
1016
  const ctx = this.gradCkpt!;
930
- const lws = ctx.byLayer.get(i) ?? [];
1017
+ const ll = ctx.byLayer.get(i) ?? { attn: [], mlp: [] };
1018
+ return ctx.splitMlp
1019
+ ? this.runSplitCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx)
1020
+ : this.runSingleCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx);
1021
+ }
1022
+
1023
+ /** One Checkpoint over the whole layer (the original path). */
1024
+ private runSingleCheckpointedLayer(
1025
+ layer: DecoderLayer, h: MlxArray, mask: Mask,
1026
+ layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
1027
+ ll: LayerLoras, ctx: GradCheckpointCtx,
1028
+ ): { h: MlxArray; shared: SharedKv | null } {
1029
+ const lws = [...ll.attn, ...ll.mlp];
931
1030
  const isSharer = layerCache === null;
932
1031
  const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
933
1032
  const hasMaskArr = mask.arr !== null;
@@ -970,6 +1069,82 @@ export class Gemma4Model {
970
1069
  return { h: outs[0]!, shared };
971
1070
  }
972
1071
 
1072
+ /** Intra-layer MLP split (lever 4): TWO checkpoints — the attention sub-block
1073
+ * (forwardAttn: inputNorm → attn → residual add, threading the donor K/V) and
1074
+ * the MLP sub-block (forwardMlp: FFN + per-layer gate) — with the post-attn
1075
+ * residual `hMid` as the boundary. The backward recompute holds only one
1076
+ * sub-block's activations at a time, dropping per-layer peak from `attn+MLP`
1077
+ * to `max(attn,MLP)+hMid`. Attn LoRA / pls / donor-KV go to the attn ck; MLP
1078
+ * LoRA to the MLP ck. */
1079
+ private runSplitCheckpointedLayer(
1080
+ layer: DecoderLayer, h: MlxArray, mask: Mask,
1081
+ layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
1082
+ ll: LayerLoras, ctx: GradCheckpointCtx,
1083
+ ): { h: MlxArray; shared: SharedKv | null } {
1084
+ const isSharer = layerCache === null;
1085
+ const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
1086
+ const hasMaskArr = mask.arr !== null;
1087
+ const hasPls = pls !== null;
1088
+ const maskMode = mask.mode;
1089
+ const attnLws = ll.attn;
1090
+ const mlpLws = ll.mlp;
1091
+
1092
+ // --- attn sub-block checkpoint: [h, maskArr?, sharedInK?, sharedInV?, attnLora*]
1093
+ const attnInputs: MlxArray[] = [h];
1094
+ if (hasMaskArr) attnInputs.push(mask.arr!);
1095
+ if (sIn) attnInputs.push(sIn.keys, sIn.values);
1096
+ for (const lw of attnLws) attnInputs.push(lw.a, lw.b);
1097
+
1098
+ const attnCk = new Checkpoint((ins) => {
1099
+ let k = 0;
1100
+ const hIn = ins[k++]!;
1101
+ const mArr = hasMaskArr ? ins[k++]! : null;
1102
+ const sharedInRebuilt: SharedKv | null = sIn
1103
+ ? { kind: "plain", keys: ins[k++]!, values: ins[k++]!, offset: 0 }
1104
+ : null;
1105
+ const saved = attnLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
1106
+ for (const lw of attnLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
1107
+ try {
1108
+ const out = layer.forwardAttn(hIn, { mode: maskMode, arr: mArr }, layerCache, sharedInRebuilt);
1109
+ if (!isSharer && out.shared && out.shared.kind === "plain")
1110
+ return [out.h, out.shared.keys, out.shared.values];
1111
+ return [out.h];
1112
+ } finally {
1113
+ attnLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
1114
+ }
1115
+ });
1116
+ ctx.keepAlive.push(attnCk);
1117
+ const attnOuts = attnCk.apply(attnInputs);
1118
+ const hMid = attnOuts[0]!;
1119
+ const shared: SharedKv | null =
1120
+ !isSharer && attnOuts.length === 3
1121
+ ? { kind: "plain", keys: attnOuts[1]!, values: attnOuts[2]!, offset: 0 }
1122
+ : null;
1123
+
1124
+ // --- MLP sub-block checkpoint: [hMid, pls?, mlpLora*] -> [hOut]
1125
+ const mlpInputs: MlxArray[] = [hMid];
1126
+ if (hasPls) mlpInputs.push(pls!);
1127
+ for (const lw of mlpLws) mlpInputs.push(lw.a, lw.b);
1128
+
1129
+ const mlpCk = new Checkpoint((ins) => {
1130
+ let k = 0;
1131
+ const hMidIn = ins[k++]!;
1132
+ const plsIn = hasPls ? ins[k++]! : null;
1133
+ const saved = mlpLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
1134
+ for (const lw of mlpLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
1135
+ try {
1136
+ return [layer.forwardMlp(hMidIn, plsIn)];
1137
+ } finally {
1138
+ mlpLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
1139
+ }
1140
+ });
1141
+ ctx.keepAlive.push(mlpCk);
1142
+ const mlpOuts = mlpCk.apply(mlpInputs);
1143
+ hMid.dispose(); // boundary leaf consumed by the MLP ck (mlx retains it in-graph)
1144
+
1145
+ return { h: mlpOuts[0]!, shared };
1146
+ }
1147
+
973
1148
  /** hidden [1, L, hidden] → softcapped logits [1, L, vocab]. */
974
1149
  logitsFromHidden(h: MlxArray): MlxArray {
975
1150
  let logits = this.embed.asLinear(h);
@@ -21,6 +21,45 @@ import {
21
21
  } from "./gemma4-base";
22
22
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
23
23
 
24
+ // Shared prompt-prefix plan (lever 7). When set, the attention ropes the
25
+ // concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)] sequence
26
+ // BLOCK-WISE — prompt at offset 0, EACH response reset to offset P — instead of
27
+ // the uniform cache.offset. This is what lets one forward over the concat be
28
+ // bit-exact with the two separate [prompt;resp] forwards (each response sees the
29
+ // prompt's RoPE positions, not shifted-by-the-other-response). Set by
30
+ // orpoLossPrefixShared around the forward (single-threaded), cleared after; null
31
+ // → the normal uniform-offset rope (every other forward is untouched). The
32
+ // matching block-sparse attention mask rides in via PrefixSharedCache.makeMask.
33
+ export interface PrefixPlan { P: number; Rc: number; Rr: number }
34
+ let _prefixPlan: PrefixPlan | null = null;
35
+ export function setMiniCpmPrefixPlan(p: PrefixPlan | null): void {
36
+ _prefixPlan = p;
37
+ }
38
+
39
+ /** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
40
+ * the sequence axis (axis 2 of [B,H,T,D]): prompt rotated at offset 0, each
41
+ * response at offset P (reset). RoPE is per-token, so roping each contiguous
42
+ * block at its scalar offset and concatenating == roping with per-token
43
+ * position-ids [0..P-1, P..P+Rc-1, P..P+Rr-1]. Caller disposes the input. */
44
+ function ropeBlocks(x: MlxArray, dims: number, base: number, plan: PrefixPlan): MlxArray {
45
+ const { P, Rc, Rr } = plan;
46
+ const [B, H, , D] = x.shape as [number, number, number, number];
47
+ const blocks: { start: number; len: number; off: number }[] = [
48
+ { start: 0, len: P, off: 0 },
49
+ { start: P, len: Rc, off: P },
50
+ { start: P + Rc, len: Rr, off: P },
51
+ ].filter((b) => b.len > 0);
52
+ const parts = blocks.map((b) => {
53
+ const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
54
+ const r = ops.rope(sl, dims, base, b.off, null);
55
+ sl.dispose();
56
+ return r;
57
+ });
58
+ const out = ops.concatAxis(parts, 2);
59
+ for (const p of parts) p.dispose();
60
+ return out;
61
+ }
62
+
24
63
  class LlamaAttention {
25
64
  readonly qProj: QuantizedLinear;
26
65
  readonly kProj: QuantizedLinear;
@@ -65,9 +104,11 @@ class LlamaAttention {
65
104
  // runs before updateAndFetch, so K and Q share the pre-write offset.
66
105
  const offsetArr = cache.ropeOffsetArr;
67
106
  const ropeStep = (x: MlxArray): MlxArray =>
68
- offsetArr
69
- ? ops.ropeDynamic(x, this.headDim, this.ropeBase, offsetArr, null)
70
- : ops.rope(x, this.headDim, this.ropeBase, cache.offset, null);
107
+ _prefixPlan
108
+ ? ropeBlocks(x, this.headDim, this.ropeBase, _prefixPlan)
109
+ : offsetArr
110
+ ? ops.ropeDynamic(x, this.headDim, this.ropeBase, offsetArr, null)
111
+ : ops.rope(x, this.headDim, this.ropeBase, cache.offset, null);
71
112
  q = disposing(q, ropeStep(q));
72
113
  k = disposing(k, ropeStep(k));
73
114
  let attn: MlxArray;
@@ -40,6 +40,13 @@ export interface PiProviderOptions {
40
40
  * reasoning and sends the on/off switch the server maps to enable_thinking.
41
41
  * Default false. */
42
42
  reasoning?: boolean;
43
+ /** The loaded model can accept images (server's `ctx.vision != null`).
44
+ * This MUST match the real model: pi-ai serializes user content as OpenAI
45
+ * multimodal content-parts (`[{type:"text",…}]`) for image-capable models
46
+ * and as a plain string otherwise. A text-only chat template renders nothing
47
+ * for a parts array, so declaring a non-vision model as image-capable makes
48
+ * the user's turn vanish ("I don't see any message"). Default false. */
49
+ vision?: boolean;
43
50
  }
44
51
 
45
52
  export interface PiProviderWiring {
@@ -69,19 +76,28 @@ export function buildPiProvider(baseUrl: string, opts: PiProviderOptions = {}):
69
76
  name: opts.name ?? "mlx-bun (local)",
70
77
  api: PI_API,
71
78
  reasoning: opts.reasoning ?? false,
72
- // Our local thinking models (Qwen3.5/MiniCPM5) are BINARY: the chat
73
- // template's enable_thinking is a boolean. "qwen-chat-template" makes
74
- // Pi send chat_template_kwargs.enable_thinking; pi-ai computes that
75
- // boolean from whether the selected level is off or not, so no level
76
- // map is needed here (null in thinkingLevelMap means "unsupported").
77
- ...(opts.reasoning
78
- ? { compat: { thinkingFormat: "qwen-chat-template" as const } }
79
- : {}),
80
- // Declare image capability so pi will carry image content to /v1.
81
- // Whether the *loaded* model can actually see images is gated at the
82
- // server (non-vision models 400) and surfaced to the web UI via the
83
- // `ready` frame's `vision` flag.
84
- input: ["text", "image"],
79
+ // Compat flags that make pi emit the wire format our local server's
80
+ // chat templates expect (docs/custom-provider.md "Model Definition
81
+ // Reference"):
82
+ // - supportsDeveloperRole:false → pi sends the system prompt as the
83
+ // `system` role, not `developer` (our templates only know
84
+ // system/user/assistant/tool).
85
+ // - thinkingFormat:"qwen-chat-template" (reasoning models only) → pi
86
+ // sends chat_template_kwargs.enable_thinking, the boolean our
87
+ // Qwen3.5/MiniCPM5 templates gate on. pi computes the boolean from
88
+ // the selected thinking level (off → false), so no thinkingLevelMap.
89
+ compat: {
90
+ supportsDeveloperRole: false,
91
+ ...(opts.reasoning ? { thinkingFormat: "qwen-chat-template" as const } : {}),
92
+ },
93
+ // input MUST match the loaded model. pi-ai serializes user content as a
94
+ // plain string for text-only models and as OpenAI content-parts
95
+ // (`[{type:"text",…}]`) once a model declares image input — and a
96
+ // text-only chat template renders NOTHING for a parts array, dropping
97
+ // the user's turn ("I don't see any message"). So declare image input
98
+ // only for an actual vision model; the web `ready` frame's `vision`
99
+ // flag is the same signal that drives the UI's image affordance.
100
+ input: opts.vision ? ["text", "image"] : ["text"],
85
101
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
86
102
  contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
87
103
  maxTokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
@@ -0,0 +1,81 @@
1
+ // Shared pi front-door assembly for mlx-bun's embedded web + terminal agents.
2
+ //
3
+ // This is the single place that decides which custom tools and bundled skills
4
+ // are visible to pi. Keeping that policy here prevents the browser and TUI from
5
+ // drifting — especially for personal memory, whose prompt surface must be
6
+ // relevant-but-not-dominant.
7
+
8
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
9
+ import { createWebTools, WEB_TOOL_NAMES } from "./web-tools";
10
+ import { materializeBundledSkillPaths } from "./web/skills";
11
+ import {
12
+ createMemoryTools,
13
+ MEMORY_TOOL_NAMES,
14
+ memoryIndexHint,
15
+ isMemoryEnabled,
16
+ createReferenceTools,
17
+ REFERENCE_TOOL_NAMES,
18
+ } from "./memory/tools";
19
+
20
+ /** Built-in pi coding tools plus mlx-bun's non-mutating web tools. */
21
+ export const PI_BASE_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls", ...WEB_TOOL_NAMES] as const;
22
+
23
+ export interface PiAgentSurfaceOptions {
24
+ /** Force memory on/off for tests. Default: auto-detect vault existence. */
25
+ memory?: "auto" | "on" | "off";
26
+ /** Include web_search/web_fetch/weather. Default true. */
27
+ webTools?: boolean;
28
+ /** Include read/bash/edit/write/grep/find/ls. Default true. */
29
+ codingTools?: boolean;
30
+ }
31
+
32
+ export interface PiAgentSurface {
33
+ memoryEnabled: boolean;
34
+ /** Tool allowlist to pass to createAgentSession/createAgentSessionFromServices. */
35
+ tools: string[];
36
+ /** Custom tools to pass to pi. Built-ins are selected by name in `tools`. */
37
+ customTools: ToolDefinition[];
38
+ /** Skill paths for DefaultResourceLoader.additionalSkillPaths. */
39
+ skillPaths: string[];
40
+ /** Short prompt addendum. Empty unless memory is enabled. */
41
+ memoryHint: string;
42
+ }
43
+
44
+ async function resolveMemoryEnabled(mode: PiAgentSurfaceOptions["memory"]): Promise<boolean> {
45
+ if (mode === "on") return true;
46
+ if (mode === "off") return false;
47
+ return isMemoryEnabled();
48
+ }
49
+
50
+ /**
51
+ * Build the shared tool + skill surface for both pi front doors.
52
+ *
53
+ * Memory policy: if the user enabled memory (vault exists), memory is available.
54
+ * It is still a scoped retrieval capability, not a default first step for every
55
+ * prompt. If memory is absent, no memory skill/tool schemas are exposed at all.
56
+ */
57
+ export async function buildPiAgentSurface(opts: PiAgentSurfaceOptions = {}): Promise<PiAgentSurface> {
58
+ const memoryEnabled = await resolveMemoryEnabled(opts.memory ?? "auto");
59
+ const includeWeb = opts.webTools ?? true;
60
+ const includeCoding = opts.codingTools ?? true;
61
+
62
+ const tools: string[] = [];
63
+ if (includeCoding) tools.push("read", "bash", "edit", "write", "grep", "find", "ls");
64
+ const customTools: ToolDefinition[] = [];
65
+ if (includeWeb) {
66
+ tools.push(...WEB_TOOL_NAMES);
67
+ customTools.push(...createWebTools());
68
+ }
69
+ if (memoryEnabled) {
70
+ tools.push(...MEMORY_TOOL_NAMES, ...REFERENCE_TOOL_NAMES);
71
+ customTools.push(...createMemoryTools(), ...createReferenceTools());
72
+ }
73
+
74
+ return {
75
+ memoryEnabled,
76
+ tools,
77
+ customTools,
78
+ skillPaths: materializeBundledSkillPaths({ includeMemory: memoryEnabled }),
79
+ memoryHint: memoryEnabled ? await memoryIndexHint() : "",
80
+ };
81
+ }
@@ -35,8 +35,7 @@ import {
35
35
  runRpcMode,
36
36
  type CreateAgentSessionRuntimeFactory,
37
37
  } from "@earendil-works/pi-coding-agent";
38
- import { createWebTools, WEB_TOOL_NAMES } from "./web-tools";
39
- import { materializeBundledSkills } from "./web/skills";
38
+ import { buildPiAgentSurface } from "./pi-session";
40
39
  import { buildPiProvider, DEFAULT_CONTEXT_WINDOW } from "./pi-provider";
41
40
 
42
41
  /** How the embedded agent runs. Mirrors pi's CLI appMode. */
@@ -49,8 +48,6 @@ export type PiTerminalMode = "interactive" | "print" | "rpc";
49
48
  * sees it. Mutating tools (bash/edit/write) surface pi's own TUI approval
50
49
  * prompt at call time — we don't add a gate.
51
50
  */
52
- const ALL_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls", ...WEB_TOOL_NAMES];
53
-
54
51
  /**
55
52
  * System prompt for the embedded terminal coding agent.
56
53
  *
@@ -70,20 +67,11 @@ export function buildTerminalSystemPrompt(modelLabel?: string): string {
70
67
  const model = modelLabel ? `the local model \`${modelLabel}\`` : "a local model";
71
68
  return `You are mlx-bun's terminal coding agent — a capable, hands-on pair programmer working alongside the user in their terminal. You run on ${model} entirely on the user's own Apple-silicon Mac via mlx-bun, so the whole session stays local and private; nothing leaves the machine except the explicit web tools below.
72
69
 
73
- You operate inside the user's current project directory and have a real toolset — use it. Prefer doing the work over describing it: open the files, search the tree, run the command, make the edit. Investigate before you answer, and follow through to a working result.
74
-
75
- Tools used freely (no confirmation needed):
76
- - read — open and read any file
77
- - ls, find, grep — list directories and search the tree by name or content
78
- - web_search — search the web for current, real-world information (docs, APIs, errors, anything that changes or that you don't already know)
79
- - web_fetch — fetch a URL and read the page or data behind it (a natural follow-up to web_search, or when the user gives you a link)
80
- - weather — current conditions and a short forecast for any place
70
+ You operate inside the user's current project directory. Available capabilities when relevant: read-only file inspection (read, ls, find, grep), web search/fetch (web_search, web_fetch), weather, and user-approved local actions (bash, edit, write).
81
71
 
82
- Tools that ask the user for approval first (they stay in control — pi shows the prompt, you don't have to ask in prose):
83
- - bash — run shell commands (build, test, git, scripts)
84
- - edit, write — modify existing files or create new ones
72
+ Use tools in service of helping the user, not as a performance. For ordinary social conversation, answer naturally yourself. For real-world facts, current events, schedules, releases, local information, recommendations, restaurants, movies, prices, travel, docs, or anything likely to have changed, look it up with web_search/web_fetch or weather instead of relying on memory or guessing. For coding tasks, inspect the files, investigate before you answer, and follow through to a working result. When you need to change code or run something, propose the concrete edit or command and let the approval prompt handle consent — don't ask permission in prose first.
85
73
 
86
- When you need to change code or run something, just propose the concrete edit or command and let the approval prompt handle consent — don't narrate "I would run…" or ask permission in text. When you hit something you don't know — especially current or version-specific facts — look it up with web_search and web_fetch instead of guessing. If a request is genuinely ambiguous, ask one short clarifying question; otherwise make a sensible choice and proceed.
74
+ Environment metadata such as current date, working directory, model name, and available tools may be provided for context; do not report or summarize that metadata unless the user asks for it or it is directly relevant. Do not narrate tool policies or internal workflows.
87
75
 
88
76
  Be honest about what you did and what you found, including mistakes, dead ends, and anything you couldn't do. Keep responses tight and skimmable: format with Markdown, show file paths and commands plainly, and cite links when you used the web.`;
89
77
  }
@@ -121,6 +109,7 @@ export async function runEmbeddedPi(opts: RunEmbeddedPiOptions): Promise<number>
121
109
  const cwd = process.cwd();
122
110
  // Own session/settings dir, isolated from any standalone pi install.
123
111
  const agentDir = join(homedir(), ".mlx-bun", "pi");
112
+ const sessionDir = join(agentDir, "sessions", `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
124
113
 
125
114
  const { authStorage, modelRegistry, model } = buildPiProvider(opts.baseUrl, {
126
115
  contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
@@ -128,10 +117,10 @@ export async function runEmbeddedPi(opts: RunEmbeddedPiOptions): Promise<number>
128
117
  reasoning: opts.reasoning ?? false,
129
118
  });
130
119
 
131
- // Curated skills (web-research, …) written to ~/.mlx-bun/skills and loaded
132
- // on top of an otherwise-isolated session. See src/web/skills.ts.
133
- const skillsRoot = materializeBundledSkills();
134
- const systemPrompt = buildTerminalSystemPrompt(opts.modelLabel);
120
+ // Shared pi tool/skill surface. Memory is included only when the user has
121
+ // enabled a vault; if present it remains scoped to user-specific continuity.
122
+ const surface = await buildPiAgentSurface();
123
+ const systemPrompt = buildTerminalSystemPrompt(opts.modelLabel) + surface.memoryHint;
135
124
 
136
125
  // The runtime factory: same shape as pi's own CLI (dist/main.js) and the
137
126
  // shipped examples/sdk/13-session-runtime.ts. The same factory is reused by
@@ -154,7 +143,7 @@ export async function runEmbeddedPi(opts: RunEmbeddedPiOptions): Promise<number>
154
143
  // AGENTS.md / CLAUDE.md context files (unlike the web chat).
155
144
  noContextFiles: false,
156
145
  // Our curated skills on top (noSkills above only disables discovery).
157
- additionalSkillPaths: [skillsRoot],
146
+ additionalSkillPaths: surface.skillPaths,
158
147
  // Replace pi's default coding-agent prompt with our mlx-bun persona.
159
148
  systemPrompt,
160
149
  },
@@ -165,15 +154,15 @@ export async function runEmbeddedPi(opts: RunEmbeddedPiOptions): Promise<number>
165
154
  sessionManager,
166
155
  sessionStartEvent,
167
156
  model,
168
- tools: ALL_TOOLS,
169
- customTools: createWebTools(),
157
+ tools: surface.tools,
158
+ customTools: surface.customTools,
170
159
  })),
171
160
  services,
172
161
  diagnostics: services.diagnostics,
173
162
  };
174
163
  };
175
164
 
176
- const sessionManager = SessionManager.create(cwd);
165
+ const sessionManager = SessionManager.create(cwd, sessionDir);
177
166
  const runtime = await createAgentSessionRuntime(createRuntime, { cwd, agentDir, sessionManager });
178
167
 
179
168
  try {