dsh-quote-annotate-pro 0.2.5 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  本项目的所有重要变更都记在这里。版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## 0.2.7 — 引用渲染定版:输入框与对话区都不再"先露原文"
6
+
7
+ - **输入框侧(本次)**:插入引用后**同一帧内**完成装饰 —— 不再走 `setTimeout`,改为在 `MutationObserver` 回调里同步执行(回调先于绘制),消除此前约 40ms 的"先露出完整引用块"窗口
8
+ - **对话区侧(本次)**:位置行不再"先露一百多毫秒再被隐藏"(依据录屏 + diag-sink 日志定位)
9
+ - 用户消息行有两副面孔:宿主的正式行带 `data-chat-anchor-key`,但消息刚发出去的那一下先渲染成 `div.*_userRow`(**还没有锚点属性**)→ 插件此前够不到,实测空窗约 122ms
10
+ - 现在两类行都按用户行处理(`USER_ROW_SEL`):行出现的**同一帧**就完成装饰(MutationObserver 回调先于绘制),不再等防抖
11
+ - 宿主在渲染后还会重排气泡内容(实测再约 120ms 一次),会把已隐藏的位置行换回原文;判定改为**看内容**(`rowHasRawHeader()`)而不是看 `data-dshq-sent` 标记,重排的同一帧即补装饰
12
+ - 观察器增加 `attributeFilter: ['data-chat-anchor-key','data-chat-flow-kind']`,覆盖"先插文本、后挂锚点"的渲染顺序
13
+ - **文本零改动**:装饰只加样式类/包一层隐藏元素,**消息文本仍是 `【引用 · 第N轮 · 角色】` + 原文** → 模型侧定位能力不变
14
+ - 新增静默诊断探针(`cmp:probe` 等),仅在运行 `npm run diag` 时上报;发布版无任何网络请求
15
+
16
+ ## 0.2.6 — 输入框与消息统一为紧凑引用(消除插入瞬间的闪烁)
17
+
18
+ - **输入框里的引用**:插入后渲染成**紧凑原文块**(位置行隐藏)
19
+ - 实现:装饰函数暴露为模块级钩子,插入成功即调用一次
20
+ - **引用文本去掉 `> ` 前缀**(`【引用 · 第N轮 · 角色】` + 原文),编辑器里不再多露一个符号
21
+ - **文本保持完整**:界面只是隐藏位置行(加样式类,不动编辑器文本),因此**发送出去的内容仍带轮次/角色** → 模型侧定位能力不变
22
+ - 消息侧(0.2.3)的紧凑渲染保持不变,输入与输出观感一致
23
+
5
24
  ## 0.2.5 — 引用块样式:贴在输入框上沿
6
25
 
7
26
  - 0.2.4 曾把引用块挪进 `conversation.input.overlay`,实测该槽位是**浮动弹层**(放的是 slash-menu / command-popup 这类弹窗),静态内容放进去不可见 → **改回 `conversation.input.dock`**
package/README.en.md CHANGED
@@ -17,8 +17,8 @@
17
17
  | --- | --- |
18
18
  | Select & annotate | Select text in the transcript → a bubble appears above the selection → write a comment (optional) → `Enter` |
19
19
  | Quote chip | An atomic `引用1` chip (short label) lands in the composer; it expands to the full quote block on send |
20
- | **Quote block** | While a quote is in the composer, a compact block above it shows the **quoted text snippet** (single line, ellipsised). Click it to jump back to the source and highlight it — no visible "jump" affordance, so the composer stays clean |
21
- | **Compact quotes in the transcript** | A sent message shows only a **compact quote block** (up to 3 lines) — the `【引用 · 第N轮 · 角色】` label is hidden. Clicking the block jumps back to the source and highlights it, while the message payload still carries the turn/role, so the model keeps the position context |
20
+ | **Quote block (in the composer)** | After inserting a quote the composer shows only the **quoted text** (the turn/role line is hidden); click it to jump back to the source and highlight it — no visible "jump" affordance |
21
+ | **Compact quotes in the transcript** | A sent message shows only a **compact quote block** (up to 3 lines). The message text still starts with `【引用 · 第N轮 · 角色】` — hidden in the UI, so the model keeps the position context |
22
22
  | **Turn-accurate context** | The quote block starts with `【引用 · 第3轮 · 你的消息】` followed by `> quoted text`, so the model knows exactly which turn and whose message |
23
23
  | Turn-level quoting | Every finished turn gets `引用提问` / `引用回答` buttons that quote the whole question or answer |
24
24
  | Anchors survive reload | The `ref → anchor` map is persisted in `localStorage` (per session, 60 entries max), so quotes still jump after a page refresh |
package/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  |---|---|
18
18
  | 划词批注 | 会话里选中任意文字 → 选区上方浮出气泡(预览 + 「批注」)→ 编辑框里写评语(可留空)→ `Enter` 插入 |
19
19
  | 引用 chip | 输入框里出现 `引用1` 的原子 chip(短标签);发送时展开为完整引用块 |
20
- | **引用块(输入框上方)** | 有引用时在输入框上方显示**原文片段**(单行、超出省略);**点一下即跳回原文并高亮** —— 不做任何可见的"跳转"提示,保持输入区干净 |
21
- | **对话页里的紧凑引用** | 已发送消息里的引用也只显示**紧凑原文块**(最多 3 行),**不显示"第N轮/谁说的"标签**;点该块可跳回原文。而**消息本体仍带位置信息**,模型照旧知道出处 |
20
+ | **引用块(输入框内)** | 插入引用后输入框里只显示**原文片段**(位置行隐藏);**点一下即跳回原文并高亮** —— 不做任何可见的"跳转"提示 |
21
+ | **对话页里的紧凑引用** | 已发送消息里的引用同样只显示**紧凑原文块**(最多 3 行)。**消息文本仍以 `【引用 · 第N轮 · 角色】` 开头** —— 界面隐藏、模型照旧知道出处 |
22
22
  | **模型侧精确定位** | 引用块首行是 `【引用 · 第5轮 · 你的消息】`,后接 `> 原文`,让模型明确知道**哪一轮、谁说的** |
23
23
  | 回合级引用 | 每条已完成回合尾部有「引用提问 / 引用回答」,整段引用该轮 |
24
24
  | 锚点持久化 | `ref → 锚点` 映射写入 `localStorage`(按会话,最多 60 条),**刷新页面后引用块仍能跳回原文** |
package/lib/client.js CHANGED
@@ -147,6 +147,10 @@ window.__ModuleLoader__.load({
147
147
  const refTargets = new Map()
148
148
  // ref -> { body, clipboardText } 提交时经 codec 序列化
149
149
  const refCodecs = new Map()
150
+ // 输入框引用装饰的"立即执行"钩子:插入后马上贴样式,避免先露出旧样子再变(用户反馈的瞬间闪烁)
151
+ let composerDecorateNow = null
152
+ // 诊断用:最近一次插入引用的时刻(毫秒),用于测"装饰相对插入晚了多久"
153
+ let lastInsertAt = 0
150
154
 
151
155
  // 页面内反馈 toast(点击 chip 时显示结果)
152
156
  const toastStore = {
@@ -735,8 +739,8 @@ window.__ModuleLoader__.load({
735
739
  try {
736
740
  const c = (comment || '').trim()
737
741
  const header = buildQuoteHeader(item)
738
- const quoted = item.text.split('\n').map((line) => '> ' + line).join('\n')
739
- const quoteBody = header === '' ? quoted : header + '\n' + quoted
742
+ // 引用正文不再加 "> " 前缀:输入框与消息里都由插件渲染成紧凑块(前缀会在编辑器里露出一截符号)
743
+ const quoteBody = header === '' ? item.text : header + '\n' + item.text
740
744
  const conversation = rootCtx ? rootCtx.get('conversation') : undefined
741
745
  const sessions = rootCtx ? rootCtx.get('sessions') : undefined
742
746
  if (!conversation || !conversation.input || !sessions) {
@@ -762,6 +766,8 @@ window.__ModuleLoader__.load({
762
766
  const ref = 'q' + REF_RUN + (++refSeq)
763
767
  // 输入框里的 chip 文案只要短、可区分即可(轮次/角色写在发送后的引用块里,不在输入框展示)
764
768
  const chipLabel = '引用' + refSeq
769
+ // 输入框与消息里显示的就是这段文本(实测:宿主消息 = 草稿文本,WYSIWYG);
770
+ // 因此位置行(【引用 · 第N轮 · 角色】)保留在文本里给模型用,界面上由插件渲染时隐藏。
765
771
  refTargets.set(ref, { sessionId, anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
766
772
  refCodecs.set(ref, { body: quoteBody, clipboardText: quoteBody })
767
773
  saveRef(sessionId, ref, { anchorKey: item.anchorKey || null, body: quoteBody, label: chipLabel })
@@ -791,7 +797,19 @@ window.__ModuleLoader__.load({
791
797
  const st3 = input.state.getSnapshot()
792
798
  input.setDraft(st3.draft.replace(/[ \t]+$/, '') + commentTail)
793
799
  }
800
+ try {
801
+ const snap2 = input.state.getSnapshot()
802
+ const occ2 = (snap2 && snap2.occurrences) || []
803
+ report('insert:draft', {
804
+ draft: String((snap2 && snap2.draft) || '').replace(/\s+/g, ' ').slice(0, 120),
805
+ occ: occ2.length,
806
+ occLabels: occ2.map((o) => (o && o.label) || '').join('|').slice(0, 80),
807
+ })
808
+ } catch (e) { /* ignore */ }
794
809
  report('insert:chip', { ref, anchorKey: item.anchorKey || null, header, chars: item.text.length })
810
+ // 立刻给刚插入的内容贴样式,避免"先露出旧样子、稍后才变紧凑"的闪烁
811
+ lastInsertAt = (typeof performance !== 'undefined' ? performance.now() : Date.now())
812
+ try { if (typeof composerDecorateNow === 'function') composerDecorateNow() } catch (e) { /* ignore */ }
795
813
  setStatus({
796
814
  ok: true,
797
815
  text: '已插入引用 ✓' + (item.anchorKey ? '(输入框上方的引用块可点回原文)' : '(未定位到消息行,可能无法跳回)'),
@@ -997,6 +1015,11 @@ window.__ModuleLoader__.load({
997
1015
  .dshq-sent-quote{display:block;margin:3px 0;padding:6px 10px;border-radius:8px;background:rgba(127,127,127,.10);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;cursor:pointer;overflow:hidden;transition:background .12s ease,color .12s ease}
998
1016
  .dshq-sent-quote:hover{color:var(--dsw-alias-label-primary)}
999
1017
  .dshq-sent-quote-text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-wrap;word-break:break-word}
1018
+ /* 输入框里的引用:隐藏位置行,正文显示为紧凑块(输入框与消息观感一致) */
1019
+ .dshq-cmp-header{display:none !important}
1020
+ .dshq-cmp-quote{background:rgba(127,127,127,.10);border-radius:8px;padding:2px 10px;margin:2px 0}
1021
+ .dshq-cmp-quote-first{margin-top:4px}
1022
+ .dshq-cmp-quote-last{margin-bottom:4px}
1000
1023
  `
1001
1024
 
1002
1025
  // ============ 插件主体 ============
@@ -1270,18 +1293,22 @@ window.__ModuleLoader__.load({
1270
1293
  })
1271
1294
  return wrap
1272
1295
  }
1273
- /** 把承载「【引用…】\n> 原文…」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
1296
+ /** 把承载「【引用…】\n原文…」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
1274
1297
  const transformTextNode = (node) => {
1275
1298
  const raw = node.nodeValue || ''
1276
1299
  const m = HEADER_RE.exec(raw)
1277
1300
  if (!m) return false
1278
- const quoteStart = m.index + m[0].length
1279
- const nl = raw.indexOf('\n', quoteStart)
1301
+ const after = raw.slice(m.index + m[0].length)
1302
+ const nl = after.indexOf('\n')
1280
1303
  if (nl < 0) return false
1281
- const lines = raw.slice(nl + 1).split('\n')
1304
+ const lines = after.slice(nl + 1).split('\n')
1305
+ // 引用正文 = 头部之后、直到空行之前的连续非空行(兼容旧格式的 "> " 前缀)
1282
1306
  const quote = []
1283
1307
  let i = 0
1284
- while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, '')); i++ }
1308
+ for (; i < lines.length; i++) {
1309
+ if (lines[i].trim() === '') break
1310
+ quote.push(lines[i].replace(/^>\s?/, ''))
1311
+ }
1285
1312
  if (quote.length === 0) return false
1286
1313
  const rest = lines.slice(i).join('\n')
1287
1314
  const frag = document.createDocumentFragment()
@@ -1300,17 +1327,22 @@ window.__ModuleLoader__.load({
1300
1327
  const scan = () => {
1301
1328
  timer = null
1302
1329
  try {
1303
- for (const row of document.querySelectorAll('[data-chat-anchor-key]')) {
1304
- if (row.getAttribute('data-chat-flow-kind') !== 'user') continue
1305
- if (row.dataset && row.dataset.dshqSent === '1') continue
1330
+ for (const row of document.querySelectorAll(USER_ROW_SEL)) {
1331
+ const kind = row.getAttribute('data-chat-flow-kind')
1332
+ if (kind && kind !== 'user') continue
1306
1333
  let walker
1307
1334
  try { walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null) } catch (e) { continue }
1308
1335
  const targets = []
1309
1336
  let n = walker.nextNode()
1310
1337
  while (n) {
1311
- if (HEADER_RE.test(n.nodeValue || '')) targets.push(n)
1338
+ const parent = n.parentElement
1339
+ // 已经装饰过的部分(隐藏的位置行 / 紧凑引用块)不再重复处理
1340
+ const managed = parent && typeof parent.closest === 'function'
1341
+ ? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
1342
+ if (!managed && HEADER_RE.test(n.nodeValue || '')) targets.push(n)
1312
1343
  n = walker.nextNode()
1313
1344
  }
1345
+ if (targets.length === 0) continue
1314
1346
  let done = false
1315
1347
  for (const t of targets) { try { if (transformTextNode(t)) done = true } catch (e) { /* ignore */ } }
1316
1348
  if (done && row.dataset) row.dataset.dshqSent = '1'
@@ -1318,10 +1350,80 @@ window.__ModuleLoader__.load({
1318
1350
  } catch (e) { report('sent:scan-throw', String((e && e.message) || e)) }
1319
1351
  }
1320
1352
  const schedule = () => { if (timer === null) timer = setTimeout(scan, 300) }
1353
+ // 消息从输入框进入对话区时,首帧先画出的是原始文本(含"【引用 · …】"位置行),
1354
+ // 300ms 防抖会让它闪一下。MutationObserver 回调在下一次绘制前执行,
1355
+ // 因此只要这次变动牵扯到"还没装饰过的用户消息行"就同步扫一次,首帧即完成隐藏;
1356
+ // 其余情况(例如助手流式输出)仍走防抖,不增加扫描开销。
1357
+ // 用户消息行有两副面孔:正式行带 anchor 属性;但实测"刚发出去"的那一下,
1358
+ // 宿主先渲染出 <div class="cJsG2q_userRow">(还没有 data-chat-anchor-key),
1359
+ // 等属性挂上才轮得到插件 —— 那段空窗就是用户看到的闪。两类都按用户行处理。
1360
+ const USER_ROW_SEL = '[data-chat-anchor-key][data-chat-flow-kind="user"], [class*="userRow" i]'
1361
+ const asElement = (node) => (node && node.nodeType === 1 ? node : (node && node.parentElement) || null)
1362
+ // 行里是否还存在"没被插件接管过的"位置行文本。
1363
+ // 宿主会在渲染后重排/替换气泡内容(实测发送后约 120ms 会有一次),
1364
+ // 那时我们贴好的隐藏 span 会被换回原始文本 —— 用内容判断而不是用标记判断,
1365
+ // 才能在重渲染的同一帧里立刻补装饰。
1366
+ const rowHasRawHeader = (row) => {
1367
+ try {
1368
+ const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null)
1369
+ let n = walker.nextNode()
1370
+ while (n) {
1371
+ const parent = n.parentElement
1372
+ const managed = parent && typeof parent.closest === 'function'
1373
+ ? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
1374
+ if (!managed && HEADER_RE.test(n.nodeValue || '')) return true
1375
+ n = walker.nextNode()
1376
+ }
1377
+ return false
1378
+ } catch (e) { return false }
1379
+ }
1380
+ const touchesPendingUserRow = (node) => {
1381
+ const el = asElement(node)
1382
+ if (!el || typeof el.closest !== 'function') return false
1383
+ const row = el.closest(USER_ROW_SEL)
1384
+ // ① 变动落在用户消息行内部(含行自身):只要还能找到未接管的位置行就立刻扫
1385
+ if (row) {
1386
+ const kind = row.getAttribute('data-chat-flow-kind')
1387
+ if (kind && kind !== 'user') return false
1388
+ return rowHasRawHeader(row)
1389
+ }
1390
+ // ② 新增的子树里带着这样的用户消息行(宿主可能整块替换容器)
1391
+ if (typeof el.querySelector === 'function') {
1392
+ try {
1393
+ for (const r of el.querySelectorAll(USER_ROW_SEL)) {
1394
+ const kind = r.getAttribute('data-chat-flow-kind')
1395
+ if (kind && kind !== 'user') continue
1396
+ if (rowHasRawHeader(r)) return true
1397
+ }
1398
+ } catch (e) { /* ignore */ }
1399
+ }
1400
+ return false
1401
+ }
1402
+ const onMutations = (records) => {
1403
+ let urgent = false
1404
+ try {
1405
+ for (const r of records) {
1406
+ const cands = [r.target]
1407
+ const added = r.addedNodes
1408
+ if (added) { for (const n of added) cands.push(n) }
1409
+ for (const c of cands) {
1410
+ if (!urgent && touchesPendingUserRow(c)) urgent = true
1411
+ }
1412
+ }
1413
+ if (urgent) scan()
1414
+ } catch (e) { /* ignore */ }
1415
+ schedule()
1416
+ }
1321
1417
  scan()
1322
1418
  try {
1323
- observer = new MutationObserver(schedule)
1324
- observer.observe(document.body, { childList: true, subtree: true })
1419
+ observer = new MutationObserver(onMutations)
1420
+ observer.observe(document.body, {
1421
+ childList: true,
1422
+ subtree: true,
1423
+ characterData: true,
1424
+ attributes: true,
1425
+ attributeFilter: ['data-chat-anchor-key', 'data-chat-flow-kind'],
1426
+ })
1325
1427
  } catch (e) { report('sent:observer-throw', String((e && e.message) || e)) }
1326
1428
  return () => {
1327
1429
  if (timer !== null) clearTimeout(timer)
@@ -1331,6 +1433,79 @@ window.__ModuleLoader__.load({
1331
1433
  } catch (e) {
1332
1434
  report('apply:sent-decor-throw', String((e && e.message) || e))
1333
1435
  }
1436
+
1437
+ // ============ 输入框里的引用:隐藏位置行 + 紧凑块(与消息里的渲染保持一致) ============
1438
+ // 只给编辑器的行元素加 class,**不改动编辑器文本** → 发送出去的内容不变(模型仍拿到位置信息)。
1439
+ // 编辑器重渲染会丢掉 class,因此用 MutationObserver 重新贴上。
1440
+ try {
1441
+ ctx.effect(() => {
1442
+ let timer = null
1443
+ let observer = null
1444
+ const isSingleLine = (t) => t !== '' && t.indexOf('\n') === -1
1445
+ let lastSig = ''
1446
+ const decorate = () => {
1447
+ timer = null
1448
+ try {
1449
+ for (const ed of document.querySelectorAll('[contenteditable="true"]')) {
1450
+ const kids = Array.from(ed.children || [])
1451
+ let headerEl = null
1452
+ const quoteEls = []
1453
+ for (const el of kids) {
1454
+ const t = String(el.textContent || '').trim()
1455
+ if (!headerEl) {
1456
+ // 位置行必须"整行且只有一行",避免误把整个编辑器当成头部
1457
+ if (isSingleLine(t) && /^【引用\s*·[^\n]*】$/.test(t)) headerEl = el
1458
+ continue
1459
+ }
1460
+ if (t === '') break
1461
+ if (!isSingleLine(t)) break
1462
+ quoteEls.push(el)
1463
+ }
1464
+ if (!headerEl) {
1465
+ // 诊断:编辑器里出现了"【引用"但没能匹配成"位置行 + 正文行"时,把结构打出来
1466
+ const raw = String(ed.textContent || '')
1467
+ if (raw.indexOf('【引用') >= 0) {
1468
+ const sig = raw.slice(0, 40) + '|' + kids.length
1469
+ if (sig !== lastSig) {
1470
+ lastSig = sig
1471
+ report('cmp:probe', {
1472
+ ms: lastInsertAt ? Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - lastInsertAt) : null,
1473
+ kids: kids.length,
1474
+ singles: kids.map((el) => (isSingleLine(String(el.textContent || '').trim()) ? 1 : 0)).join(''),
1475
+ first: String((kids[0] && kids[0].textContent) || '').slice(0, 24),
1476
+ })
1477
+ }
1478
+ }
1479
+ continue
1480
+ }
1481
+ headerEl.classList.add('dshq-cmp-header')
1482
+ quoteEls.forEach((el, idx) => {
1483
+ el.classList.add('dshq-cmp-quote')
1484
+ el.classList.toggle('dshq-cmp-quote-first', idx === 0)
1485
+ el.classList.toggle('dshq-cmp-quote-last', idx === quoteEls.length - 1)
1486
+ })
1487
+ report('cmp:decorated', { lines: quoteEls.length })
1488
+ }
1489
+ } catch (e) { report('cmp:scan-throw', String((e && e.message) || e)) }
1490
+ }
1491
+ // 关键:MutationObserver 回调在**下一次绘制之前**执行 —— 因此在这里同步装饰即可消除
1492
+ // "先露出完整引用块、稍后才变紧凑"的闪烁(之前用 setTimeout(40ms) 期间已经画了一帧)。
1493
+ // decorate() 只在缺少 class 时改动 DOM,不会形成观察循环。
1494
+ decorate()
1495
+ composerDecorateNow = decorate
1496
+ try {
1497
+ observer = new MutationObserver(() => { decorate() })
1498
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
1499
+ } catch (e) { report('cmp:observer-throw', String((e && e.message) || e)) }
1500
+ return () => {
1501
+ if (composerDecorateNow === decorate) composerDecorateNow = null
1502
+ if (timer !== null) clearTimeout(timer)
1503
+ if (observer) { try { observer.disconnect() } catch (e) { /* ignore */ } }
1504
+ }
1505
+ }, 'dsh-quote-annotate-pro: composer quote decoration')
1506
+ } catch (e) {
1507
+ report('apply:cmp-decor-throw', String((e && e.message) || e))
1508
+ }
1334
1509
  const tryRegister = (tag, fn) => {
1335
1510
  try { fn(); report('register:ok', tag); return true } catch (e) {
1336
1511
  report('register:throw', tag + ' :: ' + String((e && e.stack) || (e && e.message) || e))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-annotate-pro",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "选区引用与锚点批注(增强 fork):划词 → 批注 → 带精确锚点的引用;输入框上方只显示原文片段,点一下跳回原文;发送时把轮次/角色写入引用块,让模型精确知道你指的是哪一处。",
5
5
  "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "quote", "annotate", "selection", "reference", "anchor"],
6
6
  "license": "MIT",