dsh-local-models 0.3.2 → 0.5.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-es.md CHANGED
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
67
67
 
68
68
  ## Usage
69
69
 
70
- 1. **Choose GGUF…** — elige un archivo de modelo (atajos Home / Models, navegación con Up).
70
+ 1. **Choose GGUF…** — elige un archivo de modelo (botones Home / Root, atajos Models, navegación con Up). Up sube hasta `/`, así que un modelo en un disco montado (`/mnt/…`, `/media/…`) se abre sin registrar antes su carpeta.
71
71
  2. Ajusta **context**, **KV cache K / V**, **Max MTP head** (borrador fijo, 0-7; 3 es el punto óptimo ajustado — más profundidad colapsa con ctx grandes), **thinking level** + casilla **preserve thinking**, y los ajustes opcionales de **mmproj** y **MoE**.
72
72
  3. **Load model**, observa la tarjeta de estado, inspecciona la salida con **Open terminal**.
73
73
  4. **Register in dsh** — la ruta (por defecto `local-<alias>`) aparece en el selector de modelos.
package/README-hi.md CHANGED
@@ -53,7 +53,7 @@ dsh plugin --profile web remove dsh-local-models
53
53
 
54
54
  ## Usage
55
55
 
56
- 1. **Choose GGUF…** — कोई model फ़ाइल चुनें (Home / Models shortcuts, Up navigation)।
56
+ 1. **Choose GGUF…** — कोई model फ़ाइल चुनें (Home / Root buttons, Models shortcuts, Up navigation)। Up सीधे `/` तक जाता है, इसलिए mounted disk पर रखा model (`/mnt/…`, `/media/…`) बिना folder दर्ज किए खुल जाता है।
57
57
  2. **context**, **KV cache K / V**, **Max MTP head** (fixed draft, 0-7; 3 ही ट्यून किया गया sweet spot है — इससे गहरा draft बड़े ctx पर collapse हो जाता है), **thinking level** + **preserve thinking** checkbox, वैकल्पिक **mmproj** और **MoE** settings ट्यून करें।
58
58
  3. **Load model**, status card देखें, **Open terminal** से output जाँचें।
59
59
  4. **Register in dsh** — route (default `local-<alias>`) Models picker में दिखने लगता है।
package/README-pt.md CHANGED
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
67
67
 
68
68
  ## Usage
69
69
 
70
- 1. **Choose GGUF…** — escolha um arquivo de modelo (atalhos Home / Models, navegação Up).
70
+ 1. **Choose GGUF…** — escolha um arquivo de modelo (botões Home / Root, atalhos Models, navegação Up). O Up sobe até `/`, então um modelo em um disco montado (`/mnt/…`, `/media/…`) abre sem cadastrar a pasta antes.
71
71
  2. Ajuste **context**, **KV cache K / V**, **Max MTP head** (draft fixo, 0-7; 3 é o ponto ideal ajustado — profundidades maiores colapsam em ctx grande), **thinking level** + checkbox **preserve thinking**, e as configurações opcionais de **mmproj** e **MoE**.
72
72
  3. **Load model**, acompanhe o card de status e inspecione a saída via **Open terminal**.
73
73
  4. **Register in dsh** — a rota (padrão `local-<alias>`) aparece no seletor de modelos.
package/README-zh.md CHANGED
@@ -67,7 +67,7 @@ dsh plugin --profile web remove dsh-local-models
67
67
 
68
68
  ## Usage
69
69
 
70
- 1. **Choose GGUF…** —— 选择模型文件(Home / Models 快捷入口,Up 向上导航)。
70
+ 1. **Choose GGUF…** —— 选择模型文件(Home / Root 按钮,Models 快捷入口,Up 向上导航)。Up 可以一直走到 `/`,因此挂载磁盘上的模型(`/mnt/…`、`/media/…`)无需先登记文件夹即可打开。
71
71
  2. 调整 **context**、**KV cache K / V**、**Max MTP head**(固定草稿,0-7;3 是调校好的甜点值 —— 更深的草稿在大 ctx 下会崩溃)、**thinking level** + **preserve thinking** 复选框,以及可选的 **mmproj** 和 **MoE** 设置。
72
72
  3. **Load model**,观察状态卡片,通过 **Open terminal** 查看输出。
73
73
  4. **Register in dsh** —— 该路由(默认 `local-<alias>`)会出现在 Models 选择器中。
package/README.md CHANGED
@@ -69,7 +69,7 @@ dsh plugin --profile web remove dsh-local-models
69
69
 
70
70
  ## Usage
71
71
 
72
- 1. **Choose GGUF…** — pick a model file (Home / Models shortcuts, Up navigation).
72
+ 1. **Choose GGUF…** — pick a model file (Home / Root buttons, Models shortcuts, Up navigation). Up goes all the way to `/`, so a model on a mounted disk (`/mnt/…`, `/media/…`, a network export) opens without adding its folder to Model folders first.
73
73
  2. Tune **context**, **KV cache K / V**, **Max MTP head** (fixed draft, 0-7; 3 is the tuned sweet spot — deeper collapses at large ctx), **thinking level** + **preserve thinking** checkbox, optional **mmproj** and **MoE** settings.
74
74
  3. **Load model**, watch the status card, inspect output via **Open terminal**.
75
75
  4. **Register in dsh** — the route (default `local-<alias>`) appears in the Models picker.
@@ -113,7 +113,9 @@ Launch flags are fixed to the validated daily config: full offload, `-b 2048 -ub
113
113
  | `POST /local-models/profiles/remove` | delete a profile |
114
114
  | `GET /local-models/settings` / `POST` | read / update plugin settings (`autostartRouter`, `autoUnloadMins`, `binPath`, `shortcuts`, `vramGb`) |
115
115
  | `POST /local-models/runtime/check` | `{binPath}` → resolve + `<bin> --version` (the Runtime card's Check) |
116
- | `POST /local-models/router/start` | build presets from profiles + start router |
116
+ | `POST /local-models/router/start` | build presets from profiles + start router (also syncs the compaction headroom patch — `{compactionSync:false}` opts out) |
117
+ | `GET /local-models/compaction[?profile=]` | per-model compaction threshold coverage |
118
+ | `POST /local-models/compaction/sync` | merge headroom overrides into the profile patch (`{profile,dryRun}`) |
117
119
  | `POST /local-models/router/unload` | unload one router model |
118
120
  | `POST /local-models/router/unload-all` | unload all router models |
119
121
  | `POST /local-models/register` | add the ready server as an `llm-pi-ai` route |
package/lib/client.js CHANGED
@@ -326,6 +326,11 @@ window.__ModuleLoader__.load({
326
326
  var err = errPair[0], setErr = errPair[1];
327
327
  var selPair = useStatePair(null);
328
328
  var selected = selPair[0], setSelected = selPair[1];
329
+ // The server stops a huge folder at 2000 entries. Root browsing
330
+ // makes /proc and /usr/lib reachable, so say so rather than let the
331
+ // list look complete when it is not.
332
+ var truncPair = useStatePair(false);
333
+ var truncated = truncPair[0], setTrunc = truncPair[1];
329
334
  // Remembered folders (persisted): jump buttons + toggle for the current dir.
330
335
  var LS_FOLDERS = "dsh-local-models:remembered-folders";
331
336
  var readFolders = function () {
@@ -355,6 +360,10 @@ window.__ModuleLoader__.load({
355
360
  // empty-looking folder reads as a broken picker.
356
361
  var dirMode = props.dirMode === true;
357
362
  var allFiles = props.allFiles === true || dirMode;
363
+ // The listing starts at the filesystem root, so the naive
364
+ // base + "/" + name would ask for "//etc" and paint a path bar
365
+ // reading "//etc", with every deeper click stacking a slash.
366
+ var into = function (base, name) { return (base === "/" ? "" : base) + "/" + name; };
358
367
  var load = useCallback(function (d) {
359
368
  setLoading(true);
360
369
  setErr(null);
@@ -364,6 +373,7 @@ window.__ModuleLoader__.load({
364
373
  setDir(body.path || d);
365
374
  setEntries(body.entries);
366
375
  setParent(body.parent != null ? body.parent : null);
376
+ setTrunc(Boolean(body.truncated));
367
377
  setSelected(null);
368
378
  } else {
369
379
  setErr((body && body.error) || "could not list directory");
@@ -387,6 +397,7 @@ window.__ModuleLoader__.load({
387
397
  "div",
388
398
  { style: style.row },
389
399
  h("button", { style: style.secondaryButton, onClick: function () { load(""); } }, "Home"),
400
+ h("button", { style: style.secondaryButton, title: "the filesystem root — mounted disks hang off it (/mnt, /media, /run/media)", onClick: function () { load("/"); } }, "Root"),
390
401
  (props.shortcuts || []).map(function (sc, i) {
391
402
  return h("button", { key: i, style: style.secondaryButton, onClick: function () { load(sc.path); }, title: sc.path }, sc.label);
392
403
  }),
@@ -403,7 +414,7 @@ window.__ModuleLoader__.load({
403
414
  { style: style.list },
404
415
  (entries || []).map(function (en, i) {
405
416
  if (en.type === "dir") {
406
- return h("li", { key: i, style: Object.assign({}, style.item, style.itemDir), onClick: function () { load(dir + "/" + en.name); } }, en.name + "/");
417
+ return h("li", { key: i, style: Object.assign({}, style.item, style.itemDir), onClick: function () { load(into(dir, en.name)); } }, en.name + "/");
407
418
  }
408
419
  var sel = selected === en.name;
409
420
  return h("li", {
@@ -414,13 +425,14 @@ window.__ModuleLoader__.load({
414
425
  }, en.name + (en.size != null ? " (" + fmtGiB(en.size) + ")" : ""));
415
426
  }),
416
427
  ),
428
+ truncated ? h("p", { style: style.warn }, "showing the first 2000 entries — open a subfolder to narrow it down") : null,
417
429
  h(
418
430
  "div",
419
431
  { style: style.row },
420
432
  h("button", {
421
433
  style: (dirMode ? dir : selected) ? style.button : Object.assign({}, style.button, style.disabled),
422
434
  disabled: dirMode ? !dir : !selected,
423
- onClick: function () { if (dirMode) { if (dir) props.onPick(dir); } else if (selected) props.onPick(dir + "/" + selected); },
435
+ onClick: function () { if (dirMode) { if (dir) props.onPick(dir); } else if (selected) props.onPick(into(dir, selected)); },
424
436
  }, dirMode ? "Use this folder" : "Use this file"),
425
437
  h("button", { style: style.secondaryButton, onClick: props.onClose }, "Cancel"),
426
438
  ),
@@ -746,6 +758,11 @@ window.__ModuleLoader__.load({
746
758
  var error = errPair[0], setError = errPair[1];
747
759
  var regPair = useStatePair(null);
748
760
  var registered = regPair[0], setRegistered = regPair[1];
761
+ // Last compaction-sync note (transient action feedback like
762
+ // `registered`: derived display, no localStorage — the poll's
763
+ // status.compaction is the durable source of truth).
764
+ var cmpPair = useStatePair(null);
765
+ var compactionNote = cmpPair[0], setCompactionNote = cmpPair[1];
749
766
  var profPair = useStatePair([]);
750
767
  var profiles = profPair[0], setProfiles = profPair[1];
751
768
  var profNamePair = useStatePair("");
@@ -1287,15 +1304,43 @@ window.__ModuleLoader__.load({
1287
1304
  });
1288
1305
  };
1289
1306
 
1307
+ var compactionSummary = function (sync) {
1308
+ if (!sync) return null;
1309
+ if (!sync.ok) return "Compaction sync failed: " + (sync.error || "unknown error");
1310
+ var parts = [];
1311
+ if (sync.added && sync.added.length > 0) parts.push("+" + sync.added.length + " (" + sync.added.join(", ") + ")");
1312
+ if (sync.updated && sync.updated.length > 0) parts.push("~" + sync.updated.length + " (" + sync.updated.join(", ") + ")");
1313
+ if (parts.length === 0) {
1314
+ return "Compaction headroom already covers " + (sync.policies ? sync.policies.length : 0) + " router models.";
1315
+ }
1316
+ return "Compaction sync (" + (sync.profile || "web") + "): " + parts.join(" ") + " — restart dsh web to apply.";
1317
+ };
1318
+
1290
1319
  var doStartRouter = function () {
1291
1320
  runAction("/local-models/router/start", {},
1292
1321
  function (res) {
1293
1322
  if (!res.ok) setError((res.body && res.body.error) || "router start failed");
1294
1323
  else pendingRouterRegRef.current = true;
1324
+ if (res.body && res.body.compactionSync) {
1325
+ var msg = compactionSummary(res.body.compactionSync);
1326
+ if (res.body.compactionSync.ok) setCompactionNote(msg);
1327
+ else setError(msg);
1328
+ }
1295
1329
  refresh();
1296
1330
  });
1297
1331
  };
1298
1332
 
1333
+ var doSyncCompaction = function () {
1334
+ setCompactionNote(null);
1335
+ post("/local-models/compaction/sync", { profile: "web" })
1336
+ .then(function (res) {
1337
+ if (!res.ok) setError((res.body && res.body.error) || "compaction sync failed");
1338
+ else setCompactionNote(compactionSummary(res.body));
1339
+ })
1340
+ .catch(function (e2) { setError(String(e2)); })
1341
+ .finally(refresh);
1342
+ };
1343
+
1299
1344
  var doUpdateSettings = function (patch) {
1300
1345
  runAction("/local-models/settings", patch,
1301
1346
  function (res) {
@@ -1817,6 +1862,35 @@ window.__ModuleLoader__.load({
1817
1862
  stale ? h("p", { style: style.warn }, "Timer changed — restart the server to apply it now.") : null,
1818
1863
  );
1819
1864
  })(),
1865
+ (function () {
1866
+ // Compaction headroom coverage for the daily profile: the
1867
+ // server reports it on every poll (status.compaction), so
1868
+ // this is display-only — no local edits to fight the poll.
1869
+ var c = status.compaction;
1870
+ if (!c) return null;
1871
+ var bad = !!(c.error || (c.missing && c.missing.length > 0) || c.stale > 0);
1872
+ var line;
1873
+ if (c.error) line = "Compaction headroom: unavailable (" + c.error + ").";
1874
+ else if (c.total === 0) line = "Compaction headroom: no saved profiles yet.";
1875
+ else if (!bad) line = "Compaction headroom: " + c.covered + "/" + c.total + " router models covered.";
1876
+ else {
1877
+ var bits = [];
1878
+ if (c.missing && c.missing.length > 0) bits.push("missing: " + c.missing.join(", "));
1879
+ if (c.stale > 0) bits.push(c.stale + " stale");
1880
+ line = "Compaction headroom: " + c.covered + "/" + c.total + " covered (" + bits.join("; ") + ") — sync, then restart dsh web.";
1881
+ }
1882
+ return h(
1883
+ "div",
1884
+ { style: { display: "flex", flexDirection: "column", gap: 4 } },
1885
+ h("p", { style: bad ? style.warn : style.small }, line),
1886
+ h(
1887
+ "div",
1888
+ { style: style.row },
1889
+ h("button", { style: style.secondaryButton, onClick: doSyncCompaction, disabled: busy }, "Sync compaction headroom"),
1890
+ ),
1891
+ compactionNote !== null ? h("p", { style: style.small }, compactionNote) : null,
1892
+ );
1893
+ })(),
1820
1894
  );
1821
1895
 
1822
1896
  // ---- render: page ----
package/lib/index.js CHANGED
@@ -12,6 +12,11 @@
12
12
  * GET /local-models/logs?offset=&max= → tail of llama-server.log (terminal)
13
13
  * POST /local-models/run → {path, ctx, mtp, kv} → spawn llama-server
14
14
  * POST /local-models/stop → stop the child (or reap the port)
15
+ * POST /local-models/router/start → write router presets, sync the
16
+ * compaction headroom patch, spawn
17
+ * GET /local-models/compaction[?profile=] → per-model threshold coverage
18
+ * POST /local-models/compaction/sync → merge headroom overrides into the
19
+ * profile's cordis.patch.yml
15
20
  * POST /local-models/settings → {autostartRouter, autoUnloadMins,
16
21
  * binPath, shortcuts} → settings.json
17
22
  * POST /local-models/runtime/check → {binPath} → resolve + `<bin> --version`
@@ -33,6 +38,7 @@ import {
33
38
  existsSync,
34
39
  mkdirSync,
35
40
  openSync,
41
+ copyFileSync,
36
42
  readFileSync,
37
43
  readSync,
38
44
  readdirSync,
@@ -43,6 +49,7 @@ import {
43
49
  } from "node:fs";
44
50
  import { basename, join, dirname, resolve, sep, delimiter, isAbsolute } from "node:path";
45
51
  import { fileURLToPath } from "node:url";
52
+ import { createRequire } from "node:module";
46
53
  import { homedir } from "node:os";
47
54
  import process from "node:process";
48
55
 
@@ -180,6 +187,364 @@ export function compactionBudgetFor(contextWindow, opts = {}) {
180
187
  recommendedHeadroom,
181
188
  };
182
189
  }
190
+ /** Provider key the router registers under: every router modelPolicy targets
191
+ * this provider with the profile id as the model. Single-mode registrations
192
+ * use per-model `local-<alias>` routes instead and are NOT auto-synced
193
+ * (their route only exists while that server runs) — see syncCompactionPatch. */
194
+ export const COMPACTION_ROUTER_PROVIDER = "local-router";
195
+ /** `provider`/`model` charset shared by routes and patch policies. */
196
+ const POLICY_ID_RE = /^[a-zA-Z0-9_-]+$/;
197
+
198
+ /** One router profile's compaction policy: the headroom override that lands
199
+ * its proactive threshold near 70% of the window instead of the core
200
+ * default's early trigger (32K on a 128K window = the compaction loop).
201
+ * Pure; sorted by model so generated YAML is deterministic. */
202
+ export function desiredCompactionPolicies(profiles) {
203
+ const list = Array.isArray(profiles) ? profiles : [];
204
+ const out = [];
205
+ for (const p of list) {
206
+ if (!p || typeof p !== "object") continue;
207
+ const model = typeof p.id === "string" && p.id
208
+ ? p.id
209
+ : slugProfile(typeof p.name === "string" ? p.name : "");
210
+ if (!POLICY_ID_RE.test(model)) continue;
211
+ const ctx = Number.isInteger(p.ctx) && p.ctx > 0 ? p.ctx : 8192;
212
+ out.push({
213
+ provider: COMPACTION_ROUTER_PROVIDER,
214
+ model,
215
+ headroomTokens: compactionBudgetFor(ctx).recommendedHeadroom,
216
+ });
217
+ }
218
+ out.sort((a, b) => a.model < b.model ? -1 : a.model > b.model ? 1 : 0);
219
+ return out;
220
+ }
221
+
222
+ /** Per-profile compaction plan: default-threshold math next to the on-disk
223
+ * override state, so the tab can name the models that would still loop.
224
+ * `existing` is readModelPolicies() output (null = patch state unknown).
225
+ * Pure. */
226
+ export function compactionPlan(profiles, existing) {
227
+ const desired = desiredCompactionPolicies(profiles);
228
+ const have = new Map();
229
+ for (const e of Array.isArray(existing) ? existing : []) {
230
+ if (e && typeof e === "object" && POLICY_ID_RE.test(e.model ?? "")) {
231
+ have.set(policyKey(e.provider, e.model), e.headroomTokens);
232
+ }
233
+ }
234
+ return desired.map((d) => {
235
+ const p = (Array.isArray(profiles) ? profiles : []).find((q) =>
236
+ q && typeof q === "object" && ((q.id || slugProfile(q.name ?? "")) === d.model));
237
+ const ctx = p && Number.isInteger(p.ctx) && p.ctx > 0 ? p.ctx : 8192;
238
+ const budget = compactionBudgetFor(ctx);
239
+ const tuned = compactionBudgetFor(ctx, { headroomTokens: d.headroomTokens });
240
+ const coveredHeadroom = have.has(policyKey(d.provider, d.model))
241
+ ? have.get(policyKey(d.provider, d.model))
242
+ : null;
243
+ return {
244
+ provider: d.provider,
245
+ model: d.model,
246
+ ctx,
247
+ maxTokens: budget.maxTokens,
248
+ thresholdTokens: budget.thresholdTokens,
249
+ tunedThresholdTokens: tuned.thresholdTokens,
250
+ recommendedHeadroom: d.headroomTokens,
251
+ coveredHeadroom,
252
+ status: coveredHeadroom === null ? "missing" : coveredHeadroom === d.headroomTokens ? "covered" : "stale",
253
+ };
254
+ });
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // preset-standard patch surgery (pure, exported for tests).
259
+ //
260
+ // The live compaction engines run inside the agent-preset scopes, so the
261
+ // headroom override must ride a wholesale `config` on the `- id:
262
+ // preset-standard` row of the profile's cordis.patch.yml — a patch on the
263
+ // top-level (disabled) compaction-basic row is dead config. This surgery
264
+ // touches ONLY the nested modelPolicies list and preserves everything else
265
+ // (including the !!js expressions a YAML parser would choke on, which is why
266
+ // this is line-based instead of parse-and-reserialize). Anything it does not
267
+ // recognize is a thrown Error, never a half-written file.
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /** Map key for one policy target. \u0000 separates the two ids so no two
271
+ * distinct provider/model pairs share a key. */
272
+ function policyKey(provider, model) {
273
+ return provider + "\u0000" + model;
274
+ }
275
+
276
+ function patchLines(text) {
277
+ return String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
278
+ }
279
+
280
+ function indentOf(line) {
281
+ const m = /^ */.exec(line);
282
+ return m[0].length;
283
+ }
284
+
285
+ function isBlankOrComment(line) {
286
+ const t = line.trim();
287
+ return t === "" || t.startsWith("#");
288
+ }
289
+
290
+ /** End of the block starting at `start` (indent `base`): first later line
291
+ * that is neither blank nor a comment and dedents to `base` or less. */
292
+ function blockEnd(lines, start, base) {
293
+ for (let i = start + 1; i < lines.length; i++) {
294
+ if (isBlankOrComment(lines[i])) continue;
295
+ if (indentOf(lines[i]) <= base) return i;
296
+ }
297
+ return lines.length;
298
+ }
299
+
300
+ function listItemAt(lines, i) {
301
+ const m = /^(\s*)- id: (\S+)\s*(#.*)?$/.exec(lines[i]);
302
+ return m ? { indent: m[1].length, id: m[2] } : null;
303
+ }
304
+
305
+ function findItem(lines, start, end, base, wantId) {
306
+ for (let i = start; i < end; i++) {
307
+ if (isBlankOrComment(lines[i])) continue;
308
+ const item = listItemAt(lines, i);
309
+ if (item && item.indent === base && item.id === wantId) return i;
310
+ }
311
+ return -1;
312
+ }
313
+
314
+ /** Locate the nested compaction-basic item inside a preset-standard entry
315
+ * spanning [entryStart, entryEnd). Returns {item, nameLine, configLine} line
316
+ * indexes (configLine -1 when absent) or throws on unrecognized shape. */
317
+ function locateNestedCompactionBasic(lines, entryStart, entryEnd) {
318
+ const entryIndent = indentOf(lines[entryStart]);
319
+ const group = findItem(lines, entryStart, entryEnd, entryIndent + 6, "compaction");
320
+ if (group < 0) throw new Error("preset-standard has no nested `- id: compaction` group — re-generate it from the installed preset file");
321
+ const groupEnd = blockEnd(lines, group, indentOf(lines[group]));
322
+ let configLine = -1;
323
+ for (let i = group + 1; i < groupEnd; i++) {
324
+ if (isBlankOrComment(lines[i])) continue;
325
+ const m = /^(\s*)config:\s*(#.*)?$/.exec(lines[i]);
326
+ if (m && m[1].length === indentOf(lines[group]) + 2) { configLine = i; break; }
327
+ if (indentOf(lines[i]) <= indentOf(lines[group])) break;
328
+ }
329
+ if (configLine < 0) throw new Error("nested compaction group has no `config:` list — re-generate preset-standard from the installed preset file");
330
+ const item = findItem(lines, configLine, groupEnd, indentOf(lines[configLine]) + 2, "compaction-basic");
331
+ if (item < 0) throw new Error("nested compaction group has no `- id: compaction-basic` — re-generate preset-standard from the installed preset file");
332
+ const itemEnd = blockEnd(lines, item, indentOf(lines[item]));
333
+ let nameLine = -1, cfgLine = -1;
334
+ for (let i = item + 1; i < itemEnd; i++) {
335
+ if (isBlankOrComment(lines[i])) continue;
336
+ if (indentOf(lines[i]) !== indentOf(lines[item]) + 2) {
337
+ if (indentOf(lines[i]) <= indentOf(lines[item])) break;
338
+ continue;
339
+ }
340
+ if (/^(\s*)name:\s/.test(lines[i]) && nameLine < 0) nameLine = i;
341
+ const cm = /^(\s*)config:\s*(#.*)?$/.exec(lines[i]);
342
+ if (cm && cfgLine < 0) cfgLine = i;
343
+ else if (/^(\s*)config:\s*\S/.test(lines[i])) {
344
+ throw new Error("nested compaction-basic has a non-map `config:` — edit it by hand, refusing to guess");
345
+ }
346
+ }
347
+ return { item, itemEnd, nameLine, configLine: cfgLine };
348
+ }
349
+
350
+ function pad(n) {
351
+ return " ".repeat(n);
352
+ }
353
+
354
+ /** Render the modelPolicies value (the `modelPolicies:` line plus its items)
355
+ * at base indent `base`. Policies carry only headroomTokens: under the core
356
+ * defaults the per-request reservation still comes from the adapter, and the
357
+ * existing on-disk entries this merges with carry the same single key. */
358
+ function renderModelPolicies(base, policies) {
359
+ const out = [pad(base) + "modelPolicies:"];
360
+ if (policies.length === 0) {
361
+ out.push(pad(base + 2) + "[]");
362
+ return out;
363
+ }
364
+ for (const p of policies) {
365
+ out.push(pad(base + 2) + "- provider: " + p.provider);
366
+ out.push(pad(base + 4) + "model: " + p.model);
367
+ out.push(pad(base + 4) + "headroomTokens: " + p.headroomTokens);
368
+ }
369
+ return out;
370
+ }
371
+
372
+ function checkPolicy(p) {
373
+ if (!p || typeof p !== "object") throw new Error("compaction policy must be an object");
374
+ if (p.provider !== COMPACTION_ROUTER_PROVIDER || !POLICY_ID_RE.test(p.model ?? "")) {
375
+ throw new Error("bad compaction policy target: " + JSON.stringify(p));
376
+ }
377
+ if (!Number.isInteger(p.headroomTokens) || p.headroomTokens < 0) {
378
+ throw new Error("bad headroomTokens for " + p.model + ": " + String(p.headroomTokens));
379
+ }
380
+ }
381
+
382
+ /** Read the nested modelPolicies list from a profile patch text. Returns the
383
+ * list, [] when the engine entry exists but carries no policies, or null when
384
+ * there is no usable preset-standard entry at all (nothing to merge with).
385
+ * Lenient on purpose: diagnostics must never throw. Throws only on tabs. */
386
+ export function readModelPolicies(patchText) {
387
+ if (typeof patchText !== "string" || patchText.includes(" ")) return null;
388
+ const lines = patchLines(patchText);
389
+ let entry = -1;
390
+ for (let i = 0; i < lines.length; i++) {
391
+ if (isBlankOrComment(lines[i])) continue;
392
+ const item = listItemAt(lines, i);
393
+ if (item && item.id === "preset-standard") { entry = i; break; }
394
+ }
395
+ if (entry < 0) return null;
396
+ let loc;
397
+ try {
398
+ loc = locateNestedCompactionBasic(lines, entry, blockEnd(lines, entry, indentOf(lines[entry])));
399
+ } catch {
400
+ return null;
401
+ }
402
+ if (loc.configLine < 0) return [];
403
+ const cfgIndent = indentOf(lines[loc.configLine]);
404
+ let mpLine = -1, inlineEmpty = false;
405
+ for (let i = loc.configLine + 1; i < loc.itemEnd; i++) {
406
+ if (isBlankOrComment(lines[i])) continue;
407
+ if (indentOf(lines[i]) < cfgIndent + 2) break;
408
+ if (indentOf(lines[i]) !== cfgIndent + 2) continue;
409
+ const m = /^(\s*)modelPolicies:\s*(#.*)?$/.exec(lines[i]);
410
+ if (m) { mpLine = i; break; }
411
+ const e = /^(\s*)modelPolicies:\s*\[\]\s*(#.*)?$/.exec(lines[i]);
412
+ if (e) { inlineEmpty = true; mpLine = i; break; }
413
+ if (/^(\s*)modelPolicies:\s*\S/.test(lines[i])) return null;
414
+ }
415
+ if (mpLine < 0) return [];
416
+ if (inlineEmpty) return [];
417
+ const mpIndent = indentOf(lines[mpLine]);
418
+ const mpEnd = blockEnd(lines, mpLine, mpIndent);
419
+ const out = [];
420
+ let cur = null;
421
+ for (let i = mpLine + 1; i < mpEnd; i++) {
422
+ if (isBlankOrComment(lines[i])) continue;
423
+ const pm = /^(\s*)- provider:\s*(\S+)\s*$/.exec(lines[i]);
424
+ if (pm && pm[1].length === mpIndent + 2) {
425
+ cur = { provider: pm[2], model: null, headroomTokens: null };
426
+ out.push(cur);
427
+ continue;
428
+ }
429
+ if (cur && indentOf(lines[i]) === mpIndent + 4) {
430
+ const mm = /^\s*model:\s*(\S+)\s*$/.exec(lines[i]);
431
+ if (mm) cur.model = mm[1];
432
+ const hm = /^\s*headroomTokens:\s*(\S+)\s*$/.exec(lines[i]);
433
+ if (hm) cur.headroomTokens = Number(hm[1]);
434
+ }
435
+ }
436
+ return out.filter((e) => typeof e.provider === "string" && typeof e.model === "string");
437
+ }
438
+
439
+ /** Merge desired policies into a profile patch text (or build the
440
+ * preset-standard entry from the installed preset text when the patch has
441
+ * none). Returns {text, added, updated, unchanged, created}. Pure: no
442
+ * filesystem here, so every shape is unit-testable. Throws with an
443
+ * actionable message on anything unrecognized. */
444
+ export function syncPresetStandardPatch(userPatchText, installedPresetText, policies) {
445
+ const list = Array.isArray(policies) ? policies : [];
446
+ for (const p of list) checkPolicy(p);
447
+ const desired = new Map(list.map((p) => [policyKey(p.provider, p.model), p]));
448
+ let lines, entry, created = false;
449
+ if (userPatchText === null || userPatchText === undefined) {
450
+ if (typeof installedPresetText !== "string" || !installedPresetText.trim()) {
451
+ throw new Error("no preset-standard entry and the installed preset file was not found — create the entry once from dsh-web-app/presets/standard.patch.yml, then sync maintains it");
452
+ }
453
+ if (installedPresetText.includes(" ")) throw new Error("installed preset uses tabs — refusing to reindent");
454
+ const inst = patchLines(installedPresetText);
455
+ let instEntry = -1;
456
+ for (let i = 0; i < inst.length; i++) {
457
+ if (isBlankOrComment(inst[i])) continue;
458
+ const item = listItemAt(inst, i);
459
+ if (item && item.id === "preset-standard") { instEntry = i; break; }
460
+ }
461
+ if (instEntry < 0) throw new Error("installed preset has no preset-standard entry — refusing to invent one");
462
+ const instBase = indentOf(inst[instEntry]);
463
+ const instEnd = blockEnd(inst, instEntry, instBase);
464
+ lines = inst.slice(instEntry, instEnd).map((l) => {
465
+ if (isBlankOrComment(l) || l.trim() === "") return l.trim() === "" ? "" : l;
466
+ if (indentOf(l) < instBase) throw new Error("installed preset dedent failed — refusing to guess");
467
+ return l.slice(instBase);
468
+ });
469
+ entry = 0;
470
+ created = true;
471
+ } else {
472
+ if (typeof userPatchText !== "string" || userPatchText.includes(" ")) {
473
+ throw new Error("profile patch uses tabs — edit the modelPolicies by hand");
474
+ }
475
+ lines = patchLines(userPatchText);
476
+ entry = -1;
477
+ for (let i = 0; i < lines.length; i++) {
478
+ if (isBlankOrComment(lines[i])) continue;
479
+ const item = listItemAt(lines, i);
480
+ if (item && item.id === "preset-standard") { entry = i; break; }
481
+ }
482
+ if (entry < 0) {
483
+ // Same build as the null-patch path; recursion keeps one code path.
484
+ const built = syncPresetStandardPatch(null, installedPresetText, policies);
485
+ const sep = lines.length > 0 && lines[lines.length - 1].trim() !== "" ? [""] : [];
486
+ const header = [
487
+ "# Compaction for local models: auto-synced modelPolicies (Start",
488
+ "# Router keeps this list covering every saved router profile — restart",
489
+ "# dsh web after a sync. Re-generate after any dsh upgrade touching",
490
+ "# dsh-web-app/presets/standard.patch.yml.",
491
+ ];
492
+ return {
493
+ text: [...lines, ...sep, ...header, ...patchLines(built.text)].join("\n"),
494
+ added: built.added,
495
+ updated: built.updated,
496
+ unchanged: built.unchanged,
497
+ created: true,
498
+ };
499
+ }
500
+ }
501
+ const entryEnd = blockEnd(lines, entry, indentOf(lines[entry]));
502
+ const loc = locateNestedCompactionBasic(lines, entry, entryEnd);
503
+ const existing = readModelPolicies(lines.join("\n")) ?? [];
504
+ const have = new Map(existing.map((e) => [policyKey(e.provider, e.model), e.headroomTokens]));
505
+ const merged = new Map();
506
+ for (const e of existing) merged.set(policyKey(e.provider, e.model), { provider: e.provider, model: e.model, headroomTokens: e.headroomTokens });
507
+ const added = [], updated = [], unchanged = [];
508
+ for (const [key, p] of desired) {
509
+ if (!have.has(key)) { added.push(p.model); merged.set(key, { ...p }); }
510
+ else if (have.get(key) !== p.headroomTokens) { updated.push(p.model); merged.set(key, { ...p }); }
511
+ else unchanged.push(p.model);
512
+ }
513
+ const ordered = [...merged.values()].sort((a, b) =>
514
+ a.provider !== b.provider ? (a.provider < b.provider ? -1 : 1) : a.model < b.model ? -1 : a.model > b.model ? 1 : 0);
515
+ if (loc.configLine < 0) {
516
+ const anchor = loc.nameLine >= 0 ? loc.nameLine : loc.item;
517
+ const base = indentOf(lines[loc.item]) + 2;
518
+ const insert = [pad(base) + "config:", ...renderModelPolicies(base + 2, ordered)];
519
+ lines.splice(anchor + 1, 0, ...insert);
520
+ } else {
521
+ const cfgIndent = indentOf(lines[loc.configLine]);
522
+ let mpLine = -1, mpInline = false;
523
+ for (let i = loc.configLine + 1; i < loc.itemEnd; i++) {
524
+ if (isBlankOrComment(lines[i])) continue;
525
+ if (indentOf(lines[i]) < cfgIndent + 2) break;
526
+ if (indentOf(lines[i]) !== cfgIndent + 2) continue;
527
+ if (/^(\s*)modelPolicies:\s*(#.*)?$/.test(lines[i])) { mpLine = i; break; }
528
+ const e = /^(\s*)modelPolicies:\s*\[\]\s*(#.*)?$/.exec(lines[i]);
529
+ if (e) { mpLine = i; mpInline = true; break; }
530
+ if (/^(\s*)modelPolicies:\s*\S/.test(lines[i])) {
531
+ throw new Error("nested modelPolicies has an inline value — edit it by hand, refusing to guess");
532
+ }
533
+ }
534
+ const rendered = renderModelPolicies(cfgIndent + 2, ordered);
535
+ if (mpLine < 0) {
536
+ lines.splice(loc.configLine + 1, 0, ...rendered);
537
+ } else if (mpInline) {
538
+ lines.splice(mpLine, 1, ...rendered);
539
+ } else {
540
+ const mpEnd = blockEnd(lines, mpLine, indentOf(lines[mpLine]));
541
+ lines.splice(mpLine, mpEnd - mpLine, ...rendered);
542
+ }
543
+ }
544
+ let text = lines.join("\n");
545
+ if (!text.endsWith("\n")) text += "\n";
546
+ return { text, added, updated, unchanged, created };
547
+ }
183
548
  /** Fixed-MTP ceiling. The tab offers 0-7 and the API clamps here: upstream
184
549
  * accepts any `--spec-draft-n-max` and clamps the effective depth to the
185
550
  * model's own nextn depth at load. Depth 3 is the measured 16 GiB sweet spot
@@ -1509,6 +1874,14 @@ function createManager() {
1509
1874
  s.routerMax = ROUTER_MAX;
1510
1875
  s.autostartRouter = readSettings().autostartRouter;
1511
1876
  s.autoUnloadMins = readSettings().autoUnloadMins;
1877
+ // Compaction coverage for the daily profile: which saved router models
1878
+ // have a headroom override and which would still loop. Read-only here;
1879
+ // the sync itself runs on router start (and on demand below).
1880
+ try {
1881
+ s.compaction = readCompactionState("web", cachedProfiles());
1882
+ } catch {
1883
+ s.compaction = { profile: "web", error: "unavailable", total: 0, covered: 0, stale: 0, missing: [] };
1884
+ }
1512
1885
  s.sleepIdleSecs = state.sleepIdleSecs;
1513
1886
  s.logSize = 0;
1514
1887
  try { s.logSize = statSync(LOG_PATH).size; } catch { /* no log yet */ }
@@ -1566,19 +1939,36 @@ function sendJson(res, code, body) {
1566
1939
  res.end(payload);
1567
1940
  }
1568
1941
 
1942
+ /** The filesystem root, in the form resolve() produces (posix "/"). Every
1943
+ * absolute path is under it, so isPathAllowed() treats it as containing the
1944
+ * whole tree — the one root that makes "Up" from any folder land somewhere
1945
+ * real. */
1946
+ const FILESYSTEM_ROOT = resolve(sep);
1947
+
1948
+ /** Single wording for a path the file endpoints refuse. With the root in
1949
+ * allowedRoots() this is unreachable for any absolute path; it stays as the
1950
+ * one place the wording lives, so narrowing the roots again has something to
1951
+ * narrow. */
1952
+ const NOT_READABLE = "this tab may not read that path";
1953
+
1569
1954
  /** Roots the file endpoints (browse/gguf-meta/run, and profile saves) may
1570
- * touch: the user's home, the browser shortcut dirs (Runtime card, else
1571
- * LOCAL_MODELS_SHORTCUTS), the directories of the models already declared in
1572
- * saved profiles — a saved profile must always stay loadable, whatever the
1573
- * shortcut list says — and the llama.cpp dir when it really holds a
1574
- * llama-server. Resolves lexically and requires containment so inputs can't
1575
- * wander the filesystem.
1955
+ * touch. The list LEADS WITH THE FILESYSTEM ROOT: the picker walks up to "/"
1956
+ * on purpose, so a model can live on a mounted disk the user never declared
1957
+ * here (/mnt/data, /media/<user>/<label>, a network export) and reaching it
1958
+ * must not depend on having typed its path into Model folders first.
1959
+ *
1960
+ * Everything below the root is therefore readable, which makes the remaining
1961
+ * entries — home, the shortcut dirs (Runtime card, else
1962
+ * LOCAL_MODELS_SHORTCUTS), the model dirs of saved profiles, the llama.cpp
1963
+ * dir — jump buttons and back-compat rather than a confinement. They stay
1964
+ * because the picker offers them, and because a saved profile must stay
1965
+ * loadable whatever else happens.
1576
1966
  *
1577
- * Nothing here is taken on faith: a path only widens the roots once it is a
1578
- * directory the user configured, a profile that already exists, or a binary
1579
- * that passes isLlamaServerPath(). */
1967
+ * The guard that is still load-bearing is isLlamaServerPath(): it, not this
1968
+ * list, decides what may be spawned (POST /settings, /runtime/check and
1969
+ * requireBin()), so nothing a browse opens can turn into the executable. */
1580
1970
  function allowedRoots() {
1581
- const roots = [homedir(), ...shortcutDirs().map((s) => s.path)];
1971
+ const roots = [FILESYSTEM_ROOT, homedir(), ...shortcutDirs().map((s) => s.path)];
1582
1972
  try {
1583
1973
  for (const profile of cachedProfiles()) {
1584
1974
  for (const declared of [profile?.modelPath, profile?.mmprojPath]) {
@@ -1616,9 +2006,10 @@ function allowedRoots() {
1616
2006
  return roots;
1617
2007
  }
1618
2008
 
1619
- /** A path we are willing to register as a browsable root: absolute, no ".."
1620
- * segment, and not a filesystem root (which would list everything). Exported
1621
- * for tests. */
2009
+ /** A path we are willing to show as a picker button: absolute, no ".."
2010
+ * segment, and naming one folder rather than the whole tree. The root is
2011
+ * browsable on its own (there is a Root button and an Up that reaches it), so
2012
+ * this is button hygiene, not confinement. Exported for tests. */
1622
2013
  export function isSafeRootPath(p) {
1623
2014
  if (typeof p !== "string" || !p.trim()) return false;
1624
2015
  const raw = p.trim();
@@ -1644,7 +2035,9 @@ function isPathAllowed(p) {
1644
2035
  } catch {
1645
2036
  return false;
1646
2037
  }
1647
- return abs === r || abs.startsWith(r + sep);
2038
+ // The root needs its own clause: the prefix test below would only ever
2039
+ // match "/" itself (every other path starts "/…", not "//").
2040
+ return abs === r || r === FILESYSTEM_ROOT || abs.startsWith(r + sep);
1648
2041
  });
1649
2042
  }
1650
2043
 
@@ -1793,6 +2186,199 @@ function writeJsonAtomic(file, value) {
1793
2186
  renameSync(tmp, file);
1794
2187
  }
1795
2188
 
2189
+ function writeTextAtomic(file, text) {
2190
+ mkdirSync(dirname(file), { recursive: true });
2191
+ const tmp = file + ".tmp-" + process.pid;
2192
+ writeFileSync(tmp, text, "utf8");
2193
+ renameSync(tmp, file);
2194
+ }
2195
+
2196
+ // ---------------------------------------------------------------------------
2197
+ // Compaction patch sync (filesystem layer over the pure surgery above).
2198
+ //
2199
+ // The Start Router button runs this automatically so a newly saved profile
2200
+ // can never boot into the compaction loop: every saved router profile gets
2201
+ // a modelPolicies headroom override in the profile's cordis.patch.yml,
2202
+ // computed from its own ctx. Edits are idempotent (re-running changes
2203
+ // nothing), merge-only (entries for removed profiles are kept, never
2204
+ // deleted), and always backed up first. Composition applies at dsh boot, so
2205
+ // a sync reports needRestart — the running process keeps the old thresholds
2206
+ // until then.
2207
+ // ---------------------------------------------------------------------------
2208
+
2209
+ /** Profile patch file for a dsh profile name. The name charset is the guard:
2210
+ * anything else (including ".." or "/") is rejected instead of joined. */
2211
+ export function profilePatchFile(profileName) {
2212
+ if (typeof profileName !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(profileName)) {
2213
+ throw new Error("unknown dsh profile: " + String(profileName));
2214
+ }
2215
+ return join(dshHome(), "profiles", profileName, "cordis.patch.yml");
2216
+ }
2217
+
2218
+ /** dsh profile names that have a patch file. Best-effort: an unreadable
2219
+ * profiles dir is "no profiles", never a throw (status poll reads this). */
2220
+ export function listProfileNames(opts = {}) {
2221
+ const root = join(dshHome(), "profiles");
2222
+ const listDir = opts.listDir ?? readdirSync;
2223
+ let entries;
2224
+ try {
2225
+ entries = listDir(root);
2226
+ } catch {
2227
+ return [];
2228
+ }
2229
+ return entries.filter((n) => /^[a-zA-Z0-9_-]{1,64}$/.test(n));
2230
+ }
2231
+
2232
+ /** Locate the installed dsh-web-app preset this machine booted from, without
2233
+ * hardcoding any one machine's layout. Sources tried in order: an explicitly
2234
+ * injected path (tests), the resolvable package, then the npm-global
2235
+ * `<prefix>/lib/node_modules/...` layout derived from the `dsh` binary on
2236
+ * PATH and the usual global roots. Returns {path, source} or null. Only
2237
+ * needed when the profile patch has no preset-standard entry yet (merge-only
2238
+ * syncs never touch the installed file). */
2239
+ export function locateInstalledStandardPreset(opts = {}) {
2240
+ const isFile = opts.isFile ?? ((p) => {
2241
+ try {
2242
+ return statSync(p).isFile();
2243
+ } catch {
2244
+ return false;
2245
+ }
2246
+ });
2247
+ const exists = opts.exists ?? isFile;
2248
+ const tail = join("@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml");
2249
+ const tryPath = (p, source) => {
2250
+ if (typeof p === "string" && p && exists(p)) return { path: p, source };
2251
+ return null;
2252
+ };
2253
+ if (typeof opts.resolved === "string" && opts.resolved) {
2254
+ return tryPath(opts.resolved, "resolve");
2255
+ }
2256
+ try {
2257
+ const hit = tryPath(
2258
+ createRequire(import.meta.url).resolve("@deepseek-ai/dsh-web-app/presets/standard.patch.yml"),
2259
+ "resolve",
2260
+ );
2261
+ if (hit) return hit;
2262
+ } catch { /* not importable from here — try the filesystem layouts */ }
2263
+ const roots = [];
2264
+ const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
2265
+ for (const dir of String(pathEnv).split(delimiter)) {
2266
+ const t = dir.trim();
2267
+ if (!t) continue;
2268
+ try {
2269
+ if (isFile(join(t, "dsh"))) roots.push({ dir: join(dirname(t), "lib", "node_modules"), source: "npm-global" });
2270
+ } catch { /* no dsh here */ }
2271
+ }
2272
+ const home = opts.home ?? homedir();
2273
+ if (home) roots.push({ dir: join(home, ".npm-global", "lib", "node_modules"), source: "npm-global" });
2274
+ for (const sys of opts.systemRoots ?? ["/usr/lib/node_modules", "/usr/local/lib/node_modules"]) {
2275
+ roots.push({ dir: sys, source: "system" });
2276
+ }
2277
+ const seen = new Set();
2278
+ for (const r of roots) {
2279
+ for (const base of [join(r.dir, "@deepseek-ai", "dsh"), join(r.dir, "dsh")]) {
2280
+ const candidate = join(base, "node_modules", tail);
2281
+ if (seen.has(candidate)) continue;
2282
+ seen.add(candidate);
2283
+ const hit = tryPath(candidate, r.source);
2284
+ if (hit) return hit;
2285
+ }
2286
+ }
2287
+ return null;
2288
+ }
2289
+
2290
+ /** On-disk compaction state for one profile: whether its patch covers the
2291
+ * saved router profiles. Never throws — diagnostics must not break the poll. */
2292
+ export function readCompactionState(profileName, profiles) {
2293
+ try {
2294
+ const patchFile = profilePatchFile(profileName);
2295
+ let text = null;
2296
+ try {
2297
+ text = readFileSync(patchFile, "utf8");
2298
+ } catch (err) {
2299
+ if (err?.code !== "ENOENT") throw err;
2300
+ }
2301
+ const existing = text === null ? null : readModelPolicies(text);
2302
+ const plan = compactionPlan(profiles, existing);
2303
+ return {
2304
+ profile: profileName,
2305
+ patchFile,
2306
+ patchExists: text !== null,
2307
+ // null = no usable preset-standard entry (unknown coverage);
2308
+ // [] = entry present, no policies yet.
2309
+ policies: existing,
2310
+ plan,
2311
+ covered: plan.filter((p) => p.status === "covered").length,
2312
+ stale: plan.filter((p) => p.status === "stale").length,
2313
+ missing: plan.filter((p) => p.status === "missing").map((p) => p.model),
2314
+ total: plan.length,
2315
+ };
2316
+ } catch (err) {
2317
+ return { profile: profileName, error: err?.message ?? String(err), total: 0, covered: 0, stale: 0, missing: [] };
2318
+ }
2319
+ }
2320
+
2321
+ /** Merge the desired policies into one profile's patch file. Idempotent,
2322
+ * backup-first, atomic. Throws on unrecognized shape (never half-writes).
2323
+ * `profiles` defaults to the saved store; `allowCreate: false` (the
2324
+ * autostart sweep) skips patches without a preset-standard entry instead of
2325
+ * building one; `dryRun` computes without writing. */
2326
+ export function syncCompactionPatch(profileName, opts = {}) {
2327
+ const file = profilePatchFile(profileName);
2328
+ const profiles = opts.profiles !== undefined ? opts.profiles : readProfiles();
2329
+ const allowCreate = opts.allowCreate !== false;
2330
+ const dryRun = opts.dryRun === true;
2331
+ const policies = desiredCompactionPolicies(profiles);
2332
+ let oldText = null;
2333
+ try {
2334
+ oldText = readFileSync(file, "utf8");
2335
+ } catch (err) {
2336
+ if (err?.code !== "ENOENT") throw new Error("cannot read " + file + ": " + (err?.message ?? String(err)));
2337
+ }
2338
+ const hasEntry = typeof oldText === "string" && readModelPolicies(oldText) !== null;
2339
+ let installedText = null;
2340
+ if (oldText === null || !hasEntry) {
2341
+ if (!allowCreate) {
2342
+ return {
2343
+ ok: true, profile: profileName, patchFile: file, created: false, changed: false,
2344
+ backup: null, added: [], updated: [], unchanged: [], skipped: "no preset-standard entry",
2345
+ needRestart: false, policies,
2346
+ };
2347
+ }
2348
+ const found = locateInstalledStandardPreset(opts.locate ?? {});
2349
+ if (!found) {
2350
+ throw new Error("no preset-standard entry in " + file + " and the installed preset file was not found — create the entry once from dsh-web-app/presets/standard.patch.yml, then sync maintains it");
2351
+ }
2352
+ installedText = readFileSync(found.path, "utf8");
2353
+ }
2354
+ const synced = syncPresetStandardPatch(oldText, installedText, policies);
2355
+ if (synced.text === (oldText ?? "")) {
2356
+ return {
2357
+ ok: true, profile: profileName, patchFile: file, created: synced.created, changed: false,
2358
+ backup: null, added: [], updated: [], unchanged: synced.unchanged, needRestart: false, policies,
2359
+ };
2360
+ }
2361
+ if (dryRun) {
2362
+ return {
2363
+ ok: true, profile: profileName, patchFile: file, created: synced.created, changed: true,
2364
+ backup: null, added: synced.added, updated: synced.updated, unchanged: synced.unchanged,
2365
+ needRestart: true, policies, text: synced.text,
2366
+ };
2367
+ }
2368
+ let backup = null;
2369
+ if (oldText !== null) {
2370
+ backup = file + ".bak-" + new Date().toISOString().replace(/[:.]/g, "-");
2371
+ mkdirSync(dirname(file), { recursive: true });
2372
+ copyFileSync(file, backup);
2373
+ }
2374
+ writeTextAtomic(file, synced.text);
2375
+ return {
2376
+ ok: true, profile: profileName, patchFile: file, created: synced.created, changed: true,
2377
+ backup, added: synced.added, updated: synced.updated, unchanged: synced.unchanged,
2378
+ needRestart: true, policies,
2379
+ };
2380
+ }
2381
+
1796
2382
  function writeProfiles(list) {
1797
2383
  writeJsonAtomic(profilesFile(), list);
1798
2384
  presetsCache = list;
@@ -1868,12 +2454,12 @@ function cachedProfiles() {
1868
2454
  export function normalizeProfileConfig(raw) {
1869
2455
  const modelPath = typeof raw?.modelPath === "string" && raw.modelPath ? raw.modelPath : "";
1870
2456
  if (!modelPath || !existsSync(modelPath)) throw new Error("profile needs a modelPath that exists on disk");
1871
- if (!isPathAllowed(modelPath)) throw new Error("modelPath is outside the folders this tab may read: " + modelPath + " — add its folder under Model folders in the Runtime card first");
2457
+ if (!isPathAllowed(modelPath)) throw new Error("modelPath " + NOT_READABLE + ": " + modelPath);
1872
2458
  const ctx = Number.isInteger(raw?.ctx) && raw.ctx > 0 ? raw.ctx : 8192;
1873
2459
  const mtpHeads = Number.isInteger(raw?.mtpHeads) && raw.mtpHeads >= 0 ? Math.min(raw.mtpHeads, MTP_MAX) : 0;
1874
2460
  const mmprojPath = typeof raw?.mmprojPath === "string" && raw.mmprojPath ? raw.mmprojPath : null;
1875
2461
  if (mmprojPath && !existsSync(mmprojPath)) throw new Error("mmprojPath does not exist: " + mmprojPath);
1876
- if (mmprojPath && !isPathAllowed(mmprojPath)) throw new Error("mmprojPath is outside the folders this tab may read: " + mmprojPath + " — add its folder under Model folders in the Runtime card first");
2462
+ if (mmprojPath && !isPathAllowed(mmprojPath)) throw new Error("mmprojPath " + NOT_READABLE + ": " + mmprojPath);
1877
2463
  const effort = normalizeEffort(raw?.effort);
1878
2464
  const preserveThinking = normalizePreserveThinking(raw);
1879
2465
  // Multi-GPU placement. Legacy profiles have neither field and keep
@@ -2088,11 +2674,16 @@ export function apply(ctx) {
2088
2674
  const url = new URL(req.url ?? "/", "http://x");
2089
2675
  const dir = url.searchParams.get("dir");
2090
2676
  const allFiles = url.searchParams.get("all") === "1";
2091
- const target = dir && dir.indexOf("/") === 0
2677
+ const raw = dir && dir.indexOf("/") === 0
2092
2678
  ? dir
2093
2679
  : dir ? join(homedir(), dir) : homedir();
2680
+ // Canonical before anything else. The picker joins with "/" and
2681
+ // starts at the root, so a click on /etc arrives as "//etc" — and
2682
+ // the path bar would then read "//etc", with every deeper click
2683
+ // stacking another slash. resolve() also collapses "..".
2684
+ const target = resolve(raw);
2094
2685
  if (!isPathAllowed(target)) {
2095
- sendJson(res, 400, { error: "path is outside the folders this tab may read (home + Model folders + the llama.cpp dir): " + target });
2686
+ sendJson(res, 400, { error: NOT_READABLE + ": " + target });
2096
2687
  return;
2097
2688
  }
2098
2689
  const listing = listDir(target, allFiles);
@@ -2120,7 +2711,7 @@ export function apply(ctx) {
2120
2711
  return;
2121
2712
  }
2122
2713
  if (!isPathAllowed(path)) {
2123
- sendJson(res, 400, { error: "path is outside the folders this tab may read (home + Model folders + the llama.cpp dir)" });
2714
+ sendJson(res, 400, { error: NOT_READABLE + ": " + path });
2124
2715
  return;
2125
2716
  }
2126
2717
  try {
@@ -2213,11 +2804,11 @@ export function apply(ctx) {
2213
2804
  return;
2214
2805
  }
2215
2806
  if (!isPathAllowed(path)) {
2216
- sendJson(res, 400, { error: "model path is outside the folders this tab may read: " + path + " — add its folder under Model folders in the Runtime card" });
2807
+ sendJson(res, 400, { error: "model " + NOT_READABLE + ": " + path });
2217
2808
  return;
2218
2809
  }
2219
2810
  if (mmproj && !isPathAllowed(mmproj)) {
2220
- sendJson(res, 400, { error: "mmproj path is outside the folders this tab may read: " + mmproj + " — add its folder under Model folders in the Runtime card" });
2811
+ sendJson(res, 400, { error: "mmproj " + NOT_READABLE + ": " + mmproj });
2221
2812
  return;
2222
2813
  }
2223
2814
  const result = await manager.run(path, Number.isFinite(ctx) ? ctx : 8192, Number.isFinite(mtp) ? mtp : 0, mmproj, effort, typeof body.mmprojCpu === "boolean" ? body.mmprojCpu : MMPROJ_CPU, moe, normalizePreserveThinking(body), kvTypeK, kvTypeV, normalizeSplitMode(body.splitMode), normalizeTensorSplit(body.tensorSplit));
@@ -2362,8 +2953,8 @@ export function apply(ctx) {
2362
2953
  }
2363
2954
  patch.binPath = wanted;
2364
2955
  }
2365
- // File-browser shortcuts are browsable roots, so they are the one
2366
- // place where a bad value widens the file endpoints: absolute,
2956
+ // File-browser shortcuts become one button each in the picker,
2957
+ // so a value here has to name a folder a person would: absolute,
2367
2958
  // no "..", never the filesystem root, at most 12 entries.
2368
2959
  // Missing dirs are still kept (an unmounted disk must not
2369
2960
  // silently lose its shortcut); the tab marks them as missing.
@@ -2443,6 +3034,13 @@ export function apply(ctx) {
2443
3034
  path: "/local-models/router/start",
2444
3035
  handler: async (req, res) => {
2445
3036
  if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
3037
+ let body = {};
3038
+ try {
3039
+ body = JSON.parse((await readBody(req)) || "{}");
3040
+ } catch {
3041
+ sendJson(res, 400, { error: "invalid JSON body" });
3042
+ return;
3043
+ }
2446
3044
  let profiles;
2447
3045
  try {
2448
3046
  profiles = readProfiles();
@@ -2451,13 +3049,76 @@ export function apply(ctx) {
2451
3049
  return;
2452
3050
  }
2453
3051
  if (profiles.length === 0) { sendJson(res, 400, { error: "save at least one profile first" }); return; }
3052
+ // Compaction guard first: every saved router profile gets its
3053
+ // headroom override synced into the profile patch, so the next
3054
+ // dsh boot cannot compact-loop on it. Best-effort — a sync
3055
+ // failure is reported, never a start failure. Opt out with
3056
+ // { compactionSync: false }; target another dsh profile with
3057
+ // { compactionProfile: "<name>" } (default "web").
3058
+ let compactionSync = null;
3059
+ if (body.compactionSync !== false) {
3060
+ const target = typeof body.compactionProfile === "string" && body.compactionProfile
3061
+ ? body.compactionProfile
3062
+ : "web";
3063
+ try {
3064
+ compactionSync = syncCompactionPatch(target, { profiles, allowCreate: true });
3065
+ } catch (err) {
3066
+ compactionSync = { ok: false, profile: target, error: err?.message ?? String(err) };
3067
+ }
3068
+ }
2454
3069
  try {
2455
3070
  mkdirSync(dataDir(), { recursive: true });
2456
3071
  writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
2457
3072
  const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2458
- sendJson(res, result.ok ? 200 : 409, { ok: result.ok, error: result.error, presets: profiles.length, iniPath: ROUTER_PRESETS_FILE });
3073
+ sendJson(res, result.ok ? 200 : 409, { ok: result.ok, error: result.error, presets: profiles.length, iniPath: ROUTER_PRESETS_FILE, compactionSync });
3074
+ } catch (err) {
3075
+ sendJson(res, 500, { error: err?.message ?? String(err), compactionSync });
3076
+ }
3077
+ },
3078
+ },
3079
+ {
3080
+ // Compaction coverage diagnostics: per-profile default threshold vs
3081
+ // the on-disk override. Query ?profile=<name> (default "web").
3082
+ kind: "exact",
3083
+ path: "/local-models/compaction",
3084
+ handler: async (req, res) => {
3085
+ const url = new URL(req.url ?? "/", "http://x");
3086
+ const target = url.searchParams.get("profile") || "web";
3087
+ let profiles;
3088
+ try {
3089
+ profiles = readProfiles();
2459
3090
  } catch (err) {
2460
3091
  sendJson(res, 500, { error: err?.message ?? String(err) });
3092
+ return;
3093
+ }
3094
+ try {
3095
+ profilePatchFile(target); // name charset first: 400, not a state error
3096
+ sendJson(res, 200, readCompactionState(target, profiles));
3097
+ } catch (err) {
3098
+ sendJson(res, 400, { error: err?.message ?? String(err) });
3099
+ }
3100
+ },
3101
+ },
3102
+ {
3103
+ // On-demand compaction sync. Body { profile = "web", dryRun = false }:
3104
+ // dryRun previews { added, updated, text } without writing.
3105
+ kind: "exact",
3106
+ path: "/local-models/compaction/sync",
3107
+ handler: async (req, res) => {
3108
+ if (req.method !== "POST") { sendJson(res, 405, { error: "POST required" }); return; }
3109
+ let body;
3110
+ try {
3111
+ body = JSON.parse((await readBody(req)) || "{}");
3112
+ } catch {
3113
+ sendJson(res, 400, { error: "invalid JSON body" });
3114
+ return;
3115
+ }
3116
+ const target = typeof body.profile === "string" && body.profile ? body.profile : "web";
3117
+ try {
3118
+ const report = syncCompactionPatch(target, { allowCreate: true, dryRun: body.dryRun === true });
3119
+ sendJson(res, 200, report);
3120
+ } catch (err) {
3121
+ sendJson(res, 400, { error: err?.message ?? String(err) });
2461
3122
  }
2462
3123
  },
2463
3124
  },
@@ -2585,6 +3246,20 @@ export function apply(ctx) {
2585
3246
  note("starting router (" + profiles.length + " profiles)...");
2586
3247
  mkdirSync(dataDir(), { recursive: true });
2587
3248
  writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
3249
+ // Compaction guard, update-only: keep every profile patch that
3250
+ // already carries a preset-standard entry covering the saved
3251
+ // router models (never create entries here — boot must not invent
3252
+ // composition; the tab's Start Router does the full sync).
3253
+ for (const name of listProfileNames()) {
3254
+ try {
3255
+ const r = syncCompactionPatch(name, { profiles, allowCreate: false });
3256
+ if (r.changed) {
3257
+ note("compaction sync (" + name + "): +" + r.added.length + " ~" + r.updated.length + " (restart dsh to apply)");
3258
+ }
3259
+ } catch (err) {
3260
+ note("compaction sync (" + name + ") skipped: " + (err?.message ?? String(err)));
3261
+ }
3262
+ }
2588
3263
  const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2589
3264
  if (!result.ok) {
2590
3265
  note("router failed to start: " + (result.error ?? "unknown error"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-local-models",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "dsh addon: a Local Models settings tab that starts and stops a llama-server child process living with the dsh host process.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,9 +37,10 @@ cpuMoe, nCpuMoe, expertUsed, arch, updatedAt }`
37
37
 
38
38
  - **Paths must exist and live on the fast mount**. Referencing the failing/
39
39
  legacy `/mnt/disco1` is a red flag - targets are under `/mnt/raid0/GGUF/`.
40
- A path outside the tab's roots (home + the Runtime card's Model folders + the
41
- folders saved profiles already declare) is refused on save and on launch, so
42
- a profile pointing somewhere else needs its folder added in the tab first.
40
+ The tab can reach any absolute path (the picker's Up goes all the way to `/`,
41
+ so mounted disks are browsable without declaring them), so a path pointing
42
+ somewhere else is an operational mistake the guard will not catch - check it
43
+ by eye.
43
44
  - **MTP depth must be <= 7** (the tab's ceiling; upstream clamps the
44
45
  effective depth to the model's nextn depth) and **3 is the tuned value** —
45
46
  deeper collapses at large ctx. Old profiles may still carry 4-6 from the
@@ -126,6 +127,12 @@ is wholesale, the row freezes: re-generate after any dsh upgrade that
126
127
  touches the preset file, or upstream changes to that preset are silently
127
128
  dropped. Only patch presets actually used (`standard` here); `ptc` /
128
129
  `cordis` / `minimal` need the same treatment if adopted.
130
+ **Since 0.5.0 this manual path is the fallback: `POST
131
+ /local-models/router/start` runs `syncCompactionPatch()` automatically
132
+ (merge-only, idempotent, backup-first; `GET /local-models/compaction`
133
+ for diagnostics, `POST /local-models/compaction/sync` on demand), so a
134
+ newly saved profile can no longer boot uncovered — the remaining manual
135
+ step is the `dsh web` restart that composition requires.**
129
136
 
130
137
  Shortcut with zero config edits: run the bigger window instead — the
131
138
  200K/250K profiles threshold at ~106K/158K under defaults.