@klarkxy/dsh-current-title 0.3.1 → 0.3.3

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,11 +1,888 @@
1
- import { DEFAULT_TIMEOUT_MS, PLUGIN_NAME, PROVIDER_ID, RPC_CHANNEL, SETTINGS_KEY, defaultGenerateConfig, defaultModelRoute, isTitleLocaleMode, normalizeCadence, normalizeModelRoute, normalizePrompt } from "./contracts.js";
2
- import { registerHostRpc } from "@klarkxy/dsh-plugin-kit/host-rpc";
3
- import { isSubagentSession, modelSettingsChangesIdentity } from "@klarkxy/dsh-plugin-kit/contracts";
4
- import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
5
- import { notifyModelSettingsChanged, registerModelSettings } from "@klarkxy/dsh-plugin-kit/model-settings";
6
- import { callLlmText, resolveFeatureModel } from "@klarkxy/dsh-plugin-kit";
1
+ import { C as normalizeCadence, D as normalizeModelRoute, E as modelRouteOverride, S as isTitleLocaleMode, T as defaultModelRoute, _ as SETTINGS_KEY, b as defaultGenerateConfig, c as DEFAULT_TIMEOUT_MS, d as PLUGIN_NAME, h as RPC_CHANNEL, p as PROVIDER_ID, v as TITLE_CALL_FEATURE, w as normalizePrompt } from "./contracts2.js";
2
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
+ import { BlockAssembler, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
7
4
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
8
5
  import { z } from "zod";
6
+ //#region ../dsh-plugin-kit/src/contracts.ts
7
+ function producerMessageSource(plugin) {
8
+ return {
9
+ kind: `plugin:${plugin}`,
10
+ plugin
11
+ };
12
+ }
13
+ /**
14
+ * True when `session` is a delegated child thread rather than a human-driven top-level
15
+ * conversation.
16
+ *
17
+ * A message's `source.kind === 'user'` cannot answer this: a lead agent's dispatch prompt
18
+ * is delivered as the child's own user message, so delegated work is indistinguishable
19
+ * from a person typing once you only look at the message. Plugins use this to keep
20
+ * automatic writes and derived model calls on top-level sessions.
21
+ */
22
+ function isSubagentSession(session) {
23
+ const header = session?.header;
24
+ if (!header || typeof header !== "object") return false;
25
+ if (header.origin === "subagent" || Number(header.delegationDepth ?? 0) > 0) return true;
26
+ const humanFork = header.isSeeded === true && (header.delegationDepth === void 0 || header.delegationDepth === 0) && header.origin === void 0 && (header.classification === void 0 || header.classification === "ordinary" || header.classification === "free");
27
+ return Boolean(header.parentSession) && !humanFork;
28
+ }
29
+ /** `@klarkxy/dsh-git-commit` becomes `KlarkxyDshGitCommitModelSettingsChanges`. */
30
+ function modelSettingsChangesIdentity(packageName) {
31
+ const scoped = /^@([^/]+)\/(.+)$/.exec(packageName);
32
+ const parts = (scoped ? `${scoped[1]}/${scoped[2]}` : packageName).split(/[^A-Za-z0-9]+/).filter(Boolean);
33
+ if (!parts.length) throw new Error("Model settings package name has no RPC segment");
34
+ const segment = `${parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("")}ModelSettingsChanges`;
35
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(segment)) throw new Error(`Model settings sync name is not an RPC segment: ${segment}`);
36
+ return {
37
+ service: segment,
38
+ namespace: segment
39
+ };
40
+ }
41
+ /** Root context service key holding the shared auxiliary-model state. */
42
+ const AUXILIARY_MODEL_SERVICE = "dshAuxiliaryModel";
43
+ /** Authenticated host-RPC channel for get/set of the shared default. */
44
+ const AUXILIARY_MODEL_CHANNEL = "/dsh-auxiliary-model";
45
+ /** Model-settings namespace under which the shared default is published. */
46
+ const AUXILIARY_MODEL_NS = "@klarkxy/dsh-plugin-kit/auxiliary-model";
47
+ /**
48
+ * Typert identity of the shared change stream. Mounted on the root context
49
+ * with ownerNs filtering, so frames only carry {@link AUXILIARY_MODEL_NS}.
50
+ */
51
+ const AUXILIARY_MODEL_CHANGES_IDENTITY = modelSettingsChangesIdentity("@klarkxy/dsh-plugin-kit");
52
+ function sharedAuxiliaryModel(host) {
53
+ const root = host?.root;
54
+ try {
55
+ const shared = root?.get?.(AUXILIARY_MODEL_SERVICE, false);
56
+ return shared && typeof shared.snapshot === "function" ? shared : void 0;
57
+ } catch {
58
+ return;
59
+ }
60
+ }
61
+ /**
62
+ * Synchronously read the shared auxiliary default; `undefined` only when no
63
+ * participant retains the singleton or the default is legitimately unset.
64
+ * An initialization or snapshot failure propagates: silently falling back to
65
+ * a different model after a storage error would spend the wrong budget.
66
+ */
67
+ function getAuxiliaryModel(host) {
68
+ const shared = sharedAuxiliaryModel(host);
69
+ if (!shared) return void 0;
70
+ return modelRouteOverride(shared.snapshot().model ?? void 0);
71
+ }
72
+ //#endregion
73
+ //#region ../dsh-plugin-kit/src/host-rpc.ts
74
+ /** The shared authenticated HTTP boundary. Each plugin still owns its RPC envelope semantics. */
75
+ function registerHostRpcTransport(ctx, channel, dispatch, options = {}) {
76
+ const bodyLimit = options.maxBodyBytes ?? 65536;
77
+ if (!Number.isSafeInteger(bodyLimit) || bodyLimit <= 0) throw new Error("Invalid RPC body limit");
78
+ const isEndpoint = options.isEndpoint ?? ((endpoint) => /^[a-z.]+$/.test(endpoint));
79
+ return ctx.webServer.register({
80
+ kind: "prefix",
81
+ path: channel,
82
+ handler: async (req, res) => {
83
+ const reject = (status, error, override) => {
84
+ if (res.destroyed) return;
85
+ if (status === 413) res.writeHead(status, { connection: "close" });
86
+ else res.writeHead(status);
87
+ res.end(override ?? options.errorBody?.(status, error) ?? (status === 500 ? "rpc request failed" : ""));
88
+ };
89
+ try {
90
+ if (typeof ctx.connection.requestRejection !== "function") {
91
+ reject(503);
92
+ return;
93
+ }
94
+ const rejection = ctx.connection.requestRejection(req);
95
+ if (rejection !== void 0) {
96
+ reject(rejection);
97
+ return;
98
+ }
99
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
100
+ const endpoint = pathname.startsWith(`${channel}/`) ? pathname.slice(channel.length + 1) : "";
101
+ if (req.method !== "POST" || !isEndpoint(endpoint)) {
102
+ reject(404);
103
+ return;
104
+ }
105
+ const contentType = String(req.headers["content-type"] ?? "").split(";", 1)[0]?.trim();
106
+ if ((options.caseInsensitiveContentType ? contentType.toLowerCase() : contentType) !== "application/json") {
107
+ reject(415);
108
+ return;
109
+ }
110
+ const guarded = options.beforeBody?.(req, endpoint);
111
+ if (guarded) {
112
+ reject(guarded.status, void 0, guarded.body);
113
+ return;
114
+ }
115
+ const controller = new AbortController();
116
+ res.on("close", () => {
117
+ if (!res.writableEnded) controller.abort();
118
+ });
119
+ const chunks = [];
120
+ let size = 0;
121
+ for await (const chunk of req) {
122
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
123
+ size += buffer.length;
124
+ if (size > bodyLimit) {
125
+ reject(413);
126
+ req.destroy();
127
+ return;
128
+ }
129
+ chunks.push(buffer);
130
+ }
131
+ let body;
132
+ try {
133
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || (options.emptyBodyAsNull ? "null" : ""));
134
+ } catch {
135
+ reject(400);
136
+ return;
137
+ }
138
+ await dispatch(endpoint, body, controller.signal, res);
139
+ } catch (error) {
140
+ reject(500, error);
141
+ }
142
+ }
143
+ });
144
+ }
145
+ /** Write the native Connection response envelope after a plugin has handled a request. */
146
+ function sendHostRpcResponse(response, rpcId, result, noStore = true) {
147
+ if (response.destroyed) return;
148
+ response.writeHead(200, {
149
+ "content-type": "application/json",
150
+ ...noStore ? { "cache-control": "no-store" } : {}
151
+ });
152
+ response.end(JSON.stringify({
153
+ type: "server-response",
154
+ rpcId,
155
+ result
156
+ }));
157
+ }
158
+ /** Safe host RPC registration. Missing requestRejection fails closed. */
159
+ function registerHostRpc(ctx, channel, call, options = {}) {
160
+ return registerHostRpcTransport(ctx, channel, async (endpoint, body, signal, res) => {
161
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
162
+ res.writeHead(400);
163
+ res.end();
164
+ return;
165
+ }
166
+ const request = body;
167
+ if (request.type !== "client-request" || request.method !== endpoint || typeof request.rpcId !== "string" || options.requirePayload && !Object.hasOwn(request, "payload")) {
168
+ res.writeHead(400);
169
+ res.end();
170
+ return;
171
+ }
172
+ const raw = await call(endpoint, request.payload, signal);
173
+ const result = raw.ok ? raw : {
174
+ ...raw,
175
+ error: {
176
+ details: {},
177
+ ...raw.error
178
+ }
179
+ };
180
+ sendHostRpcResponse(res, request.rpcId, result);
181
+ }, options);
182
+ }
183
+ //#endregion
184
+ //#region ../dsh-plugin-kit/src/model-settings.ts
185
+ const MODEL_SETTINGS_EVENT = "model-settings/collect";
186
+ const MODEL_SETTINGS_CHANGED_EVENT = "model-settings/changed";
187
+ /** Publish only after the owner's save commits. A failed observer must never
188
+ * turn an already committed write into an apparent save failure. */
189
+ function notifyModelSettingsChanged(ctx, ns, revision) {
190
+ try {
191
+ ctx.parallel(MODEL_SETTINGS_CHANGED_EVENT, {
192
+ ns,
193
+ revision
194
+ }).catch(() => {});
195
+ } catch {}
196
+ }
197
+ /** Cordis disposes the listener with its fiber. In-flight references also fail closed. */
198
+ function registerModelSettings(ctx, provider) {
199
+ let active = true;
200
+ const requireActive = (signal) => {
201
+ signal.throwIfAborted();
202
+ if (!active) throw new Error("Model settings owner unloaded; refresh and retry");
203
+ };
204
+ const guarded = {
205
+ ns: provider.ns,
206
+ title: provider.title,
207
+ instanceId: globalThis.crypto.randomUUID(),
208
+ async read(signal) {
209
+ requireActive(signal);
210
+ const value = await provider.read(signal);
211
+ requireActive(signal);
212
+ return value;
213
+ },
214
+ async write(path, route, revision, signal) {
215
+ requireActive(signal);
216
+ return provider.write(path, route, revision, signal);
217
+ }
218
+ };
219
+ const off = ctx.on(MODEL_SETTINGS_EVENT, (collect) => {
220
+ if (active) collect(guarded);
221
+ });
222
+ return () => {
223
+ active = false;
224
+ off();
225
+ };
226
+ }
227
+ //#endregion
228
+ //#region ../dsh-plugin-kit/src/model-settings-client.ts
229
+ /** This stream carries invalidations only; ready is never a settings snapshot. */
230
+ function parseModelSettingsChangeFrame(value) {
231
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid model settings change frame");
232
+ const frame = value;
233
+ if (frame.kind === "ready" && Object.keys(frame).length === 1) return { kind: "ready" };
234
+ if (frame.kind === "changed" && Object.keys(frame).length === 3 && typeof frame.ns === "string" && frame.ns.length > 0 && typeof frame.revision === "number" && Number.isSafeInteger(frame.revision) && frame.revision >= 0) return {
235
+ kind: "changed",
236
+ ns: frame.ns,
237
+ revision: frame.revision
238
+ };
239
+ throw new Error("Invalid model settings change frame");
240
+ }
241
+ //#endregion
242
+ //#region ../dsh-plugin-kit/src/model-settings-changes.ts
243
+ var __runInitializers = function(thisArg, initializers, value) {
244
+ var useValue = arguments.length > 2;
245
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
246
+ return useValue ? value : void 0;
247
+ };
248
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
249
+ function accept(f) {
250
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
251
+ return f;
252
+ }
253
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
254
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
255
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
256
+ var _, done = false;
257
+ for (var i = decorators.length - 1; i >= 0; i--) {
258
+ var context = {};
259
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
260
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
261
+ context.addInitializer = function(f) {
262
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
263
+ extraInitializers.push(accept(f || null));
264
+ };
265
+ var result = (0, decorators[i])(kind === "accessor" ? {
266
+ get: descriptor.get,
267
+ set: descriptor.set
268
+ } : descriptor[key], context);
269
+ if (kind === "accessor") {
270
+ if (result === void 0) continue;
271
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
272
+ if (_ = accept(result.get)) descriptor.get = _;
273
+ if (_ = accept(result.set)) descriptor.set = _;
274
+ if (_ = accept(result.init)) initializers.unshift(_);
275
+ } else if (_ = accept(result)) {
276
+ if (kind === "field") initializers.unshift(_);
277
+ else descriptor[key] = _;
278
+ }
279
+ }
280
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
281
+ done = true;
282
+ };
283
+ /** Attach first, then yield ready: commits during a consumer's baseline read
284
+ * remain queued. Coalescing revisions never invents a settings snapshot.
285
+ * `ownerNs` keeps one plugin's stream to its own settings; omit it to forward every namespace. */
286
+ async function* watchModelSettingsChanges(events, signal, ownerNs) {
287
+ signal.throwIfAborted();
288
+ const pending = /* @__PURE__ */ new Map();
289
+ let full = false;
290
+ let wake;
291
+ const changed = (ns, revision) => {
292
+ if (ownerNs !== void 0 && ns !== ownerNs) return;
293
+ let frame;
294
+ try {
295
+ frame = parseModelSettingsChangeFrame({
296
+ kind: "changed",
297
+ ns,
298
+ revision
299
+ });
300
+ } catch {
301
+ return;
302
+ }
303
+ if (!full) {
304
+ if (!pending.has(ns) && pending.size >= 256) {
305
+ pending.clear();
306
+ full = true;
307
+ } else pending.set(ns, frame);
308
+ }
309
+ wake?.();
310
+ };
311
+ const disposers = [];
312
+ const cleanup = () => {
313
+ for (const off of disposers.splice(0).reverse()) off();
314
+ pending.clear();
315
+ };
316
+ const aborted = () => {
317
+ cleanup();
318
+ wake?.();
319
+ };
320
+ signal.addEventListener("abort", aborted, { once: true });
321
+ try {
322
+ disposers.push(events.on(MODEL_SETTINGS_CHANGED_EVENT, ({ ns, revision }) => changed(ns, revision)));
323
+ disposers.push(events.on("settings/document-updated", changed));
324
+ signal.throwIfAborted();
325
+ yield { kind: "ready" };
326
+ while (true) {
327
+ signal.throwIfAborted();
328
+ if (full) {
329
+ full = false;
330
+ yield { kind: "ready" };
331
+ continue;
332
+ }
333
+ if (pending.size) {
334
+ const [ns, frame] = pending.entries().next().value;
335
+ pending.delete(ns);
336
+ yield frame;
337
+ continue;
338
+ }
339
+ await new Promise((resolve) => {
340
+ wake = resolve;
341
+ if (signal.aborted || full || pending.size) resolve();
342
+ });
343
+ wake = void 0;
344
+ }
345
+ } finally {
346
+ signal.removeEventListener("abort", aborted);
347
+ cleanup();
348
+ }
349
+ }
350
+ /** One owner's invalidation stream, registered on the calling plugin fiber.
351
+ * Nothing here is stored in a module-level singleton. */
352
+ let ModelSettingsChangesController = (() => {
353
+ let _classSuper = TypertRemoteService;
354
+ let _instanceExtraInitializers = [];
355
+ let _watch_decorators;
356
+ return class ModelSettingsChangesController extends _classSuper {
357
+ static {
358
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
359
+ _watch_decorators = [Remote({ mode: "stream" })];
360
+ __esDecorate(this, null, _watch_decorators, {
361
+ kind: "method",
362
+ name: "watch",
363
+ static: false,
364
+ private: false,
365
+ access: {
366
+ has: (obj) => "watch" in obj,
367
+ get: (obj) => obj.watch
368
+ },
369
+ metadata: _metadata
370
+ }, null, _instanceExtraInitializers);
371
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
372
+ enumerable: true,
373
+ configurable: true,
374
+ writable: true,
375
+ value: _metadata
376
+ });
377
+ }
378
+ lifetime = (__runInitializers(this, _instanceExtraInitializers), new AbortController());
379
+ ownerNs;
380
+ constructor(ctx, identity, ownerNs) {
381
+ super(ctx, identity.service, { namespace: identity.namespace });
382
+ this.ownerNs = ownerNs;
383
+ ctx.effect(() => () => {
384
+ this.lifetime.abort(/* @__PURE__ */ new Error("Model settings sync owner unloaded"));
385
+ });
386
+ }
387
+ watch(signal) {
388
+ return watchModelSettingsChanges(this.ctx, AbortSignal.any([this.lifetime.signal, signal]), this.ownerNs);
389
+ }
390
+ };
391
+ })();
392
+ /** Register this owner's controller. `ownerNs` limits the stream to that settings namespace. */
393
+ function createModelSettingsChanges(ctx, identity, ownerNs) {
394
+ return new ModelSettingsChangesController(ctx, identity, ownerNs);
395
+ }
396
+ //#endregion
397
+ //#region ../dsh-plugin-kit/src/auxiliary-model.ts
398
+ const DOMAIN_NAME = "dsh_auxiliary";
399
+ const DOMAIN_VERSION = 1;
400
+ const TABLE = "settings";
401
+ const KEY = "defaults";
402
+ const INJECT = [
403
+ "storageDomain",
404
+ "webServer",
405
+ "connection"
406
+ ];
407
+ /** Marker on the benign "released while the domain was still opening" error. */
408
+ const RECOVERABLE = Symbol.for("klarkxy.dsh.auxiliary-model.recoverable");
409
+ function recoverable(error) {
410
+ error[RECOVERABLE] = true;
411
+ return error;
412
+ }
413
+ async function openSharedDomain(scope) {
414
+ const [{ defineDomain, domainTable }, { z }] = await Promise.all([import("@deepseek-ai/dsh-storage-domain"), import("zod")]);
415
+ const identifier = z.string().min(1).max(250).refine((value) => value === value.trim() && !/[\x00-\x1f\x7f]/.test(value), "model route identifiers must be non-blank, trimmed and control-free");
416
+ const model = z.union([z.object({
417
+ provider: identifier,
418
+ model: identifier,
419
+ reasoningEffort: identifier.max(80).optional()
420
+ }).strict(), z.null()]);
421
+ return scope.storageDomain.open(defineDomain({
422
+ name: DOMAIN_NAME,
423
+ version: DOMAIN_VERSION,
424
+ tables: { [TABLE]: domainTable(z.object({
425
+ revision: z.number().int().nonnegative(),
426
+ model
427
+ }).strict()) }
428
+ }));
429
+ }
430
+ /** Marks an optimistic-lock conflict so the RPC envelope can carry 'conflict'. */
431
+ function conflictError(message) {
432
+ return Object.assign(new Error(message), { conflict: true });
433
+ }
434
+ /** RPC-visible error classification; storage failures stay sanitized. */
435
+ function rpcFailure(error) {
436
+ if (error && typeof error === "object" && "conflict" in error) return {
437
+ ok: false,
438
+ error: {
439
+ code: "auxiliary-model/conflict",
440
+ message: error instanceof Error ? error.message : "Revision conflict"
441
+ }
442
+ };
443
+ if (error instanceof Error && /Invalid model route|valid expected revision|Unknown auxiliary model setting/.test(error.message)) return {
444
+ ok: false,
445
+ error: {
446
+ code: "auxiliary-model/rejected",
447
+ message: error.message
448
+ }
449
+ };
450
+ return {
451
+ ok: false,
452
+ error: {
453
+ code: "auxiliary-model/store",
454
+ message: "Auxiliary model store request failed"
455
+ }
456
+ };
457
+ }
458
+ /**
459
+ * Serialized optimistic-lock write; resolves after the durable put commits.
460
+ * Revision verification happens inside the queue: two writers carrying the
461
+ * same expectedRevision race for one slot and only the first commits.
462
+ * Notification fires only after commit, so a failed observer can never turn
463
+ * an already committed write into an apparent save failure.
464
+ */
465
+ function setSharedModel(state, ctx, input, expectedRevision) {
466
+ if (!Number.isSafeInteger(expectedRevision)) return Promise.reject(conflictError("A valid expected revision is required; reload and retry"));
467
+ const row = input && typeof input === "object" && !Array.isArray(input) ? input : void 0;
468
+ const validField = (value) => typeof value === "string" && value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
469
+ if (!row || [
470
+ "provider",
471
+ "model",
472
+ "reasoningEffort"
473
+ ].some((key) => row[key] !== void 0 && !validField(row[key]))) return Promise.reject(/* @__PURE__ */ new Error("Invalid model route"));
474
+ const normalized = normalizeModelRoute(row);
475
+ if ((Boolean(row.provider) || Boolean(row.model)) && !modelRouteOverride(normalized)) return Promise.reject(/* @__PURE__ */ new Error("Invalid model route"));
476
+ const commit = state.tail.then(async () => {
477
+ if (state.closed || !state.domain) throw new Error("Auxiliary model settings are unloading; retry in a moment");
478
+ if (expectedRevision !== state.data.revision) throw conflictError("Auxiliary model settings changed; reload and retry");
479
+ const next = {
480
+ revision: state.data.revision + 1,
481
+ model: modelRouteOverride(normalized) ?? null
482
+ };
483
+ await state.domain.table(TABLE).put(KEY, {
484
+ revision: next.revision,
485
+ model: next.model
486
+ });
487
+ state.data = next;
488
+ notifyModelSettingsChanged(ctx.root, AUXILIARY_MODEL_NS, next.revision);
489
+ return next;
490
+ });
491
+ state.tail = commit.then(() => {}, () => {});
492
+ return commit;
493
+ }
494
+ function initScope(state, ctx, scope) {
495
+ const generation = ++state.generation;
496
+ let disposed = false;
497
+ scope.effect(() => () => {
498
+ disposed = true;
499
+ });
500
+ const stale = () => state.closed || disposed || state.generation !== generation;
501
+ const run = (async () => {
502
+ state.available = false;
503
+ const domain = await openSharedDomain(scope);
504
+ if (stale()) {
505
+ await domain.close();
506
+ throw recoverable(/* @__PURE__ */ new Error("Auxiliary model store released during open"));
507
+ }
508
+ state.domain = domain;
509
+ try {
510
+ scope.effect(() => () => domain.close());
511
+ } catch (error) {
512
+ await domain.close();
513
+ throw error;
514
+ }
515
+ const stored = domain.table(TABLE).get(KEY);
516
+ if (stale()) {
517
+ await domain.close();
518
+ throw recoverable(/* @__PURE__ */ new Error("Auxiliary model store superseded during open"));
519
+ }
520
+ state.data = {
521
+ revision: stored?.revision ?? 0,
522
+ model: stored?.model ?? null
523
+ };
524
+ state.error = void 0;
525
+ state.available = true;
526
+ scope.effect(() => registerModelSettings(scope, {
527
+ ns: AUXILIARY_MODEL_NS,
528
+ title: {
529
+ zh: "辅助模型",
530
+ en: "Auxiliary model"
531
+ },
532
+ read(signal) {
533
+ signal.throwIfAborted();
534
+ const { revision, model } = state.data;
535
+ return {
536
+ revision,
537
+ fields: [{
538
+ path: ["model"],
539
+ label: {
540
+ zh: "全局辅助模型",
541
+ en: "Global auxiliary model"
542
+ },
543
+ current: model ?? void 0,
544
+ allowFollow: true,
545
+ followLabel: {
546
+ zh: "当前会话模型",
547
+ en: "Current conversation model"
548
+ }
549
+ }]
550
+ };
551
+ },
552
+ async write(path, route, expectedRevision, signal) {
553
+ signal.throwIfAborted();
554
+ if (path.length !== 1 || path[0] !== "model") throw new Error("Unknown auxiliary model setting");
555
+ return setSharedModel(state, ctx, route, expectedRevision);
556
+ }
557
+ }));
558
+ scope.effect(() => registerHostRpc(scope, AUXILIARY_MODEL_CHANNEL, async (endpoint, payload, signal) => {
559
+ signal.throwIfAborted();
560
+ if (state.closed) return {
561
+ ok: false,
562
+ error: {
563
+ code: "auxiliary-model/unloading",
564
+ message: "Auxiliary model settings are unloading; retry in a moment"
565
+ }
566
+ };
567
+ if (!state.available) return state.error ? {
568
+ ok: false,
569
+ error: {
570
+ code: "auxiliary-model/store",
571
+ message: "Auxiliary model store request failed"
572
+ }
573
+ } : {
574
+ ok: false,
575
+ error: {
576
+ code: "auxiliary-model/unavailable",
577
+ message: "Auxiliary model settings are unavailable; retry in a moment"
578
+ }
579
+ };
580
+ if (endpoint === "defaults.get") {
581
+ const { revision, model } = state.data;
582
+ return {
583
+ ok: true,
584
+ value: {
585
+ revision,
586
+ model
587
+ }
588
+ };
589
+ }
590
+ if (endpoint === "defaults.set") try {
591
+ const body = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
592
+ const value = await setSharedModel(state, ctx, body.model, body.expectedRevision);
593
+ signal.throwIfAborted();
594
+ return {
595
+ ok: true,
596
+ value
597
+ };
598
+ } catch (error) {
599
+ if (signal.aborted) throw error;
600
+ return rpcFailure(error);
601
+ }
602
+ return {
603
+ ok: false,
604
+ error: {
605
+ code: "auxiliary-model/unknown-endpoint",
606
+ message: `Unknown endpoint: ${endpoint}`
607
+ }
608
+ };
609
+ }, { isEndpoint: (endpoint) => /^[a-z.]+$/.test(endpoint) }));
610
+ createModelSettingsChanges(scope, AUXILIARY_MODEL_CHANGES_IDENTITY, AUXILIARY_MODEL_NS);
611
+ scope.effect(() => () => {
612
+ if (state.generation === generation) {
613
+ state.available = false;
614
+ state.domain = void 0;
615
+ }
616
+ });
617
+ })().catch((error) => {
618
+ if (error && typeof error === "object") error.auxGeneration = generation;
619
+ if (state.generation === generation) {
620
+ state.available = false;
621
+ state.error = error;
622
+ }
623
+ throw error;
624
+ });
625
+ run.catch(() => {});
626
+ const armed = run.then(() => {});
627
+ armed.catch(() => {});
628
+ state.ready = armed;
629
+ return run;
630
+ }
631
+ function teardown(state, ctx) {
632
+ if (!state.closing) {
633
+ state.closed = true;
634
+ const root = ctx.root;
635
+ state.closing = (async () => {
636
+ await state.scopeInit?.catch(() => {});
637
+ await state.tail.catch(() => {});
638
+ try {
639
+ await state.fiber?.dispose();
640
+ } catch (error) {
641
+ try {
642
+ ctx.logger?.warn("auxiliary-model: fiber dispose failed", error);
643
+ } catch {}
644
+ }
645
+ try {
646
+ if (root.get("dshAuxiliaryModel", false) === state) state.stopUnprovide?.();
647
+ } catch {}
648
+ state.stopUnprovide = void 0;
649
+ })();
650
+ }
651
+ return state.closing;
652
+ }
653
+ function createSharedState(ctx) {
654
+ const root = ctx.root;
655
+ const state = {
656
+ users: 0,
657
+ closed: false,
658
+ available: false,
659
+ generation: 0,
660
+ tail: Promise.resolve(),
661
+ data: {
662
+ revision: 0,
663
+ model: null
664
+ },
665
+ snapshot() {
666
+ if (this.error) throw this.error;
667
+ if (!this.available) throw new Error("Auxiliary model store is unavailable; retry in a moment");
668
+ return this.data;
669
+ },
670
+ ready: Promise.resolve(),
671
+ teardown() {
672
+ return teardown(this, ctx);
673
+ }
674
+ };
675
+ const inactive = INJECT.filter((service) => {
676
+ try {
677
+ return root.get(service, true) === void 0;
678
+ } catch {
679
+ return true;
680
+ }
681
+ });
682
+ if (inactive.length) throw new Error(`retainAuxiliaryModel requires active ${inactive.join(", ")}; declare them in inject`);
683
+ state.fiber = root.inject(INJECT, (scope) => {
684
+ state.scopeInit = initScope(state, ctx, scope);
685
+ });
686
+ state.ready = Promise.resolve(state.fiber).then(() => {
687
+ if (!state.scopeInit) throw recoverable(/* @__PURE__ */ new Error("Auxiliary model owner fiber settled without initializing its scope"));
688
+ return state.scopeInit;
689
+ });
690
+ state.ready.catch((error) => {
691
+ const tag = error.auxGeneration;
692
+ if (tag !== void 0 && tag !== state.generation) return;
693
+ state.error = state.error ?? error;
694
+ state.closed = true;
695
+ state.teardown();
696
+ });
697
+ state.stopUnprovide = root.provide(AUXILIARY_MODEL_SERVICE, state);
698
+ return state;
699
+ }
700
+ async function obtainShared(ctx) {
701
+ const root = ctx.root;
702
+ for (;;) {
703
+ const existing = root.get(AUXILIARY_MODEL_SERVICE, false);
704
+ if (existing) {
705
+ if (!existing.closed) return existing;
706
+ await existing.closing;
707
+ continue;
708
+ }
709
+ return createSharedState(ctx);
710
+ }
711
+ }
712
+ /**
713
+ * Retain the shared auxiliary-model store for the calling plugin.
714
+ *
715
+ * Resolves once storage is open and the read/write surfaces are live, with a
716
+ * release function; register it as `ctx.effect(() => release)` so the store
717
+ * unloads with the plugin. The first participant opens the shared owner
718
+ * fiber; `await release()` drains pending writes and fully tears the owner
719
+ * down when the last participant leaves. Requires `storageDomain`,
720
+ * `webServer` and `connection` to exist on the host.
721
+ *
722
+ * Concurrent retainers share one owner; a retain arriving during teardown
723
+ * waits for the closing owner and then reinstalls — it never inherits a
724
+ * half-torn-down state.
725
+ */
726
+ async function retainAuxiliaryModel(ctx) {
727
+ for (;;) {
728
+ const state = await obtainShared(ctx);
729
+ await state.ready;
730
+ if (state.closed) continue;
731
+ state.users += 1;
732
+ let released = false;
733
+ return async () => {
734
+ if (released) return;
735
+ released = true;
736
+ state.users -= 1;
737
+ if (state.users <= 0) await state.teardown();
738
+ else await state.tail.catch(() => {});
739
+ };
740
+ }
741
+ }
742
+ new TextEncoder();
743
+ function normalizeSegments(context) {
744
+ if (context === void 0) return [];
745
+ return (typeof context === "string" ? [context] : context).filter((segment) => typeof segment === "string" && segment.length > 0);
746
+ }
747
+ /** Only word characters, dots, colons and dashes are kept inside the feature attribute. */
748
+ function featureAttribute(feature) {
749
+ return feature.replaceAll(/[^\w.:-]/g, "_");
750
+ }
751
+ /**
752
+ * Assemble the single user message for an auxiliary model call: stable
753
+ * material in one labelled block first, the live request last. With no
754
+ * context the result is byte-identical to `text`, so callers without
755
+ * material keep exactly the previous wire shape.
756
+ */
757
+ function buildAuxiliaryUserText(request) {
758
+ const context = normalizeSegments(request.context);
759
+ if (!context.length) return request.text;
760
+ return `<auxiliary-context${request.feature ? ` feature="${featureAttribute(request.feature)}"` : ""}>\n${context.join("\n")}\n</auxiliary-context>\n<request>\n${request.text}\n</request>`;
761
+ }
762
+ //#endregion
763
+ //#region ../dsh-plugin-kit/src/llm-call.ts
764
+ function routeOf(value) {
765
+ if (typeof value?.provider !== "string" || !value.provider.trim()) return void 0;
766
+ if (typeof value.model !== "string" || !value.model.trim()) return void 0;
767
+ const effort = typeof value.reasoningEffort === "string" ? value.reasoningEffort.trim() : "";
768
+ return effort ? {
769
+ provider: value.provider.trim(),
770
+ model: value.model.trim(),
771
+ reasoningEffort: effort
772
+ } : {
773
+ provider: value.provider.trim(),
774
+ model: value.model.trim()
775
+ };
776
+ }
777
+ /**
778
+ * Plugin selection first; then the shared auxiliary default (when any
779
+ * participant retains it); otherwise the live session model, then the host
780
+ * default. Cordis callers must declare agents, sessionProjections and
781
+ * agentDefaultModel in their inject list so the fallback reads use the
782
+ * caller's live services.
783
+ */
784
+ function resolveFeatureModel(host, saved, sessionId) {
785
+ const explicit = routeOf(saved);
786
+ if (explicit) return explicit;
787
+ const auxiliary = getAuxiliaryModel(host);
788
+ if (auxiliary) return auxiliary;
789
+ const face = host;
790
+ if (sessionId && face?.agents && face.sessionProjections) {
791
+ const session = face.agents.get(sessionId)?.session;
792
+ if (session) {
793
+ try {
794
+ const pending = face.sessionProjections.stateOf(session, "modelSelection")?.pending;
795
+ const selected = routeOf(pending ?? void 0);
796
+ if (selected) return selected;
797
+ } catch {}
798
+ const header = session.requestHeader?.();
799
+ const recorded = header?.config;
800
+ if (recorded) {
801
+ const route = routeOf(header?.adapterDefaults?.reasoningEffort === true ? {
802
+ provider: recorded.provider,
803
+ model: recorded.model
804
+ } : recorded);
805
+ if (route) return route;
806
+ }
807
+ }
808
+ }
809
+ return routeOf(face?.agentDefaultModel?.currentSelection());
810
+ }
811
+ function joinText(assembler) {
812
+ return assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("");
813
+ }
814
+ async function readStream(stream, signal, live) {
815
+ const assembler = new BlockAssembler();
816
+ const iterator = stream[Symbol.asyncIterator]();
817
+ try {
818
+ while (live()) {
819
+ if (signal.aborted) break;
820
+ const item = await iterator.next();
821
+ if (item.done) break;
822
+ assembler.push(item.value);
823
+ }
824
+ } finally {
825
+ await iterator.return?.();
826
+ }
827
+ return assembler;
828
+ }
829
+ /** One native model call. No retry, timeout policy, or provider queue. */
830
+ async function callLlmText(llm, request) {
831
+ request.signal.throwIfAborted();
832
+ const live = () => request.isCurrent?.() !== false && !request.signal.aborted;
833
+ if (!live()) throw Object.assign(/* @__PURE__ */ new Error("调用已取消。"), { name: "AbortError" });
834
+ const effort = request.route.reasoningEffort?.trim();
835
+ const prepared = await llm.prepareCall({
836
+ provider: request.route.provider,
837
+ model: request.route.model,
838
+ ...effort ? { reasoningEffort: ReasoningEffortId(effort) } : {},
839
+ maxTokens: request.maxTokens
840
+ }, request.signal, request.configure);
841
+ if (!live()) throw Object.assign(/* @__PURE__ */ new Error("调用已取消。"), { name: "AbortError" });
842
+ const options = {
843
+ provider: prepared.config.provider,
844
+ model: prepared.config.model,
845
+ ...prepared.config.reasoningEffort ? { reasoningEffort: prepared.config.reasoningEffort } : {},
846
+ ...prepared.config.maxTokens !== void 0 ? { maxTokens: Math.min(request.maxTokens, prepared.config.maxTokens) } : { maxTokens: request.maxTokens },
847
+ ...prepared.config.temperature !== void 0 ? { temperature: prepared.config.temperature } : {},
848
+ ...prepared.config.stop ? { stop: [...prepared.config.stop] } : {},
849
+ system: request.system,
850
+ messages: [createUserMessage({
851
+ source: producerMessageSource(request.plugin),
852
+ content: [{
853
+ type: "text",
854
+ text: buildAuxiliaryUserText(request)
855
+ }]
856
+ })],
857
+ signal: request.signal,
858
+ ...request.purpose ? { purpose: request.purpose } : {},
859
+ ...request.sessionId ? { sessionId: request.sessionId } : {}
860
+ };
861
+ const assembler = await readStream(prepared.stream(options), request.signal, live);
862
+ if (request.signal.aborted || assembler.finish.kind === "aborted") throw Object.assign(/* @__PURE__ */ new Error("调用已取消。"), { name: "AbortError" });
863
+ if (!live()) throw Object.assign(/* @__PURE__ */ new Error("调用已取消。"), { name: "AbortError" });
864
+ if (assembler.finish.kind === "error") {
865
+ const code = assembler.finish.failure.code;
866
+ throw Object.assign(new Error(assembler.finish.failure.message || "模型调用失败。"), { code });
867
+ }
868
+ if (assembler.blocks().some((block) => block.type === "tool-call") || assembler.finish.kind === "tool-calls") throw new Error("这次调用不使用工具。");
869
+ if (assembler.finish.kind !== "stop") throw new Error("模型调用未完成。");
870
+ const usage = assembler.usage;
871
+ return {
872
+ text: joinText(assembler),
873
+ provider: prepared.config.provider,
874
+ model: prepared.config.model,
875
+ ...usage ? {
876
+ inputTokens: usage.inputTokens,
877
+ outputTokens: usage.outputTokens,
878
+ ...usage.totalTokens !== void 0 ? { totalTokens: usage.totalTokens } : {},
879
+ ...usage.cacheReadTokens !== void 0 ? { cacheReadTokens: usage.cacheReadTokens } : {},
880
+ ...usage.cacheWriteTokens !== void 0 ? { cacheWriteTokens: usage.cacheWriteTokens } : {},
881
+ ...usage.reasoningTokens !== void 0 ? { reasoningTokens: usage.reasoningTokens } : {}
882
+ } : {}
883
+ };
884
+ }
885
+ //#endregion
9
886
  //#region src/input.ts
10
887
  const FRAME_PREFIX = "Choose the current task from these recent human messages, ordered oldest to newest:\n";
11
888
  function frameMessages(messages) {
@@ -205,6 +1082,18 @@ function sourceVersionOf(sessionId, messages) {
205
1082
  }
206
1083
  //#endregion
207
1084
  //#region src/generate.ts
1085
+ /**
1086
+ * Fixed bytes the auxiliary-context envelope adds around the selected
1087
+ * messages, so maxInputBytes keeps bounding the wire text. The stable block
1088
+ * carries the framing rule and the request block the JSON message list —
1089
+ * selectRecentMessages measures that same pair through frameMessages, so
1090
+ * only the constant envelope overhead is new.
1091
+ */
1092
+ const ENVELOPE_OVERHEAD_BYTES = utf8Bytes(buildAuxiliaryUserText({
1093
+ feature: TITLE_CALL_FEATURE,
1094
+ context: FRAME_PREFIX,
1095
+ text: "[]"
1096
+ })) - utf8Bytes(frameMessages([]));
208
1097
  function systemPrompt(config, customPrompt = "") {
209
1098
  return [
210
1099
  customPrompt.trim() || "Name the current task in an AI coding-assistant session.",
@@ -236,15 +1125,15 @@ function titleModelRoute(route) {
236
1125
  async function generateCurrentTitle(options) {
237
1126
  options.signal.throwIfAborted();
238
1127
  if (!options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
239
- const selected = selectRecentMessages(options.messages, options.config.maxRecentMessages, options.config.maxInputBytes);
1128
+ const selected = selectRecentMessages(options.messages, options.config.maxRecentMessages, options.config.maxInputBytes - ENVELOPE_OVERHEAD_BYTES);
240
1129
  if (selected.length === 0) throw new Error("dsh-current-title: at least one source message is required");
241
1130
  const route = resolveFeatureModel(options.host, titleModelRoute(options.model), options.sessionId);
242
- if (!route) throw new Error("dsh-current-title: 请在插件页选择模型,或设置宿主默认对话模型。");
1131
+ if (!route) throw new Error("dsh-current-title: 请在设置页选择模型,或设置默认辅助模型 / 宿主默认对话模型。");
243
1132
  const callRoute = {
244
1133
  provider: route.provider,
245
1134
  model: route.model
246
1135
  };
247
- const framedInput = frameMessages(selected);
1136
+ const framedInput = JSON.stringify(selected);
248
1137
  const locale = resolveTitleLocale(options.localeMode ?? options.config.locale, selected.map((message) => message.text), options.localePreference);
249
1138
  const sourceVersion = sourceVersionOf(options.sessionId, selected);
250
1139
  const deadline = new AbortController();
@@ -257,6 +1146,8 @@ async function generateCurrentTitle(options) {
257
1146
  plugin: PLUGIN_NAME,
258
1147
  route: callRoute,
259
1148
  system: systemPrompt(options.config, options.prompt),
1149
+ feature: TITLE_CALL_FEATURE,
1150
+ context: FRAME_PREFIX,
260
1151
  text: framedInput,
261
1152
  maxTokens: options.config.maxOutputTokens,
262
1153
  purpose: "session-title",
@@ -811,6 +1702,8 @@ const inject = [
811
1702
  ];
812
1703
  async function apply(ctx, config = {}) {
813
1704
  createModelSettingsChanges(ctx, modelSettingsChangesIdentity(PLUGIN_NAME), PLUGIN_NAME);
1705
+ const release = await retainAuxiliaryModel(ctx);
1706
+ ctx.effect(() => release, "current-title.auxiliary-model");
814
1707
  const host = ctx;
815
1708
  const domain = await host.storageDomain.open(titleDomain);
816
1709
  const table = domain.table("settings");
@@ -856,8 +1749,8 @@ async function apply(ctx, config = {}) {
856
1749
  },
857
1750
  current: settings.model,
858
1751
  followLabel: {
859
- zh: "当前会话 / 宿主默认模型",
860
- en: "Current conversation / host default"
1752
+ zh: "默认辅助模型",
1753
+ en: "Default auxiliary model"
861
1754
  },
862
1755
  allowFollow: true
863
1756
  }]