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 +3 -1
- package/lib/client.js +62 -0
- package/lib/index.js +651 -1
- package/package.json +1 -1
- package/skills/dsh-local-models-ops/SKILL.md +6 -0
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
|
@@ -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.
|