dsh-local-models 0.4.0 → 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.md CHANGED
@@ -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
@@ -758,6 +758,11 @@ window.__ModuleLoader__.load({
758
758
  var error = errPair[0], setError = errPair[1];
759
759
  var regPair = useStatePair(null);
760
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];
761
766
  var profPair = useStatePair([]);
762
767
  var profiles = profPair[0], setProfiles = profPair[1];
763
768
  var profNamePair = useStatePair("");
@@ -1299,15 +1304,43 @@ window.__ModuleLoader__.load({
1299
1304
  });
1300
1305
  };
1301
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
+
1302
1319
  var doStartRouter = function () {
1303
1320
  runAction("/local-models/router/start", {},
1304
1321
  function (res) {
1305
1322
  if (!res.ok) setError((res.body && res.body.error) || "router start failed");
1306
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
+ }
1307
1329
  refresh();
1308
1330
  });
1309
1331
  };
1310
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
+
1311
1344
  var doUpdateSettings = function (patch) {
1312
1345
  runAction("/local-models/settings", patch,
1313
1346
  function (res) {
@@ -1829,6 +1862,35 @@ window.__ModuleLoader__.load({
1829
1862
  stale ? h("p", { style: style.warn }, "Timer changed — restart the server to apply it now.") : null,
1830
1863
  );
1831
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
+ })(),
1832
1894
  );
1833
1895
 
1834
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 */ }
@@ -1813,6 +2186,199 @@ function writeJsonAtomic(file, value) {
1813
2186
  renameSync(tmp, file);
1814
2187
  }
1815
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
+
1816
2382
  function writeProfiles(list) {
1817
2383
  writeJsonAtomic(profilesFile(), list);
1818
2384
  presetsCache = list;
@@ -2468,6 +3034,13 @@ export function apply(ctx) {
2468
3034
  path: "/local-models/router/start",
2469
3035
  handler: async (req, res) => {
2470
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
+ }
2471
3044
  let profiles;
2472
3045
  try {
2473
3046
  profiles = readProfiles();
@@ -2476,13 +3049,76 @@ export function apply(ctx) {
2476
3049
  return;
2477
3050
  }
2478
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
+ }
2479
3069
  try {
2480
3070
  mkdirSync(dataDir(), { recursive: true });
2481
3071
  writeFileSync(ROUTER_PRESETS_FILE, generateRouterPresets(profiles), "utf8");
2482
3072
  const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2483
- 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();
2484
3090
  } catch (err) {
2485
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) });
2486
3122
  }
2487
3123
  },
2488
3124
  },
@@ -2610,6 +3246,20 @@ export function apply(ctx) {
2610
3246
  note("starting router (" + profiles.length + " profiles)...");
2611
3247
  mkdirSync(dataDir(), { recursive: true });
2612
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
+ }
2613
3263
  const result = await manager.startRouter(ROUTER_PRESETS_FILE);
2614
3264
  if (!result.ok) {
2615
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.4.0",
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",
@@ -127,6 +127,12 @@ is wholesale, the row freezes: re-generate after any dsh upgrade that
127
127
  touches the preset file, or upstream changes to that preset are silently
128
128
  dropped. Only patch presets actually used (`standard` here); `ptc` /
129
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.**
130
136
 
131
137
  Shortcut with zero config edits: run the bigger window instead — the
132
138
  200K/250K profiles threshold at ~106K/158K under defaults.