@klarkxy/dsh-current-title 0.1.5 → 0.1.7

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,5 +1,7 @@
1
- import { PLUGIN_NAME, PROMPT_VERSION, PROVIDER_ID, PURPOSE_ID, RPC_CHANNEL, SCHEMA_VERSION, SETTINGS_KEY, defaultGenerateConfig, isTitleLocaleMode } from "./contracts.js";
2
- import { registerHostRpc } from "@klarkxy/dsh-ai-services/host-rpc";
1
+ import { 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 } from "@klarkxy/dsh-plugin-kit/contracts";
4
+ import { callLlmText, resolveFeatureModel } from "@klarkxy/dsh-plugin-kit";
3
5
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
4
6
  import { z } from "zod";
5
7
  //#region src/input.ts
@@ -135,22 +137,36 @@ function truncateSummary(value, targetWords, targetCjkCharacters) {
135
137
  if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value)) return Array.from(value.replace(/\s+/gu, "")).slice(0, targetCjkCharacters).join("");
136
138
  return value.split(/\s+/u).filter(Boolean).slice(0, targetWords).join(" ");
137
139
  }
138
- function parseModelTitle(raw, targetWords, targetCjkCharacters) {
139
- let value;
140
+ function unwrapModelText(raw) {
141
+ const trimmed = raw.trim();
142
+ return (/^```(?:json)?\s*([\s\S]*?)\s*```$/iu.exec(trimmed)?.[1] ?? trimmed).trim();
143
+ }
144
+ function readJsonObject(raw) {
145
+ const text = unwrapModelText(raw);
146
+ const start = text.indexOf("{");
147
+ const end = text.lastIndexOf("}");
148
+ if (start === -1 || end <= start) return void 0;
140
149
  try {
141
- value = JSON.parse(raw.trim());
142
- } catch (cause) {
143
- throw new Error("dsh-current-title: title model returned invalid JSON", { cause });
150
+ const value = JSON.parse(text.slice(start, end + 1));
151
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
152
+ } catch {
153
+ return;
144
154
  }
145
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("dsh-current-title: title model response must be an object");
146
- const candidate = value;
147
- if (Object.keys(candidate).sort().join(",") !== "summary,type") throw new Error("dsh-current-title: title model response must contain only type and summary");
148
- if (typeof candidate.type !== "string" || !isTitleType(candidate.type)) throw new Error("dsh-current-title: title model returned an unsupported type");
149
- if (typeof candidate.summary !== "string") throw new Error("dsh-current-title: title model summary must be a string");
150
- const summary = truncateSummary(normalizeSessionTitle(removeTitleSyntax(candidate.summary), Number.MAX_SAFE_INTEGER), targetWords, targetCjkCharacters);
155
+ }
156
+ /**
157
+ * Accept the required JSON shape, including a code fence or a short preface.
158
+ * Extra keys are ignored. A plain one-line title still becomes a title so a
159
+ * chatty model does not leave the session on the built-in fallback.
160
+ */
161
+ function parseModelTitle(raw, targetWords, targetCjkCharacters) {
162
+ const candidate = readJsonObject(raw);
163
+ const typeValue = typeof candidate?.type === "string" ? candidate.type.trim().toLowerCase() : "";
164
+ const summaryValue = typeof candidate?.summary === "string" ? candidate.summary : "";
165
+ const type = isTitleType(typeValue) ? typeValue : "discuss";
166
+ const summary = truncateSummary(normalizeSessionTitle(removeTitleSyntax(summaryValue || (candidate ? "" : unwrapModelText(raw).replace(/^["'`]+|["'`]+$/gu, ""))), Number.MAX_SAFE_INTEGER), targetWords, targetCjkCharacters);
151
167
  if (summary.length === 0) throw new Error("dsh-current-title: title model returned an empty summary");
152
168
  return {
153
- type: candidate.type,
169
+ type,
154
170
  summary
155
171
  };
156
172
  }
@@ -187,9 +203,9 @@ function sourceVersionOf(sessionId, messages) {
187
203
  }
188
204
  //#endregion
189
205
  //#region src/generate.ts
190
- function systemPrompt(config) {
206
+ function systemPrompt(config, customPrompt = "") {
191
207
  return [
192
- "Name the current task in an AI coding-assistant session.",
208
+ customPrompt.trim() || "Name the current task in an AI coding-assistant session.",
193
209
  "Prioritize the newest human messages. Older messages are context only and must not keep a completed task in the title.",
194
210
  "Allowed type values: feature, fix, optimize, refactor, test, docs, release, config, explore, discuss.",
195
211
  "Return exactly one JSON object: {\"type\":\"<type>\",\"summary\":\"<summary>\"}.",
@@ -198,68 +214,53 @@ function systemPrompt(config) {
198
214
  `Aim for at most ${config.targetWords} non-CJK words or ${config.targetCjkCharacters} CJK characters.`
199
215
  ].join("\n");
200
216
  }
201
- function registerTitlePurpose(scope) {
202
- return scope.registerPurpose({
203
- id: PURPOSE_ID,
204
- label: "自动标题",
205
- defaultTarget: {
206
- kind: "role",
207
- role: "weak"
208
- },
209
- maxOutputTokens: 64
210
- });
217
+ function titleModelRoute(route) {
218
+ if (!route?.provider || !route.model) return void 0;
219
+ return route.reasoningEffort ? {
220
+ provider: route.provider,
221
+ model: route.model,
222
+ reasoningEffort: route.reasoningEffort
223
+ } : {
224
+ provider: route.provider,
225
+ model: route.model
226
+ };
211
227
  }
212
228
  async function generateCurrentTitle(options) {
213
229
  options.signal.throwIfAborted();
214
- if (!options.scope.active || !options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
230
+ if (!options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
215
231
  const selected = selectRecentMessages(options.messages, options.config.maxRecentMessages, options.config.maxInputBytes);
216
232
  if (selected.length === 0) throw new Error("dsh-current-title: at least one source message is required");
233
+ const route = resolveFeatureModel(options.host, titleModelRoute(options.model), options.sessionId);
234
+ if (!route) throw new Error("dsh-current-title: 请在插件页选择模型,或设置宿主默认对话模型。");
235
+ const callRoute = {
236
+ provider: route.provider,
237
+ model: route.model
238
+ };
217
239
  const framedInput = frameMessages(selected);
218
240
  const locale = resolveTitleLocale(options.localeMode ?? options.config.locale, selected.map((message) => message.text), options.localePreference);
219
241
  const sourceVersion = sourceVersionOf(options.sessionId, selected);
220
- let settled = false;
221
- const aborted = new Promise((_, reject) => {
222
- const fail = () => {
223
- if (settled) return;
224
- reject(options.signal.reason ?? /* @__PURE__ */ new Error("dsh-current-title: generation cancelled"));
225
- };
226
- if (options.signal.aborted) {
227
- fail();
228
- return;
229
- }
230
- options.signal.addEventListener("abort", fail, { once: true });
242
+ const result = await callLlmText(options.llm, {
243
+ plugin: PLUGIN_NAME,
244
+ route: callRoute,
245
+ system: systemPrompt(options.config, options.prompt),
246
+ text: framedInput,
247
+ maxTokens: options.config.maxOutputTokens,
248
+ purpose: "session-title",
249
+ signal: options.signal,
250
+ sessionId: options.sessionId,
251
+ isCurrent: options.isCurrent
231
252
  });
232
- let result;
233
- try {
234
- result = await Promise.race([options.scope.run({
235
- purpose: PURPOSE_ID,
236
- sessionId: options.sessionId,
237
- input: framedInput,
238
- system: systemPrompt(options.config),
239
- sourceVersion,
240
- contractVersion: 1,
241
- promptVersion: PROMPT_VERSION,
242
- schemaVersion: SCHEMA_VERSION,
243
- signal: options.signal,
244
- isCurrent: () => options.scope.active && options.isCurrent(),
245
- priority: "background"
246
- }), aborted]);
247
- } finally {
248
- settled = true;
249
- }
250
253
  options.signal.throwIfAborted();
251
- if (!options.scope.active || !options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
252
- if (result.receipt.status === "cancelled" || result.receipt.status === "superseded") throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation did not complete"), { code: result.receipt.status });
254
+ if (!options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
253
255
  const title = normalizeSessionTitle(formatTitle(parseModelTitle(result.text, options.config.targetWords, options.config.targetCjkCharacters), locale, options.now ?? /* @__PURE__ */ new Date()), options.config.maxTitleBytes);
254
256
  if (!title) throw new Error("dsh-current-title: title model returned an empty summary");
255
- const route = result.receipt.route;
256
257
  return {
257
258
  title,
258
259
  messageSeqs: selected.map((message) => message.seq),
259
- ...route?.provider && route.model ? { model: {
260
- provider: route.provider,
261
- model: route.model
262
- } } : {},
260
+ model: {
261
+ provider: result.provider,
262
+ model: result.model
263
+ },
263
264
  sourceVersion
264
265
  };
265
266
  }
@@ -343,7 +344,7 @@ function createNativeTitleSlot(options) {
343
344
  async occupy(id) {
344
345
  const provider = {
345
346
  id: PROVIDER_ID,
346
- automatic: "all-prompts",
347
+ automatic: options.automatic(),
347
348
  generate: options.generate
348
349
  };
349
350
  try {
@@ -381,29 +382,69 @@ function createNativeTitleSlot(options) {
381
382
  }
382
383
  //#endregion
383
384
  //#region src/storage.ts
385
+ const localeSchema = z.enum([
386
+ "auto",
387
+ "zh",
388
+ "en"
389
+ ]);
390
+ /** Version 1 stored only the locale. Prompt and model are filled as defaults. */
391
+ const legacySettingsSchema = z.object({
392
+ revision: z.number().int().nonnegative(),
393
+ locale: localeSchema
394
+ }).strict();
395
+ const modelRouteSchema = z.object({
396
+ provider: z.string(),
397
+ model: z.string(),
398
+ reasoningEffort: z.string().optional()
399
+ }).strict();
384
400
  const titleSettingsSchema = z.object({
385
401
  revision: z.number().int().nonnegative(),
386
- locale: z.enum([
387
- "auto",
388
- "zh",
389
- "en"
390
- ])
402
+ locale: localeSchema,
403
+ prompt: z.string(),
404
+ model: modelRouteSchema,
405
+ /** Defaulted so rows stored before the cadence switch existed still parse. */
406
+ cadence: z.enum(["all-prompts", "first-prompt"]).default("all-prompts")
391
407
  }).strict();
392
408
  const titleDomain = defineDomain({
393
409
  name: "dsh_editor_current_title",
394
- version: 1,
410
+ version: 2,
395
411
  tables: { settings: domainTable(titleSettingsSchema) }
396
412
  });
397
413
  function parseTitleSettings(value) {
398
- const parsed = titleSettingsSchema.safeParse(value);
399
- if (!parsed.success) return void 0;
400
- if (!isTitleLocaleMode(parsed.data.locale)) return void 0;
401
- return parsed.data;
414
+ const current = titleSettingsSchema.safeParse(value);
415
+ if (current.success) return {
416
+ revision: current.data.revision,
417
+ locale: isTitleLocaleMode(current.data.locale) ? current.data.locale : "auto",
418
+ prompt: normalizePrompt(current.data.prompt),
419
+ model: normalizeModelRoute(current.data.model),
420
+ cadence: normalizeCadence(current.data.cadence)
421
+ };
422
+ const legacy = legacySettingsSchema.safeParse(value);
423
+ if (!legacy.success || !isTitleLocaleMode(legacy.data.locale)) return void 0;
424
+ return {
425
+ revision: legacy.data.revision,
426
+ locale: legacy.data.locale,
427
+ prompt: "",
428
+ model: defaultModelRoute(),
429
+ cadence: "all-prompts"
430
+ };
402
431
  }
403
432
  function storedSettings(value, _fallbackLocale = "auto") {
433
+ const parsed = parseTitleSettings(value);
404
434
  return {
405
- revision: parseTitleSettings(value)?.revision ?? 0,
406
- locale: "auto"
435
+ revision: parsed?.revision ?? 0,
436
+ locale: "auto",
437
+ prompt: parsed?.prompt ?? "",
438
+ model: parsed?.model ?? defaultModelRoute(),
439
+ cadence: parsed?.cadence ?? "all-prompts"
440
+ };
441
+ }
442
+ function settingsPatch(current, patch) {
443
+ return {
444
+ locale: "auto",
445
+ prompt: patch.prompt === void 0 ? current.prompt : normalizePrompt(patch.prompt),
446
+ model: patch.model === void 0 ? current.model : normalizeModelRoute(patch.model),
447
+ cadence: patch.cadence === void 0 ? current.cadence : normalizeCadence(patch.cadence)
407
448
  };
408
449
  }
409
450
  //#endregion
@@ -412,11 +453,11 @@ var CurrentTitleService = class {
412
453
  options;
413
454
  settings;
414
455
  config;
415
- scope;
416
- unregisterPurpose;
417
456
  claim;
418
457
  nativeSlot;
419
458
  generation = 0;
459
+ settingsPending = Promise.resolve();
460
+ disposed = false;
420
461
  inFlight = /* @__PURE__ */ new Map();
421
462
  support;
422
463
  constructor(options) {
@@ -430,17 +471,16 @@ var CurrentTitleService = class {
430
471
  };
431
472
  }
432
473
  async start() {
433
- const { ai, sessionTitle, loader, sessions } = this.options;
434
- if (!ai || !sessionTitle?.register || !sessions || !loader) {
474
+ const { llm, sessionTitle, loader, sessions } = this.options;
475
+ if (!llm || !sessionTitle?.register || !sessions || !loader) {
435
476
  this.support.weOwn = false;
436
477
  this.support.limitation = "required host services are missing";
437
478
  return;
438
479
  }
439
- this.scope = ai.activate(this.options.plugin);
440
- this.unregisterPurpose = registerTitlePurpose(this.scope);
441
480
  this.nativeSlot = createNativeTitleSlot({
442
481
  sessionTitle,
443
482
  loader,
483
+ automatic: () => this.settings.cadence,
444
484
  generate: (request) => this.nativeGenerate(request)
445
485
  });
446
486
  try {
@@ -455,9 +495,11 @@ var CurrentTitleService = class {
455
495
  }
456
496
  }
457
497
  async dispose() {
498
+ this.disposed = true;
458
499
  this.generation += 1;
459
500
  for (const controller of this.inFlight.values()) controller.abort(/* @__PURE__ */ new Error("current-title disabled"));
460
501
  this.inFlight.clear();
502
+ await this.settingsPending;
461
503
  const snapshot = this.nativeSlot?.displacement;
462
504
  const outcome = await releaseTitleSlot(this.nativeSlot ?? {
463
505
  owner: () => void 0,
@@ -468,16 +510,15 @@ var CurrentTitleService = class {
468
510
  this.support.weOwn = false;
469
511
  this.support.nativeOwner = this.nativeSlot?.owner() ?? null;
470
512
  if (outcome === "left-other-owner") this.support.limitation = "another title provider owns the slot; previous owner was not restored";
471
- this.unregisterPurpose?.();
472
- this.unregisterPurpose = void 0;
473
- this.scope?.dispose();
474
- this.scope = void 0;
475
513
  }
476
514
  status(sessionId) {
477
515
  const session = sessionId && this.options.sessions ? this.options.sessions.get(sessionId) : void 0;
478
516
  const snapshot = session ? this.read(session) : void 0;
479
517
  return {
480
- settings: { ...this.settings },
518
+ settings: {
519
+ ...this.settings,
520
+ model: { ...this.settings.model }
521
+ },
481
522
  support: {
482
523
  ...this.support,
483
524
  nativeOwner: this.nativeSlot?.owner() ?? this.support.nativeOwner
@@ -491,19 +532,54 @@ var CurrentTitleService = class {
491
532
  } } : {}
492
533
  };
493
534
  }
535
+ async updateSettings(patch) {
536
+ patch = structuredClone(patch);
537
+ const result = this.settingsPending.then(async () => {
538
+ if (this.disposed) throw Object.assign(/* @__PURE__ */ new Error("current-title disabled"), { code: "unavailable" });
539
+ if (this.settings.revision !== patch.expectedRevision) throw Object.assign(/* @__PURE__ */ new Error("current-title settings changed"), { code: "revision" });
540
+ const previous = this.settings;
541
+ const next = {
542
+ revision: this.settings.revision + 1,
543
+ ...settingsPatch(this.settings, patch)
544
+ };
545
+ if (this.options.store) await this.options.store.save(next);
546
+ this.settings = next;
547
+ this.config = {
548
+ ...this.config,
549
+ locale: "auto"
550
+ };
551
+ if (!this.disposed && next.cadence !== previous.cadence) await this.reclaimWithCadence();
552
+ return {
553
+ revision: next.revision,
554
+ locale: next.locale,
555
+ prompt: next.prompt,
556
+ model: { ...next.model },
557
+ cadence: next.cadence
558
+ };
559
+ });
560
+ this.settingsPending = result.then(() => {}, () => {});
561
+ return result;
562
+ }
563
+ /** Re-register the provider so a cadence change takes effect without a restart. */
564
+ async reclaimWithCadence() {
565
+ if (!this.claim || !this.nativeSlot) return;
566
+ const stale = this.claim;
567
+ this.claim = void 0;
568
+ await stale.dispose();
569
+ try {
570
+ this.claim = await claimTitleSlot(this.nativeSlot, PROVIDER_ID);
571
+ this.support.weOwn = true;
572
+ this.support.nativeOwner = PROVIDER_ID;
573
+ delete this.support.limitation;
574
+ } catch (error) {
575
+ this.support.weOwn = false;
576
+ this.support.nativeOwner = this.nativeSlot.owner() ?? null;
577
+ this.support.limitation = error instanceof Error ? error.message : "native title provider slot is occupied";
578
+ }
579
+ }
580
+ /** @deprecated Locale is always auto. Kept so older clients still save a revision. */
494
581
  async updateLocale(_locale, expectedRevision) {
495
- if (this.settings.revision !== expectedRevision) throw Object.assign(/* @__PURE__ */ new Error("current-title settings changed"), { code: "revision" });
496
- const next = {
497
- revision: this.settings.revision + 1,
498
- locale: "auto"
499
- };
500
- if (this.options.store) await this.options.store.save(next);
501
- this.settings = next;
502
- this.config = {
503
- ...this.config,
504
- locale: "auto"
505
- };
506
- return { ...this.settings };
582
+ return this.updateSettings({ expectedRevision });
507
583
  }
508
584
  async regenerate(sessionId, signal) {
509
585
  if (!this.support.weOwn) throw Object.assign(/* @__PURE__ */ new Error("title provider is inactive"), { code: "unavailable" });
@@ -528,19 +604,16 @@ var CurrentTitleService = class {
528
604
  };
529
605
  }
530
606
  async nativeGenerate(request) {
531
- const scope = this.scope;
532
- if (!scope) throw new Error("dsh-current-title: aiServices is not available");
607
+ const llm = this.options.llm;
608
+ if (!llm) throw new Error("dsh-current-title: llm is not available");
533
609
  const generation = this.generation;
534
610
  const controller = new AbortController();
535
611
  this.inFlight.get(request.session.id)?.abort(/* @__PURE__ */ new Error("newer title generation superseded older work"));
536
612
  this.inFlight.set(request.session.id, controller);
537
613
  try {
538
- const combined = AbortSignal.any([
539
- request.signal,
540
- controller.signal,
541
- scope.signal
542
- ]);
614
+ const combined = AbortSignal.any([request.signal, controller.signal]);
543
615
  const session = this.options.sessions?.get(request.session.id);
616
+ if (isSubagentSession(session)) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: delegated child thread keeps the host fallback title"), { code: "subagent-session" });
544
617
  const messages = collectHumanMessages(request.messages.map((message) => ({
545
618
  type: "user/message",
546
619
  seq: message.seq,
@@ -553,13 +626,16 @@ var CurrentTitleService = class {
553
626
  }
554
627
  })));
555
628
  const generated = await generateCurrentTitle({
556
- scope,
629
+ llm,
630
+ host: this.options.host ?? {},
557
631
  config: this.config,
558
632
  sessionId: request.session.id,
559
633
  messages: messages.length > 0 ? messages : [...request.messages],
634
+ prompt: this.settings.prompt,
635
+ model: this.settings.model,
560
636
  localePreference: this.options.localePreference?.(),
561
637
  signal: combined,
562
- isCurrent: () => this.generation === generation && scope.active && this.support.weOwn && (!session || this.options.sessions?.get(request.session.id) === session),
638
+ isCurrent: () => this.generation === generation && this.support.weOwn && (!session || this.options.sessions?.get(request.session.id) === session),
563
639
  now: this.options.now?.()
564
640
  });
565
641
  return {
@@ -590,17 +666,22 @@ async function handleRpc(service, endpoint, payload, signal) {
590
666
  value: service.status(typeof body.sessionId === "string" ? body.sessionId : void 0)
591
667
  };
592
668
  if (endpoint === "settings") {
593
- if (!isTitleLocaleMode(body.locale) || typeof body.expectedRevision !== "number") return {
669
+ if (typeof body.expectedRevision !== "number") return {
594
670
  ok: false,
595
671
  error: {
596
672
  code: "bad-request",
597
- message: "标题语言设置无效。"
673
+ message: "标题设置无效。"
598
674
  }
599
675
  };
600
676
  try {
601
677
  return {
602
678
  ok: true,
603
- value: await service.updateLocale(body.locale, body.expectedRevision)
679
+ value: await service.updateSettings({
680
+ ...body.prompt !== void 0 ? { prompt: body.prompt } : {},
681
+ ...body.model !== void 0 ? { model: body.model } : {},
682
+ ...body.cadence !== void 0 ? { cadence: body.cadence } : {},
683
+ expectedRevision: body.expectedRevision
684
+ })
604
685
  };
605
686
  } catch (error) {
606
687
  const code = error instanceof Error && "code" in error ? String(error.code) : "failed";
@@ -678,7 +759,7 @@ async function handleRpc(service, endpoint, payload, signal) {
678
759
  const name = PLUGIN_NAME;
679
760
  const inject = [
680
761
  "sessionTitle",
681
- "aiServices",
762
+ "llm",
682
763
  "sessions",
683
764
  "loader",
684
765
  "storageDomain",
@@ -692,7 +773,8 @@ async function apply(ctx, config = {}) {
692
773
  const locale = isTitleLocaleMode(config.locale) ? config.locale : "auto";
693
774
  const service = new CurrentTitleService({
694
775
  plugin: PLUGIN_NAME,
695
- ai: host.aiServices,
776
+ llm: host.llm,
777
+ host,
696
778
  sessionTitle: host.sessionTitle,
697
779
  loader: host.loader,
698
780
  sessions: host.sessions,