@klarkxy/dsh-model-hub 0.1.8 → 0.2.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/lib/index.js CHANGED
@@ -1,261 +1,26 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
1
  import Schema from "@deepseek-ai/schemastery";
4
2
  import { registerHostRpc } from "@klarkxy/dsh-plugin-kit/host-rpc";
5
- import { modelRouteRoleSchemastery, modelRouteSchemastery } from "@klarkxy/dsh-model-route/schemastery";
6
- import { detectModelFields, modelRouteOverride, normalizeModelRoute } from "@klarkxy/dsh-model-route";
7
- import { collectModelSettings, notifyModelSettingsChanged } from "@klarkxy/dsh-plugin-kit/model-settings";
8
- import { isSubagentSession, modelSettingsChangesIdentity } from "@klarkxy/dsh-plugin-kit/contracts";
9
- import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
3
+ import { notifyModelSettingsChanged } from "@klarkxy/dsh-plugin-kit/model-settings";
10
4
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
- //#region src/diagnostics.ts
12
- /** Invalidation is constant-time. Only startup and explicit reads request a
13
- * diagnostic; a burst shares one deferred run. Work never reschedules itself. */
14
- function createProbeDiagnostics(run, onError) {
15
- let dirty = true;
16
- let stopped = false;
17
- let timer;
18
- let running;
19
- return {
20
- invalidate() {
21
- if (!stopped) dirty = true;
22
- },
23
- request() {
24
- if (stopped || !dirty || timer !== void 0 || running) return;
25
- timer = setTimeout(() => {
26
- timer = void 0;
27
- if (stopped || !dirty) return;
28
- dirty = false;
29
- running = Promise.resolve().then(run).catch(onError).finally(() => {
30
- running = void 0;
31
- });
32
- }, 0);
33
- },
34
- async dispose() {
35
- stopped = true;
36
- if (timer !== void 0) clearTimeout(timer);
37
- timer = void 0;
38
- await running;
39
- }
40
- };
41
- }
42
- //#endregion
43
- //#region src/probe.ts
44
- /**
45
- * Pure probe-report builder for the model hub. Takes the host's
46
- * `settings.describe()` output and reports every detected model-route field,
47
- * plus whether this plugin's own self-test markers survived projection.
48
- */
49
- const SELF_TEST_EXTRA_KEY_PATH = "selfTestMeta";
50
- const SELF_TEST_ROLE_PATH = "selfTestRole";
51
- function channelOf(fields, firstSegment) {
52
- const hit = fields.find((field) => field.path[0] === firstSegment);
53
- return hit ? hit.via : "absent";
54
- }
55
- function buildProbeReport(descriptors, now = /* @__PURE__ */ new Date()) {
56
- const entries = listModelFields(descriptors).map(({ ns, fields }) => ({
57
- ns,
58
- fields
59
- }));
60
- let ownSchema;
61
- for (const descriptor of descriptors) if (descriptor.ns.includes("model-hub")) ownSchema = descriptor.schema;
62
- const own = entries.find((entry) => entry.ns.includes("model-hub"));
63
- return {
64
- generatedAt: now.toISOString(),
65
- totalNamespaces: descriptors.length,
66
- namespacesWithModelFields: entries.length,
67
- entries,
68
- selfTest: {
69
- extraKey: own ? channelOf(own.fields, SELF_TEST_EXTRA_KEY_PATH) : "absent",
70
- role: own ? channelOf(own.fields, SELF_TEST_ROLE_PATH) : "absent",
71
- ownSchema
72
- }
73
- };
74
- }
75
- /** The field matrix the hub page edits: every detected model-route field. */
76
- function listModelFields(descriptors) {
77
- const entries = [];
78
- for (const descriptor of descriptors) {
79
- const fields = detectModelFields(descriptor.schema, { value: descriptor.value });
80
- if (!fields.length) continue;
81
- entries.push({
82
- ns: descriptor.ns,
83
- revision: typeof descriptor.revision === "number" ? descriptor.revision : 0,
84
- fields: fields.map((field) => ({
85
- path: [...field.path],
86
- via: field.via,
87
- ...field.label ? { label: field.label } : {},
88
- ...field.description ? { description: field.description } : {},
89
- ...field.supportsReasoning === false ? { supportsReasoning: false } : {},
90
- ...field.rootKeys ? { rootKeys: [...field.rootKeys] } : {},
91
- ...field.writeKeys ? { writeKeys: [...field.writeKeys] } : {},
92
- ...field.preserveSiblings ? { preserveSiblings: true } : {},
93
- ...field.marker ? { marker: field.marker } : {},
94
- ...field.current ? { current: field.current } : {},
95
- ...field.editable === false ? { editable: false } : {},
96
- ...field.arrayItem ? { arrayItem: true } : {}
97
- }))
98
- });
99
- }
100
- return entries;
101
- }
102
- /**
103
- * Validate a hub-page write against a fresh detection pass: the target must be
104
- * a currently detected model-route field, so the page can never write a
105
- * non-model key. An empty route unsets the field back to its schema default
106
- * ("follow the host default"); a concrete route is set normalized.
107
- */
108
- function planFieldWrite(entries, payload) {
109
- const row = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : void 0;
110
- const ns = typeof row?.ns === "string" ? row.ns : "";
111
- const path = Array.isArray(row?.path) && row.path.every((s) => typeof s === "string" && s) ? row.path : void 0;
112
- if (!ns || !path) throw new Error("Invalid field reference");
113
- const entry = entries.find((item) => item.ns === ns);
114
- const field = entry?.fields.find((item) => item.path.length === path.length && item.path.every((segment, index) => segment === path[index]));
115
- if (!entry || !field) throw new Error("Not a detected model field");
116
- if (field.editable === false) throw new Error("No collection item to edit");
117
- const override = modelRouteOverride(normalizeModelRoute(row?.route));
118
- if (row?.expectedRevision !== void 0 && (!Number.isSafeInteger(row.expectedRevision) || row.expectedRevision < 0)) throw new Error("Invalid revision");
119
- if (typeof row?.expectedRevision !== "number") throw new Error("A valid expected revision is required; refresh and retry");
120
- const expectedRevision = row.expectedRevision;
121
- if (!path.length || field.preserveSiblings) return {
122
- ns,
123
- expectedRevision,
124
- ops: (field.rootKeys ?? field.writeKeys ?? ["provider", "model"]).map((key) => {
125
- const value = (override ?? (field.arrayItem ? {
126
- provider: "",
127
- model: ""
128
- } : void 0))?.[key];
129
- return value === void 0 ? {
130
- op: "unset",
131
- path: [...path, key]
132
- } : {
133
- op: "set",
134
- path: [...path, key],
135
- value
136
- };
137
- })
138
- };
139
- return {
140
- ns,
141
- expectedRevision,
142
- ops: override || field.arrayItem ? [{
143
- op: "set",
144
- path,
145
- value: override ?? {
146
- provider: "",
147
- model: ""
148
- }
149
- }] : [{
150
- op: "unset",
151
- path
152
- }]
153
- };
154
- }
155
- //#endregion
156
- //#region src/owners.ts
157
- /** Owner contributions supersede the same native namespace, never its storage. */
158
- function createHubSettings(events, settings, ownNamespace = "@klarkxy/dsh-model-hub") {
159
- const readOwner = async (owner, signal) => {
160
- try {
161
- const snapshot = await owner.read(signal);
162
- if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) throw new Error("Invalid owner revision");
163
- return {
164
- ns: owner.ns,
165
- title: owner.title,
166
- source: "owner",
167
- ownerId: owner.instanceId,
168
- revision: snapshot.revision,
169
- fields: snapshot.fields.map((field) => ({
170
- ...field,
171
- path: [...field.path],
172
- via: "contribution"
173
- })),
174
- ...snapshot.catalog !== void 0 ? { catalog: snapshot.catalog } : {},
175
- ...snapshot.catalogError ? { catalogError: snapshot.catalogError } : {}
176
- };
177
- } catch (error) {
178
- signal.throwIfAborted();
179
- return {
180
- ns: owner.ns,
181
- title: owner.title,
182
- source: "owner",
183
- ownerId: owner.instanceId,
184
- revision: 0,
185
- fields: [],
186
- error: error instanceof Error ? error.message : String(error)
187
- };
188
- }
189
- };
190
- const list = async (signal) => {
191
- signal.throwIfAborted();
192
- const owners = await collectModelSettings(events);
193
- const namespaces = new Set(owners.map((owner) => owner.ns));
194
- const native = listModelFields(settings.describe({ redactSecrets: true })).filter((entry) => !namespaces.has(entry.ns)).map((entry) => ({
195
- ...entry,
196
- fields: entry.fields.filter((field) => !(entry.ns === ownNamespace && ["selfTestMeta", "selfTestRole"].includes(field.path[0] ?? "")))
197
- })).filter((entry) => entry.fields.length > 0);
198
- const entries = await Promise.all(owners.map((owner) => readOwner(owner, signal)));
199
- signal.throwIfAborted();
200
- return [...native, ...entries].sort((a, b) => a.ns.localeCompare(b.ns));
201
- };
202
- const set = async (payload, signal) => {
203
- signal.throwIfAborted();
204
- const row = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : void 0;
205
- if (!row || typeof row.ns !== "string" || !Array.isArray(row.path) || !row.path.every((segment) => typeof segment === "string" && segment)) throw new Error("Invalid field reference");
206
- if (!Number.isSafeInteger(row.expectedRevision) || row.expectedRevision < 0) throw new Error("A valid expected revision is required; refresh and retry");
207
- const input = row.route && typeof row.route === "object" && !Array.isArray(row.route) ? row.route : void 0;
208
- if (!input || [
209
- "provider",
210
- "model",
211
- "reasoningEffort"
212
- ].some((key) => input[key] !== void 0 && typeof input[key] !== "string")) throw new Error("Invalid model route");
213
- const normalized = normalizeModelRoute(input);
214
- if ((Boolean(String(input.provider ?? "").trim()) || Boolean(String(input.model ?? "").trim())) && (!normalized.provider || !normalized.model)) throw new Error("Invalid model route");
215
- const owner = (await collectModelSettings(events)).find((item) => item.ns === row.ns);
216
- if (owner) {
217
- if (row.ownerId !== owner.instanceId) throw new Error("Model settings owner changed; refresh and retry");
218
- const entry = await readOwner(owner, signal);
219
- if (entry.error) throw new Error(typeof entry.error === "string" ? entry.error : entry.error.en);
220
- if (entry.revision !== row.expectedRevision) throw new Error("Settings changed; refresh and retry");
221
- const field = entry.fields.find((field) => JSON.stringify(field.path) === JSON.stringify(row.path));
222
- if (!field || field.editable === false) throw new Error("Not an editable model field");
223
- const route = normalized;
224
- if (field.allowFollow === false && (!route.provider || !route.model)) throw new Error("This model setting requires a concrete model");
225
- await owner.write(field.path, route, row.expectedRevision, signal);
226
- } else {
227
- if (row.ownerId !== void 0) throw new Error("Model settings owner unloaded; refresh and retry");
228
- if (row.ns === ownNamespace) throw new Error("Self-test fields are diagnostic only");
229
- const plan = planFieldWrite(listModelFields(settings.describe({ redactSecrets: true })), payload);
230
- await settings.mutate(plan.ns, plan.ops, plan.expectedRevision);
231
- }
232
- return {
233
- committed: true,
234
- ns: row.ns
235
- };
236
- };
237
- return {
238
- list,
239
- set
240
- };
241
- }
242
- //#endregion
243
- //#region src/changes.ts
244
- /** Model Hub forwards every settings namespace; feature plugins filter their own. */
245
- function registerHubModelSettingsChanges(ctx) {
246
- createModelSettingsChanges(ctx, modelSettingsChangesIdentity("@klarkxy/dsh-model-hub"));
247
- }
248
- //#endregion
5
+ import { dirname, join } from "node:path";
6
+ import { modelSettingsChangesIdentity } from "@klarkxy/dsh-plugin-kit/contracts";
7
+ import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
249
8
  //#region src/cast.ts
250
9
  /**
251
10
  * dsh-model-hub — subagent cast (host half).
252
11
  *
253
12
  * Edits the host-native `subagent-model-selection-settings` namespace
254
13
  * (`enabled` + `allowedModels` routes) through the official settings CAS
255
- * path, and keeps one user-annotated "task tendency" per route in
256
- * `<profile>/data/model-hub/cast.json`. Tendencies are advisory hints for
257
- * the system-prompt injection in index.ts; the main agent still decides
258
- * which model a delegated subagent receives.
14
+ * path, and keeps the per-route user preferences in
15
+ * `<profile>/data/model-hub/cast.json`: the free-text "task tendency" note
16
+ * and the reasoning effort.
17
+ *
18
+ * The native AllowedModelRoute schema carries provider and model only — the
19
+ * host strips a reasoning effort from the settings value — so the effort is a
20
+ * plugin-side preference keyed by provider/model and saved with the file's own
21
+ * CAS. Tendencies and efforts are advisory hints for the system-prompt
22
+ * injection in index.ts; the main agent still decides which model a delegated
23
+ * subagent receives.
259
24
  */
260
25
  /** Native namespace owning the subagent model-selection preference. */
261
26
  const CAST_NAMESPACE = "subagent-model-selection-settings";
@@ -264,12 +29,13 @@ const CAST_TENDENCIES_NAMESPACE = "@klarkxy/dsh-model-hub/cast-tendencies";
264
29
  /** Tendency ids are limited like host route ids; notes stay short prompt lines. */
265
30
  const CAST_LIMITS = {
266
31
  id: 250,
267
- tendency: 500
32
+ tendency: 500,
33
+ effort: 80
268
34
  };
269
35
  function isRecord(value) {
270
36
  return typeof value === "object" && value !== null && !Array.isArray(value);
271
37
  }
272
- /** Tendency store key for one provider/model pair. */
38
+ /** Store key for one provider/model pair; it keys tendencies and efforts. */
273
39
  function tendencyKey(provider, model) {
274
40
  return JSON.stringify([provider, model]);
275
41
  }
@@ -279,13 +45,12 @@ function parseAllowedModels(value) {
279
45
  const routes = [];
280
46
  list.forEach((entry, index) => {
281
47
  if (!isRecord(entry)) return;
282
- const { provider, model, reasoningEffort } = entry;
48
+ const { provider, model } = entry;
283
49
  if (typeof provider !== "string" || !provider || typeof model !== "string" || !model) return;
284
50
  routes.push({
285
51
  index,
286
52
  provider,
287
- model,
288
- ...typeof reasoningEffort === "string" && reasoningEffort ? { reasoningEffort } : {}
53
+ model
289
54
  });
290
55
  });
291
56
  return {
@@ -293,22 +58,17 @@ function parseAllowedModels(value) {
293
58
  length: list.length
294
59
  };
295
60
  }
61
+ /**
62
+ * Normalize a native route write. The native schema owns provider and model
63
+ * only: a reasoning effort is a plugin-side preference and never travels
64
+ * through the settings CAS.
65
+ */
296
66
  function normalizeRouteInput(input) {
297
67
  const row = isRecord(input) ? input : {};
298
68
  const provider = typeof row.provider === "string" ? row.provider.trim() : "";
299
69
  const model = typeof row.model === "string" ? row.model.trim() : "";
300
70
  if (!provider || !model) throw new Error("A route requires non-empty provider and model ids");
301
71
  if (provider.length > CAST_LIMITS.id || model.length > CAST_LIMITS.id) throw new Error(`Route provider and model ids are limited to ${CAST_LIMITS.id} characters`);
302
- if (row.reasoningEffort !== void 0) {
303
- const effort = typeof row.reasoningEffort === "string" ? row.reasoningEffort.trim() : "";
304
- if (!effort) throw new Error("A route reasoning effort must be a non-empty string");
305
- if (effort.length > CAST_LIMITS.id) throw new Error(`Route reasoning effort is limited to ${CAST_LIMITS.id} characters`);
306
- return {
307
- provider,
308
- model,
309
- reasoningEffort: effort
310
- };
311
- }
312
72
  return {
313
73
  provider,
314
74
  model
@@ -324,12 +84,13 @@ function validateIndex(value) {
324
84
  }
325
85
  /**
326
86
  * Render the injected guidance. Pure: callers resolve enabled state and the
327
- * tendency-merged routes. Returns '' when the injection should not appear —
87
+ * preference-merged routes. Returns '' when the injection should not appear —
328
88
  * the host prompt renderer drops empty sections.
329
89
  */
330
90
  function buildCastPrompt(enabled, routes) {
331
91
  if (!enabled || !routes.length) return "";
332
92
  const annotated = routes.filter((route) => typeof route.tendency === "string" && route.tendency.trim());
93
+ const withEffort = routes.filter((route) => typeof route.reasoningEffort === "string" && route.reasoningEffort.trim());
333
94
  const guidance = [
334
95
  "Subagent model selection:",
335
96
  "When delegating an independent task, actively choose a suitable model instead of silently inheriting your own. If list_subagent_models and a delegation tool with provider/model fields are available, inspect the available routes before the first model-selectable delegation in this task, reuse those results for later delegations, and supply provider and model together explicitly.",
@@ -341,17 +102,27 @@ function buildCastPrompt(enabled, routes) {
341
102
  provider: route.provider,
342
103
  model: route.model
343
104
  })}${route.reasoningEffort ? ` (reasoning effort: ${route.reasoningEffort})` : ""}: ${route.tendency}`));
105
+ if (withEffort.length) guidance.push("Configured reasoning effort (advisory reference configuration, applied when a subagent starts; it does not change subagents that are already running): pass reasoning_effort only when the selected route supports that value and omit it otherwise. When a route's provider/model changes, an effort the new route does not support is dropped.", ...withEffort.map((route) => `- ${JSON.stringify({
106
+ provider: route.provider,
107
+ model: route.model
108
+ })}: reasoning effort "${route.reasoningEffort}"`));
344
109
  return guidance.join("\n");
345
110
  }
346
111
  function createCast(deps) {
347
112
  const castFile = () => {
348
- if (!deps.profileDir) throw new Error("No profile directory; cast tendencies cannot be stored");
113
+ if (!deps.profileDir) throw new Error("No profile directory; cast preferences cannot be stored");
349
114
  return join(deps.profileDir, "data", "model-hub", "cast.json");
350
115
  };
351
- const readTendencies = () => {
116
+ /**
117
+ * Always read the durable revision: another tab or plugin incarnation may
118
+ * have committed since the last snapshot. Failed reads never become a cache.
119
+ * v1 and v2 stores hold tendencies only; v3 adds the effort map.
120
+ */
121
+ const readPreferences = () => {
352
122
  if (!deps.profileDir) return {
353
123
  revision: 0,
354
- tendencies: {}
124
+ tendencies: {},
125
+ efforts: {}
355
126
  };
356
127
  let raw;
357
128
  try {
@@ -359,42 +130,48 @@ function createCast(deps) {
359
130
  } catch (error) {
360
131
  if (error.code === "ENOENT") return {
361
132
  revision: 0,
362
- tendencies: {}
133
+ tendencies: {},
134
+ efforts: {}
363
135
  };
364
- throw new Error("Cast tendency store cannot be read; restore access to cast.json and refresh", { cause: error });
136
+ throw new Error("Cast preference store cannot be read; restore access to cast.json and refresh", { cause: error });
365
137
  }
366
138
  let parsed;
367
139
  try {
368
140
  parsed = JSON.parse(raw);
369
141
  } catch (error) {
370
- throw new Error("Cast tendency store is damaged; repair cast.json and refresh", { cause: error });
142
+ throw new Error("Cast preference store is damaged; repair cast.json and refresh", { cause: error });
371
143
  }
372
- if (isRecord(parsed) && parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) throw new Error("Cast tendency store version is unsupported; use a compatible plugin before saving");
144
+ const version = isRecord(parsed) ? parsed.schemaVersion : void 0;
145
+ if (version !== 1 && version !== 2 && version !== 3) throw new Error("Cast preference store version is unsupported; use a compatible plugin before saving");
373
146
  try {
374
- if (!isRecord(parsed) || !isRecord(parsed.tendencies)) throw new Error("Invalid tendency store schema");
375
- if (parsed.schemaVersion === 2 && (!Number.isSafeInteger(parsed.revision) || parsed.revision < 0)) throw new Error("Invalid tendency store revision");
376
- for (const [key, value] of Object.entries(parsed.tendencies)) {
147
+ if (!isRecord(parsed) || !isRecord(parsed.tendencies)) throw new Error("Invalid preference store schema");
148
+ if (version !== 1 && (!Number.isSafeInteger(parsed.revision) || parsed.revision < 0)) throw new Error("Invalid preference store revision");
149
+ const efforts = version === 3 ? parsed.efforts : {};
150
+ if (!isRecord(efforts)) throw new Error("Invalid preference store schema");
151
+ for (const map of [parsed.tendencies, efforts]) for (const [key, value] of Object.entries(map)) {
377
152
  const ids = JSON.parse(key);
378
- if (!Array.isArray(ids) || ids.length !== 2 || !ids.every((id) => typeof id === "string" && id.trim()) || typeof value !== "string") throw new Error("Invalid tendency store entry");
153
+ if (!Array.isArray(ids) || ids.length !== 2 || !ids.every((id) => typeof id === "string" && id.trim()) || typeof value !== "string") throw new Error("Invalid preference store entry");
379
154
  }
380
155
  return {
381
- revision: parsed.schemaVersion === 1 ? 0 : parsed.revision,
382
- tendencies: parsed.tendencies
156
+ revision: version === 1 ? 0 : parsed.revision,
157
+ tendencies: parsed.tendencies,
158
+ efforts
383
159
  };
384
160
  } catch (error) {
385
- throw new Error("Cast tendency store schema is invalid; repair cast.json and refresh", { cause: error });
161
+ throw new Error("Cast preference store schema is invalid; repair cast.json and refresh", { cause: error });
386
162
  }
387
163
  };
388
164
  /** Atomic store write: temp file plus rename, so a crash never halves JSON. */
389
- const writeTendencies = (next, revision) => {
165
+ const writePreferences = (next, revision) => {
390
166
  const file = castFile();
391
167
  mkdirSync(dirname(file), { recursive: true });
392
168
  const temp = `${file}.${process.pid}.tmp`;
393
169
  try {
394
170
  writeFileSync(temp, JSON.stringify({
395
- schemaVersion: 2,
171
+ schemaVersion: 3,
396
172
  revision,
397
- tendencies: next
173
+ tendencies: next.tendencies,
174
+ efforts: next.efforts
398
175
  }, null, 2));
399
176
  renameSync(temp, file);
400
177
  } catch (error) {
@@ -416,24 +193,28 @@ function createCast(deps) {
416
193
  length: parsed.length
417
194
  };
418
195
  };
419
- const mergeTendencies = (routes, stored) => {
196
+ /** Merge the stored preferences into the native routes. */
197
+ const mergePreferences = (routes, stored) => {
420
198
  return routes.map((route) => {
421
- const tendency = stored[tendencyKey(route.provider, route.model)];
422
- return tendency ? {
199
+ const key = tendencyKey(route.provider, route.model);
200
+ const tendency = stored.tendencies[key];
201
+ const effort = stored.efforts[key];
202
+ return {
423
203
  ...route,
424
- tendency
425
- } : { ...route };
204
+ ...tendency ? { tendency } : {},
205
+ ...effort ? { reasoningEffort: effort } : {}
206
+ };
426
207
  });
427
208
  };
428
209
  const getCast = () => {
429
210
  const raw = readRaw();
430
211
  try {
431
- const stored = readTendencies();
212
+ const stored = readPreferences();
432
213
  return {
433
214
  enabled: raw.enabled,
434
215
  revision: raw.revision,
435
216
  tendencyRevision: stored.revision,
436
- routes: mergeTendencies(raw.routes, stored.tendencies)
217
+ routes: mergePreferences(raw.routes, stored)
437
218
  };
438
219
  } catch (error) {
439
220
  return {
@@ -507,24 +288,41 @@ function createCast(deps) {
507
288
  return ops;
508
289
  });
509
290
  };
510
- /** Synchronous read/CAS/rename in one host turn; no native settings write. */
511
- const setTendency = (provider, model, tendency, expectedRevision, signal) => {
512
- if (typeof provider !== "string" || !provider.trim() || typeof model !== "string" || !model.trim()) throw new Error("A tendency requires non-empty provider and model ids");
513
- if (typeof tendency !== "string") throw new Error("A tendency must be a string");
514
- const text = tendency.trim();
515
- if (text.length > CAST_LIMITS.tendency) throw new Error(`A tendency is limited to ${CAST_LIMITS.tendency} characters`);
516
- if (!deps.profileDir) throw new Error("No profile directory; cast tendencies cannot be stored");
291
+ /**
292
+ * Save one route's plugin-side preferences with the file's own CAS. The
293
+ * returned state is the committed one, so the page never waits for a later
294
+ * refresh that could still carry the previous value.
295
+ */
296
+ const setRoutePreferences = (provider, model, preferences, expectedRevision, signal) => {
297
+ if (typeof provider !== "string" || !provider.trim() || typeof model !== "string" || !model.trim()) throw new Error("A route preference requires non-empty provider and model ids");
298
+ if (!deps.profileDir) throw new Error("No profile directory; cast preferences cannot be stored");
517
299
  const revision = validateRevision(expectedRevision);
518
- const stored = readTendencies();
519
- if (stored.revision !== revision) throw new Error("Cast tendencies changed; refresh and retry");
520
- if (revision === Number.MAX_SAFE_INTEGER) throw new Error("Cast tendency revision limit reached");
521
- const raw = readRaw();
522
- const next = { ...stored.tendencies };
300
+ const stored = readPreferences();
301
+ if (stored.revision !== revision) throw new Error("Cast preferences changed; refresh and retry");
302
+ if (revision === Number.MAX_SAFE_INTEGER) throw new Error("Cast preference revision limit reached");
303
+ const patch = isRecord(preferences) ? preferences : {};
304
+ const next = {
305
+ tendencies: { ...stored.tendencies },
306
+ efforts: { ...stored.efforts }
307
+ };
523
308
  const key = tendencyKey(provider.trim(), model.trim());
524
- if (text) next[key] = text;
525
- else Reflect.deleteProperty(next, key);
309
+ if ("tendency" in patch) {
310
+ if (typeof patch.tendency !== "string") throw new Error("A tendency must be a string");
311
+ const text = patch.tendency.trim();
312
+ if (text.length > CAST_LIMITS.tendency) throw new Error(`A tendency is limited to ${CAST_LIMITS.tendency} characters`);
313
+ if (text) next.tendencies[key] = text;
314
+ else Reflect.deleteProperty(next.tendencies, key);
315
+ }
316
+ if ("reasoningEffort" in patch) {
317
+ if (typeof patch.reasoningEffort !== "string") throw new Error("A reasoning effort must be a string");
318
+ const effort = patch.reasoningEffort.trim();
319
+ if (effort.length > CAST_LIMITS.effort) throw new Error(`A reasoning effort is limited to ${CAST_LIMITS.effort} characters`);
320
+ if (effort) next.efforts[key] = effort;
321
+ else Reflect.deleteProperty(next.efforts, key);
322
+ }
323
+ const raw = readRaw();
526
324
  signal?.throwIfAborted();
527
- writeTendencies(next, revision + 1);
325
+ writePreferences(next, revision + 1);
528
326
  try {
529
327
  deps.notifyTendenciesChanged?.(revision + 1);
530
328
  } catch {}
@@ -532,9 +330,14 @@ function createCast(deps) {
532
330
  enabled: raw.enabled,
533
331
  revision: raw.revision,
534
332
  tendencyRevision: revision + 1,
535
- routes: mergeTendencies(raw.routes, next)
333
+ routes: mergePreferences(raw.routes, next)
536
334
  };
537
335
  };
336
+ /** Synchronous read/CAS/rename in one host turn; no native settings write. */
337
+ const setTendency = (provider, model, tendency, expectedRevision, signal) => {
338
+ if (typeof tendency !== "string") throw new Error("A tendency must be a string");
339
+ return setRoutePreferences(provider, model, { tendency }, expectedRevision, signal);
340
+ };
538
341
  /**
539
342
  * Section text for the system-prompt injection. The native
540
343
  * `subagentModelSelection` service is authoritative when present; a missing
@@ -562,9 +365,9 @@ function createCast(deps) {
562
365
  return "";
563
366
  }
564
367
  try {
565
- return buildCastPrompt(snapshot.enabled, mergeTendencies(snapshot.routes, readTendencies().tendencies));
368
+ return buildCastPrompt(snapshot.enabled, mergePreferences(snapshot.routes, readPreferences()));
566
369
  } catch (error) {
567
- deps.logger?.warn("model-hub: cast tendencies unavailable: %s", error.message);
370
+ deps.logger?.warn("model-hub: cast preferences unavailable: %s", error.message);
568
371
  return buildCastPrompt(snapshot.enabled, snapshot.routes);
569
372
  }
570
373
  };
@@ -575,145 +378,18 @@ function createCast(deps) {
575
378
  setRoute,
576
379
  removeRoute,
577
380
  setTendency,
381
+ setRoutePreferences,
578
382
  promptText
579
383
  };
580
384
  }
581
385
  //#endregion
582
- //#region src/running.ts
583
- /**
584
- * dsh-model-hub — running subagent routes (host half).
585
- *
586
- * Read-only live view answering "which model and reasoning effort is each
587
- * running subagent actually using". `subagent/start` and `subagent/end`
588
- * maintain the live set; each child's effective route is read from its
589
- * session epoch header (`session.requestHeader()`) and kept current through
590
- * the session's own `request/header` events (reason `change` covers mid-run
591
- * model switches). Children already running when this plugin (re)loads are
592
- * recovered from the live agent registry via `isSubagentSession`.
593
- *
594
- * The effective route is session data recorded by the host; this tracker
595
- * never writes to sessions or settings. Display-only invalidations reuse the
596
- * model-settings change channel with RUNNING_NAMESPACE so open pages refetch.
597
- */
598
- /** Invalidation namespace for the running-subagent view; carries no settings. */
599
- const RUNNING_NAMESPACE = "@klarkxy/dsh-model-hub/running-subagents";
600
- function text(value) {
601
- return typeof value === "string" ? value.trim() : "";
602
- }
603
- /** Normalize one epoch header into the display route plus the adapter-default flag. */
604
- function routeFromHeader(header) {
605
- const config = header?.config;
606
- const provider = text(config?.provider);
607
- const model = text(config?.model);
608
- if (!provider || !model) return {};
609
- const effort = text(config?.reasoningEffort);
610
- return {
611
- route: effort ? {
612
- provider,
613
- model,
614
- reasoningEffort: effort
615
- } : {
616
- provider,
617
- model
618
- },
619
- ...effort && header?.adapterDefaults?.reasoningEffort === true ? { effortIsAdapterDefault: true } : {}
620
- };
621
- }
622
- function sameRoute(left, right) {
623
- return left.route?.provider === right.route?.provider && left.route?.model === right.route?.model && left.route?.reasoningEffort === right.route?.reasoningEffort && left.effortIsAdapterDefault === right.effortIsAdapterDefault;
624
- }
625
- function createRunningTracker(deps) {
626
- const entries = /* @__PURE__ */ new Map();
627
- let revision = 0;
628
- const bump = () => deps.notify(++revision);
629
- const add = (id, info) => {
630
- const session = (deps.agents?.get(id))?.session;
631
- const next = routeFromHeader(session?.requestHeader?.());
632
- const existing = entries.get(id);
633
- if (existing) {
634
- const providerName = info?.provider ?? existing.providerName;
635
- const local = info?.local ?? existing.local;
636
- const changed = existing.providerName !== providerName || existing.local !== local || !sameRoute(existing, next);
637
- existing.providerName = providerName;
638
- existing.local = local;
639
- existing.route = next.route;
640
- existing.effortIsAdapterDefault = next.effortIsAdapterDefault;
641
- if (changed) bump();
642
- return;
643
- }
644
- entries.set(id, {
645
- id,
646
- local: info?.local !== false,
647
- ...info?.provider ? { providerName: info.provider } : {},
648
- ...typeof session?.header?.parentSession === "string" && session.header.parentSession ? { parentSessionId: session.header.parentSession } : {},
649
- ...typeof session?.header?.createdAt === "number" ? { startedAt: session.header.createdAt } : {},
650
- route: next.route,
651
- effortIsAdapterDefault: next.effortIsAdapterDefault
652
- });
653
- bump();
654
- };
655
- const remove = (id) => {
656
- if (entries.delete(id)) bump();
657
- };
658
- try {
659
- for (const agent of deps.agents?.list() ?? []) if (agent?.id && isSubagentSession(agent.session)) add(agent.id);
660
- } catch {}
661
- deps.events.on("subagent/start", (info) => {
662
- if (typeof info?.id === "string" && info.id) add(info.id, info);
663
- });
664
- deps.events.on("subagent/end", (info) => {
665
- if (typeof info?.id === "string") remove(info.id);
666
- });
667
- deps.events.on("agent/disposed", (payload) => {
668
- if (typeof payload?.agent?.id === "string") remove(payload.agent.id);
669
- });
670
- deps.events.on("session/event", (session, event) => {
671
- if (event?.type !== "request/header") return;
672
- const id = typeof session?.id === "string" ? session.id : void 0;
673
- const entry = id ? entries.get(id) : void 0;
674
- if (!entry) return;
675
- const next = routeFromHeader(event.data?.header);
676
- if (sameRoute(entry, next)) return;
677
- entry.route = next.route;
678
- entry.effortIsAdapterDefault = next.effortIsAdapterDefault;
679
- bump();
680
- });
681
- return { async list(signal) {
682
- const pendingParents = /* @__PURE__ */ new Set();
683
- for (const entry of entries.values()) if (entry.label === void 0 && entry.parentSessionId) pendingParents.add(entry.parentSessionId);
684
- if (deps.subagents && pendingParents.size) await Promise.allSettled([...pendingParents].map(async (parent) => {
685
- const children = await deps.subagents.listChildren(parent, signal);
686
- const labels = new Map(children.map((child) => [child.id, child.label]));
687
- for (const entry of entries.values()) {
688
- if (entry.parentSessionId !== parent || entry.label !== void 0) continue;
689
- const label = labels.get(entry.id);
690
- if (typeof label === "string" && label.trim()) entry.label = label.trim();
691
- }
692
- }));
693
- const snapshot = [...entries.values()].map((entry) => ({
694
- ...entry,
695
- route: entry.route ? { ...entry.route } : void 0
696
- })).sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0) || a.id.localeCompare(b.id));
697
- return {
698
- revision,
699
- entries: snapshot
700
- };
701
- } };
386
+ //#region src/changes.ts
387
+ /** Model Hub forwards every settings namespace; feature plugins filter their own. */
388
+ function registerHubModelSettingsChanges(ctx) {
389
+ createModelSettingsChanges(ctx, modelSettingsChangesIdentity("@klarkxy/dsh-model-hub"));
702
390
  }
703
391
  //#endregion
704
392
  //#region src/index.ts
705
- /**
706
- * dsh-model-hub — cross-plugin model settings hub.
707
- *
708
- * Host half: reads every plugin's settings descriptor through the host
709
- * `settings` service, runs model-route field detection, and serves the field
710
- * matrix to the browser half over an authenticated RPC channel. Writes go
711
- * through `settings.mutate` — the official settings page's own path — with
712
- * the descriptor revision as CAS. The boot probe report is kept: it is logged
713
- * and written to `<profile>/data/model-hub/probe-report.json`. The subagent
714
- * cast (cast.ts) edits the native subagent model-selection settings the same
715
- * way and injects per-route task tendencies into the system prompt.
716
- */
717
393
  const name = "@klarkxy/dsh-model-hub";
718
394
  const inject = [
719
395
  "settings",
@@ -722,94 +398,26 @@ const inject = [
722
398
  "systemPrompt"
723
399
  ];
724
400
  const CHANNEL = "/dsh-model-hub";
725
- const Config = Schema.object({
726
- selfTestMeta: modelRouteSchemastery({
727
- purpose: "self-test-meta",
728
- label: {
729
- zh: "自检字段(自定义键通道)",
730
- en: "Self-test field (custom key)"
731
- }
732
- }).volatile(),
733
- selfTestRole: modelRouteRoleSchemastery({
734
- purpose: "self-test-role",
735
- label: {
736
- zh: "自检字段(role 通道)",
737
- en: "Self-test field (role)"
738
- }
739
- }).volatile()
740
- });
401
+ const Config = Schema.object({});
741
402
  function apply(ctx) {
742
403
  registerHubModelSettingsChanges(ctx);
743
404
  const profile = ctx.get("profileContext");
744
405
  const settings = ctx.settings;
745
- const describe = () => settings.describe({ redactSecrets: true });
746
- const ownNamespace = ctx.fiber.entry?.options?.id ?? "@klarkxy/dsh-model-hub";
747
- const hub = createHubSettings(ctx, {
748
- describe,
749
- mutate: (ns, ops, revision) => settings.mutate(ns, ops, revision)
750
- }, ownNamespace);
751
406
  const cast = createCast({
752
- describe,
753
- mutate: (ns, ops, revision) => settings.mutate(ns, ops, revision),
407
+ describe: () => settings.describe({ redactSecrets: true }),
408
+ mutate: (ns, ops, revision) => settings.mutate(ns, [...ops], revision),
754
409
  profileDir: profile?.dir,
755
410
  logger: ctx.logger,
756
411
  notifyTendenciesChanged: (revision) => notifyModelSettingsChanged(ctx, CAST_TENDENCIES_NAMESPACE, revision)
757
412
  });
758
- const running = createRunningTracker({
759
- events: ctx,
760
- agents: ctx.get("agents"),
761
- subagents: ctx.get("subagents"),
762
- notify: (revision) => notifyModelSettingsChanged(ctx, RUNNING_NAMESPACE, revision)
763
- });
764
413
  ctx.systemPrompt.section({
765
414
  name: "model-hub:subagent-cast",
766
415
  order: ctx.systemPrompt.getSectionOrder("TOOL_SUBAGENT") + 1,
767
416
  interpolate: false,
768
417
  text: () => cast.promptText(() => ctx.get("subagentModelSelection")?.current())
769
418
  });
770
- const run = async () => {
771
- const reason = "requested";
772
- let report;
773
- try {
774
- report = buildProbeReport(describe());
775
- } catch (error) {
776
- ctx.logger.warn("model-hub: probe failed (%s): %s", reason, error instanceof Error ? error.message : String(error));
777
- return;
778
- }
779
- ctx.logger.info("model-hub: probe (%s): %d namespaces, %d with model fields; self-test extra-key=%s role=%s", reason, report.totalNamespaces, report.namespacesWithModelFields, report.selfTest.extraKey, report.selfTest.role);
780
- if (profile?.dir) try {
781
- const dir = join(profile.dir, "data", "model-hub");
782
- await mkdir(dir, { recursive: true });
783
- await writeFile(join(dir, "probe-report.json"), JSON.stringify(report, null, 2));
784
- } catch (error) {
785
- ctx.logger.warn("model-hub: could not write probe report: %s", error instanceof Error ? error.message : String(error));
786
- }
787
- };
788
- const diagnostics = createProbeDiagnostics(run, (error) => {
789
- ctx.logger.warn("model-hub: diagnostics failed: %s", String(error));
790
- });
791
- ctx.effect(() => {
792
- const off = ctx.on("settings/document-updated", () => diagnostics.invalidate());
793
- diagnostics.request();
794
- return async () => {
795
- off();
796
- await diagnostics.dispose();
797
- };
798
- });
799
419
  ctx.effect(() => registerHostRpc(ctx, CHANNEL, async (endpoint, payload, signal) => {
800
420
  try {
801
- if (endpoint === "fields.list") {
802
- const value = await hub.list(signal);
803
- diagnostics.request();
804
- return {
805
- ok: true,
806
- value
807
- };
808
- }
809
- if (endpoint === "fields.set") return {
810
- ok: true,
811
- value: await hub.set(payload, signal)
812
- };
813
421
  if (endpoint.startsWith("cast.")) {
814
422
  const row = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
815
423
  if (endpoint === "cast.get") return {
@@ -836,9 +444,9 @@ function apply(ctx) {
836
444
  ok: true,
837
445
  value: cast.setTendency(row.provider, row.model, row.tendency, row.expectedRevision, signal)
838
446
  };
839
- if (endpoint === "cast.running") return {
447
+ if (endpoint === "cast.setPreferences") return {
840
448
  ok: true,
841
- value: await running.list(signal)
449
+ value: cast.setRoutePreferences(row.provider, row.model, row.preferences, row.expectedRevision, signal)
842
450
  };
843
451
  }
844
452
  throw new Error(`Unknown endpoint: ${endpoint}`);