@objectivex666/dsh-settings-search 1.14.0 → 1.14.1

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.en.md CHANGED
@@ -7,6 +7,8 @@
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
9
  [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
10
+ [![dshbase 实测可装](https://dshbase.com/badges/dsh-settings-search-plugin.svg)](https://dshbase.com/zh/plugins/dsh-settings-search-plugin/)
11
+ [![dshfind](https://dshfind.com/api/card/objectivex666/dsh-settings-search?lang=zh)](https://dshfind.com/zh/plugins/objectivex666/dsh-settings-search?ref=badge)
10
12
 
11
13
  **English** · [中文](./README.md)
12
14
 
@@ -43,7 +45,7 @@ The commands below target a command-line profile. The desktop app manages its ow
43
45
 
44
46
  ### Prerequisites
45
47
 
46
- - **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers. Verified against **0.2.1-rc.2** item by item (`npm run compat`); behaviour on 0.2.0 and earlier is unchanged.
48
+ - **DeepSeek Harness (DSH)** — DSH 0.2 stable (desktop and `dsh web`) and earlier 0.1 builds; the plugin picks the API the running release offers. Verified against **0.2.2-rc.3** item by item (`npm run compat`); behaviour on 0.2.1 and earlier is unchanged.
47
49
  - **Node.js** >= 18 (LTS recommended).
48
50
 
49
51
  ### From npm (recommended)
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  [![node](https://img.shields.io/node/v/@objectivex666/dsh-settings-search)](https://nodejs.org)
8
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
9
  [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/objectivex666/dsh-settings-search)
10
+ [![dshbase 实测可装](https://dshbase.com/badges/dsh-settings-search-plugin.svg)](https://dshbase.com/zh/plugins/dsh-settings-search-plugin/)
11
+ [![dshfind](https://dshfind.com/api/card/objectivex666/dsh-settings-search?lang=zh)](https://dshfind.com/zh/plugins/objectivex666/dsh-settings-search?ref=badge)
10
12
 
11
13
  **中文** · [English](./README.en.md)
12
14
 
@@ -45,7 +47,7 @@ DSH 的设置项散落在各个分区和插件页里,找一个开关往往要
45
47
 
46
48
  ### 前置要求
47
49
 
48
- - **DeepSeek Harness (DSH)**。DSH 0.2 正式版(桌面版与 `dsh web`)和更早的 0.1 版本都可以,插件会按运行中的版本自动选择对应接口。当前已在 **0.2.1-rc.2** 上逐项核对过(`npm run compat`),0.2.0 及之前的行为保持不变。
50
+ - **DeepSeek Harness (DSH)**。DSH 0.2 正式版(桌面版与 `dsh web`)和更早的 0.1 版本都可以,插件会按运行中的版本自动选择对应接口。当前已在 **0.2.2-rc.3** 上逐项核对过(`npm run compat`),0.2.1 及更早版本的行为保持不变。
49
51
  - **Node.js**。18 或更高,推荐 LTS。
50
52
 
51
53
  ### 通过 npm 安装(推荐)
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
 
31
31
  const NS = 'settings-search-v5'
32
32
  /** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
33
- const PKG_VERSION = '1.14.0'
33
+ const PKG_VERSION = '1.14.1'
34
34
  const NPM_NAME = '@objectivex666/dsh-settings-search'
35
35
  const REPO_URL = 'https://github.com/objectivex666/dsh-settings-search'
36
36
  const ISSUES_URL = 'https://github.com/objectivex666/dsh-settings-search/issues/new/choose'
@@ -384,11 +384,13 @@ window.__ModuleLoader__.load({
384
384
  )
385
385
  const CJK_RE = /[\u4e00-\u9fff]/
386
386
  const ALNUM_RE = /[a-zA-Z0-9]/
387
+ const PINYIN_UNKNOWN = '\u0000'
387
388
 
388
389
  /**
389
390
  * Transliterate a label into full-pinyin and initial-letter keys for
390
- * association search. Returns null when any CJK character is outside
391
- * the built-in table (then only substring matching applies).
391
+ * association search. Characters outside the compact table become segment
392
+ * boundaries, so one unknown character does not disable every known pinyin
393
+ * segment in the same label.
392
394
  *
393
395
  * Results are memoized: the same labels and intent keywords are
394
396
  * re-transliterated on every keystroke's re-filter, and this CJK loop
@@ -405,8 +407,9 @@ window.__ModuleLoader__.load({
405
407
  if (CJK_RE.test(ch)) {
406
408
  const py = PINYIN.get(ch)
407
409
  if (py === undefined) {
408
- PINYIN_KEY_CACHE.set(text, null)
409
- return null
410
+ full += PINYIN_UNKNOWN
411
+ initials += PINYIN_UNKNOWN
412
+ continue
410
413
  }
411
414
  full += py
412
415
  initials += py[0]
@@ -416,7 +419,11 @@ window.__ModuleLoader__.load({
416
419
  initials += lower
417
420
  }
418
421
  }
419
- const keys = full === '' ? null : { full, initials }
422
+ const segments = full.split(PINYIN_UNKNOWN).filter(Boolean)
423
+ const initialSegments = initials.split(PINYIN_UNKNOWN).filter(Boolean)
424
+ const keys = segments.length === 0
425
+ ? null
426
+ : { full, initials, segments, initialSegments }
420
427
  PINYIN_KEY_CACHE.set(text, keys)
421
428
  return keys
422
429
  }
@@ -426,7 +433,7 @@ window.__ModuleLoader__.load({
426
433
  if (haystackLower.includes(token)) return 'text'
427
434
  if (keys !== null) {
428
435
  if (keys.full.includes(token)) return 'pinyin'
429
- if (keys.initials.startsWith(token)) return 'initials'
436
+ if (keys.initialSegments.some((segment) => segment.startsWith(token))) return 'initials'
430
437
  }
431
438
  return null
432
439
  }
@@ -500,9 +507,11 @@ window.__ModuleLoader__.load({
500
507
  if (cached !== undefined) return cached
501
508
  const hits = []
502
509
  for (const intent of INTENTS) {
503
- const phraseHit = intent.phrase.includes(tok) || tok.includes(intent.phrase) ||
510
+ const phraseHit = intent.phrase === tok ||
511
+ (tok.length >= 2 && (intent.phrase.startsWith(tok) || tok.includes(intent.phrase))) ||
504
512
  (intent.keys !== null &&
505
- (intent.keys.full.startsWith(tok) || intent.keys.initials === tok))
513
+ (intent.keys.initials === tok ||
514
+ (tok.length >= 2 && intent.keys.full.startsWith(tok))))
506
515
  if (phraseHit) {
507
516
  for (const kw of intent.keywords) {
508
517
  if (!hits.includes(kw)) hits.push(kw)
@@ -513,6 +522,47 @@ window.__ModuleLoader__.load({
513
522
  return hits
514
523
  }
515
524
 
525
+ /** Match every whitespace-separated query token against one setting. */
526
+ const directMatch = (item, haystackLower, tokens) =>
527
+ tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
528
+
529
+ const kwMatch = (_item, haystackLower, kw) => {
530
+ if (/^[\x00-\x7f]+$/.test(kw)) {
531
+ if (wordBoundaryRegex(kw).test(haystackLower)) return true
532
+ } else if (haystackLower.includes(kw)) return true
533
+ return false
534
+ }
535
+
536
+ /**
537
+ * Local intent expansion keeps the same AND semantics as direct search:
538
+ * every query token must match literally or through that token's own intent
539
+ * keywords. This stops an unrelated extra token from being ignored.
540
+ */
541
+ const localRelatedKeyword = (item, haystackLower, tokens, expandedKeywords) => {
542
+ if (directMatch(item, haystackLower, tokens)) return null
543
+ let via = null
544
+ for (const tok of tokens) {
545
+ if (tokenMatch(haystackLower, item.keys, tok) !== null) continue
546
+ let tokenVia = null
547
+ for (const kw of expandedKeywords.get(tok) ?? []) {
548
+ if (kwMatch(item, haystackLower, kw)) {
549
+ tokenVia = kw
550
+ break
551
+ }
552
+ }
553
+ if (tokenVia === null) return null
554
+ if (via === null) via = tokenVia
555
+ }
556
+ return via
557
+ }
558
+
559
+ const aiRelatedKeyword = (item, haystackLower, aiKeywords) => {
560
+ for (const kw of aiKeywords) {
561
+ if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
562
+ }
563
+ return null
564
+ }
565
+
516
566
  const AI_TOGGLE_EVENT = 'dsh-settings-search:ai-toggle'
517
567
  const AI_CONFIG_EVENT = 'dsh-settings-search:ai-config'
518
568
  /**
@@ -713,6 +763,8 @@ window.__ModuleLoader__.load({
713
763
  return lines.join('\n')
714
764
  }
715
765
  const AI_CACHE = new Map()
766
+ /** Bound untrusted provider bodies before decoding/parsing them. */
767
+ const AI_RESPONSE_MAX_BYTES = 1024 * 1024
716
768
  const AI_STORAGE_KEY = 'dsh-settings-search:ai-enabled'
717
769
  const AI_PROVIDER_KEY = 'dsh-settings-search:ai-provider'
718
770
  const AI_BASE_URL_KEY = 'dsh-settings-search:ai-base-url'
@@ -732,6 +784,63 @@ window.__ModuleLoader__.load({
732
784
  const { provider, baseUrl, model, apiKey } = normalizeAiConfig(cfg)
733
785
  return Boolean(baseUrl && model && (provider === 'ollama' || apiKey))
734
786
  }
787
+ const aiBadResponse = (message) => {
788
+ const error = new Error(message)
789
+ error.name = 'AI_BAD_RESPONSE'
790
+ return error
791
+ }
792
+ /**
793
+ * Parse a provider response with a byte ceiling. A configured provider is
794
+ * still an untrusted network peer, so its response must not become an
795
+ * unbounded browser allocation merely because it is valid JSON.
796
+ */
797
+ const readAiJson = async (response) => {
798
+ const declared = Number(response.headers?.get?.('content-length'))
799
+ if (Number.isFinite(declared) && declared > AI_RESPONSE_MAX_BYTES) {
800
+ try { await response.body?.cancel?.() } catch {}
801
+ throw aiBadResponse('AI response too large')
802
+ }
803
+ const parse = (text) => {
804
+ try { return JSON.parse(text) } catch {
805
+ throw aiBadResponse('Invalid AI JSON response')
806
+ }
807
+ }
808
+ if (response.body !== null && typeof response.body?.getReader === 'function') {
809
+ const reader = response.body.getReader()
810
+ const chunks = []
811
+ let bytes = 0
812
+ try {
813
+ while (true) {
814
+ const { done, value } = await reader.read()
815
+ if (done) break
816
+ if (!ArrayBuffer.isView(value) || value.BYTES_PER_ELEMENT !== 1) {
817
+ throw aiBadResponse('Invalid AI response body')
818
+ }
819
+ bytes += value.byteLength
820
+ if (bytes > AI_RESPONSE_MAX_BYTES) {
821
+ try { await reader.cancel() } catch {}
822
+ throw aiBadResponse('AI response too large')
823
+ }
824
+ chunks.push(value)
825
+ }
826
+ } finally {
827
+ try { reader.releaseLock() } catch {}
828
+ }
829
+ const merged = new Uint8Array(bytes)
830
+ let offset = 0
831
+ for (const chunk of chunks) {
832
+ merged.set(chunk, offset)
833
+ offset += chunk.byteLength
834
+ }
835
+ return parse(new TextDecoder().decode(merged))
836
+ }
837
+ if (typeof response.text !== 'function') throw aiBadResponse('Invalid AI response')
838
+ const text = await response.text()
839
+ if (new TextEncoder().encode(text).byteLength > AI_RESPONSE_MAX_BYTES) {
840
+ throw aiBadResponse('AI response too large')
841
+ }
842
+ return parse(text)
843
+ }
735
844
  /** Session-only API key storage avoids durable browser storage exposure. */
736
845
  const readApiKey = () => {
737
846
  const legacy = typeof localStorage !== 'undefined' ? (localStorage.getItem(AI_API_KEY_KEY) ?? '').trim() : ''
@@ -788,13 +897,14 @@ window.__ModuleLoader__.load({
788
897
  const SEARCH_SHORTCUT_EVENT = 'dsh-settings-search:shortcut-change'
789
898
  /**
790
899
  * Persisted index schema. Bumped whenever the harvest gains entries an old
791
- * cache cannot contain (v2: in-page group titles such as 会话插件/全局插件),
792
- * so an upgrade rebuilds instead of serving a stale index.
900
+ * cache cannot contain (v3: the settings-search page, merged inline options,
901
+ * and controls revealed by plugin cards), so an upgrade rebuilds instead of
902
+ * serving a stale index.
793
903
  */
794
- const INDEX_STORAGE_VERSION = 2
795
- const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v2'
904
+ const INDEX_STORAGE_VERSION = 3
905
+ const INDEX_STORAGE_KEY = 'dsh-settings-search:index:v3'
796
906
  /** Older schema keys, removed when the index is written again. */
797
- const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v1']
907
+ const LEGACY_INDEX_STORAGE_KEYS = ['dsh-settings-search:index:v2', 'dsh-settings-search:index:v1']
798
908
  const INDEX_INVALID_EVENT = 'dsh-settings-search:index-invalid'
799
909
  const INDEX_REBUILD_EVENT = 'dsh-settings-search:index-rebuild'
800
910
  const INDEX_REBUILD_DONE_EVENT = 'dsh-settings-search:index-rebuild-done'
@@ -958,10 +1068,7 @@ window.__ModuleLoader__.load({
958
1068
  error.status = response.status
959
1069
  throw error
960
1070
  }
961
- const data = await response.json().catch((error) => {
962
- if (error.name === 'AbortError') throw error
963
- return null
964
- })
1071
+ const data = await readAiJson(response)
965
1072
  if (data?.error) {
966
1073
  const error = new Error('Unexpected model list response')
967
1074
  error.name = 'AI_BAD_RESPONSE'
@@ -1048,10 +1155,7 @@ window.__ModuleLoader__.load({
1048
1155
  error.status = response.status
1049
1156
  throw error
1050
1157
  }
1051
- const data = await response.json().catch((error) => {
1052
- if (error.name === 'AbortError') throw error
1053
- return null
1054
- })
1158
+ const data = await readAiJson(response)
1055
1159
  if (data?.error || typeof aiResponseContent(provider, data) !== 'string') {
1056
1160
  const error = new Error('Unexpected AI response')
1057
1161
  error.name = 'AI_BAD_RESPONSE'
@@ -1479,7 +1583,7 @@ window.__ModuleLoader__.load({
1479
1583
  // Structural headings go first: the Plugins page can render dozens
1480
1584
  // of card titles before its next group title, and `out` is capped
1481
1585
  // below. Shallower matches are the page/group headings.
1482
- const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title" i],[class*="heading" i],[class*="label" i]')]
1586
+ const heads = [...node.querySelectorAll('h1,h2,h3,h4,h5,label,li,[role="listitem"],[class*="title" i],[class*="heading" i],[class*="label" i]')]
1483
1587
  .filter((el) => !isPageFilter(el))
1484
1588
  .sort((a, b) => depthOf(a) - depthOf(b))
1485
1589
  for (const h of heads) {
@@ -1497,12 +1601,28 @@ window.__ModuleLoader__.load({
1497
1601
  push(bestTitle(row), row)
1498
1602
  }
1499
1603
  for (const h of node.querySelectorAll('[class*="name" i]')) push(bestTitle(h) || titleOf(h), h)
1500
- return out.slice(0, 24)
1604
+ return out.slice(0, 64)
1501
1605
  } catch {
1502
1606
  return []
1503
1607
  }
1504
1608
  }
1505
1609
 
1610
+ /**
1611
+ * Keep options discovered while visiting other tabs/cards and merge any
1612
+ * controls revealed by a later DOM pass. Replacing the list would either
1613
+ * forget previously visited tabs or fail to add a freshly expanded card.
1614
+ */
1615
+ const mergeInnerOptions = (previous, incoming) => {
1616
+ const merged = []
1617
+ const seen = new Set()
1618
+ for (const option of [...(Array.isArray(incoming) ? incoming : []), ...(Array.isArray(previous) ? previous : [])]) {
1619
+ if (!option || typeof option.label !== 'string' || option.label === '' || seen.has(option.label)) continue
1620
+ seen.add(option.label)
1621
+ merged.push(option)
1622
+ }
1623
+ return merged
1624
+ }
1625
+
1506
1626
 
1507
1627
  const runHarvest = () => {
1508
1628
  let grew = false
@@ -1566,10 +1686,17 @@ window.__ModuleLoader__.load({
1566
1686
  if (!rec) {
1567
1687
  map.set(active.id, { title: active.label, idx: 0, text, inner })
1568
1688
  grew = true
1569
- } else if ((rec.inner?.length ?? 0) === 0 && inner.length > 0) {
1570
- rec.inner = inner
1571
- rec.text = text
1572
- grew = true
1689
+ } else {
1690
+ const mergedInner = mergeInnerOptions(rec.inner, inner)
1691
+ const nextText = text.length > (rec.text?.length ?? 0) ? text : rec.text
1692
+ if (JSON.stringify(mergedInner) !== JSON.stringify(rec.inner ?? []) ||
1693
+ nextText !== rec.text ||
1694
+ rec.title !== active.label) {
1695
+ rec.title = active.label
1696
+ rec.inner = mergedInner
1697
+ rec.text = nextText
1698
+ grew = true
1699
+ }
1573
1700
  }
1574
1701
  }
1575
1702
  }
@@ -1596,7 +1723,6 @@ window.__ModuleLoader__.load({
1596
1723
  }
1597
1724
 
1598
1725
  const readSections = () => sortEntries(entriesOf('settings.section'))
1599
- .filter((e) => e.options?.id !== 'settings-search')
1600
1726
  .map((e) => ({
1601
1727
  kind: 'page',
1602
1728
  id: String(e.options?.id ?? ''),
@@ -1801,6 +1927,40 @@ window.__ModuleLoader__.load({
1801
1927
  return true
1802
1928
  }
1803
1929
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1930
+ const settingSectionRoot = () => {
1931
+ const root = panelRoot()
1932
+ if (root === null) return null
1933
+ return root.querySelector('[data-slot="settings.section"]')
1934
+ }
1935
+ /**
1936
+ * DSH plugin settings are often rendered inside collapsed disclosure cards.
1937
+ * Their controls do not exist in the DOM until the card is expanded, so the
1938
+ * indexer must reveal them before harvesting.
1939
+ */
1940
+ const collapsedSettingCards = () => {
1941
+ const section = settingSectionRoot()
1942
+ if (section === null) return []
1943
+ return [...section.querySelectorAll('button[aria-expanded="false"]')].filter((button) => {
1944
+ const label = `${button.getAttribute?.('aria-label') ?? ''} ${button.textContent ?? ''}`
1945
+ return /展开|expand/i.test(label)
1946
+ })
1947
+ }
1948
+ const expandSettingCards = async () => {
1949
+ const buttons = collapsedSettingCards()
1950
+ for (const button of buttons) {
1951
+ button.click()
1952
+ await delay(90)
1953
+ }
1954
+ return buttons
1955
+ }
1956
+ const restoreSettingCards = async (buttons) => {
1957
+ for (const button of [...buttons].reverse()) {
1958
+ if (button?.isConnected !== false && button?.getAttribute?.('aria-expanded') === 'true') {
1959
+ button.click()
1960
+ await delay(60)
1961
+ }
1962
+ }
1963
+ }
1804
1964
  /** Label of the currently-active first-level settings nav item. */
1805
1965
  const activeSectionLabel = () => {
1806
1966
  const root = panelRoot()
@@ -1831,7 +1991,9 @@ window.__ModuleLoader__.load({
1831
1991
  for (const sec of sections.slice(0, 40)) {
1832
1992
  if (!clickNavButton(sec.label)) continue
1833
1993
  await delay(180)
1994
+ const expanded = await expandSettingCards()
1834
1995
  model.runHarvest()
1996
+ await restoreSettingCards(expanded)
1835
1997
  visited += 1
1836
1998
  }
1837
1999
  if (clickNavButton(original)) {
@@ -1848,20 +2010,44 @@ window.__ModuleLoader__.load({
1848
2010
  }
1849
2011
 
1850
2012
  function createNavigator(ctx) {
2013
+ const waitForActiveSection = async (label) => {
2014
+ for (let attempt = 0; attempt < 5; attempt += 1) {
2015
+ if (activeSectionLabel() === label) return true
2016
+ await delay(80)
2017
+ }
2018
+ return false
2019
+ }
1851
2020
  /** Jump to a page result; falls back to showing a manual hint. */
1852
- const jumpToPage = async (item) => clickNavButton(item.label)
2021
+ const jumpToPage = async (item) => {
2022
+ if (!clickNavButton(item.label)) return false
2023
+ return waitForActiveSection(String(item.label ?? '').trim())
2024
+ }
1853
2025
  /** Jump to an in-page option: open its page, select its tab, flash its row. */
1854
2026
  const jumpToOption = async (item) => {
1855
- const opened = clickNavButton(item.pageLabel ?? item.parentLabel)
1856
- if (opened !== true) { flashOptionRow(item); return false }
1857
- await delay(160)
2027
+ const pageLabel = String(item.pageLabel ?? item.parentLabel ?? '').trim()
2028
+ if (!clickNavButton(pageLabel)) return false
2029
+ if (!await waitForActiveSection(pageLabel)) return false
1858
2030
  const label = item.label
1859
2031
  const buttons = panelButtons()
1860
2032
  const tab = buttons.find((b) =>
1861
2033
  b.getAttribute('role') === 'tab' && (b.textContent ?? '').trim() === label)
1862
2034
  if (tab) { tab.click(); await delay(120) }
1863
- flashOptionRow(item)
1864
- return opened
2035
+ const waitForRow = async () => {
2036
+ for (let attempt = 0; attempt < 4; attempt += 1) {
2037
+ if (flashOptionRow(item)) return true
2038
+ await delay(80)
2039
+ }
2040
+ return false
2041
+ }
2042
+ if (await waitForRow()) return true
2043
+ // Plugin settings can live inside a collapsed disclosure card. Reveal
2044
+ // cards one at a time until the indexed row exists, then flash it.
2045
+ for (const button of collapsedSettingCards()) {
2046
+ button.click()
2047
+ await delay(110)
2048
+ if (await waitForRow()) return true
2049
+ }
2050
+ return false
1865
2051
  }
1866
2052
  /** Highlight the rendered row card for one indexed option. */
1867
2053
  const flashOptionRow = (item) => {
@@ -1896,13 +2082,16 @@ window.__ModuleLoader__.load({
1896
2082
  target = card
1897
2083
  }
1898
2084
  }
1899
- if (!target) return
2085
+ if (!target) return false
1900
2086
  target.scrollIntoView({ block: 'nearest' })
1901
2087
  target.classList.remove('sss-flash')
1902
2088
  void target.offsetWidth // restart animation if re-flashed
1903
2089
  target.classList.add('sss-flash')
1904
2090
  setTimeout(() => target.classList.remove('sss-flash'), 1900)
1905
- } catch { /* visual nicety only */ }
2091
+ return true
2092
+ } catch {
2093
+ return false
2094
+ }
1906
2095
  }
1907
2096
  void ctx
1908
2097
  return { jumpToPage, jumpToOption }
@@ -2388,29 +2577,6 @@ window.__ModuleLoader__.load({
2388
2577
  const sections = model.readSections()
2389
2578
  const deeps = q !== '' ? model.readDeep() : []
2390
2579
  const expandedKeywords = new Map(tokens.map((tok) => [tok, intentKeywords(tok)]))
2391
- const directMatch = (item, haystackLower) =>
2392
- tokens.every((tok) => tokenMatch(haystackLower, item.keys, tok) !== null)
2393
- const kwMatch = (item, haystackLower, kw) => {
2394
- if (/^[\x00-\x7f]+$/.test(kw)) {
2395
- if (wordBoundaryRegex(kw).test(haystackLower)) return true
2396
- } else if (haystackLower.includes(kw)) return true
2397
- if (item.keys === null) return false
2398
- const kwKeys = pinyinKeys(kw)
2399
- if (kwKeys === null) return false
2400
- return item.keys.full.includes(kwKeys.full) || item.keys.initials.startsWith(kwKeys.initials)
2401
- }
2402
- const relatedKeyword = (item, haystackLower) => {
2403
- if (directMatch(item, haystackLower)) return null
2404
- for (const tok of tokens) {
2405
- for (const kw of expandedKeywords.get(tok) ?? []) {
2406
- if (kwMatch(item, haystackLower, kw)) return kw
2407
- }
2408
- }
2409
- for (const kw of aiKeywords) {
2410
- if (kwMatch(item, haystackLower, kw)) return `AI · ${kw}`
2411
- }
2412
- return null
2413
- }
2414
2580
  const mappedSections = q === '' ? [] : sections.map((s) => ({
2415
2581
  ...s,
2416
2582
  keys: pinyinKeys(s.label),
@@ -2421,24 +2587,34 @@ window.__ModuleLoader__.load({
2421
2587
  keys: pinyinKeys(`${o.label} ${o.parentLabel}`),
2422
2588
  hay: `${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase(),
2423
2589
  }))
2424
- const shownPages = mappedSections.filter((s) => directMatch(s, s.hay))
2425
- const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay))
2426
- const noLocalMatch = shownPages.length === 0 && shownOptions.length === 0 &&
2427
- tokens.every((tok) => (expandedKeywords.get(tok) ?? []).length === 0)
2428
- const related = [
2590
+ const shownPages = mappedSections.filter((s) => directMatch(s, s.hay, tokens))
2591
+ const shownOptions = mappedOptions.filter((o) => directMatch(o, o.hay, tokens))
2592
+ const intentRelated = [
2429
2593
  ...mappedSections
2430
- .map((s) => ({ item: s, kind: 'page', via: relatedKeyword(s, s.hay) }))
2594
+ .map((s) => ({ item: s, kind: 'page', via: localRelatedKeyword(s, s.hay, tokens, expandedKeywords) }))
2431
2595
  .filter((r) => r.via !== null),
2432
2596
  ...mappedOptions
2433
- .map((o) => ({ item: o, kind: 'option', via: relatedKeyword(o, o.hay) }))
2597
+ .map((o) => ({ item: o, kind: 'option', via: localRelatedKeyword(o, o.hay, tokens, expandedKeywords) }))
2434
2598
  .filter((r) => r.via !== null),
2435
- ].slice(0, 8)
2599
+ ]
2600
+ const localResultsEmpty = shownPages.length === 0 && shownOptions.length === 0 && intentRelated.length === 0
2601
+ const aiRelated = localResultsEmpty
2602
+ ? [
2603
+ ...mappedSections
2604
+ .map((s) => ({ item: s, kind: 'page', via: aiRelatedKeyword(s, s.hay, aiKeywords) }))
2605
+ .filter((r) => r.via !== null),
2606
+ ...mappedOptions
2607
+ .map((o) => ({ item: o, kind: 'option', via: aiRelatedKeyword(o, o.hay, aiKeywords) }))
2608
+ .filter((r) => r.via !== null),
2609
+ ]
2610
+ : []
2611
+ const related = [...intentRelated, ...aiRelated].slice(0, 8)
2436
2612
  const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0 && related.length === 0
2437
2613
  const results = [...shownPages, ...shownOptions, ...related.map((r) => r.item)]
2438
2614
 
2439
2615
  const cfg = aiConfig()
2440
2616
  const aiConfigured = aiConfigReady(cfg)
2441
- const aiReady = aiEnabled && aiConfigured && q.length >= 2 && noLocalMatch
2617
+ const aiReady = aiEnabled && aiConfigured && q.length >= 2 && localResultsEmpty
2442
2618
  React.useEffect(() => {
2443
2619
  setAiKeywords([])
2444
2620
  setAiStatus('idle')
package/lib/index.js CHANGED
@@ -60,6 +60,7 @@ const TAIL_BYTES = 256 * 1024
60
60
  const MAX_LINE_LENGTH = 1000
61
61
  const MAX_LINES_PER_REQUEST = 200
62
62
  const MAX_REQUEST_BYTES = 256 * 1024
63
+ const COUNT_CHUNK_BYTES = 64 * 1024
63
64
  /** How long a folder launcher may run before it counts as launched. */
64
65
  const LAUNCH_WATCH_MS = 1500
65
66
 
@@ -262,14 +263,27 @@ export function createLogStore(options = {}) {
262
263
  }
263
264
  }
264
265
  const countLines = (path) => {
266
+ let fd
265
267
  try {
266
268
  if (!existsSync(path)) return 0
267
- const text = readFileSync(path, 'utf8')
269
+ fd = io.openSync(path, 'r')
270
+ const buffer = Buffer.alloc(COUNT_CHUNK_BYTES)
268
271
  let lines = 0
269
- for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) lines += 1
272
+ let position = 0
273
+ while (true) {
274
+ const readBytes = io.readSync(fd, buffer, 0, buffer.length, position)
275
+ if (!Number.isFinite(readBytes) || readBytes <= 0) break
276
+ const filled = Math.min(readBytes, buffer.length)
277
+ for (let i = 0; i < filled; i += 1) if (buffer[i] === 10) lines += 1
278
+ position += filled
279
+ }
270
280
  return lines
271
281
  } catch {
272
282
  return 0
283
+ } finally {
284
+ if (fd !== undefined) {
285
+ try { io.closeSync(fd) } catch {}
286
+ }
273
287
  }
274
288
  }
275
289
  // The host is a file's only writer, so its entry count is tracked in memory
@@ -379,11 +393,36 @@ export function createLogStore(options = {}) {
379
393
  }
380
394
  }
381
395
 
396
+ /** Read one request body without buffering past the route's size budget. */
397
+ const readLimitedBody = async (request) => {
398
+ if (request.body === null) return ''
399
+ const reader = request.body.getReader()
400
+ const chunks = []
401
+ let bytes = 0
402
+ try {
403
+ while (true) {
404
+ const { done, value } = await reader.read()
405
+ if (done) return Buffer.concat(chunks, bytes).toString('utf8')
406
+ if (!(value instanceof Uint8Array)) return null
407
+ bytes += value.byteLength
408
+ if (bytes > MAX_REQUEST_BYTES) {
409
+ try { await reader.cancel() } catch {}
410
+ return null
411
+ }
412
+ chunks.push(value)
413
+ }
414
+ } catch {
415
+ return null
416
+ } finally {
417
+ try { reader.releaseLock() } catch {}
418
+ }
419
+ }
420
+
382
421
  /** Parse one JSON request body within the route's own size budget. */
383
422
  const readJsonBody = async (request) => {
384
423
  try {
385
- const text = await request.text()
386
- if (text === '' || text.length > MAX_REQUEST_BYTES) return null
424
+ const text = await readLimitedBody(request)
425
+ if (text === null || text === '') return null
387
426
  const parsed = JSON.parse(text)
388
427
  return parsed !== null && typeof parsed === 'object' ? parsed : null
389
428
  } catch {
@@ -405,7 +444,7 @@ export function createLogRoute(store) {
405
444
  return {
406
445
  path: ROUTE_PATH,
407
446
  methods: ['GET', 'POST'],
408
- requestBody: 'buffered',
447
+ requestBody: 'streaming',
409
448
  fetch: async (request) => {
410
449
  try {
411
450
  if (request.method === 'GET') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectivex666/dsh-settings-search",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -14,6 +14,8 @@ const instrumented = source.replace(hook, `
14
14
  modelsUrl, fetchAiModels,
15
15
  readIndexCache, serializeHarvest,
16
16
  readDshModelConfig, otherPluginFingerprint, DEEP_SLOTS,
17
+ pinyinKeys, tokenMatch, intentKeywords, directMatch,
18
+ localRelatedKeyword, aiRelatedKeyword,
17
19
  log, flushLogs, discardPendingLogs, sessionLogText, logTarget, readLogCount,
18
20
  })
19
21
  ${hook}`)
@@ -25,11 +27,17 @@ const storage = () => {
25
27
  removeItem: (key) => values.delete(key),
26
28
  }
27
29
  }
28
- const json = (data, status = 200) => ({
29
- ok: status >= 200 && status < 300,
30
- status,
31
- json: async () => data,
32
- })
30
+ const json = (data, status = 200) => {
31
+ const text = JSON.stringify(data)
32
+ return {
33
+ ok: status >= 200 && status < 300,
34
+ status,
35
+ headers: { get: () => String(new TextEncoder().encode(text).byteLength) },
36
+ body: null,
37
+ text: async () => text,
38
+ json: async () => data,
39
+ }
40
+ }
33
41
  const plain = (value) => JSON.parse(JSON.stringify(value))
34
42
  const ollama = { provider: 'ollama', baseUrl: '', model: 'local-model:7b', apiKey: '' }
35
43
  const openai = { provider: 'openai', baseUrl: 'https://example.test/v1', model: 'cloud-model', apiKey: 'test-secret' }
@@ -151,9 +159,15 @@ const pluginsPageDom = () => {
151
159
  el('span', { class: 'RotMhW_visuallyHidden', text: '搜索插件' }),
152
160
  el('input', { attr: { type: 'search' } }),
153
161
  ]),
154
- // More card titles than the 24-entry cap, so a DOM-order harvest would
162
+ el('ul', { class: 'RotMhW_plainPluginList' }, [
163
+ el('li', {}, [
164
+ el('strong', { text: 'PowerShell 命令工具' }),
165
+ el('span', { text: 'tool-pwsh' }),
166
+ ]),
167
+ ]),
168
+ // More card titles than the 64-entry cap, so a DOM-order harvest would
155
169
  // truncate the second group title before seeing it.
156
- group('preset', '会话插件', 30),
170
+ group('preset', '会话插件', 70),
157
171
  group('global', '全局插件'),
158
172
  ]),
159
173
  ])
@@ -191,6 +205,8 @@ function load(fetchImpl = async () => json(reply('ollama'))) {
191
205
  CustomEvent: class { constructor(type) { this.type = type } },
192
206
  URL,
193
207
  AbortController,
208
+ TextEncoder,
209
+ TextDecoder,
194
210
  setTimeout,
195
211
  clearTimeout,
196
212
  fetch: async (url, options) => {
@@ -214,8 +230,8 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
214
230
  },
215
231
  }
216
232
  assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
217
- sandbox.localStorage.setItem('dsh-settings-search:index:v2', JSON.stringify({
218
- version: 2,
233
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', JSON.stringify({
234
+ version: 3,
219
235
  fingerprint: 'manager:test',
220
236
  updatedAt: 1,
221
237
  harvest: records,
@@ -255,9 +271,10 @@ test('persistent settings index reuses matching fingerprints and drops invalid r
255
271
 
256
272
  test('readIndexCache rejects malformed and legacy persisted index payloads', () => {
257
273
  const { api, sandbox } = load()
258
- const legacy = JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: {} })
259
- for (const value of ['{', '{"version":2}', 'null', legacy]) {
260
- sandbox.localStorage.setItem('dsh-settings-search:index:v2', value)
274
+ const legacyV2 = JSON.stringify({ version: 2, fingerprint: 'manager:v2', updatedAt: 1, harvest: {} })
275
+ const legacyV1 = JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: {} })
276
+ for (const value of ['{', '{"version":3}', 'null', legacyV2, legacyV1]) {
277
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', value)
261
278
  assert.equal(api.readIndexCache(), null)
262
279
  }
263
280
  })
@@ -267,6 +284,7 @@ test('writing the index uses the current schema and drops the legacy cache key',
267
284
  const records = { 'settings.general.item': { row1: { title: 'Row', idx: 0, text: '', inner: [] } } }
268
285
  sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({ version: 1, fingerprint: 'manager:old', updatedAt: 1, harvest: records }))
269
286
  sandbox.localStorage.setItem('dsh-settings-search:index:v2', JSON.stringify({ version: 2, fingerprint: 'manager:test', updatedAt: 1, harvest: records }))
287
+ sandbox.localStorage.setItem('dsh-settings-search:index:v3', JSON.stringify({ version: 3, fingerprint: 'manager:test', updatedAt: 1, harvest: records }))
270
288
  api.apply({
271
289
  effect: (fn) => fn(),
272
290
  get: () => null,
@@ -282,8 +300,9 @@ test('writing the index uses the current schema and drops the legacy cache key',
282
300
  const model = api.__indexTestModel
283
301
  assert.equal(model.persistIndex('manager:next'), true)
284
302
  assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v1'), null)
285
- const raw = JSON.parse(sandbox.localStorage.getItem('dsh-settings-search:index:v2'))
286
- assert.equal(raw.version, 2)
303
+ assert.equal(sandbox.localStorage.getItem('dsh-settings-search:index:v2'), null)
304
+ const raw = JSON.parse(sandbox.localStorage.getItem('dsh-settings-search:index:v3'))
305
+ assert.equal(raw.version, 3)
287
306
  assert.equal(raw.fingerprint, 'manager:next')
288
307
  })
289
308
 
@@ -299,6 +318,7 @@ test('in-page group titles on the Plugins page are harvested and searchable', ()
299
318
  const labels = options.map((item) => item.label)
300
319
  assert.ok(labels.includes('会话插件'), `会话插件 missing from ${JSON.stringify(labels)}`)
301
320
  assert.ok(labels.includes('全局插件'), `全局插件 missing from ${JSON.stringify(labels)}`)
321
+ assert.ok(labels.includes('PowerShell 命令工具'), `plain plugin-list row missing from ${JSON.stringify(labels)}`)
302
322
  for (const label of ['会话插件', '全局插件']) {
303
323
  const option = options.find((item) => item.label === label)
304
324
  assert.equal(option.kind, 'option')
@@ -310,6 +330,95 @@ test('in-page group titles on the Plugins page are harvested and searchable', ()
310
330
  assert.ok(!labels.includes('搜索插件'), `the page filter leaked into the index: ${JSON.stringify(labels)}`)
311
331
  })
312
332
 
333
+ test('the settings-search page and its .sss-vr-label controls stay indexed', () => {
334
+ const { api, sandbox } = load()
335
+ const label = (text) => el('span', { class: 'sss-vr-label', text })
336
+ const page = el('div', { class: 'sss-vr' }, [
337
+ el('span', { class: 'sss-vr-name', text: '设置搜索' }),
338
+ el('div', { class: 'sss-vr-field' }, [label('搜索框样式')]),
339
+ ])
340
+ const dialog = el('div', { attr: { role: 'dialog' } }, [
341
+ el('div', { class: 'settings-nav' }, [
342
+ el('button', { attr: { 'aria-current': 'true' }, text: '设置搜索' }),
343
+ ]),
344
+ el('div', { attr: { 'data-slot': 'settings.section' } }, [page]),
345
+ ])
346
+ sandbox.document = fakeDocument(dialog)
347
+ const ctx = pluginsPageCtx()
348
+ ctx.slots.entries = (slot) => slot === 'settings.section'
349
+ ? [{ options: { id: 'settings-search', order: 1600, label: '设置搜索' } }]
350
+ : []
351
+ api.apply(ctx)
352
+ const model = api.__indexTestModel
353
+
354
+ assert.ok(model.readSections().some((section) => section.id === 'settings-search'))
355
+ model.runHarvest()
356
+ let options = model.readDeep().filter((item) => item.parentLabel === '设置搜索')
357
+ assert.deepEqual(plain(options.map((item) => item.label)), ['搜索框样式'])
358
+
359
+ // Expanding the page adds controls after the first non-empty harvest. They
360
+ // must merge into the existing record rather than being ignored.
361
+ for (const text of ['AI 联想搜索', '设置索引']) {
362
+ const field = el('div', { class: 'sss-vr-field' }, [label(text)])
363
+ field.parentElement = page
364
+ page.children.push(field)
365
+ }
366
+ model.runHarvest()
367
+ options = model.readDeep().filter((item) => item.parentLabel === '设置搜索')
368
+ const labels = options.map((item) => item.label)
369
+ for (const expected of ['搜索框样式', 'AI 联想搜索', '设置索引']) {
370
+ assert.ok(labels.includes(expected), `${expected} missing from ${JSON.stringify(labels)}`)
371
+ }
372
+ for (const option of options) {
373
+ assert.equal(option.slot, 'settings.section')
374
+ assert.equal(option.pageLabel, '设置搜索')
375
+ }
376
+ })
377
+
378
+ test('pinyin keeps known segments after an unknown character', () => {
379
+ const { api } = load()
380
+ const keys = api.pinyinKeys('知演功能')
381
+ assert.ok(keys, 'known pinyin segments must survive an unknown character')
382
+ assert.equal(keys.full, 'zhi\u0000gongneng')
383
+ assert.ok(keys.initialSegments.includes('gn'))
384
+ assert.ok(api.tokenMatch('知演功能', keys, 'gn'))
385
+ assert.equal(api.tokenMatch('知演功能', keys, 'zhign'), null, 'matching must not cross the unknown character')
386
+ })
387
+
388
+ test('intent expansion rejects one-character and internal substring noise', () => {
389
+ const { api } = load()
390
+ assert.deepEqual(plain(api.intentKeywords('文件')), [])
391
+ assert.deepEqual(plain(api.intentKeywords('文')), [])
392
+ const cancel = api.intentKeywords('取消')
393
+ assert.ok(cancel.includes('订阅'))
394
+ assert.ok(api.intentKeywords('我想取消订阅').includes('订阅'))
395
+ })
396
+
397
+ test('local intent suggestions require every query token', () => {
398
+ const { api } = load()
399
+ const language = {
400
+ keys: api.pinyinKeys('语言'),
401
+ hay: '语言',
402
+ }
403
+ const expanded = new Map([
404
+ ['中文', ['语言']],
405
+ ['外观', []],
406
+ ])
407
+ assert.equal(api.localRelatedKeyword(language, language.hay, ['中文'], expanded), '语言')
408
+ assert.equal(
409
+ api.localRelatedKeyword(language, language.hay, ['外观', '中文'], expanded),
410
+ null,
411
+ 'an unmatched token must not be ignored',
412
+ )
413
+ const theme = { keys: api.pinyinKeys('主题'), hay: '主题' }
414
+ const themeExpanded = new Map([
415
+ ['太亮', ['主题']],
416
+ ['主题', []],
417
+ ])
418
+ assert.equal(api.localRelatedKeyword(theme, theme.hay, ['太亮', '主题'], themeExpanded), '主题')
419
+ assert.equal(api.aiRelatedKeyword(language, language.hay, ['语言']), 'AI · 语言')
420
+ })
421
+
313
422
  test('Ollama configuration persists and requires a model but no key', () => {
314
423
  const { api, sandbox } = load()
315
424
  api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
@@ -491,10 +600,52 @@ test('malformed responses and native error payloads cannot pass connectivity che
491
600
  const { api } = load(async () => json(data))
492
601
  assert.equal((await api.testAiService(ollama)).code, 'bad-response')
493
602
  }
494
- const { api } = load(async () => ({ ok: true, json: async () => { throw new SyntaxError('invalid JSON') } }))
603
+ const { api } = load(async () => ({ ok: true, status: 200, headers: { get: () => null }, body: null, text: async () => '{' }))
495
604
  assert.equal((await api.testAiService(ollama)).code, 'bad-response')
496
605
  })
497
606
 
607
+ test('oversized provider responses are rejected without an unbounded read', async () => {
608
+ let cancelled = false
609
+ const { api } = load(async () => ({
610
+ ok: true,
611
+ status: 200,
612
+ headers: { get: () => String(1024 * 1024 + 1) },
613
+ body: { cancel: async () => { cancelled = true } },
614
+ text: async () => { throw new Error('must not read declared-oversized body') },
615
+ }))
616
+ assert.equal((await api.fetchAiModels(openai)).code, 'bad-response')
617
+ assert.equal(cancelled, true)
618
+ api.setAiConfig(openai)
619
+ await assert.rejects(api.requestUserAi('intent', 'Language'), { name: 'AI_BAD_RESPONSE' })
620
+ })
621
+
622
+ test('streamed provider responses stop at the byte ceiling', async () => {
623
+ let cancelled = false
624
+ const chunk = new Uint8Array(1024 * 1024 + 1)
625
+ const { api } = load(async () => ({
626
+ ok: true,
627
+ status: 200,
628
+ headers: { get: () => null },
629
+ body: {
630
+ getReader: () => {
631
+ let sent = false
632
+ return {
633
+ read: async () => {
634
+ if (sent) return { done: true, value: undefined }
635
+ sent = true
636
+ return { done: false, value: chunk }
637
+ },
638
+ cancel: async () => { cancelled = true },
639
+ releaseLock: () => {},
640
+ }
641
+ },
642
+ },
643
+ text: async () => { throw new Error('stream must be used') },
644
+ }))
645
+ assert.equal((await api.fetchAiModels(openai)).code, 'bad-response')
646
+ assert.equal(cancelled, true)
647
+ })
648
+
498
649
  test('network failure is reported without claiming connection success', async () => {
499
650
  const { api } = load(async () => { throw new TypeError('Failed to fetch') })
500
651
  assert.equal((await api.testAiService(ollama)).code, 'network')
@@ -122,6 +122,15 @@ const mustInclude = (text, needle, message) => {
122
122
  if (!text.includes(needle)) throw new Error(message ?? `missing ${JSON.stringify(needle)}`)
123
123
  }
124
124
 
125
+ /**
126
+ * Assert one pattern. Bundled client code is minified and re-emitted by every
127
+ * release, so it may quote a string with `"`, `'`, or a template literal
128
+ * without any behavioural change — match the code, not the quoting.
129
+ */
130
+ const mustMatch = (text, pattern, message) => {
131
+ if (!pattern.test(text)) throw new Error(message ?? `missing ${pattern}`)
132
+ }
133
+
125
134
  const main = () => {
126
135
  const archivePath = resolveAsar()
127
136
  const archive = openAsar(archivePath)
@@ -133,13 +142,17 @@ const main = () => {
133
142
  console.log(`Version: ${connectionManifest.version}\n`)
134
143
 
135
144
  check('client connection service is provided', () => {
136
- mustInclude(clientSourceOf(archive, 'dsh-client-connection', cache), 'provide("connection"', 'the connection handle is gone')
145
+ mustMatch(
146
+ clientSourceOf(archive, 'dsh-client-connection', cache),
147
+ /provide\(\s*(['"`])connection\1/,
148
+ 'the connection handle is gone',
149
+ )
137
150
  return 'ctx.connection'
138
151
  })
139
152
 
140
153
  check('Remote namespaces live at remote.<namespace>', () => {
141
154
  const gateway = clientSourceOf(archive, 'dsh-api-gateway', cache)
142
- mustInclude(gateway, 'super(ctx, "remote")', 'the `remote` service is gone')
155
+ mustMatch(gateway, /super\(\s*ctx\s*,\s*(['"`])remote\1\s*\)/, 'the `remote` service is gone')
143
156
  mustInclude(gateway, 'remote.${namespace}', 'namespace service keys changed')
144
157
  return 'remote.<ns>'
145
158
  })
@@ -181,6 +194,7 @@ const main = () => {
181
194
  check('host connection still owns the exact-Fetch route registry', () => {
182
195
  const host = archive.read('dsh/node_modules/@deepseek-ai/dsh-client-connection/lib/index.js')
183
196
  mustInclude(host, 'registerFetchRoute', 'the host fetch registry is gone')
197
+ mustInclude(host, 'bodyMode === "streaming"', 'streaming exact routes are no longer supported')
184
198
  const segment = /const ENDPOINT_SEGMENT_PATTERN = \/\^\[([^\]]+)\]/.exec(host)
185
199
  if (segment === null) throw new Error('endpoint validation pattern not found')
186
200
  const sample = 'settings-search-log'
@@ -8,7 +8,10 @@
8
8
  * that never deletes the active file, and a tail that never starts mid-line.
9
9
  */
10
10
  import assert from 'node:assert/strict'
11
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
11
+ import {
12
+ closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync,
13
+ rmSync, statSync, writeFileSync,
14
+ } from 'node:fs'
12
15
  import { basename, join } from 'node:path'
13
16
  import { tmpdir } from 'node:os'
14
17
  import test from 'node:test'
@@ -228,6 +231,31 @@ test('a fresh store reads the count of a file written by an earlier run', () =>
228
231
  }
229
232
  })
230
233
 
234
+ test('a persisted log is counted in bounded chunks instead of one large allocation', () => {
235
+ const { dir, cleanup } = withStore({ now: () => day('2026-10-06') })
236
+ try {
237
+ const file = join(dir, 'settings-search-2026-10-06.log')
238
+ writeFileSync(file, 'entry\n'.repeat(20000), 'utf8')
239
+ let largestRequest = 0
240
+ let reads = 0
241
+ const io = {
242
+ openSync,
243
+ closeSync,
244
+ readSync(fd, buffer, offset, length, position) {
245
+ largestRequest = Math.max(largestRequest, length)
246
+ reads += 1
247
+ return readSync(fd, buffer, offset, length, position)
248
+ },
249
+ }
250
+ const reopened = createLogStore({ dir, now: () => day('2026-10-06'), io })
251
+ assert.equal(reopened.count(), 20000)
252
+ assert.ok(largestRequest <= 64 * 1024, `requested ${largestRequest} bytes`)
253
+ assert.ok(reads > 1, 'the file must be streamed across multiple reads')
254
+ } finally {
255
+ cleanup()
256
+ }
257
+ })
258
+
231
259
  const send = async (route, method, body, query = '') => {
232
260
  const request = new Request(`http://dsh.internal${route.path}${query}`, {
233
261
  method,
@@ -268,7 +296,7 @@ test('the log route serves the tail and accepts append/clear only', async () =>
268
296
  try {
269
297
  assert.equal(route.path, '/api/settings-search-log')
270
298
  assert.deepEqual(route.methods, ['GET', 'POST'])
271
- assert.equal(route.requestBody, 'buffered')
299
+ assert.equal(route.requestBody, 'streaming')
272
300
 
273
301
  const appended = await send(route, 'POST', { action: 'append', lines: ['hello'] })
274
302
  assert.equal(appended.status, 200)
@@ -290,6 +318,28 @@ test('the log route serves the tail and accepts append/clear only', async () =>
290
318
  }
291
319
  })
292
320
 
321
+ test('an over-budget request body is rejected before any action runs', async () => {
322
+ let actions = 0
323
+ const route = createLogRoute({
324
+ dir: 'unused',
325
+ file: 'unused',
326
+ ensure() {},
327
+ append() { actions += 1; return 0 },
328
+ count() { actions += 1; return 0 },
329
+ clear() { actions += 1; return true },
330
+ open() { actions += 1; return { failure: undefined, command: 'test' } },
331
+ read() { actions += 1; return { text: '', size: 0, truncated: false } },
332
+ })
333
+ const request = new Request('http://dsh.internal/api/settings-search-log', {
334
+ method: 'POST',
335
+ headers: { 'content-type': 'application/json' },
336
+ body: JSON.stringify({ action: 'append', lines: ['x'.repeat(257 * 1024)] }),
337
+ })
338
+ const response = await route.fetch(request)
339
+ assert.equal(response.status, 400)
340
+ assert.equal(actions, 0)
341
+ })
342
+
293
343
  test('the folder launcher uses the OS shell open verb, never a hidden file manager', () => {
294
344
  const win = folderLaunchCommand('win32', 'C:\\logs\\dsh-settings-search')
295
345
  assert.equal(win.command, 'cmd.exe')