@goodandready/dsh-clinebot 0.5.33 → 0.6.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/lib/index.js CHANGED
@@ -1,502 +1,569 @@
1
- import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
2
- import { registerPluginUpdater } from './updater.js'
3
- import {
4
- sessionStats,
5
- recordSessionRequest,
6
- resetSessionStats,
7
- clearUsageCache,
8
- clearProbeCache,
9
- } from './cline-client.js'
10
- import {
11
- rotateToNextAccount,
12
- isQuotaExceededError,
13
- } from './account-pool.js'
14
- import { PROVIDER_ID } from './models.js'
15
- import {
16
- upsertPiAiProvider,
17
- removePiAiProvider,
18
- autoDiscoverPlanModels,
19
- resolveActiveAccountKey,
20
- } from './provider-sync.js'
21
- import { registerSettingsRoutes } from './routes/settings.js'
22
- import { registerAccountsRoutes } from './routes/accounts.js'
23
- import { registerModelsRoutes } from './routes/models.js'
24
- import { registerAuthRoutes } from './routes/auth.js'
25
- import { registerProxyRoutes } from './routes/proxy.js'
26
- import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
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'
30
- import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
31
-
32
- export const name = '@goodandready/dsh-clinebot'
33
- export const inject = ['webServer', 'credentials']
34
-
35
- export function configReader(config) {
36
- return () => config
37
- }
38
-
39
- export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
40
-
41
- export function apply(ctx, config) {
42
- let currentConfig = config || {}
43
- const overrides = {}
44
- const getConfig = () => {
45
- if (!currentConfig) return currentConfig
46
- if (Object.isFrozen(currentConfig)) {
47
- return new Proxy({}, {
48
- get(_target, prop) {
49
- const orig = Reflect.get(currentConfig, prop)
50
- if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(currentConfig, prop)?.get) {
51
- return orig
52
- }
53
- if (prop in overrides) {
54
- return overrides[prop]
55
- }
56
- return orig
57
- },
58
- has(_target, prop) {
59
- return prop in overrides || Reflect.has(currentConfig, prop)
60
- },
61
- ownKeys() {
62
- return Array.from(new Set([...Reflect.ownKeys(currentConfig), ...Object.keys(overrides)]))
63
- },
64
- getOwnPropertyDescriptor(_target, prop) {
65
- if (prop in overrides) {
66
- return { configurable: true, enumerable: true, value: overrides[prop], writable: true }
67
- }
68
- const desc = Object.getOwnPropertyDescriptor(currentConfig, prop)
69
- if (desc) {
70
- return { configurable: true, enumerable: desc.enumerable ?? true, value: Reflect.get(currentConfig, prop) }
71
- }
72
- return undefined
73
- }
74
- })
75
- }
76
- return currentConfig
77
- }
78
- const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
79
- let settingsApi
80
-
81
- const syncProviderState = async (cfg) => {
82
- try {
83
- const pub = publicConfig(cfg)
84
- if (!pub.enabled) {
85
- await removePiAiProvider(ctx)
86
- return
87
- }
88
- if (pub.proxyMode !== false && (ctx?.webServer || typeof ctx?.get === 'function')) {
89
- await startLoopbackProxyServer(ctx, routeEnv)
90
- } else {
91
- await stopLoopbackProxyServer()
92
- }
93
- const activeKey = await resolveActiveAccountKey(ctx, cfg)
94
- if (activeKey.value) {
95
- await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
96
- }
97
- } catch {
98
- /* ignore transient settings unavailable */
99
- }
100
- }
101
-
102
- const triggerAutoDiscover = () => {
103
- autoDiscoverPlanModels(ctx, { live, getSettingsApi: () => settingsApi, syncProviderState })
104
- }
105
-
106
- let activeStatsPath = resolvePath(publicConfig(live()).statsPath)
107
- loadStats(activeStatsPath)
108
-
109
- const syncStatsStorage = (cfg) => {
110
- const nextPath = resolvePath(publicConfig(cfg).statsPath)
111
- if (nextPath !== activeStatsPath) {
112
- switchStatsStorage(nextPath)
113
- activeStatsPath = nextPath
114
- }
115
- }
116
-
117
- const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
118
- const getCatalogIdentity = (cfg) => {
119
- const pub = publicConfig(cfg)
120
- return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
121
- }
122
-
123
- let lastCatalogIdentity = getCatalogIdentity(live())
124
- const resetCatalogSnapshot = () => {
125
- try {
126
- if (currentConfig && typeof currentConfig === 'object') {
127
- currentConfig.dynamicModels = []
128
- currentConfig.planSyncedAt = 0
129
- }
130
- } catch {
131
- /* ignore read-only or frozen */
132
- }
133
- overrides.dynamicModels = []
134
- overrides.planSyncedAt = 0
135
- clearUsageCache()
136
- clearProbeCache()
137
- }
138
-
139
- if (typeof ctx?.on === 'function') {
140
- ctx.on('loader/volatile-update', () => {
141
- const liveCfg = live()
142
- const currentIdentity = getCatalogIdentity(liveCfg)
143
- if (currentIdentity !== lastCatalogIdentity) {
144
- lastCatalogIdentity = currentIdentity
145
- resetCatalogSnapshot()
146
- }
147
- syncStatsStorage(liveCfg)
148
- })
149
- }
150
-
151
- const getRevision = (svc) => {
152
- try {
153
- return svc?.describe?.().find((row) => row.ns === NS)?.revision
154
- } catch {
155
- return undefined
156
- }
157
- }
158
-
159
- const applyConfigMutations = (target, source) => {
160
- const targetConfig = typeof target === 'function' ? target() : target
161
- if (targetConfig && typeof targetConfig === 'object') {
162
- if (Object.isFrozen(targetConfig)) {
163
- for (const [key, value] of Object.entries(source)) {
164
- const orig = targetConfig[key]
165
- if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
166
- continue
167
- }
168
- overrides[key] = value
169
- }
170
- return
171
- }
172
- for (const [key, value] of Object.entries(source)) {
173
- try {
174
- if (isVolatileRef(targetConfig[key])) {
175
- // Cosmokit native Volatile references are reactive and authoritative;
176
- // preserve the reference without overwriting with plain value.
177
- continue
178
- }
179
- const desc = Object.getOwnPropertyDescriptor(targetConfig, key)
180
- if (desc?.get && !desc.set) {
181
- continue
182
- }
183
- if (desc?.set) {
184
- targetConfig[key] = value
185
- } else {
186
- targetConfig[key] = value
187
- }
188
- } catch {
189
- /* ignore read-only */
190
- }
191
- }
192
- }
193
- }
194
-
195
- settingsApi = {
196
- get: () => live(),
197
- replace: async (next) => {
198
- const svc = ctx?.get?.('settings')
199
- if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
200
- throw new Error('DSH settings service is unavailable or non-writable')
201
- }
202
- const prevIdentity = getCatalogIdentity(live())
203
- const parsed = Config(structuredClone(plainConfig(next)))
204
- const nextIdentity = getCatalogIdentity(parsed)
205
- const identityChanged = nextIdentity !== prevIdentity
206
- if (identityChanged) {
207
- if (!('dynamicModels' in next)) parsed.dynamicModels = []
208
- if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
209
- }
210
- const payload = volatileConfig(parsed)
211
- const rev = getRevision(svc)
212
- if (typeof svc.replace === 'function') {
213
- await svc.replace(NS, payload, rev)
214
- } else {
215
- await svc.update(NS, payload, rev)
216
- }
217
- if (identityChanged) {
218
- lastCatalogIdentity = nextIdentity
219
- resetCatalogSnapshot()
220
- }
221
- applyConfigMutations(currentConfig, parsed)
222
- syncStatsStorage(live())
223
- await syncProviderState(live())
224
- return plainConfig(parsed)
225
- },
226
- update: async (patch) => {
227
- const svc = ctx?.get?.('settings')
228
- if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
229
- throw new Error('DSH settings service is unavailable or non-writable')
230
- }
231
- const prevIdentity = getCatalogIdentity(live())
232
- const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
233
- const nextIdentity = getCatalogIdentity(merged)
234
- const identityChanged = nextIdentity !== prevIdentity
235
- if (identityChanged) {
236
- if (!('dynamicModels' in patch)) merged.dynamicModels = []
237
- if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
238
- }
239
- const payload = volatileConfig(merged)
240
- const rev = getRevision(svc)
241
- if (typeof svc.update === 'function') {
242
- await svc.update(NS, payload, rev)
243
- } else {
244
- await svc.replace(NS, payload, rev)
245
- }
246
- if (identityChanged) {
247
- lastCatalogIdentity = nextIdentity
248
- resetCatalogSnapshot()
249
- }
250
- applyConfigMutations(currentConfig, merged)
251
- syncStatsStorage(live())
252
- await syncProviderState(live())
253
- return plainConfig(merged)
254
- },
255
-
256
- }
257
-
258
- if (typeof ctx.effect === 'function') {
259
- ctx.effect(() => {
260
- return () => {
261
- flushStats(activeStatsPath)
262
- }
263
- }, 'dsh-clinebot: stats-storage')
264
- }
265
-
266
- const routeEnv = {
267
- live,
268
- getSettingsApi: () => settingsApi,
269
- syncProviderState,
270
- triggerAutoDiscover,
271
- }
272
-
273
- if (typeof ctx.effect === 'function') {
274
- ctx.effect(() => {
275
- return () => {
276
- stopLoopbackProxyServer()
277
- }
278
- }, 'dsh-clinebot: loopback-proxy-server')
279
- }
280
-
281
- syncProviderState(live())
282
- if (typeof ctx.effect === 'function') {
283
- ctx.effect(() => {
284
- const timer = setTimeout(triggerAutoDiscover, 500)
285
- const retryTimer = setTimeout(triggerAutoDiscover, 3000)
286
- return () => {
287
- clearTimeout(timer)
288
- clearTimeout(retryTimer)
289
- }
290
- }, 'dsh-clinebot: auto-discover')
291
- }
292
-
293
- if (ctx.webServer?.register) {
294
- const unregisterUpdater = registerPluginUpdater(ctx, {
295
- endpoint: '/dsh-clinebot/update',
296
- packageName: name,
297
- manifestUrl: new URL('../package.json', import.meta.url),
298
- })
299
- if (typeof ctx.effect === 'function') {
300
- ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
301
- }
302
-
303
- registerSettingsRoutes(ctx, routeEnv)
304
- registerAccountsRoutes(ctx, routeEnv)
305
- registerModelsRoutes(ctx, routeEnv)
306
- registerAuthRoutes(ctx, routeEnv)
307
- registerProxyRoutes(ctx, routeEnv)
308
- }
309
-
310
- registerSlashCommand(ctx, {
311
- live,
312
- getSettingsApi: () => settingsApi,
313
- syncProviderState,
314
- })
315
-
316
- let lastFailoverAt = 0
317
-
318
- const handleStream429 = async () => {
319
- const now = Date.now()
320
- if (now - lastFailoverAt < 30000) {
321
- ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
322
- return
323
- }
324
- lastFailoverAt = now
325
- try {
326
- const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
327
- if (failover?.rotated) {
328
- await syncProviderState(live())
329
- }
330
- } catch (err) {
331
- ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
332
- }
333
- }
334
-
335
- if (typeof ctx.on === 'function') {
336
- const unlisten = ctx.on('llm/stream', (options, next) => {
337
- const isCline = options?.provider === PROVIDER_ID
338
- const stream = next()
339
- const startTime = Date.now()
340
- let promptTokens = 0
341
- let completionTokens = 0
342
- let finished = false
343
-
344
- return (async function* () {
345
- try {
346
- for await (const chunk of stream) {
347
- if (isCline && chunk?.type === 'usage' && chunk?.usage) {
348
- const u = chunk.usage
349
- promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
350
- completionTokens += Number(u.outputTokens) || 0
351
- }
352
- if (isCline && chunk?.type === 'finish' && chunk?.reason) {
353
- finished = true
354
- const latencyMs = Date.now() - startTime
355
- const reason = chunk.reason
356
- const failure = reason.failure || {}
357
- const isError = reason.kind === 'error'
358
- const isAborted = reason.kind === 'aborted'
359
- const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
360
- const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
361
-
362
- const isProxy = publicConfig(live()).proxyMode !== false
363
- if (isError) {
364
- recordSessionRequest({
365
- latencyMs,
366
- ok: false,
367
- error: failure.message || 'Stream error',
368
- promptTokens,
369
- completionTokens,
370
- model: options?.model,
371
- })
372
- if (!isProxy) {
373
- recordUsage({
374
- model: options?.model,
375
- promptTokens,
376
- completionTokens,
377
- totalTokens: promptTokens + completionTokens,
378
- isError: true,
379
- is429,
380
- statsPath: publicConfig(live()).statsPath,
381
- })
382
- }
383
- if (is429 || isExceeded) {
384
- handleStream429()
385
- }
386
- } else if (isAborted) {
387
- recordSessionRequest({
388
- latencyMs,
389
- ok: true,
390
- aborted: true,
391
- promptTokens,
392
- completionTokens,
393
- model: options?.model,
394
- })
395
- } else {
396
- recordSessionRequest({
397
- latencyMs,
398
- ok: true,
399
- promptTokens,
400
- completionTokens,
401
- model: options?.model,
402
- })
403
- if (!isProxy) {
404
- recordUsage({
405
- model: options?.model,
406
- promptTokens,
407
- completionTokens,
408
- totalTokens: promptTokens + completionTokens,
409
- statsPath: publicConfig(live()).statsPath,
410
- })
411
- }
412
- }
413
- }
414
- yield chunk
415
- }
416
- if (isCline && !finished) {
417
- recordSessionRequest({
418
- latencyMs: Date.now() - startTime,
419
- ok: true,
420
- promptTokens,
421
- completionTokens,
422
- model: options?.model,
423
- })
424
- const isProxy = publicConfig(live()).proxyMode !== false
425
- if (!isProxy) {
426
- recordUsage({
427
- model: options?.model,
428
- promptTokens,
429
- completionTokens,
430
- totalTokens: promptTokens + completionTokens,
431
- statsPath: publicConfig(live()).statsPath,
432
- })
433
- }
434
- }
435
- } catch (err) {
436
- if (isCline) {
437
- const latencyMs = Date.now() - startTime
438
- const status = err?.status || err?.statusCode
439
- const msg = String(err?.message || err)
440
- recordSessionRequest({
441
- latencyMs,
442
- ok: false,
443
- error: msg,
444
- promptTokens,
445
- completionTokens,
446
- model: options?.model,
447
- })
448
- const isProxy = publicConfig(live()).proxyMode !== false
449
- if (!isProxy) {
450
- recordUsage({
451
- model: options?.model,
452
- promptTokens,
453
- completionTokens,
454
- totalTokens: promptTokens + completionTokens,
455
- isError: true,
456
- is429: status === 429,
457
- statsPath: publicConfig(live()).statsPath,
458
- })
459
- }
460
- if (status === 429 || isQuotaExceededError(msg)) {
461
- handleStream429()
462
- }
463
- }
464
- throw err
465
- }
466
- })()
467
- }, { global: true, prepend: true })
468
-
469
- if (typeof ctx.effect === 'function') {
470
- ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
471
- }
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
- }
502
- }
1
+ import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
2
+ import { registerPluginUpdater } from './updater.js'
3
+ import {
4
+ sessionStats,
5
+ recordSessionRequest,
6
+ resetSessionStats,
7
+ clearUsageCache,
8
+ clearProbeCache,
9
+ } from './cline-client.js'
10
+ import {
11
+ rotateToNextAccount,
12
+ isQuotaExceededError,
13
+ } from './account-pool.js'
14
+ import { PROVIDER_ID } from './models.js'
15
+ import {
16
+ upsertPiAiProvider,
17
+ removePiAiProvider,
18
+ autoDiscoverPlanModels,
19
+ resolveActiveAccountKey,
20
+ } from './provider-sync.js'
21
+ import { registerSettingsRoutes } from './routes/settings.js'
22
+ import { registerAccountsRoutes } from './routes/accounts.js'
23
+ import { registerModelsRoutes } from './routes/models.js'
24
+ import { registerAuthRoutes } from './routes/auth.js'
25
+ import { registerProxyRoutes } from './routes/proxy.js'
26
+ import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
27
+ import { ClineBotAdapter } from './adapter.js'
28
+ import { registerSlashCommand } from './slash-command.js'
29
+ import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
30
+ import { resolveAccountPool } from './account-pool.js'
31
+ import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
32
+ import { service, migrateLegacyPiAiProvider } from './services.js'
33
+ import { applyTuiSettings } from './tui-settings.js'
34
+
35
+ export const name = '@goodandready/dsh-clinebot'
36
+ export const inject = []
37
+
38
+ export function configReader(config) {
39
+ return () => config
40
+ }
41
+
42
+ export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
43
+
44
+ export function apply(ctx, config) {
45
+ let currentConfig = config || {}
46
+ const overrides = {}
47
+ const getConfig = () => {
48
+ if (!currentConfig) return currentConfig
49
+ if (Object.isFrozen(currentConfig)) {
50
+ return new Proxy({}, {
51
+ get(_target, prop) {
52
+ const orig = Reflect.get(currentConfig, prop)
53
+ if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(currentConfig, prop)?.get) {
54
+ return orig
55
+ }
56
+ if (prop in overrides) {
57
+ return overrides[prop]
58
+ }
59
+ return orig
60
+ },
61
+ has(_target, prop) {
62
+ return prop in overrides || Reflect.has(currentConfig, prop)
63
+ },
64
+ ownKeys() {
65
+ return Array.from(new Set([...Reflect.ownKeys(currentConfig), ...Object.keys(overrides)]))
66
+ },
67
+ getOwnPropertyDescriptor(_target, prop) {
68
+ if (prop in overrides) {
69
+ return { configurable: true, enumerable: true, value: overrides[prop], writable: true }
70
+ }
71
+ const desc = Object.getOwnPropertyDescriptor(currentConfig, prop)
72
+ if (desc) {
73
+ return { configurable: true, enumerable: desc.enumerable ?? true, value: Reflect.get(currentConfig, prop) }
74
+ }
75
+ return undefined
76
+ }
77
+ })
78
+ }
79
+ return currentConfig
80
+ }
81
+ const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
82
+ let settingsApi
83
+ let adapterRegistration = null
84
+ let adapterDirectory = null
85
+
86
+ const syncProviderState = async (cfg) => {
87
+ try {
88
+ const pub = publicConfig(cfg)
89
+ if (pub.transport !== 'pi-ai') {
90
+ // Native ctx.llm adapter: the route set follows `enabled`; no
91
+ // llm-pi-ai mutation and no loopback proxy are involved.
92
+ if (adapterRegistration) {
93
+ adapterRegistration.replace(pub.enabled ? [PROVIDER_ID] : [])
94
+ }
95
+ return
96
+ }
97
+ if (!pub.enabled) {
98
+ await removePiAiProvider(ctx)
99
+ return
100
+ }
101
+ if (pub.proxyMode !== false && (service(ctx, 'webServer') || typeof ctx?.get === 'function')) {
102
+ await startLoopbackProxyServer(ctx, routeEnv)
103
+ } else {
104
+ await stopLoopbackProxyServer()
105
+ }
106
+ const activeKey = await resolveActiveAccountKey(ctx, cfg)
107
+ if (activeKey.value) {
108
+ await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
109
+ }
110
+ } catch {
111
+ /* ignore transient settings unavailable */
112
+ }
113
+ }
114
+
115
+ const triggerAutoDiscover = () => {
116
+ autoDiscoverPlanModels(ctx, { live, getSettingsApi: () => settingsApi, syncProviderState })
117
+ }
118
+
119
+ let activeStatsPath = resolvePath(publicConfig(live()).statsPath)
120
+ loadStats(activeStatsPath)
121
+
122
+ const syncStatsStorage = (cfg) => {
123
+ const nextPath = resolvePath(publicConfig(cfg).statsPath)
124
+ if (nextPath !== activeStatsPath) {
125
+ switchStatsStorage(nextPath)
126
+ activeStatsPath = nextPath
127
+ }
128
+ }
129
+
130
+ const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
131
+ const getCatalogIdentity = (cfg) => {
132
+ const pub = publicConfig(cfg)
133
+ return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
134
+ }
135
+
136
+ let lastCatalogIdentity = getCatalogIdentity(live())
137
+ const resetCatalogSnapshot = () => {
138
+ try {
139
+ if (currentConfig && typeof currentConfig === 'object') {
140
+ currentConfig.dynamicModels = []
141
+ currentConfig.planSyncedAt = 0
142
+ }
143
+ } catch {
144
+ /* ignore read-only or frozen */
145
+ }
146
+ overrides.dynamicModels = []
147
+ overrides.planSyncedAt = 0
148
+ clearUsageCache()
149
+ clearProbeCache()
150
+ }
151
+
152
+ if (typeof ctx?.on === 'function') {
153
+ ctx.on('loader/volatile-update', () => {
154
+ const liveCfg = live()
155
+ const currentIdentity = getCatalogIdentity(liveCfg)
156
+ if (currentIdentity !== lastCatalogIdentity) {
157
+ lastCatalogIdentity = currentIdentity
158
+ resetCatalogSnapshot()
159
+ }
160
+ syncStatsStorage(liveCfg)
161
+ })
162
+ }
163
+
164
+ const getRevision = (svc) => {
165
+ try {
166
+ return svc?.describe?.().find((row) => row.ns === NS)?.revision
167
+ } catch {
168
+ return undefined
169
+ }
170
+ }
171
+
172
+ const applyConfigMutations = (target, source) => {
173
+ const targetConfig = typeof target === 'function' ? target() : target
174
+ if (targetConfig && typeof targetConfig === 'object') {
175
+ if (Object.isFrozen(targetConfig)) {
176
+ for (const [key, value] of Object.entries(source)) {
177
+ const orig = targetConfig[key]
178
+ if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
179
+ continue
180
+ }
181
+ overrides[key] = value
182
+ }
183
+ return
184
+ }
185
+ for (const [key, value] of Object.entries(source)) {
186
+ try {
187
+ if (isVolatileRef(targetConfig[key])) {
188
+ // Cosmokit native Volatile references are reactive and authoritative;
189
+ // preserve the reference without overwriting with plain value.
190
+ continue
191
+ }
192
+ const desc = Object.getOwnPropertyDescriptor(targetConfig, key)
193
+ if (desc?.get && !desc.set) {
194
+ continue
195
+ }
196
+ if (desc?.set) {
197
+ targetConfig[key] = value
198
+ } else {
199
+ targetConfig[key] = value
200
+ }
201
+ } catch {
202
+ /* ignore read-only */
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ settingsApi = {
209
+ get: () => live(),
210
+ replace: async (next) => {
211
+ const svc = ctx?.get?.('settings')
212
+ if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
213
+ throw new Error('DSH settings service is unavailable or non-writable')
214
+ }
215
+ const prevIdentity = getCatalogIdentity(live())
216
+ const parsed = Config(structuredClone(plainConfig(next)))
217
+ const nextIdentity = getCatalogIdentity(parsed)
218
+ const identityChanged = nextIdentity !== prevIdentity
219
+ if (identityChanged) {
220
+ if (!('dynamicModels' in next)) parsed.dynamicModels = []
221
+ if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
222
+ }
223
+ const payload = volatileConfig(parsed)
224
+ const rev = getRevision(svc)
225
+ if (typeof svc.replace === 'function') {
226
+ await svc.replace(NS, payload, rev)
227
+ } else {
228
+ await svc.update(NS, payload, rev)
229
+ }
230
+ if (identityChanged) {
231
+ lastCatalogIdentity = nextIdentity
232
+ resetCatalogSnapshot()
233
+ }
234
+ applyConfigMutations(currentConfig, parsed)
235
+ syncStatsStorage(live())
236
+ await syncProviderState(live())
237
+ return plainConfig(parsed)
238
+ },
239
+ update: async (patch) => {
240
+ const svc = ctx?.get?.('settings')
241
+ if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
242
+ throw new Error('DSH settings service is unavailable or non-writable')
243
+ }
244
+ const prevIdentity = getCatalogIdentity(live())
245
+ const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
246
+ const nextIdentity = getCatalogIdentity(merged)
247
+ const identityChanged = nextIdentity !== prevIdentity
248
+ if (identityChanged) {
249
+ if (!('dynamicModels' in patch)) merged.dynamicModels = []
250
+ if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
251
+ }
252
+ const payload = volatileConfig(merged)
253
+ const rev = getRevision(svc)
254
+ if (typeof svc.update === 'function') {
255
+ await svc.update(NS, payload, rev)
256
+ } else {
257
+ await svc.replace(NS, payload, rev)
258
+ }
259
+ if (identityChanged) {
260
+ lastCatalogIdentity = nextIdentity
261
+ resetCatalogSnapshot()
262
+ }
263
+ applyConfigMutations(currentConfig, merged)
264
+ syncStatsStorage(live())
265
+ await syncProviderState(live())
266
+ return plainConfig(merged)
267
+ },
268
+
269
+ }
270
+
271
+ if (typeof ctx.effect === 'function') {
272
+ ctx.effect(() => {
273
+ return () => {
274
+ flushStats(activeStatsPath)
275
+ }
276
+ }, 'dsh-clinebot: stats-storage')
277
+ }
278
+
279
+ const routeEnv = {
280
+ live,
281
+ getSettingsApi: () => settingsApi,
282
+ syncProviderState,
283
+ triggerAutoDiscover,
284
+ }
285
+
286
+ const ensureAdapter = () => {
287
+ if (publicConfig(live()).transport === 'pi-ai') return
288
+ if (typeof ctx.inject !== 'function') return
289
+ ctx.inject(['llm'], (llmCtx) => {
290
+ const llm = llmCtx?.llm || llmCtx?.get?.('llm')
291
+ if (!llm || typeof llm.registerAdapter !== 'function') return
292
+ try {
293
+ const adapter = new ClineBotAdapter({
294
+ ctx,
295
+ live,
296
+ resolveAttachments: () => ctx?.get?.('attachments') || ctx?.attachments,
297
+ logger: ctx?.logger,
298
+ })
299
+ adapterRegistration = llm.registerAdapter([PROVIDER_ID], adapter)
300
+ if (typeof llm.registerConfigurableProviders === 'function') {
301
+ adapterDirectory = llm.registerConfigurableProviders([{
302
+ provider: PROVIDER_ID,
303
+ displayName: 'ClineBot (ClinePass)',
304
+ settingsNs: NS,
305
+ settingsPath: [],
306
+ defaultBaseURL: 'https://api.cline.bot/api/v1',
307
+ }])
308
+ }
309
+ ctx.effect(() => () => {
310
+ try { adapterDirectory?.() } catch {}
311
+ try { adapterRegistration?.() } catch {}
312
+ adapterDirectory = null
313
+ adapterRegistration = null
314
+ }, 'dsh-clinebot: llm adapter')
315
+ void syncProviderState(live())
316
+ void migrateLegacyPiAiProvider(ctx)
317
+ } catch (error) {
318
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to register llm adapter: ' + (error?.message || error))
319
+ }
320
+ })
321
+ }
322
+ ensureAdapter()
323
+ applyTuiSettings(ctx, { live })
324
+
325
+ if (typeof ctx.inject === 'function' && publicConfig(live()).transport !== 'pi-ai') {
326
+ ctx.inject(['settings'], () => {
327
+ void migrateLegacyPiAiProvider(ctx)
328
+ })
329
+ }
330
+
331
+ if (typeof ctx.effect === 'function') {
332
+ ctx.effect(() => {
333
+ return () => {
334
+ stopLoopbackProxyServer()
335
+ }
336
+ }, 'dsh-clinebot: loopback-proxy-server')
337
+ }
338
+
339
+ syncProviderState(live())
340
+ if (typeof ctx.effect === 'function') {
341
+ ctx.effect(() => {
342
+ const timer = setTimeout(triggerAutoDiscover, 500)
343
+ const retryTimer = setTimeout(triggerAutoDiscover, 3000)
344
+ return () => {
345
+ clearTimeout(timer)
346
+ clearTimeout(retryTimer)
347
+ }
348
+ }, 'dsh-clinebot: auto-discover')
349
+ }
350
+
351
+ const registerWebSurfaces = (webCtx) => {
352
+ if (!service(webCtx, 'webServer')?.register) return
353
+ const unregisterUpdater = registerPluginUpdater(webCtx, {
354
+ endpoint: '/dsh-clinebot/update',
355
+ packageName: name,
356
+ manifestUrl: new URL('../package.json', import.meta.url),
357
+ })
358
+ if (typeof webCtx.effect === 'function') {
359
+ webCtx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
360
+ }
361
+
362
+ registerSettingsRoutes(webCtx, routeEnv)
363
+ registerAccountsRoutes(webCtx, routeEnv)
364
+ registerModelsRoutes(webCtx, routeEnv)
365
+ registerAuthRoutes(webCtx, routeEnv)
366
+ registerProxyRoutes(webCtx, routeEnv)
367
+ }
368
+
369
+ if (service(ctx, 'webServer')?.register) {
370
+ registerWebSurfaces(ctx)
371
+ } else if (typeof ctx.inject === 'function') {
372
+ // Headless/TUI profiles mount no web server; the adapter and slash command
373
+ // still work, and web routes appear if a server mounts later.
374
+ ctx.inject(['webServer'], registerWebSurfaces)
375
+ }
376
+
377
+ registerSlashCommand(ctx, {
378
+ live,
379
+ getSettingsApi: () => settingsApi,
380
+ syncProviderState,
381
+ })
382
+
383
+ let lastFailoverAt = 0
384
+
385
+ const handleStream429 = async () => {
386
+ const now = Date.now()
387
+ if (now - lastFailoverAt < 30000) {
388
+ ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
389
+ return
390
+ }
391
+ lastFailoverAt = now
392
+ try {
393
+ const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
394
+ if (failover?.rotated) {
395
+ await syncProviderState(live())
396
+ }
397
+ } catch (err) {
398
+ ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
399
+ }
400
+ }
401
+
402
+ if (typeof ctx.on === 'function' && publicConfig(live()).transport === 'pi-ai') {
403
+ const unlisten = ctx.on('llm/stream', (options, next) => {
404
+ const isCline = options?.provider === PROVIDER_ID
405
+ const stream = next()
406
+ const startTime = Date.now()
407
+ let promptTokens = 0
408
+ let completionTokens = 0
409
+ let finished = false
410
+
411
+ return (async function* () {
412
+ try {
413
+ for await (const chunk of stream) {
414
+ if (isCline && chunk?.type === 'usage' && chunk?.usage) {
415
+ const u = chunk.usage
416
+ promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
417
+ completionTokens += Number(u.outputTokens) || 0
418
+ }
419
+ if (isCline && chunk?.type === 'finish' && chunk?.reason) {
420
+ finished = true
421
+ const latencyMs = Date.now() - startTime
422
+ const reason = chunk.reason
423
+ const failure = reason.failure || {}
424
+ const isError = reason.kind === 'error'
425
+ const isAborted = reason.kind === 'aborted'
426
+ const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
427
+ const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
428
+
429
+ const isProxy = publicConfig(live()).proxyMode !== false
430
+ if (isError) {
431
+ recordSessionRequest({
432
+ latencyMs,
433
+ ok: false,
434
+ error: failure.message || 'Stream error',
435
+ promptTokens,
436
+ completionTokens,
437
+ model: options?.model,
438
+ })
439
+ if (!isProxy) {
440
+ recordUsage({
441
+ model: options?.model,
442
+ promptTokens,
443
+ completionTokens,
444
+ totalTokens: promptTokens + completionTokens,
445
+ isError: true,
446
+ is429,
447
+ statsPath: publicConfig(live()).statsPath,
448
+ })
449
+ }
450
+ if (is429 || isExceeded) {
451
+ handleStream429()
452
+ }
453
+ } else if (isAborted) {
454
+ recordSessionRequest({
455
+ latencyMs,
456
+ ok: true,
457
+ aborted: true,
458
+ promptTokens,
459
+ completionTokens,
460
+ model: options?.model,
461
+ })
462
+ } else {
463
+ recordSessionRequest({
464
+ latencyMs,
465
+ ok: true,
466
+ promptTokens,
467
+ completionTokens,
468
+ model: options?.model,
469
+ })
470
+ if (!isProxy) {
471
+ recordUsage({
472
+ model: options?.model,
473
+ promptTokens,
474
+ completionTokens,
475
+ totalTokens: promptTokens + completionTokens,
476
+ statsPath: publicConfig(live()).statsPath,
477
+ })
478
+ }
479
+ }
480
+ }
481
+ yield chunk
482
+ }
483
+ if (isCline && !finished) {
484
+ recordSessionRequest({
485
+ latencyMs: Date.now() - startTime,
486
+ ok: true,
487
+ promptTokens,
488
+ completionTokens,
489
+ model: options?.model,
490
+ })
491
+ const isProxy = publicConfig(live()).proxyMode !== false
492
+ if (!isProxy) {
493
+ recordUsage({
494
+ model: options?.model,
495
+ promptTokens,
496
+ completionTokens,
497
+ totalTokens: promptTokens + completionTokens,
498
+ statsPath: publicConfig(live()).statsPath,
499
+ })
500
+ }
501
+ }
502
+ } catch (err) {
503
+ if (isCline) {
504
+ const latencyMs = Date.now() - startTime
505
+ const status = err?.status || err?.statusCode
506
+ const msg = String(err?.message || err)
507
+ recordSessionRequest({
508
+ latencyMs,
509
+ ok: false,
510
+ error: msg,
511
+ promptTokens,
512
+ completionTokens,
513
+ model: options?.model,
514
+ })
515
+ const isProxy = publicConfig(live()).proxyMode !== false
516
+ if (!isProxy) {
517
+ recordUsage({
518
+ model: options?.model,
519
+ promptTokens,
520
+ completionTokens,
521
+ totalTokens: promptTokens + completionTokens,
522
+ isError: true,
523
+ is429: status === 429,
524
+ statsPath: publicConfig(live()).statsPath,
525
+ })
526
+ }
527
+ if (status === 429 || isQuotaExceededError(msg)) {
528
+ handleStream429()
529
+ }
530
+ }
531
+ throw err
532
+ }
533
+ })()
534
+ }, { global: true, prepend: true })
535
+
536
+ if (typeof ctx.effect === 'function') {
537
+ ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
538
+ }
539
+ }
540
+
541
+ // Native DSH WebSearchProvider over ctx.web (#233)
542
+ if (typeof ctx.inject === 'function') {
543
+ ctx.inject(['web'], (webCtx) => {
544
+ const cfg = publicConfig(live())
545
+ if (cfg.webSearch === false) return
546
+ const web = webCtx?.get?.('web')
547
+ if (web && typeof web.registerSearchProvider === 'function') {
548
+ const searchProvider = new ClineBotSearchProvider({
549
+ getBaseUrl: () => publicConfig(live()).baseUrl,
550
+ getApiKey: async () => {
551
+ const pool = await resolveAccountPool(ctx, live())
552
+ const active = pool.find((a) => a.present && a.value)
553
+ return active?.value || ''
554
+ },
555
+ })
556
+ try {
557
+ const unreg = web.registerSearchProvider(CLINEBOT_SEARCH_PROVIDER_ID, searchProvider)
558
+ if (typeof ctx.effect === 'function') {
559
+ ctx.effect(() => () => {
560
+ try { if (typeof unreg === 'function') unreg() } catch {}
561
+ }, 'dsh-clinebot: web search provider')
562
+ }
563
+ } catch (e) {
564
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to register web search provider: ' + (e?.message || e))
565
+ }
566
+ }
567
+ })
568
+ }
569
+ }