@goodandready/dsh-clinebot 0.5.28 → 0.5.30

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/config.js CHANGED
@@ -43,6 +43,12 @@ export const Config = z.object({
43
43
  .description('User-selected visible models in chat model picker (empty means all supported models).').volatile(),
44
44
  showSidebarQuota: z.boolean().default(true)
45
45
  .description('Display compact quota meter in DSH sidebar footer.').volatile(),
46
+ webSearch: z.boolean().default(true)
47
+ .description('Enable native DSH web search provider via ClineBot.').volatile(),
48
+ offloadSeenImages: z.boolean().default(true)
49
+ .description('Offload previously seen images in conversation history to optimize prompt cache.').volatile(),
50
+ maxPayloadMb: z.number().default(40)
51
+ .description('Maximum multimodal payload budget in MB before evicting oldest images.').volatile(),
46
52
  enabledModels: z.array(z.string()).default([])
47
53
  .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
48
54
  dynamicModels: z.array(z.object({
@@ -212,6 +218,9 @@ export function publicConfig(cfg) {
212
218
  filterModelsByPlan,
213
219
  visibleModels,
214
220
  showSidebarQuota: cfg?.showSidebarQuota !== false,
221
+ webSearch: cfg?.webSearch !== false,
222
+ offloadSeenImages: cfg?.offloadSeenImages !== false,
223
+ maxPayloadMb: Number(cfg?.maxPayloadMb) || 40,
215
224
  enabledModels: activeIds,
216
225
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
217
226
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
package/lib/index.js CHANGED
@@ -25,6 +25,8 @@ import { registerAuthRoutes } from './routes/auth.js'
25
25
  import { registerProxyRoutes } from './routes/proxy.js'
26
26
  import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
27
27
  import { registerSlashCommand } from './slash-command.js'
28
+ import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
29
+ import { resolveAccountPool } from './account-pool.js'
28
30
  import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
29
31
 
30
32
  export const name = '@goodandready/dsh-clinebot'
@@ -468,4 +470,33 @@ export function apply(ctx, config) {
468
470
  ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
469
471
  }
470
472
  }
473
+
474
+ // Native DSH WebSearchProvider over ctx.web (#233)
475
+ if (typeof ctx.inject === 'function') {
476
+ ctx.inject(['web'], (webCtx) => {
477
+ const cfg = publicConfig(live())
478
+ if (cfg.webSearch === false) return
479
+ const web = webCtx?.get?.('web')
480
+ if (web && typeof web.registerSearchProvider === 'function') {
481
+ const searchProvider = new ClineBotSearchProvider({
482
+ getBaseUrl: () => publicConfig(live()).baseUrl,
483
+ getApiKey: async () => {
484
+ const pool = await resolveAccountPool(ctx, live())
485
+ const active = pool.find((a) => a.present && a.value)
486
+ return active?.value || ''
487
+ },
488
+ })
489
+ try {
490
+ const unreg = web.registerSearchProvider(CLINEBOT_SEARCH_PROVIDER_ID, searchProvider)
491
+ if (typeof ctx.effect === 'function') {
492
+ ctx.effect(() => () => {
493
+ try { if (typeof unreg === 'function') unreg() } catch {}
494
+ }, 'dsh-clinebot: web search provider')
495
+ }
496
+ } catch (e) {
497
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to register web search provider: ' + (e?.message || e))
498
+ }
499
+ }
500
+ })
501
+ }
471
502
  }
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ const CLINE_KEY_REGEX = /cline_[A-Za-z0-9_\-]{16,}/g
6
+
7
+ function maskApiKey(key) {
8
+ if (!key || typeof key !== 'string') return ''
9
+ if (key.length <= 12) return '••••••••'
10
+ return key.slice(0, 8) + '••••' + key.slice(-4)
11
+ }
12
+
13
+ function scanFileForKeys(filePath, sourceName) {
14
+ const results = []
15
+ try {
16
+ if (!fs.existsSync(filePath)) return results
17
+ const stat = fs.statSync(filePath)
18
+ if (!stat.isFile() || stat.size > 5 * 1024 * 1024) return results
19
+ const content = fs.readFileSync(filePath, 'utf8')
20
+ const matches = content.match(CLINE_KEY_REGEX)
21
+ if (matches) {
22
+ for (const m of matches) {
23
+ results.push({
24
+ source: sourceName,
25
+ key: m,
26
+ maskedKey: maskApiKey(m),
27
+ path: filePath,
28
+ })
29
+ }
30
+ }
31
+ } catch {
32
+ // quiet on unreadable files
33
+ }
34
+ return results
35
+ }
36
+
37
+ export function discoverLocalClineKeys() {
38
+ const discovered = []
39
+ const seenKeys = new Set()
40
+
41
+ const addKey = (item) => {
42
+ if (!item?.key || seenKeys.has(item.key)) return
43
+ seenKeys.add(item.key)
44
+ discovered.push(item)
45
+ }
46
+
47
+ // 1. Check environment variables
48
+ for (const envName of ['CLINE_API_KEY', 'CLINEPASS_API_KEY']) {
49
+ const val = process.env[envName]
50
+ if (val && typeof val === 'string' && val.startsWith('cline_')) {
51
+ addKey({
52
+ source: `Environment (${envName})`,
53
+ key: val,
54
+ maskedKey: maskApiKey(val),
55
+ path: `$${envName}`,
56
+ })
57
+ }
58
+ }
59
+
60
+ // 2. Candidate directories & file paths across OSes
61
+ const home = os.homedir()
62
+ const candidateFiles = [
63
+ // CLI configs
64
+ { path: path.join(home, '.cline', 'config.json'), source: 'Cline CLI' },
65
+ { path: path.join(home, '.cline', 'data', 'settings.json'), source: 'Cline CLI Data' },
66
+ { path: path.join(home, '.cline', 'settings.json'), source: 'Cline CLI Settings' },
67
+ { path: path.join(home, '.config', 'cline', 'config.json'), source: 'Cline Config' },
68
+ { path: path.join(home, '.cline.json'), source: 'Cline Dotfile' },
69
+
70
+ // VS Code Extension Global Storage (Linux, macOS, Windows)
71
+ { path: path.join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension' },
72
+ { path: path.join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'state.vscdb'), source: 'VS Code Extension Storage' },
73
+ { path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension (macOS)' },
74
+ { path: path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'VS Code Extension (Windows)' },
75
+
76
+ // Cursor Extension Global Storage
77
+ { path: path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension' },
78
+ { path: path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'state.vscdb'), source: 'Cursor Extension Storage' },
79
+ { path: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension (macOS)' },
80
+ { path: path.join(home, 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_custom_modes.json'), source: 'Cursor Extension (Windows)' },
81
+ ]
82
+
83
+ for (const candidate of candidateFiles) {
84
+ const hits = scanFileForKeys(candidate.path, candidate.source)
85
+ for (const h of hits) {
86
+ addKey(h)
87
+ }
88
+ }
89
+
90
+ // Also check if any file in ~/.cline/ contains cline_ keys
91
+ const clineDir = path.join(home, '.cline')
92
+ try {
93
+ if (fs.existsSync(clineDir)) {
94
+ const entries = fs.readdirSync(clineDir)
95
+ for (const entry of entries) {
96
+ const full = path.join(clineDir, entry)
97
+ try {
98
+ const st = fs.statSync(full)
99
+ if (st.isFile()) {
100
+ const hits = scanFileForKeys(full, 'Cline CLI (~/.cline)')
101
+ for (const h of hits) addKey(h)
102
+ }
103
+ } catch {}
104
+ }
105
+ }
106
+ } catch {}
107
+
108
+ return discovered
109
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Multimodal Request Budgeting & Prompt Cache Image Offload (Issue #234)
3
+ *
4
+ * 1. Offloads previously seen images from earlier turns in the conversation history
5
+ * into stable text placeholders, preserving model KV/prompt cache prefixes.
6
+ * 2. Enforces a strict payload byte ceiling (default 40MB) to prevent upstream HTTP 413
7
+ * rejections and memory spikes.
8
+ */
9
+
10
+ const DEFAULT_MAX_PAYLOAD_BYTES = 40 * 1024 * 1024 // 40 MB
11
+
12
+ function estimateImageBlockBytes(block) {
13
+ if (!block || typeof block !== 'object') return 0
14
+ if (block.type === 'image_url' && typeof block.image_url?.url === 'string') {
15
+ return block.image_url.url.length
16
+ }
17
+ if (block.type === 'image' && typeof block.source?.data === 'string') {
18
+ return block.source.data.length
19
+ }
20
+ return 0
21
+ }
22
+
23
+ function makeTextPlaceholder(reason = 'analyzed') {
24
+ return {
25
+ type: 'text',
26
+ text: reason === 'cache'
27
+ ? '[Image: previously analyzed visual attachment retained in context]'
28
+ : '[Image: attachment pruned to satisfy request payload budget]',
29
+ }
30
+ }
31
+
32
+ export function optimizePromptCacheAndBudget(messages, options = {}) {
33
+ if (!Array.isArray(messages) || messages.length === 0) {
34
+ return { messages: messages || [], offloadedCount: 0, totalImageBytes: 0 }
35
+ }
36
+
37
+ const offloadSeen = options.offloadSeenImages !== false
38
+ const maxBytes = Number(options.maxPayloadBytes) || DEFAULT_MAX_PAYLOAD_BYTES
39
+
40
+ // Find index of the latest assistant message in the history
41
+ let lastAssistantIdx = -1
42
+ for (let i = messages.length - 1; i >= 0; i--) {
43
+ if (messages[i]?.role === 'assistant') {
44
+ lastAssistantIdx = i
45
+ break
46
+ }
47
+ }
48
+
49
+ let offloadedCount = 0
50
+ const clone = messages.map(m => {
51
+ if (!m || typeof m !== 'object') return m
52
+ if (Array.isArray(m.content)) {
53
+ return { ...m, content: [...m.content] }
54
+ }
55
+ return { ...m }
56
+ })
57
+
58
+ // Phase 1: Offload seen images (appear before lastAssistantIdx)
59
+ if (offloadSeen && lastAssistantIdx > 0) {
60
+ for (let i = 0; i < lastAssistantIdx; i++) {
61
+ const msg = clone[i]
62
+ if (Array.isArray(msg?.content)) {
63
+ for (let j = 0; j < msg.content.length; j++) {
64
+ const item = msg.content[j]
65
+ if (item?.type === 'image_url' || item?.type === 'image') {
66
+ msg.content[j] = makeTextPlaceholder('cache')
67
+ offloadedCount += 1
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // Phase 2: Calculate total remaining image bytes
75
+ const remainingImages = []
76
+ let totalBytes = 0
77
+
78
+ for (let i = 0; i < clone.length; i++) {
79
+ const msg = clone[i]
80
+ if (Array.isArray(msg?.content)) {
81
+ for (let j = 0; j < msg.content.length; j++) {
82
+ const item = msg.content[j]
83
+ if (item?.type === 'image_url' || item?.type === 'image') {
84
+ const bytes = estimateImageBlockBytes(item)
85
+ totalBytes += bytes
86
+ remainingImages.push({ msgIdx: i, blockIdx: j, bytes })
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Phase 3: Budget check - if over maxBytes, evict oldest remaining images
93
+ if (totalBytes > maxBytes && remainingImages.length > 0) {
94
+ for (const img of remainingImages) {
95
+ if (totalBytes <= maxBytes) break
96
+ const msg = clone[img.msgIdx]
97
+ msg.content[img.blockIdx] = makeTextPlaceholder('budget')
98
+ totalBytes -= img.bytes
99
+ offloadedCount += 1
100
+ }
101
+ }
102
+
103
+ return {
104
+ messages: clone,
105
+ offloadedCount,
106
+ totalImageBytes: totalBytes,
107
+ }
108
+ }
@@ -14,9 +14,9 @@ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
14
14
  let cachedToken = null
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.28'
17
+ let packageVersion = '0.5.30'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.28'
19
+ packageVersion = require('../package.json').version || '0.5.30'
20
20
  } catch (err) {
21
21
  /* fallback to default version if manifest is unreadable */
22
22
  }
@@ -1,4 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
+ import { discoverLocalClineKeys } from '../key-discovery.js'
2
3
  import { assertTrustedSettingsRequest } from '../access.js'
3
4
  import { publicConfig, plainConfig, Config } from '../config.js'
4
5
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
@@ -336,4 +337,20 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
336
337
  }
337
338
  },
338
339
  }), 'dsh-clinebot: /unregister')
340
+
341
+ // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232)
342
+ ctx.effect(() => ctx.webServer.register({
343
+ kind: 'exact',
344
+ path: '/dsh-clinebot/keys/discovered',
345
+ handler: async (req, res) => {
346
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
347
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
348
+ try {
349
+ const discovered = discoverLocalClineKeys()
350
+ writeJson(res, 200, { ok: true, discovered })
351
+ } catch (err) {
352
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
353
+ }
354
+ },
355
+ }), 'dsh-clinebot: /keys/discovered')
339
356
  }
@@ -21,13 +21,10 @@ import { resolveAccountPool } from '../account-pool.js'
21
21
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
22
22
  import { recordUsage } from '../stats-storage.js'
23
23
  import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
+ import { optimizePromptCacheAndBudget } from '../multimodal-budget.js'
24
25
  import {
25
- getLocalProxyToken,
26
- hasDisallowedBrowserOrigin,
27
- isAuthorizedProxyRequest,
28
- USER_AGENT,
29
- sendProxyTerminalError,
30
- extractUsageMetrics,
26
+ getLocalProxyToken, hasDisallowedBrowserOrigin, isAuthorizedProxyRequest,
27
+ USER_AGENT, sendProxyTerminalError, extractUsageMetrics
31
28
  } from '../proxy-token.js'
32
29
 
33
30
  export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, USER_AGENT }
@@ -85,6 +82,14 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
85
82
  body.reasoning_effort = pub.modelReasoningDefaults[body.model]
86
83
  }
87
84
 
85
+ if (Array.isArray(body?.messages) && body.messages.length > 0) {
86
+ const budgetResult = optimizePromptCacheAndBudget(body.messages, {
87
+ offloadSeenImages: pub.offloadSeenImages !== false,
88
+ maxPayloadBytes: (Number(pub.maxPayloadMb) || 40) * 1024 * 1024,
89
+ })
90
+ body.messages = budgetResult.messages
91
+ }
92
+
88
93
  const isStreaming = Boolean(body.stream)
89
94
  const configuredAccounts = pool.filter(a => a.present && a.value)
90
95
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ClineBot Native WebSearchProvider for ctx.web (Issue #233)
3
+ *
4
+ * Implements DSH WebSearchProvider contract so the model-facing
5
+ * web_search tool can search the web via ClinePass upstream.
6
+ */
7
+
8
+ export const CLINEBOT_SEARCH_PROVIDER_ID = 'clinebot'
9
+
10
+ function clampNumResults(maxResults) {
11
+ const n = Number(maxResults)
12
+ if (Number.isNaN(n) || n <= 0) return 5
13
+ return Math.max(1, Math.min(10, Math.round(n)))
14
+ }
15
+
16
+ function toSource(result) {
17
+ if (!result || typeof result !== 'object') return null
18
+ const url = String(result.url || '').trim()
19
+ if (!url) return null
20
+ const title = String(result.title || '').trim()
21
+ const snippet = String(result.snippet || '').trim()
22
+ return {
23
+ url,
24
+ ...(title ? { title } : {}),
25
+ ...(snippet ? { snippet } : {}),
26
+ }
27
+ }
28
+
29
+ export class ClineBotSearchProvider {
30
+ constructor(deps) {
31
+ this.deps = deps || {}
32
+ this.id = CLINEBOT_SEARCH_PROVIDER_ID
33
+ }
34
+
35
+ available() {
36
+ const base = this.deps.getBaseUrl ? this.deps.getBaseUrl() : 'https://api.cline.bot/api/v1'
37
+ return Boolean(base && typeof base === 'string')
38
+ }
39
+
40
+ async search(request, signal) {
41
+ if (signal?.aborted) {
42
+ const err = new Error('ClineBot web search aborted')
43
+ err.name = 'AbortError'
44
+ throw err
45
+ }
46
+
47
+ const query = String(request?.query || '').trim()
48
+ if (!query) return { sources: [] }
49
+
50
+ const baseUrl = (this.deps.getBaseUrl ? this.deps.getBaseUrl() : 'https://api.cline.bot/api/v1').replace(/\/+$/, '')
51
+ const key = this.deps.getApiKey ? await this.deps.getApiKey() : ''
52
+ const endpoint = `${baseUrl}/alpha/web-search`
53
+ const numResults = clampNumResults(request?.maxResults)
54
+
55
+ const fetchImpl = this.deps.fetchImpl || fetch
56
+
57
+ try {
58
+ const resp = await fetchImpl(endpoint, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
63
+ 'x-cli-environment': 'production',
64
+ },
65
+ body: JSON.stringify({ query, numResults }),
66
+ signal,
67
+ })
68
+
69
+ if (!resp.ok) {
70
+ return { sources: [] }
71
+ }
72
+
73
+ const data = await resp.json().catch(() => ({}))
74
+ const rawResults = Array.isArray(data?.results) ? data.results : []
75
+ const sources = rawResults.map(toSource).filter(Boolean)
76
+ return { sources }
77
+ } catch (err) {
78
+ if (err.name === 'AbortError') throw err
79
+ return { sources: [] }
80
+ }
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",