dsh-quote-followup 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,13 +17,15 @@ A Web-only [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) p
17
17
  ## Features
18
18
 
19
19
  - Select text inside the Web conversation transcript to reveal a floating **Quote** button.
20
- - Append a native DSH conversation-reference chip without replacing the existing draft.
20
+ - Append a native DSH conversation-reference chip without replacing the existing draft, using the session-scoped composer input facade rather than Lexical's private node registry.
21
21
  - Reuse the same atomic `ReferenceChipNode`, conversation icon, and business color as `@file` / `@session`.
22
22
  - Keep each chip compact by showing only the excerpt; the bubble icon already conveys that it is a quote.
23
23
  - Follow the active DSH locale for the floating action and serialized quote frame.
24
- - Attach the conversation turn number to the serialized frame when the selection comes from a DSH chat row, so follow-ups like "revisit turn 3" stay resolvable. Selections without a turn marker keep the provenance-free frame.
24
+ - Attach the conversation turn number to the serialized frame when both ends of the selection belong to the same DSH chat row. Selections spanning messages keep the excerpt but omit ambiguous role/turn provenance.
25
25
  - Quote multiple excerpts; on send, the plugin codec expands each chip into a model-readable Markdown blockquote.
26
+ - Resolve the active session through the DSH 0.1.7 UI session adapter, while retaining the older session-list selection path; never paste an unscoped quote when no session binding is available.
26
27
  - Fall back to a plain-text quote when the host lacks native chip support.
28
+ - Show a localized notice if the composer is missing or unavailable, restore the selection, and let you retry quoting the original text.
27
29
 
28
30
  TUI is intentionally unsupported.
29
31
 
@@ -57,7 +59,7 @@ Add `dsh-quote-followup` to the Web profile's `dsh.profile.bundles`, then restar
57
59
  npm test
58
60
  ```
59
61
 
60
- The regression harness covers compact native quote chips, locale switching, turn-number provenance with graceful degradation, repeated quoting, spacing after an existing draft, codec serialization, the Firefox text fallback, and current-client takeover of stale singleton/button state after a hot swap.
62
+ The regression harness covers modern and legacy session selection, absent selection/binding, compact native quote chips, locale switching, single-row versus cross-message provenance, missing/locked composer feedback and retry, repeated quoting, spacing after an existing draft, codec serialization, the Firefox text fallback, and current-client takeover of stale singleton/button state after a hot swap.
61
63
 
62
64
  An opt-in [assembled DSH Web regression](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md) runs the plugin through a pinned, built DSH checkout with Chromium. It covers existing drafts, repeated quoting, undo/redo, session isolation, prompt serialization, and Host-driven unavailable input.
63
65
 
package/README.zh.md CHANGED
@@ -17,13 +17,15 @@
17
17
  ## 功能
18
18
 
19
19
  - 在 Web 对话区选中文本后显示浮动的 **❐ 引用** 按钮。
20
- - 以 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容。
20
+ - 通过会话级输入接口将 DSH 原生对话引用 chip 追加到现有草稿,不覆盖已输入内容,也不直接读取 Lexical 私有节点注册表。
21
21
  - chip 使用与 `@文件` / `@对话` 相同的 `ReferenceChipNode`、对话图标和业务色,可整体删除。
22
22
  - chip 只展示摘录正文,引用语义由气泡图标表达,减少重复标签。
23
23
  - 浮动按钮和发送时的引用框架跟随 DSH 当前语言。
24
- - 选中内容来自 DSH 聊天行时,序列化引用会附带对话轮次编号,模型可定位"第 3 轮引用的内容";无轮次标记时保持原有框架。
24
+ - 选区两端属于同一 DSH 聊天行时,序列化引用附带对话轮次编号;跨消息选区保留摘录,但不标注归属不明的角色与轮次。
25
25
  - 可连续引用多段内容;发送时由插件 codec 将各 chip 展开为模型可读的 Markdown 引用块。
26
+ - 通过 DSH 0.1.7 的 UI 会话适配器确定当前会话,同时兼容旧版会话列表的选中态;未取得会话 binding 时不会把引用作为无会话归属的文本插入。
26
27
  - 旧版 DSH 缺少原生 chip 能力时,自动降级为纯文本引用。
28
+ - 输入框缺失或暂不可编辑时显示本地化提示,恢复原选区,便于重试引用。
27
29
 
28
30
  本插件不再适配 TUI。
29
31
 
@@ -57,7 +59,7 @@ dsh plugin --profile web add dsh-quote-followup
57
59
  npm test
58
60
  ```
59
61
 
60
- 回归测试覆盖精简的原生引用 chip、中英文 locale 切换、轮次溯源与降级、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
62
+ 回归测试覆盖新版与旧版会话选中态、无选中会话或 binding、精简的原生引用 chip、中英文 locale 切换、同消息与跨消息的来源判定、输入框缺失/不可编辑时的提示与重试、连续引用、已有草稿后的间距、codec 序列化、Firefox 文本降级路径,以及热替换后新版 client 接管旧按钮/单例状态。
61
63
 
62
64
  可选的[真实 DSH Web 集成回归](https://github.com/tr1v3r/dsh-quote-followup/blob/master/test/integration/README.md)通过固定版本、已构建的 DSH 和 Chromium 加载插件,验证已有草稿、连续引用、撤销重做、会话隔离、发送序列化及 Host 驱动的不可写输入状态。
63
65
 
package/lib/client.js CHANGED
@@ -7,9 +7,10 @@
7
7
  * can edit before sending — a visible, targeted follow-up with zero hidden
8
8
  * state and zero host-side privileges.
9
9
  *
10
- * Pure DOM (no React). The only injected host services are DSH's
11
- * `inputTriggers` codec registry and `locale` (both optional — the module
12
- * degrades to English text fallbacks when either is absent). Mounted by the
10
+ * Pure DOM (no React). The injected host services are DSH's
11
+ * `inputTriggers` codec registry, `locale`, `uiSession`, and `sessions`. Without the
12
+ * optional codec, quotes degrade to plain text; without locale, copy is English.
13
+ * Session scoping is needed for native reference insertion. Mounted by the
13
14
  * web client runtime through the package's `dsh.client` declaration
14
15
  * (`platform: "web"`).
15
16
  *
@@ -26,6 +27,7 @@ window.__ModuleLoader__.load({
26
27
  const TRANSCRIPT_SELECTOR = '[data-slot="conversation.session"]';
27
28
  /** Composer input candidates, most specific first (official slot → legacy hints). */
28
29
  const COMPOSER_SELECTORS = [
30
+ '[data-composer-input]',
29
31
  '[data-slot="conversation.composer"] textarea',
30
32
  '[data-slot="conversation.composer"] [contenteditable="true"]',
31
33
  'textarea[data-phase]',
@@ -35,11 +37,13 @@ window.__ModuleLoader__.load({
35
37
  ];
36
38
  const BUTTON_ID = "dsh-quote-followup-btn";
37
39
  const BUTTON_VERSION_ATTR = "data-dsh-quote-followup-version";
38
- const CLIENT_VERSION = "0.2.7";
40
+ const CLIENT_VERSION = "0.2.9";
39
41
  /** Shared <style> element; versioned like the button so hot-swaps replace it. */
40
42
  const STYLE_ID = "dsh-quote-followup-style";
41
43
  const STYLE_VERSION_ATTR = "data-dsh-quote-followup-style-version";
42
44
  const BUTTON_CLASS = "dsh-qf-btn";
45
+ const NOTICE_ID = "dsh-quote-followup-notice";
46
+ const NOTICE_CLASS = "dsh-qf-notice";
43
47
  /** Codec owner for native DSH reference chips. */
44
48
  const QUOTE_SOURCE = "quote-followup";
45
49
  const LOCALE_NS = "quote-followup";
@@ -53,7 +57,10 @@ window.__ModuleLoader__.load({
53
57
  "conversation.role.user": "用户",
54
58
  "conversation.role.assistant": "助手",
55
59
  "quote.turn": "第 {n} 轮",
56
- "quote.truncated": "已截断"
60
+ "quote.truncated": "已截断",
61
+ "notice.composer-missing": "未找到输入框;摘录已保留,请选中原文重试。",
62
+ "notice.composer-locked": "输入框暂不可编辑;摘录已保留,请选中原文重试。",
63
+ "notice.insert-failed": "引用未能插入;摘录已保留,请选中原文重试。"
57
64
  },
58
65
  en: {
59
66
  "button.label": "❐ Quote",
@@ -63,7 +70,10 @@ window.__ModuleLoader__.load({
63
70
  "conversation.role.user": "user",
64
71
  "conversation.role.assistant": "assistant",
65
72
  "quote.turn": "turn {n}",
66
- "quote.truncated": "truncated"
73
+ "quote.truncated": "truncated",
74
+ "notice.composer-missing": "Composer not found; excerpt saved. Select the original text to retry.",
75
+ "notice.composer-locked": "Composer is unavailable; excerpt saved. Select the original text to retry.",
76
+ "notice.insert-failed": "Quote could not be inserted; excerpt saved. Select the original text to retry."
67
77
  }
68
78
  };
69
79
  const fallbackT = (key) => LOCALE_DICT.en[key] ?? key;
@@ -99,6 +109,9 @@ window.__ModuleLoader__.load({
99
109
  return null;
100
110
  return anchor;
101
111
  };
112
+ /** Only assign provenance when both ends belong to the same marked chat row. */
113
+ const messageRowOf = (node, transcript) =>
114
+ walkAncestors(node, transcript, (element) => element.hasAttribute?.("data-chat-turn") ? element : undefined);
102
115
  /**
103
116
  * Best-effort role for the quoted fragment: walk up from the range start
104
117
  * looking for role markers (data attributes, then class-name hints).
@@ -209,50 +222,87 @@ window.__ModuleLoader__.load({
209
222
  }
210
223
  return null;
211
224
  };
225
+ /** The UI adapter owns selection in 0.1.7; older hosts expose list.current. */
226
+ const selectedSessionId = (ctx, sessions) => {
227
+ let uiSession;
228
+ try {
229
+ uiSession = ctx?.get?.("uiSession");
230
+ } catch {
231
+ // Older hosts do not provide the UI session adapter.
232
+ }
233
+ const current = uiSession?.adapter?.current;
234
+ if (typeof current?.getSnapshot === "function") {
235
+ try {
236
+ return current.getSnapshot()?.key;
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+ return sessions?.list?.getSnapshot?.()?.current;
242
+ };
212
243
  /**
213
- * Insert through DSH's registered ReferenceChipNode so the quote uses the
214
- * exact same atomic editor entity and visual treatment as @file/@session.
215
- * The node class is already registered on the live composer; no second
216
- * Lexical or React runtime is loaded by this plugin.
244
+ * The per-session input facade owns chip creation, editor transactions and
245
+ * phase/revision guards. Append at its published draft end (detect-text
246
+ * offsets differ from clipboard-text lengths when a draft contains chips).
217
247
  */
218
- const appendQuoteChip = (element, payload, t) => {
219
- const editor = element.__lexicalEditor;
220
- const registry = editor?._nodes;
221
- const ChipNode = registry?.get?.("reference-chip")?.klass;
222
- const TextNode = registry?.get?.("text")?.klass;
223
- const ParagraphNode = registry?.get?.("paragraph")?.klass;
224
- if (typeof editor?.update !== "function" || typeof ChipNode !== "function" || typeof TextNode !== "function" || typeof ParagraphNode !== "function")
248
+ const selectedSessionScope = (ctx) => {
249
+ const sessions = ctx?.get?.("sessions");
250
+ if (sessions === null || sessions === undefined)
251
+ return null;
252
+ const sessionId = selectedSessionId(ctx, sessions);
253
+ // No selected session is not a missing composer capability. Never paste
254
+ // unscoped text into a composer that may belong to another session.
255
+ if (sessionId === undefined || sessionId === null)
256
+ return null;
257
+ // A binding is borrowed only for this synchronous edit; never retain it
258
+ // across a promise or a subsequent click after its generation may release.
259
+ // DSH 0.1.7 returns a SessionBinding wrapper whose scoped Cordis context
260
+ // lives at `.ctx`; older hosts returned that context from sessions.scope().
261
+ if (typeof sessions.binding === "function") {
262
+ const binding = sessions.binding(sessionId);
263
+ return binding?.ctx ?? null;
264
+ }
265
+ return sessions.scope?.(sessionId);
266
+ };
267
+ const appendQuoteChip = (scope, element, payload, t) => {
268
+ const conversation = scope.get?.("conversation");
269
+ if (typeof conversation?.input?.for !== "function")
225
270
  return false;
271
+ let input;
226
272
  try {
227
- let inserted = false;
228
- editor.update(() => {
229
- const state = editor._pendingEditorState ?? editor._editorState;
230
- const root = state?._nodeMap?.get?.("root");
231
- if (root === void 0 || root === null || typeof root.append !== "function")
232
- return;
233
- let block = typeof root.getLastChild === "function" ? root.getLastChild() : null;
234
- if (block === null || typeof block.append !== "function") {
235
- block = new ParagraphNode();
236
- root.append(block);
237
- }
238
- const nodes = [];
239
- const tail = typeof block.getTextContent === "function" ? block.getTextContent().slice(-1) : "";
240
- if (tail !== "" && !/\s/u.test(tail))
241
- nodes.push(new TextNode(" "));
242
- nodes.push(new ChipNode(quoteInsert(payload, t)));
243
- const trailing = new TextNode(" ");
244
- nodes.push(trailing);
245
- block.append(...nodes);
246
- if (typeof trailing.selectEnd === "function")
247
- trailing.selectEnd();
248
- inserted = true;
249
- }, { discrete: true });
273
+ input = conversation.input.for(scope);
274
+ } catch (error) {
275
+ console.warn("[dsh-quote-followup] scoped composer unavailable", error);
276
+ return false;
277
+ }
278
+ const state = input?.state?.getSnapshot?.();
279
+ if (state === undefined || typeof state.draftRev !== "number" || typeof input.insertReference !== "function")
280
+ return false;
281
+ // This is a rejected edit, not a missing feature: never bypass the
282
+ // facade's admission guard with an unguarded text fallback.
283
+ if (state.phase !== "plain" && state.phase !== "claimed")
284
+ return null;
285
+ const end = state.draft.length - (state.occurrences ?? []).reduce((extra, occurrence) =>
286
+ extra + Math.max(0, occurrence.length - 1), 0);
287
+ if (!Number.isSafeInteger(end) || end < 0)
288
+ return null;
289
+ const lead = end > 0 && !/\s/u.test(state.draft.slice(-1)) ? " " : "";
290
+ try {
291
+ if (lead !== "" && input.insertText(lead, { start: end, end, draftRev: state.draftRev }) !== true)
292
+ return null;
293
+ const current = input.state.getSnapshot();
294
+ if (current.draftRev !== state.draftRev + lead.length)
295
+ return null;
296
+ const at = end + lead.length;
297
+ const inserted = input.insertReference(quoteInsert(payload, t), {
298
+ start: at, end: at, draftRev: current.draftRev
299
+ });
250
300
  if (inserted)
251
301
  element.focus();
252
- return inserted;
302
+ return inserted ? true : null;
253
303
  } catch (error) {
254
- console.warn("[dsh-quote-followup] native reference chip failed; using text fallback", error);
255
- return false;
304
+ console.warn("[dsh-quote-followup] scoped reference insertion failed", error);
305
+ return null;
256
306
  }
257
307
  };
258
308
  /**
@@ -362,6 +412,34 @@ window.__ModuleLoader__.load({
362
412
  let pendingQuote = null;
363
413
  let quoteSourceReady = false;
364
414
  let currentT = fallbackT;
415
+ let clientContext = null;
416
+ let noticeTimer = null;
417
+ const clearNotice = () => {
418
+ if (noticeTimer !== null)
419
+ clearTimeout(noticeTimer);
420
+ noticeTimer = null;
421
+ document.getElementById(NOTICE_ID)?.remove();
422
+ };
423
+ const showNotice = (key) => {
424
+ clearNotice();
425
+ const notice = document.createElement("div");
426
+ notice.id = NOTICE_ID;
427
+ notice.className = NOTICE_CLASS;
428
+ notice.setAttribute("role", "status");
429
+ notice.setAttribute("data-message-key", key);
430
+ notice.textContent = currentT(key);
431
+ document.body.appendChild(notice);
432
+ noticeTimer = setTimeout(clearNotice, 4000);
433
+ };
434
+ const restoreSelection = (range) => {
435
+ if (range === null)
436
+ return;
437
+ try {
438
+ const selection = document.getSelection();
439
+ selection?.removeAllRanges();
440
+ selection?.addRange(range);
441
+ } catch {}
442
+ };
365
443
  /**
366
444
  * Inject the button stylesheet once per client version. Visuals follow
367
445
  * the dsh-annotation convention: a single idempotent <style> element
@@ -414,6 +492,21 @@ window.__ModuleLoader__.load({
414
492
  " outline: 2px solid var(--dsw-alias-text-accent, #4c9aff);",
415
493
  " outline-offset: 1px;",
416
494
  "}",
495
+ `.${NOTICE_CLASS} {`,
496
+ " position: fixed;",
497
+ " z-index: 2147483600;",
498
+ " left: 50%;",
499
+ " bottom: 24px;",
500
+ " transform: translateX(-50%);",
501
+ " max-width: min(90vw, 480px);",
502
+ " padding: 8px 12px;",
503
+ " border-radius: 8px;",
504
+ " background: var(--dsw-alias-button-primary-fill, rgba(56, 56, 62, .94));",
505
+ " color: var(--dsw-alias-label-primary-foreground, #f5f5f7);",
506
+ " font-family: var(--dsw-font-family, system-ui);",
507
+ " font-size: 12px;",
508
+ " pointer-events: none;",
509
+ "}",
417
510
  "@keyframes dsh-qf-pop {",
418
511
  " from { opacity: 0; transform: translateY(3px); }",
419
512
  " to { opacity: 1; transform: none; }",
@@ -469,29 +562,36 @@ window.__ModuleLoader__.load({
469
562
  if (button !== null)
470
563
  button.style.display = "none";
471
564
  };
565
+ const dismissQuote = () => {
566
+ pendingQuote = null;
567
+ hideButton();
568
+ };
472
569
  const onSelectionChange = () => {
473
570
  const selection = document.getSelection();
474
571
  const transcript = selectionInTranscript(selection);
475
572
  if (transcript === null) {
476
- pendingQuote = null;
477
- hideButton();
573
+ dismissQuote();
478
574
  return;
479
575
  }
480
576
  const text = selection.toString();
481
577
  if (text === null || text.trim() === "") {
482
- pendingQuote = null;
483
- hideButton();
578
+ dismissQuote();
484
579
  return;
485
580
  }
486
- const rect = selection.getRangeAt(0).getBoundingClientRect();
581
+ const range = selection.getRangeAt(0);
582
+ const rect = range.getBoundingClientRect();
487
583
  if (rect.width === 0 && rect.height === 0) {
488
- hideButton();
584
+ dismissQuote();
489
585
  return;
490
586
  }
587
+ const startRow = messageRowOf(range.startContainer, transcript);
588
+ const endRow = messageRowOf(range.endContainer, transcript);
589
+ const sameRow = startRow !== null && startRow === endRow;
491
590
  pendingQuote = {
492
591
  text: text.trim(),
493
- role: detectRole(selection.getRangeAt(0), transcript),
494
- turn: detectTurn(selection.getRangeAt(0), transcript)
592
+ role: sameRow ? detectRole(range, transcript) : null,
593
+ turn: sameRow ? detectTurn(range, transcript) : null,
594
+ range: range.cloneRange()
495
595
  };
496
596
  placeButton(ensureButton(), rect);
497
597
  };
@@ -499,19 +599,42 @@ window.__ModuleLoader__.load({
499
599
  if (pendingQuote === null)
500
600
  return;
501
601
  const quote = pendingQuote;
502
- pendingQuote = null;
503
602
  hideButton();
504
603
  const composer = findComposer();
505
604
  if (composer === null) {
506
- console.warn("[dsh-quote-followup] composer input not found — quote dropped");
605
+ restoreSelection(quote.range);
606
+ showNotice("notice.composer-missing");
607
+ return;
608
+ }
609
+ if (composer.disabled || composer.readOnly || composer.getAttribute("contenteditable") === "false" ||
610
+ (composer.isContentEditable === false && composer.tagName !== "TEXTAREA" && composer.tagName !== "INPUT")) {
611
+ restoreSelection(quote.range);
612
+ showNotice("notice.composer-locked");
507
613
  return;
508
614
  }
509
615
  // Clear the transcript range BEFORE focusing the composer. Clearing after
510
616
  // insertion destroys Lexical's caret and makes the next quote unreliable.
511
617
  document.getSelection()?.removeAllRanges();
512
618
  const payload = quotePayload(quote.text, quote.role, quote.turn);
513
- if (!(quoteSourceReady && appendQuoteChip(composer, payload, currentT)))
514
- appendToComposer(composer, quoteFrame(payload, currentT));
619
+ try {
620
+ const scope = selectedSessionScope(clientContext);
621
+ const inserted = scope === null || scope === undefined
622
+ ? null
623
+ : quoteSourceReady ? appendQuoteChip(scope, composer, payload, currentT) : false;
624
+ if (inserted === false)
625
+ appendToComposer(composer, quoteFrame(payload, currentT));
626
+ if (inserted === null) {
627
+ restoreSelection(quote.range);
628
+ showNotice("notice.insert-failed");
629
+ return;
630
+ }
631
+ pendingQuote = null;
632
+ clearNotice();
633
+ } catch (error) {
634
+ console.warn("[dsh-quote-followup] quote insertion failed", error);
635
+ restoreSelection(quote.range);
636
+ showNotice("notice.insert-failed");
637
+ }
515
638
  };
516
639
  //#endregion
517
640
  //#region apply
@@ -522,6 +645,7 @@ window.__ModuleLoader__.load({
522
645
  return;
523
646
  if (typeof previous?.dispose === "function")
524
647
  previous.dispose();
648
+ clientContext = ctx;
525
649
  let unregisterSource = null;
526
650
  let unregisterLocale = null;
527
651
  let unsubscribeLocale = null;
@@ -531,6 +655,9 @@ window.__ModuleLoader__.load({
531
655
  button.textContent = currentT("button.label");
532
656
  button.title = currentT("button.title");
533
657
  }
658
+ const notice = document.getElementById(NOTICE_ID);
659
+ if (notice !== null)
660
+ notice.textContent = currentT(notice.getAttribute("data-message-key"));
534
661
  };
535
662
  try {
536
663
  const locale = typeof ctx?.get === "function" ? ctx.get("locale") : null;
@@ -556,14 +683,14 @@ window.__ModuleLoader__.load({
556
683
  } catch (error) {
557
684
  console.warn("[dsh-quote-followup] native reference codec unavailable; using text fallback", error);
558
685
  }
559
- const onScroll = () => hideButton();
686
+ const onScroll = () => dismissQuote();
560
687
  const onKeyDown = (event) => {
561
688
  if (event.key === "Escape")
562
- hideButton();
689
+ dismissQuote();
563
690
  };
564
691
  const onMouseDown = (event) => {
565
692
  if (event.target instanceof Element && event.target.id !== BUTTON_ID)
566
- hideButton();
693
+ dismissQuote();
567
694
  };
568
695
  let disposed = false;
569
696
  let state;
@@ -571,6 +698,9 @@ window.__ModuleLoader__.load({
571
698
  if (disposed)
572
699
  return;
573
700
  disposed = true;
701
+ clientContext = null;
702
+ dismissQuote();
703
+ clearNotice();
574
704
  quoteSourceReady = false;
575
705
  if (typeof unsubscribeLocale === "function")
576
706
  unsubscribeLocale();
@@ -605,7 +735,7 @@ window.__ModuleLoader__.load({
605
735
  ctx.effect(() => cleanup, "dsh-quote-followup: selection quote ui");
606
736
  }
607
737
  //#endregion
608
- exports.inject = ["inputTriggers", "locale"];
738
+ exports.inject = ["inputTriggers", "locale", "sessions", "uiSession"];
609
739
  exports.apply = apply;
610
740
  return module.exports;
611
741
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-followup",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Quote selected Web conversation text into the DeepSeek Harness composer for targeted follow-up turns.",
5
5
  "keywords": [
6
6
  "dsh",