mlx-bun 0.0.5 → 0.0.7

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 (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +3 -3
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
package/src/web/app.html CHANGED
@@ -105,6 +105,39 @@
105
105
  #chat-think.think-on .dot{background:var(--blue);box-shadow:0 0 11px var(--blue)}
106
106
  .pill.bad .dot{background:var(--red);box-shadow:0 0 11px var(--red)}
107
107
  .pill.warn .dot{background:var(--orange);box-shadow:0 0 11px var(--orange)}
108
+ /* sampling disclosure: a small pill that opens a popover above the composer */
109
+ #chat-sampling-wrap{position:relative;display:inline-flex}
110
+ #chat-sampling{cursor:pointer;gap:6px;padding:5px 11px;font-size:12px;user-select:none;-webkit-user-select:none;
111
+ transition:color .22s var(--ease),border-color .22s var(--ease)}
112
+ #chat-sampling:hover{border-color:rgba(255,255,255,.28);color:var(--ink)}
113
+ #chat-sampling.on{color:var(--ink);border-color:rgba(255,255,255,.28)}
114
+ #chat-sampling.dirty .dot{background:var(--blue);box-shadow:0 0 11px var(--blue)}
115
+ #chat-sampling .dot{width:7px;height:7px}
116
+ .samplepop{position:absolute;bottom:calc(100% + 9px);right:0;z-index:40;width:300px;
117
+ background:rgba(30,30,34,.98);border:1px solid rgba(255,255,255,.12);border-radius:13px;padding:13px 14px;
118
+ backdrop-filter:blur(22px) saturate(1.3);-webkit-backdrop-filter:blur(22px) saturate(1.3);
119
+ box-shadow:0 18px 50px rgba(0,0,0,.7);display:none;
120
+ transform:translateY(4px);opacity:0;transition:opacity .16s var(--ease),transform .16s var(--ease)}
121
+ .samplepop.open{display:block;transform:translateY(0);opacity:1}
122
+ .samplepop h4{font-size:12px;font-weight:600;color:var(--dim);letter-spacing:.02em;
123
+ text-transform:uppercase;margin:0 0 10px}
124
+ .samprow{display:flex;align-items:center;gap:9px;margin:0 0 11px}
125
+ .samprow label{font-size:12.5px;color:var(--ink);width:74px;flex:0 0 auto}
126
+ .samprow input[type=range]{flex:1 1 auto;-webkit-appearance:none;appearance:none;height:4px;margin:0;
127
+ border-radius:3px;background:rgba(255,255,255,.15);outline:none;cursor:pointer}
128
+ .samprow input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:15px;height:15px;
129
+ border-radius:50%;background:var(--ink);border:0;box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer;
130
+ transition:background .15s var(--ease)}
131
+ .samprow input[type=range]:hover::-webkit-slider-thumb,
132
+ .samprow input[type=range]:focus::-webkit-slider-thumb{background:var(--blue)}
133
+ .samp-val{width:40px;flex:0 0 auto;text-align:right;font-family:var(--mono);font-size:12px;color:var(--ink)}
134
+ .samp-val.auto{color:var(--dimmer)}
135
+ .samppop-foot{display:flex;justify-content:space-between;align-items:center;margin-top:3px}
136
+ .samppop-foot .note{font-size:10.5px;color:var(--dimmer);line-height:1.3;flex:1 1 auto;padding-right:8px}
137
+ .samppop-foot button{appearance:none;cursor:pointer;background:var(--card);border:1px solid var(--hairline);
138
+ color:var(--dim);border-radius:8px;padding:5px 11px;font-size:11.5px;font-weight:600;flex:0 0 auto;
139
+ transition:color .18s var(--ease),border-color .18s var(--ease)}
140
+ .samppop-foot button:hover{color:var(--ink);border-color:rgba(255,255,255,.28)}
108
141
  #nav-download{color:var(--ink);font-family:var(--mono);font-size:11.5px;font-weight:500}
109
142
  #nav-download .dot{background:var(--orange);box-shadow:0 0 11px var(--orange);animation:dlpulse 1.4s ease-in-out infinite}
110
143
  @keyframes dlpulse{0%,100%{opacity:.4}50%{opacity:1}}
@@ -485,6 +518,7 @@
485
518
  <a class="tab" href="#/dataset" data-tab="dataset">Build Dataset</a>
486
519
  <a class="tab" href="#/status" data-tab="status">Status</a>
487
520
  <a class="tab" href="/curves" data-tab="curves">Curves</a>
521
+ <a class="tab" href="#/routes" data-tab="routes">Routes</a>
488
522
  </div>
489
523
  <div class="right">
490
524
  <button class="hf-gear" id="nav-hf" title="Hugging Face token settings" aria-label="Hugging Face settings">
@@ -552,6 +586,32 @@
552
586
  <div class="chat-hint">
553
587
  <span id="chat-status-line">Enter to send · Shift+Enter for a new line · type while it streams to steer</span>
554
588
  <span class="hint-right">
589
+ <span id="chat-sampling-wrap">
590
+ <span id="chat-sampling" class="pill" role="button" tabindex="0" aria-haspopup="true" aria-expanded="false"
591
+ title="Sampling controls (temperature · top_p · top_k)"><span class="dot"></span>Sampling</span>
592
+ <div id="chat-sampling-pop" class="samplepop" role="dialog" aria-label="Sampling controls">
593
+ <h4>Sampling</h4>
594
+ <div class="samprow">
595
+ <label for="samp-temp">Temperature</label>
596
+ <input id="samp-temp" type="range" min="0" max="2" step="0.05" aria-label="Temperature">
597
+ <output id="samp-temp-val" class="samp-val auto">0.70</output>
598
+ </div>
599
+ <div class="samprow">
600
+ <label for="samp-topp">top_p</label>
601
+ <input id="samp-topp" type="range" min="0" max="1" step="0.01" aria-label="top_p">
602
+ <output id="samp-topp-val" class="samp-val auto">0.95</output>
603
+ </div>
604
+ <div class="samprow">
605
+ <label for="samp-topk">top_k</label>
606
+ <input id="samp-topk" type="range" min="0" max="100" step="1" aria-label="top_k">
607
+ <output id="samp-topk-val" class="samp-val auto">off</output>
608
+ </div>
609
+ <div class="samppop-foot">
610
+ <span class="note">Sliders default to the recommended value — temperature follows the thinking toggle. Reset returns to defaults.</span>
611
+ <button id="samp-reset" type="button">Reset</button>
612
+ </div>
613
+ </div>
614
+ </span>
555
615
  <select id="chat-adapter" class="pill" title="LoRA adapter for this chat (none = base model)">
556
616
  <option value="">no adapter</option>
557
617
  </select>
@@ -581,10 +641,14 @@
581
641
  <p class="lead" style="margin:6px 0 22px;font-size:14.5px">Point at a Hugging Face repo id or a local snapshot path. Inspect first to confirm support and size.</p>
582
642
  <div class="field">
583
643
  <label>Model id or path</label>
584
- <input type="text" id="q-model" placeholder="mlx-community/Qwen3-8B-bf16" autocomplete="off">
585
- <div class="hint">Anything on huggingface.co, or an absolute path to a local snapshot.</div>
644
+ <div id="q-drop">
645
+ <input type="text" id="q-model" placeholder="mlx-community/Qwen3-8B-bf16" autocomplete="off">
646
+ </div>
647
+ <input type="file" id="q-folder" webkitdirectory directory style="display:none">
648
+ <div class="hint">Anything on huggingface.co, an absolute path to a local snapshot, or <strong>choose / drop a folder</strong> below — we read its <code>config.json</code> and find the snapshot on disk.</div>
586
649
  </div>
587
650
  <div class="btnrow">
651
+ <button class="btn ghost" id="q-browse">Choose folder…</button>
588
652
  <button class="btn ghost" id="q-inspect">Inspect</button>
589
653
  </div>
590
654
  <div id="q-inspect-out"></div>
@@ -643,7 +707,7 @@
643
707
  <p class="lead" style="margin:6px 0 18px;font-size:14.5px">Your quantized model is written to disk.</p>
644
708
  <div class="field"><label>Output directory</label>
645
709
  <pre class="logbox" id="q-out" style="max-height:none;margin-top:0"></pre></div>
646
- <div class="flash ok">Run <code>mlx-bun scan</code> (or it's auto-picked up on restart) to make this model serveable from the Library.</div>
710
+ <div class="flash ok">Written to your model cache and registered automatically — it's already serveable from the Library and the model picker.</div>
647
711
  <div class="grid g4" style="margin-top:14px">
648
712
  <div class="card"><h3>Push to Hugging Face</h3><div class="cap">Publish this quantized model to a Hugging Face repo.</div>
649
713
  <div class="btnrow" style="margin-top:14px"><button class="btn ghost sm" id="q-push">Push to HF</button></div>
@@ -671,10 +735,16 @@
671
735
  <p class="lead" style="margin:6px 0 22px;font-size:14.5px">The model you're adapting. Point at a local model directory or snapshot path.</p>
672
736
  <div class="field">
673
737
  <label>Model directory / path</label>
674
- <input type="text" id="f-model" placeholder="/abs/path/to/model or mlx-community/Qwen3-8B-4bit" autocomplete="off">
675
- <div class="hint">An OptiQ quant or any supported local model works.</div>
738
+ <div id="f-drop">
739
+ <input type="text" id="f-model" placeholder="/abs/path/to/model or mlx-community/Qwen3-8B-4bit" autocomplete="off">
740
+ </div>
741
+ <input type="file" id="f-folder" webkitdirectory directory style="display:none">
742
+ <div class="hint">An OptiQ quant or any supported local model works — type a path/repo id, or <strong>choose / drop a folder</strong> below.</div>
743
+ </div>
744
+ <div class="btnrow">
745
+ <button class="btn ghost" id="f-browse">Choose folder…</button>
746
+ <button class="btn primary" id="f-next0">Continue</button>
676
747
  </div>
677
- <div class="btnrow"><button class="btn primary" id="f-next0">Continue</button></div>
678
748
  </div>
679
749
 
680
750
  <!-- step 2: dataset -->
@@ -962,6 +1032,12 @@
962
1032
  </div></div>
963
1033
  </section>
964
1034
 
1035
+ <!-- ═══════════════════ ROUTES ═══════════════════ -->
1036
+ <section data-route="routes" id="s-routes">
1037
+ <iframe src="/dag" title="Training · inference route map"
1038
+ style="width:100%;height:100%;border:0;display:block"></iframe>
1039
+ </section>
1040
+
965
1041
  </div>
966
1042
 
967
1043
  <div id="toasts"></div>
@@ -1039,7 +1115,7 @@ function mdInline(t) {
1039
1115
  // Pull code spans out first (any run of backticks), then escape the rest.
1040
1116
  t = String(t).replace(/(`+)([\s\S]+?)\1/g, (_m, _ticks, c) => {
1041
1117
  codes.push(c.replace(/^ | $/g, ""));
1042
- return "C" + (codes.length - 1) + "";
1118
+ return "\u0000C" + (codes.length - 1) + "\u0000";
1043
1119
  });
1044
1120
  let s = esc(t);
1045
1121
  // [text](url "title") — title optional and ignored.
@@ -1057,7 +1133,7 @@ function mdInline(t) {
1057
1133
  // Autolink bare URLs that markdown didn't already wrap.
1058
1134
  s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:!?'"])/g,
1059
1135
  (_m, pre, url) => pre + '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + "</a>");
1060
- return s.replace(/C(\d+)/g, (_m, i) => "<code>" + esc(codes[+i]) + "</code>");
1136
+ return s.replace(/\u0000C(\d+)\u0000/g, (_m, i) => "<code>" + esc(codes[+i]) + "</code>");
1061
1137
  }
1062
1138
 
1063
1139
  function mdCodeBlock(lang, code) {
@@ -1288,7 +1364,7 @@ async function pushToHub(panel, { kind, job_id, source_path }) {
1288
1364
  /* ════════════════════════════════════════════════════════════════════
1289
1365
  ROUTER — toggles section[data-route]; lazily inits each controller.
1290
1366
  ════════════════════════════════════════════════════════════════════ */
1291
- const ROUTES = ["chat", "quantize", "finetune", "dataset", "status"];
1367
+ const ROUTES = ["chat", "quantize", "finetune", "dataset", "status", "routes"];
1292
1368
  const inited = {};
1293
1369
  const controllers = {}; // name -> { init, enter, leave }
1294
1370
 
@@ -1431,7 +1507,7 @@ controllers.chat = (() => {
1431
1507
  el("div", "who", m).textContent = "local agent";
1432
1508
  const bubble = el("div", "bubble", m);
1433
1509
  const thinkBox = el("details", "thinkbox", bubble);
1434
- thinkBox.open = true;
1510
+ thinkBox.open = false; // collapsed by default; the reasoning stays tucked behind "Thinking" — click to expand
1435
1511
  el("summary", "", thinkBox).textContent = "Thinking";
1436
1512
  const thinkBody = el("div", "thinkbody", thinkBox);
1437
1513
  thinkBox.style.display = "none";
@@ -1738,7 +1814,15 @@ controllers.chat = (() => {
1738
1814
  let attachments = []; // { id, kind:"image"|"text", name, mimeType, data, text, truncated }
1739
1815
  let visionCapable = false; // from the `ready` frame; gates image attachments
1740
1816
  let thinkingCapable = false; // from `ready`; gates the reasoning toggle
1741
- let thinkingOn = true; // on/off state for the model's reasoning channel
1817
+ let thinkingOn = true; // default on: with tools (read/web_search) the reasoning step gives the small model far better tool judgment (answers math/writing itself, only searches when needed). The thinking box defaults collapsed. Users can toggle it off.
1818
+ // Per-request sampling. Each field is either a user override (a number) or
1819
+ // null = "use the recommended default" (resolved server-side). The recommended
1820
+ // TEMPERATURE tracks the thinking toggle (0.7 off ↔ 0.9 on); top_p/top_k recs
1821
+ // are fixed mirrors of the server defaults (display only). The placeholder in
1822
+ // each input shows the live recommended value when the field isn't overridden.
1823
+ const sampling = { temperature: null, top_p: null, top_k: null };
1824
+ const SAMP_REC = { topP: 0.95, topK: 0 }; // fixed recommended top_p / top_k (server genDefaults)
1825
+ function recTemp() { return thinkingOn ? 0.9 : 0.7; } // mirrors server toOptions defaultTemp
1742
1826
  let attachSeq = 0;
1743
1827
  const MAX_TEXT_BYTES = 256 * 1024;
1744
1828
  const TEXT_EXTS = /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|ini|cfg|conf|log|xml|html?|css|scss|js|mjs|cjs|ts|tsx|jsx|py|rs|go|java|c|h|cpp|hpp|cc|cs|rb|php|sh|bash|zsh|fish|sql|swift|kt|lua|r|jl|tex|svg|env|gitignore|dockerfile|makefile)$/i;
@@ -1835,6 +1919,81 @@ controllers.chat = (() => {
1835
1919
  b.classList.toggle("think-on", thinkingOn);
1836
1920
  b.title = thinkingOn ? "Reasoning on — click to answer directly" : "Reasoning off — click to let the model think";
1837
1921
  b.setAttribute("aria-pressed", String(thinkingOn));
1922
+ refreshSamplingRecs(); // recommended temperature follows the thinking state
1923
+ }
1924
+
1925
+ /* — sampling controls — */
1926
+ // Each row is a slider + a live value readout. A field is "auto" (null
1927
+ // override → the server uses its mode-aware default; temperature's default
1928
+ // follows the thinking toggle) until the user drags it, which records an
1929
+ // explicit override. Reset returns every field to auto.
1930
+ const SAMP_FIELDS = [
1931
+ { id: "samp-temp", field: "temperature", valId: "samp-temp-val", rec: () => recTemp(), fmt: (v) => v.toFixed(2) },
1932
+ { id: "samp-topp", field: "top_p", valId: "samp-topp-val", rec: () => SAMP_REC.topP, fmt: (v) => v.toFixed(2) },
1933
+ { id: "samp-topk", field: "top_k", valId: "samp-topk-val", rec: () => SAMP_REC.topK, fmt: (v) => (v >= 1 ? String(Math.round(v)) : "off") },
1934
+ ];
1935
+
1936
+ // Sync every slider + readout to current state: an overridden field shows its
1937
+ // set value (bright readout); an auto field shows the recommended value
1938
+ // (dimmed) and tracks it live — e.g. temperature when the thinking toggle
1939
+ // flips. Called on thinking-toggle flips, popover open, and reset.
1940
+ function refreshSamplingRecs() {
1941
+ for (const f of SAMP_FIELDS) {
1942
+ const slider = $(f.id), val = $(f.valId); if (!slider || !val) continue;
1943
+ const over = sampling[f.field];
1944
+ const v = over != null ? over : f.rec();
1945
+ slider.value = String(v);
1946
+ val.textContent = f.fmt(v);
1947
+ val.classList.toggle("auto", over == null);
1948
+ }
1949
+ }
1950
+
1951
+ // User dragged a slider → record the explicit override, brighten the readout,
1952
+ // and push the whole sampling state to the server.
1953
+ function onSlide(f) {
1954
+ const slider = $(f.id); if (!slider) return;
1955
+ const v = Number(slider.value);
1956
+ sampling[f.field] = v;
1957
+ const val = $(f.valId); if (val) { val.textContent = f.fmt(v); val.classList.remove("auto"); }
1958
+ updateSamplingUi();
1959
+ send({ type: "set_sampling", temperature: sampling.temperature, top_p: sampling.top_p, top_k: sampling.top_k });
1960
+ }
1961
+
1962
+ // The pill shows a lit dot whenever any field is overridden.
1963
+ function updateSamplingUi() {
1964
+ const dirty = sampling.temperature != null || sampling.top_p != null || sampling.top_k != null;
1965
+ const pill = $("chat-sampling");
1966
+ if (pill) { pill.classList.toggle("dirty", dirty); pill.title = dirty
1967
+ ? "Sampling overridden — click to edit or reset" : "Sampling controls (temperature · top_p · top_k)"; }
1968
+ }
1969
+
1970
+ function resetSampling() {
1971
+ sampling.temperature = null; sampling.top_p = null; sampling.top_k = null;
1972
+ refreshSamplingRecs(); updateSamplingUi();
1973
+ send({ type: "set_sampling", temperature: null, top_p: null, top_k: null });
1974
+ }
1975
+ function initSampling() {
1976
+ const pill = $("chat-sampling"), pop = $("chat-sampling-pop");
1977
+ if (!pill || !pop) return;
1978
+ const setOpen = (open) => {
1979
+ pop.classList.toggle("open", open);
1980
+ pill.classList.toggle("on", open);
1981
+ pill.setAttribute("aria-expanded", String(open));
1982
+ if (open) refreshSamplingRecs();
1983
+ };
1984
+ const toggle = () => setOpen(!pop.classList.contains("open"));
1985
+ pill.onclick = (e) => { e.stopPropagation(); toggle(); };
1986
+ pill.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } });
1987
+ pop.addEventListener("click", (e) => e.stopPropagation());
1988
+ // Click-away / Escape closes the popover.
1989
+ document.addEventListener("click", () => setOpen(false));
1990
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOpen(false); });
1991
+ for (const f of SAMP_FIELDS) {
1992
+ const e = $(f.id); if (!e) continue;
1993
+ e.addEventListener("input", () => onSlide(f));
1994
+ }
1995
+ const reset = $("samp-reset"); if (reset) reset.onclick = (e) => { e.stopPropagation(); resetSampling(); };
1996
+ updateSamplingUi(); refreshSamplingRecs();
1838
1997
  }
1839
1998
 
1840
1999
  /* — composer — */
@@ -1854,8 +2013,10 @@ controllers.chat = (() => {
1854
2013
  addUserMsg(text, attachments);
1855
2014
  box.value = ""; box.style.height = "auto";
1856
2015
  const frame = imgs.length ? { text: combined, images: imgs } : { text: combined };
1857
- if (turnActive) send({ type: "steer", ...frame }); // mid-turn: steer/queue
1858
- else send({ type: "prompt", ...frame });
2016
+ // A composer submission is always a new user turn. If the model is still
2017
+ // streaming, the server passes it to pi with streamingBehavior:"followUp"
2018
+ // so it becomes the next turn instead of steering/mutating the current one.
2019
+ send({ type: "prompt", ...frame });
1859
2020
  clearAttachments();
1860
2021
  }
1861
2022
  const scroll = () => $("chat-scroll");
@@ -1872,6 +2033,7 @@ controllers.chat = (() => {
1872
2033
  $("chat-think").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); thinkToggle(); } });
1873
2034
  const adapterSel = $("chat-adapter");
1874
2035
  if (adapterSel) adapterSel.onchange = () => onSelectAdapter(adapterSel);
2036
+ initSampling();
1875
2037
  refreshAdapters();
1876
2038
  const box = $("chat-box");
1877
2039
  box.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } });
@@ -1934,6 +2096,33 @@ controllers.quantize = (() => {
1934
2096
  }));
1935
2097
  }
1936
2098
 
2099
+ // --- OS folder picker / drag-drop → real on-disk snapshot path ---
2100
+ // The browser can't reveal an absolute path, and the HF cache uses symlinked
2101
+ // snapshots over a blobs/ store — so we DON'T read any files. We only take the
2102
+ // picked folder's NAME (which encodes the repo id) and let the server
2103
+ // reconstruct the snapshot path it owns. No upload, no "config.json" hunt.
2104
+ async function resolveByName(name, relPath) {
2105
+ if (!name && !relPath) return;
2106
+ const out = $("q-inspect-out");
2107
+ out.innerHTML = '<div class="flash"><span class="shimmer">locating on disk…</span></div>';
2108
+ const d = await api("/api/quantize/resolve-folder", { method: "POST", body: { folder_name: name || "", rel_path: relPath || "" } })
2109
+ .catch(() => ({ ok: false, error: "request failed" }));
2110
+ if (!d.ok) { out.innerHTML = '<div class="flash err">' + esc(d.error || "could not locate this folder on the server") + "</div>"; return; }
2111
+ $("q-model").value = d.path;
2112
+ inspect();
2113
+ }
2114
+ async function pickFolder() {
2115
+ // Native OS folder dialog with no "upload N files" prompt; we only read the
2116
+ // folder's .name, never its contents.
2117
+ if (window.showDirectoryPicker) {
2118
+ try {
2119
+ const handle = await window.showDirectoryPicker({ id: "mlx-models", mode: "read" });
2120
+ return resolveByName(handle.name);
2121
+ } catch (e) { return; } // user cancelled / denied
2122
+ }
2123
+ $("q-folder").click(); // fallback for browsers without the File System Access API
2124
+ }
2125
+
1937
2126
  async function inspect() {
1938
2127
  const id = $("q-model").value.trim();
1939
2128
  if (!id) { toast("Enter a model id or path first.", "err"); return; }
@@ -1990,6 +2179,26 @@ controllers.quantize = (() => {
1990
2179
  show(0);
1991
2180
  $("q-inspect").onclick = inspect;
1992
2181
  $("q-model").addEventListener("keydown", (e) => { if (e.key === "Enter") inspect(); });
2182
+ // OS folder dialog (native; resolves by folder name, no upload)
2183
+ $("q-browse").onclick = pickFolder;
2184
+ $("q-folder").addEventListener("change", (e) => {
2185
+ const f = e.target.files[0]; e.target.value = "";
2186
+ if (f) resolveByName((f.webkitRelativePath || f.name).split("/")[0], f.webkitRelativePath || "");
2187
+ });
2188
+ // drag a folder onto the box — use the directory entry's NAME (no read)
2189
+ const drop = $("q-drop");
2190
+ const hi = (on) => { drop.style.outline = on ? "2px dashed var(--blue)" : ""; drop.style.outlineOffset = on ? "4px" : ""; };
2191
+ ["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); hi(true); }));
2192
+ ["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => {
2193
+ e.preventDefault(); hi(false);
2194
+ if (ev !== "drop" || !e.dataTransfer) return;
2195
+ for (const it of [...(e.dataTransfer.items || [])]) {
2196
+ const en = it.webkitGetAsEntry && it.webkitGetAsEntry();
2197
+ if (en && en.isDirectory) return resolveByName(en.name);
2198
+ }
2199
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
2200
+ if (f) resolveByName((f.webkitRelativePath || f.name).split("/")[0]);
2201
+ }));
1993
2202
  seg($("q-bits"), (v) => { bits = +v; $("q-bits-hint").textContent = v === "4"
1994
2203
  ? "4-bit · ~4× smaller than bf16 · the recommended default for local serving."
1995
2204
  : "8-bit · ~2× smaller than bf16 · highest fidelity quant."; });
@@ -2009,7 +2218,7 @@ controllers.quantize = (() => {
2009
2218
  ════════════════════════════════════════════════════════════════════ */
2010
2219
  controllers.finetune = (() => {
2011
2220
  const STEPS = ["Base", "Dataset", "Hyperparams", "Train", "Done"];
2012
- let step = 0, method = "sft", lrTouched = false, es = null, log = [];
2221
+ let step = 0, method = "sft", lrTouched = false, rankTouched = false, es = null, log = [];
2013
2222
  let datasetOk = false, adapterPath = "";
2014
2223
  const trainPts = [], valPts = [];
2015
2224
 
@@ -2019,6 +2228,23 @@ controllers.finetune = (() => {
2019
2228
  renderSteps($("f-steps"), STEPS, n);
2020
2229
  }
2021
2230
 
2231
+ // OS folder picker for the base model — resolve by NAME server-side (no
2232
+ // upload, works with the HF cache's symlinked layout). Mirrors Quantize.
2233
+ async function resolveBaseByName(name, relPath) {
2234
+ if (!name && !relPath) return;
2235
+ const d = await api("/api/model/resolve-folder", { method: "POST", body: { folder_name: name || "", rel_path: relPath || "" } })
2236
+ .catch(() => ({ ok: false, error: "request failed" }));
2237
+ if (!d.ok) { toast(d.error || "could not locate this folder", "err"); return; }
2238
+ $("f-model").value = d.path;
2239
+ }
2240
+ async function pickBaseFolder() {
2241
+ if (window.showDirectoryPicker) {
2242
+ try { const h = await window.showDirectoryPicker({ id: "mlx-models", mode: "read" }); return resolveBaseByName(h.name); }
2243
+ catch (e) { return; }
2244
+ }
2245
+ $("f-folder").click();
2246
+ }
2247
+
2022
2248
  async function inspectDataset() {
2023
2249
  const path = $("f-data").value.trim();
2024
2250
  if (!path) { toast("Enter a dataset directory.", "err"); return; }
@@ -2043,6 +2269,8 @@ controllers.finetune = (() => {
2043
2269
  ? "Odds Ratio Preference Optimization (reference-free) — data is {prompt, chosen, rejected}."
2044
2270
  : "Supervised fine-tuning — data is messages or prompt+completion.";
2045
2271
  if (!lrTouched) $("f-lr").value = m === "dpo" ? 0.00005 : m === "orpo" ? 0.00001 : 0.0002;
2272
+ // ORPO's validated LoRA recipe uses rank 16 (vs 8 for SFT); apply unless edited.
2273
+ if (!rankTouched) $("f-rank").value = m === "orpo" ? 16 : 8;
2046
2274
  }
2047
2275
 
2048
2276
  function collectHP() {
@@ -2178,12 +2406,34 @@ controllers.finetune = (() => {
2178
2406
  init() {
2179
2407
  show(0);
2180
2408
  $("f-next0").onclick = () => { if (!$("f-model").value.trim()) { toast("Enter a base model path.", "err"); return; } show(1); };
2409
+ // OS folder dialog for the base model (native; resolves by name, no upload)
2410
+ $("f-browse").onclick = pickBaseFolder;
2411
+ $("f-folder").addEventListener("change", (e) => {
2412
+ const f = e.target.files[0]; e.target.value = "";
2413
+ if (f) resolveBaseByName((f.webkitRelativePath || f.name).split("/")[0], f.webkitRelativePath || "");
2414
+ });
2415
+ {
2416
+ const drop = $("f-drop");
2417
+ const hi = (on) => { drop.style.outline = on ? "2px dashed var(--blue)" : ""; drop.style.outlineOffset = on ? "4px" : ""; };
2418
+ ["dragenter", "dragover"].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); hi(true); }));
2419
+ ["dragleave", "drop"].forEach((ev) => drop.addEventListener(ev, (e) => {
2420
+ e.preventDefault(); hi(false);
2421
+ if (ev !== "drop" || !e.dataTransfer) return;
2422
+ for (const it of [...(e.dataTransfer.items || [])]) {
2423
+ const en = it.webkitGetAsEntry && it.webkitGetAsEntry();
2424
+ if (en && en.isDirectory) return resolveBaseByName(en.name);
2425
+ }
2426
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
2427
+ if (f) resolveBaseByName((f.webkitRelativePath || f.name).split("/")[0]);
2428
+ }));
2429
+ }
2181
2430
  $("f-inspect").onclick = inspectDataset;
2182
2431
  $("f-data").addEventListener("keydown", (e) => { if (e.key === "Enter") inspectDataset(); });
2183
2432
  $("f-data").addEventListener("input", () => { datasetOk = false; $("f-next1").disabled = true; $("f-inspect-out").innerHTML = ""; });
2184
2433
  $("f-next1").onclick = () => { if (datasetOk) show(2); };
2185
2434
  $("f-method").querySelectorAll("button").forEach((b) => b.onclick = () => setMethod(b.dataset.v));
2186
2435
  $("f-lr").addEventListener("input", () => { lrTouched = true; });
2436
+ $("f-rank").addEventListener("input", () => { rankTouched = true; });
2187
2437
  document.querySelectorAll("#s-finetune [data-fback]").forEach((b) => b.onclick = () => show(step - 1));
2188
2438
  $("f-submit").onclick = submit;
2189
2439
  $("f-merge-go").onclick = mergeAdapters;
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: memory
3
+ description: Use when the answer depends on the user's prior conversations or personal context: explicit memory/recall requests, named personal projects, people, preferences, decisions, or history not explained in the current chat. Do not use for weather, current public facts, generic web research, or ordinary coding/file tasks when current files are sufficient. Also use when the user wants to set up, inspect, or manage memory.
4
+ ---
5
+
6
+ # Memory
7
+
8
+ The user has a personal **memory** — a wiki of Markdown articles synthesized
9
+ from their past conversations, stored locally at `~/.mlx-bun/wiki/`. User memory
10
+ articles live under `articles/`. Read-only mlx-bun reference docs live under
11
+ `Reference/` as symlinks and are also searchable/readable, but synthesis never
12
+ writes them. Each user article is the consolidated, finished understanding of one
13
+ topic. Your job is to *use it* so the user never has to re-explain what they've
14
+ already told you.
15
+
16
+ It is entirely local: the articles, the synthesis, and these tools all run on
17
+ the user's machine. Nothing about their memory leaves the device.
18
+
19
+ ## Consulting memory (the common case)
20
+
21
+ When the answer depends on personal continuity — an explicit memory/recall
22
+ request, or a named project, person, decision, preference, or history that is
23
+ not explained in the current chat — consult memory before answering or asking
24
+ the user to re-explain.
25
+
26
+ Do **not** use memory for weather, current public facts, generic web research,
27
+ or ordinary coding/file tasks when the current files or the web tools answer the
28
+ question. Memory is for user-specific context, not for performing familiarity.
29
+
30
+ 1. **Search.** Call `memory_search` with the topic (plain phrasing is fine —
31
+ a name or key noun). It returns ranked articles and sample lines.
32
+ 2. **Read enough.** For short or central articles, call `memory_read`. For long
33
+ articles, use `memory_toc` then `memory_section` to read the relevant part.
34
+ The article is the answer — read it rather than reconstructing the user's
35
+ position from scattered fragments. Treat it as their current view.
36
+ 3. **Follow nearby context when useful.** Use `memory_links` if backlinks or
37
+ related concepts matter. `memory_list` gives an overview when search comes up
38
+ short. mlx-bun reference docs are separate from personal memory; use
39
+ `reference_search`, `reference_read`, and `reference_list` for those.
40
+ `memory_status` answers setup/schedule/location questions.
41
+ 4. **Use it silently, as a prior.** Let what you find shape your response like
42
+ a long-time colleague would — don't announce "I searched your memory."
43
+
44
+ Use memory as an invisible prior: it should narrow relevance and improve the
45
+ answer, not become the topic of the answer. Avoid performative recall — do not
46
+ name-drop unrelated remembered facts to sound familiar.
47
+
48
+ ## Opening memory for the user
49
+
50
+ The wiki is a normal Markdown/Obsidian vault. If the user asks to browse, open,
51
+ inspect, or edit their memory directly, you may open it for them after a brief
52
+ confirmation:
53
+
54
+ ```bash
55
+ mlx-bun memory open
56
+ mlx-bun memory open <article>
57
+ ```
58
+
59
+ The first form opens `~/.mlx-bun/wiki/` in Obsidian when available, falling back
60
+ to the folder in Finder. The second form opens a specific article stem such as
61
+ `Archie_Project` directly in Obsidian, falling back to the default Markdown app.
62
+ Opening the vault or article is allowed; editing articles during chat is not. If
63
+ the user wants a change to become durable memory, keep the conversation natural
64
+ and let synthesis incorporate it later, unless they explicitly ask to edit files
65
+ by hand in Obsidian.
66
+
67
+ ## Setting up memory (if it doesn't exist yet)
68
+
69
+ If memory isn't set up (the memory tools aren't available, or the user asks to
70
+ turn it on), explain it plainly and set it up on agreement:
71
+
72
+ > Your memory is a folder of Markdown articles at `~/.mlx-bun/wiki/`,
73
+ > synthesized from your conversations so I can remember your context across
74
+ > sessions. It's a normal git-tracked wiki you fully own and can edit or
75
+ > delete — and it never leaves your machine.
76
+
77
+ On a yes, run the setup via the bash tool (the user approves it):
78
+
79
+ ```
80
+ mlx-bun memory init
81
+ ```
82
+
83
+ This creates the vault and walks through optional steps (importing an existing
84
+ wiki, scheduling nightly synthesis). Confirm before each persistent action.
85
+
86
+ ## What not to do
87
+
88
+ - Don't claim a fact "from memory" that you didn't read in an article — search
89
+ and read first, or say you don't have it.
90
+ - Don't write to or edit the wiki during a normal conversation; the read tools
91
+ are read-only by design. Synthesis is a separate, deliberate step.
92
+ - Do open the wiki or a specific article in Obsidian/Finder if the user asks to
93
+ browse or edit their memory themselves (`mlx-bun memory open [article]`).
94
+ - Don't dump article contents at the user verbatim — use them to inform a
95
+ direct, relevant answer.
96
+ - Don't perform familiarity. If the user asks for weather, current facts, or a
97
+ normal coding task, use the task-appropriate tool instead of memory.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-research
3
- description: Use when the user asks you to research something, do a deep dive, fact-check a claim, compare options, or answer a question that needs current, accurate, real-world information ("what's the latest on…", prices, releases, news, specs). Gives a rigorous workflow for searching the web, reading primary sources, cross-checking claims, and answering with citations.
3
+ description: Use only when the user asks you to research something, do a deep dive, fact-check a claim, compare options, or answer a question that needs current, accurate, real-world information ("what's the latest on…", prices, releases, news, specs). Do not use for ordinary conversation, ordinary coding tasks, or questions answerable from the current conversation/files.
4
4
  ---
5
5
 
6
6
  # Web Research
@@ -16,6 +16,8 @@ trustworthy, well-sourced answer — not a guess dressed up as one.
16
16
  - The user wants a thorough or authoritative answer, or asks for sources.
17
17
  - A specific claim needs verifying, or you would otherwise be speculating.
18
18
 
19
+ Do **not** use this for ordinary conversation that does not require external information. In that case, reply naturally instead of explaining the research workflow.
20
+
19
21
  ## Workflow
20
22
 
21
23
  1. **Plan.** Restate the question in your own words and list the 2–4 concrete
package/src/web/skills.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  // the user's personal ~/.pi and project .pi skills, so the chat stays
14
14
  // isolated while still getting our curated set (resource-loader.js:277).
15
15
  //
16
+ // Memory is deliberately gated by vault existence. If memory is not enabled,
17
+ // we do not expose its skill at all; otherwise small local models can fixate on
18
+ // the skill description even when the user asks unrelated things like weather.
19
+ //
16
20
  // To add a skill: drop a folder under ./skills/<name>/SKILL.md, add a text
17
21
  // import for it, and append it to BUNDLED_SKILLS.
18
22
 
@@ -20,6 +24,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
20
24
  import { homedir } from "node:os";
21
25
  import { join } from "node:path";
22
26
  import webResearchMd from "./skills/web-research/SKILL.md" with { type: "text" };
27
+ import memoryMd from "./skills/memory/SKILL.md" with { type: "text" };
23
28
 
24
29
  interface BundledSkill {
25
30
  /** Directory name under the skills root; should match the SKILL.md `name`. */
@@ -30,15 +35,23 @@ interface BundledSkill {
30
35
 
31
36
  const BUNDLED_SKILLS: readonly BundledSkill[] = [
32
37
  { name: "web-research", md: webResearchMd },
38
+ { name: "memory", md: memoryMd },
33
39
  ];
34
40
 
41
+ export interface BundledSkillOptions {
42
+ /** Include the web-research workflow skill. Default false: tool schemas are enough, and small local models can over-apply workflow skills. */
43
+ includeWebResearch?: boolean;
44
+ /** Include the personal-memory skill. Default true for backward compatibility. */
45
+ includeMemory?: boolean;
46
+ }
47
+
35
48
  /** Where the bundled skills are written; also pi's per-session scratch root. */
36
49
  export function bundledSkillsRoot(): string {
37
50
  return join(homedir(), ".mlx-bun", "skills");
38
51
  }
39
52
 
40
53
  /** Memoize: the embedded content can't change within a process run. */
41
- let materialized = false;
54
+ const materialized = new Set<string>();
42
55
 
43
56
  /**
44
57
  * Write the embedded skills to disk (overwriting, so updates propagate) and
@@ -46,14 +59,39 @@ let materialized = false;
46
59
  * pi scans it for per-skill SKILL.md files. Runs its disk writes once per
47
60
  * process even when called per browser connection.
48
61
  */
49
- export function materializeBundledSkills(): string {
62
+ function selectedSkills(opts: BundledSkillOptions = {}): readonly BundledSkill[] {
63
+ const includeMemory = opts.includeMemory ?? true;
64
+ const includeWebResearch = opts.includeWebResearch ?? false;
65
+ return BUNDLED_SKILLS.filter((skill) => {
66
+ if (!includeMemory && skill.name === "memory") return false;
67
+ if (!includeWebResearch && skill.name === "web-research") return false;
68
+ return true;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Materialize selected bundled skills and return exact skill directories. Use
74
+ * these paths in DefaultResourceLoader.additionalSkillPaths. Returning exact
75
+ * directories (rather than the shared root) prevents an old on-disk memory
76
+ * skill from being discovered when memory is disabled for the current session.
77
+ */
78
+ export function materializeBundledSkillPaths(opts: BundledSkillOptions = {}): string[] {
50
79
  const root = bundledSkillsRoot();
51
- if (materialized) return root;
52
- for (const skill of BUNDLED_SKILLS) {
80
+ const paths: string[] = [];
81
+ for (const skill of selectedSkills(opts)) {
53
82
  const dir = join(root, skill.name);
54
- mkdirSync(dir, { recursive: true });
55
- writeFileSync(join(dir, "SKILL.md"), skill.md);
83
+ if (!materialized.has(skill.name)) {
84
+ mkdirSync(dir, { recursive: true });
85
+ writeFileSync(join(dir, "SKILL.md"), skill.md);
86
+ materialized.add(skill.name);
87
+ }
88
+ paths.push(dir);
56
89
  }
57
- materialized = true;
58
- return root;
90
+ return paths;
91
+ }
92
+
93
+ /** Backward-compatible root materializer for older call sites. Prefer materializeBundledSkillPaths(). */
94
+ export function materializeBundledSkills(): string {
95
+ materializeBundledSkillPaths({ includeMemory: true, includeWebResearch: true });
96
+ return bundledSkillsRoot();
59
97
  }