@goodandready/dsh-clinebot 0.6.29 → 0.6.31

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,426 +1,427 @@
1
- import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, 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 { rotateToNextAccount } from './account-pool.js'
11
- import { PROVIDER_ID } from './models.js'
12
- import {
13
- autoDiscoverPlanModels,
14
- } from './provider-sync.js'
15
- import { registerSettingsRoutes } from './routes/settings.js'
16
- import { registerAccountsRoutes } from './routes/accounts.js'
17
- import { registerModelsRoutes } from './routes/models.js'
18
- import { registerAuthRoutes } from './routes/auth.js'
19
- import { ClineBotAdapter } from './adapter.js'
20
- import { registerSlashCommand } from './slash-command.js'
21
- import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
22
- import { resolveAccountPool } from './account-pool.js'
23
- import { loadStats, flushStats, resolvePath, switchStatsStorage, setStatsRetentionDays } from './stats-storage.js'
24
- import { loadPublicPriceCache } from './price-sync.js'
25
- import { setPublicPriceCatalog } from './model-prices.js'
26
- import { loadAccountEvents, flushAccountEvents, recordAccountEvent } from './account-events.js'
27
- import { setAccountEventSink } from './session-router.js'
28
- import { service, migrateLegacyPiAiProvider } from './services.js'
29
- import { applyTuiSettings } from './tui-settings.js'
30
- import { isSafeDataPath } from './path-safety.js'
31
- import { safeDict, setOwn } from './safe-dict.js'
32
-
33
- export const name = '@goodandready/dsh-clinebot'
34
- export const inject = []
35
-
36
- const CATALOG_REFRESH_MS = 6 * 60 * 60 * 1000
37
- const CATALOG_STALE_MS = 5 * 60 * 1000
38
-
39
- export function configReader(config) {
40
- return () => config
41
- }
42
-
43
- export { NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
44
-
45
- export function apply(ctx, config) {
46
- let currentConfig = configReader(config)() || {}
47
- const overrides = safeDict()
48
- const getConfig = () => {
49
- if (!currentConfig) return currentConfig
50
- if (Object.isFrozen(currentConfig)) {
51
- return new Proxy({}, {
52
- get(_target, prop) {
53
- const orig = Reflect.get(currentConfig, prop)
54
- if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(currentConfig, prop)?.get) {
55
- return orig
56
- }
57
- if (prop in overrides) {
58
- return overrides[prop]
59
- }
60
- return orig
61
- },
62
- has(_target, prop) {
63
- return prop in overrides || Reflect.has(currentConfig, prop)
64
- },
65
- ownKeys() {
66
- return Array.from(new Set([...Reflect.ownKeys(currentConfig), ...Object.keys(overrides)]))
67
- },
68
- getOwnPropertyDescriptor(_target, prop) {
69
- if (prop in overrides) {
70
- return { configurable: true, enumerable: true, value: overrides[prop], writable: true }
71
- }
72
- const desc = Object.getOwnPropertyDescriptor(currentConfig, prop)
73
- if (desc) {
74
- return { configurable: true, enumerable: desc.enumerable ?? true, value: Reflect.get(currentConfig, prop) }
75
- }
76
- return undefined
77
- }
78
- })
79
- }
80
- return currentConfig
81
- }
82
- const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
83
- let settingsApi
84
- let adapterRegistration = null
85
- let adapterDirectory = null
86
-
87
- const syncProviderState = async (cfg) => {
88
- try {
89
- const pub = publicConfig(cfg)
90
- if (adapterRegistration) {
91
- adapterRegistration.replace(pub.enabled ? [PROVIDER_ID] : [])
92
- }
93
- } catch {
94
- /* ignore transient settings unavailable */
95
- }
96
- }
97
-
98
- let autoDiscoverInFlight = false
99
- const triggerAutoDiscover = () => {
100
- if (autoDiscoverInFlight) return
101
- autoDiscoverInFlight = true
102
- Promise.resolve(autoDiscoverPlanModels(ctx, {
103
- live,
104
- getSettingsApi: () => settingsApi,
105
- syncProviderState,
106
- }))
107
- .catch(() => {})
108
- .finally(() => {
109
- autoDiscoverInFlight = false
110
- })
111
- }
112
-
113
- const initial = publicConfig(live())
114
- let activeStatsPath = resolvePath(isSafeDataPath(initial.statsPath) ? initial.statsPath : '~/.dsh/clinebot-stats.json')
115
- setStatsRetentionDays(initial.statsRetentionDays)
116
- setPublicPriceCatalog(loadPublicPriceCache(isSafeDataPath(initial.priceCatalogPath) ? initial.priceCatalogPath : '~/.dsh/clinebot-prices.json'))
117
- let activeEventPath = resolvePath(isSafeDataPath(initial.eventLogPath) ? initial.eventLogPath : '~/.dsh/clinebot-events.json')
118
- loadAccountEvents(activeEventPath)
119
- setAccountEventSink(recordAccountEvent)
120
- loadStats(activeStatsPath)
121
-
122
- const syncStatsStorage = (cfg) => {
123
- const pub = publicConfig(cfg)
124
- setStatsRetentionDays(pub.statsRetentionDays)
125
- const priceCatalogPath = isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : '~/.dsh/clinebot-prices.json'
126
- setPublicPriceCatalog(loadPublicPriceCache(priceCatalogPath))
127
- const nextEventPath = resolvePath(isSafeDataPath(pub.eventLogPath) ? pub.eventLogPath : '~/.dsh/clinebot-events.json')
128
- if (nextEventPath !== activeEventPath) {
129
- flushAccountEvents()
130
- activeEventPath = nextEventPath
131
- loadAccountEvents(activeEventPath)
132
- }
133
- const nextPath = resolvePath(isSafeDataPath(pub.statsPath) ? pub.statsPath : '~/.dsh/clinebot-stats.json')
134
- if (nextPath !== activeStatsPath) {
135
- switchStatsStorage(nextPath)
136
- activeStatsPath = nextPath
137
- }
138
- }
139
-
140
- const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
141
- const getCatalogIdentity = (cfg) => {
142
- const pub = publicConfig(cfg)
143
- return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
144
- }
145
-
146
- let lastCatalogIdentity = getCatalogIdentity(live())
147
- const resetCatalogSnapshot = () => {
148
- try {
149
- if (currentConfig && typeof currentConfig === 'object') {
150
- currentConfig.dynamicModels = []
151
- currentConfig.planSyncedAt = 0
152
- }
153
- } catch {
154
- /* ignore read-only or frozen */
155
- }
156
- overrides.dynamicModels = []
157
- overrides.planSyncedAt = 0
158
- clearUsageCache()
159
- clearProbeCache()
160
- }
161
-
162
- if (typeof ctx?.on === 'function') {
163
- ctx.on('loader/volatile-update', () => {
164
- const liveCfg = live()
165
- const currentIdentity = getCatalogIdentity(liveCfg)
166
- if (currentIdentity !== lastCatalogIdentity) {
167
- lastCatalogIdentity = currentIdentity
168
- resetCatalogSnapshot()
169
- }
170
- syncStatsStorage(liveCfg)
171
- })
172
- }
173
-
174
- const getRevision = (svc) => {
175
- try {
176
- return svc?.describe?.().find((row) => row.ns === NS)?.revision
177
- } catch {
178
- return undefined
179
- }
180
- }
181
-
182
- const applyConfigMutations = (target, source) => {
183
- const targetConfig = typeof target === 'function' ? target() : target
184
- if (targetConfig && typeof targetConfig === 'object') {
185
- if (Object.isFrozen(targetConfig)) {
186
- for (const [key, value] of Object.entries(source)) {
187
- const orig = targetConfig[key]
188
- if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
189
- continue
190
- }
191
- setOwn(overrides, key, value)
192
- }
193
- return
194
- }
195
- for (const [key, value] of Object.entries(source)) {
196
- try {
197
- if (isVolatileRef(targetConfig[key])) {
198
- // Cosmokit native Volatile references are reactive and authoritative;
199
- // preserve the reference without overwriting with plain value.
200
- continue
201
- }
202
- const desc = Object.getOwnPropertyDescriptor(targetConfig, key)
203
- if (desc?.get && !desc.set) {
204
- continue
205
- }
206
- if (desc?.set) {
207
- targetConfig[key] = value
208
- } else {
209
- setOwn(targetConfig, key, value)
210
- }
211
- } catch {
212
- /* ignore read-only */
213
- }
214
- }
215
- }
216
- }
217
-
218
- settingsApi = {
219
- get: () => live(),
220
- replace: async (next) => {
221
- const svc = ctx?.get?.('settings')
222
- if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
223
- throw new Error('DSH settings service is unavailable or non-writable')
224
- }
225
- const prevIdentity = getCatalogIdentity(live())
226
- const parsed = Config(structuredClone(plainConfig(next)))
227
- const nextIdentity = getCatalogIdentity(parsed)
228
- const identityChanged = nextIdentity !== prevIdentity
229
- if (identityChanged) {
230
- if (!('dynamicModels' in next)) parsed.dynamicModels = []
231
- if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
232
- }
233
- const payload = volatileConfig(parsed)
234
- const rev = getRevision(svc)
235
- if (typeof svc.replace === 'function') {
236
- await svc.replace(NS, payload, rev)
237
- } else {
238
- await svc.update(NS, payload, rev)
239
- }
240
- if (identityChanged) {
241
- lastCatalogIdentity = nextIdentity
242
- resetCatalogSnapshot()
243
- }
244
- applyConfigMutations(currentConfig, parsed)
245
- syncStatsStorage(live())
246
- await syncProviderState(live())
247
- return plainConfig(parsed)
248
- },
249
- update: async (patch) => {
250
- const svc = ctx?.get?.('settings')
251
- if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
252
- throw new Error('DSH settings service is unavailable or non-writable')
253
- }
254
- const prevIdentity = getCatalogIdentity(live())
255
- const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
256
- const nextIdentity = getCatalogIdentity(merged)
257
- const identityChanged = nextIdentity !== prevIdentity
258
- if (identityChanged) {
259
- if (!('dynamicModels' in patch)) merged.dynamicModels = []
260
- if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
261
- }
262
- const payload = volatileConfig(merged)
263
- const rev = getRevision(svc)
264
- if (typeof svc.update === 'function') {
265
- await svc.update(NS, payload, rev)
266
- } else {
267
- await svc.replace(NS, payload, rev)
268
- }
269
- if (identityChanged) {
270
- lastCatalogIdentity = nextIdentity
271
- resetCatalogSnapshot()
272
- }
273
- applyConfigMutations(currentConfig, merged)
274
- syncStatsStorage(live())
275
- await syncProviderState(live())
276
- return plainConfig(merged)
277
- },
278
-
279
- }
280
-
281
- if (typeof ctx.effect === 'function') {
282
- ctx.effect(() => {
283
- return () => {
284
- flushStats(activeStatsPath)
285
- flushAccountEvents()
286
- setAccountEventSink(null)
287
- }
288
- }, 'dsh-clinebot: stats-storage')
289
- }
290
-
291
- const routeEnv = {
292
- live,
293
- getSettingsApi: () => settingsApi,
294
- syncProviderState,
295
- triggerAutoDiscover,
296
- }
297
-
298
- const ensureAdapter = () => {
299
- if (typeof ctx.inject !== 'function') return
300
- ctx.inject(['llm'], (llmCtx) => {
301
- const llm = llmCtx?.llm || llmCtx?.get?.('llm')
302
- if (!llm || typeof llm.registerAdapter !== 'function') return
303
- try {
304
- const adapter = new ClineBotAdapter({
305
- ctx,
306
- live,
307
- resolveAttachments: () => ctx?.get?.('attachments') || ctx?.attachments,
308
- logger: ctx?.logger,
309
- })
310
- adapterRegistration = llm.registerAdapter([PROVIDER_ID], adapter)
311
- if (typeof llm.registerConfigurableProviders === 'function') {
312
- adapterDirectory = llm.registerConfigurableProviders([{
313
- provider: PROVIDER_ID,
314
- displayName: 'ClineBot (ClinePass)',
315
- settingsNs: NS,
316
- settingsPath: [],
317
- defaultBaseURL: 'https://api.cline.bot/api/v1',
318
- }])
319
- }
320
- ctx.effect(() => () => {
321
- try { adapterDirectory?.() } catch { /* explicit best-effort fallback: non-fatal by design */ }
322
- try { adapterRegistration?.() } catch { /* explicit best-effort fallback: non-fatal by design */ }
323
- adapterDirectory = null
324
- adapterRegistration = null
325
- }, 'dsh-clinebot: llm adapter')
326
- void syncProviderState(live())
327
- void migrateLegacyPiAiProvider(ctx)
328
- } catch (error) {
329
- ctx?.logger?.warn?.('[dsh-clinebot] Failed to register llm adapter: ' + (error?.message || error))
330
- }
331
- })
332
- }
333
- ensureAdapter()
334
- applyTuiSettings(ctx, { live })
335
-
336
- if (typeof ctx.inject === 'function') {
337
- ctx.inject(['settings'], () => {
338
- void migrateLegacyPiAiProvider(ctx)
339
- })
340
- }
341
-
342
- syncProviderState(live())
343
- if (typeof ctx.effect === 'function') {
344
- ctx.effect(() => {
345
- const timer = setTimeout(triggerAutoDiscover, 500)
346
- const retryTimer = setTimeout(triggerAutoDiscover, 3000)
347
- const lateRetry = setTimeout(() => {
348
- const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
349
- if (Date.now() - syncedAt >= CATALOG_STALE_MS) triggerAutoDiscover()
350
- }, 15000)
351
- const refreshTimer = setInterval(() => {
352
- const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
353
- if (Date.now() - syncedAt >= CATALOG_REFRESH_MS) triggerAutoDiscover()
354
- }, 60 * 60 * 1000)
355
- for (const handle of [timer, retryTimer, lateRetry, refreshTimer]) {
356
- if (typeof handle.unref === 'function') handle.unref()
357
- }
358
- return () => {
359
- clearTimeout(timer)
360
- clearTimeout(retryTimer)
361
- clearTimeout(lateRetry)
362
- clearInterval(refreshTimer)
363
- }
364
- }, 'dsh-clinebot: auto-discover')
365
- }
366
-
367
- const registerWebSurfaces = (webCtx) => {
368
- if (!service(webCtx, 'webServer')?.register) return
369
- const unregisterUpdater = registerPluginUpdater(webCtx, {
370
- endpoint: '/dsh-clinebot/update',
371
- packageName: name,
372
- manifestUrl: new URL('../package.json', import.meta.url),
373
- })
374
- if (typeof webCtx.effect === 'function') {
375
- webCtx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
376
- }
377
-
378
- registerSettingsRoutes(webCtx, routeEnv)
379
- registerAccountsRoutes(webCtx, routeEnv)
380
- registerModelsRoutes(webCtx, routeEnv)
381
- registerAuthRoutes(webCtx, routeEnv)
382
- }
383
-
384
- if (service(ctx, 'webServer')?.register) {
385
- registerWebSurfaces(ctx)
386
- } else if (typeof ctx.inject === 'function') {
387
- // Headless/TUI profiles mount no web server; the adapter and slash command
388
- // still work, and web routes appear if a server mounts later.
389
- ctx.inject(['webServer'], registerWebSurfaces)
390
- }
391
-
392
- registerSlashCommand(ctx, {
393
- live,
394
- getSettingsApi: () => settingsApi,
395
- syncProviderState,
396
- })
397
-
398
- // Native DSH WebSearchProvider over ctx.web (#233)
399
- if (typeof ctx.inject === 'function') {
400
- ctx.inject(['web'], (webCtx) => {
401
- const cfg = publicConfig(live())
402
- if (cfg.webSearch === false) return
403
- const web = webCtx?.get?.('web')
404
- if (web && typeof web.registerSearchProvider === 'function') {
405
- const searchProvider = new ClineBotSearchProvider({
406
- getBaseUrl: () => publicConfig(live()).baseUrl,
407
- getApiKey: async () => {
408
- const pool = await resolveAccountPool(ctx, live())
409
- const active = pool.find((a) => a.present && a.value)
410
- return active?.value || ''
411
- },
412
- })
413
- try {
414
- const unreg = web.registerSearchProvider(CLINEBOT_SEARCH_PROVIDER_ID, searchProvider)
415
- if (typeof ctx.effect === 'function') {
416
- ctx.effect(() => () => {
417
- try { if (typeof unreg === 'function') unreg() } catch { /* explicit best-effort fallback: non-fatal by design */ }
418
- }, 'dsh-clinebot: web search provider')
419
- }
420
- } catch (e) {
421
- ctx?.logger?.warn?.('[dsh-clinebot] Failed to register web search provider: ' + (e?.message || e))
422
- }
423
- }
424
- })
425
- }
426
- }
1
+ import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, 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 { rotateToNextAccount } from './account-pool.js'
11
+ import { PROVIDER_ID } from './models.js'
12
+ import {
13
+ autoDiscoverPlanModels,
14
+ } from './provider-sync.js'
15
+ import { registerSettingsRoutes } from './routes/settings.js'
16
+ import { registerAccountsRoutes } from './routes/accounts.js'
17
+ import { registerModelsRoutes } from './routes/models.js'
18
+ import { registerAuthRoutes } from './routes/auth.js'
19
+ import { ClineBotAdapter } from './adapter.js'
20
+ import { registerSlashCommand } from './slash-command.js'
21
+ import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
22
+ import { resolveAccountPool } from './account-pool.js'
23
+ import { loadStats, flushStats, resolvePath, switchStatsStorage, setStatsRetentionDays } from './stats-storage.js'
24
+ import { loadPublicPriceCache } from './price-sync.js'
25
+ import { setPublicPriceCatalog } from './model-prices.js'
26
+ import { loadAccountEvents, flushAccountEvents, recordAccountEvent } from './account-events.js'
27
+ import { setAccountEventSink } from './session-router.js'
28
+ import { service, migrateLegacyPiAiProvider } from './services.js'
29
+ import { applyTuiSettings } from './tui-settings.js'
30
+ import { isSafeDataPath, defaultStatsPath, defaultEventLogPath, defaultPriceCatalogPath } from './path-safety.js'
31
+ import { safeDict, setOwn } from './safe-dict.js'
32
+
33
+ export const name = '@goodandready/dsh-clinebot'
34
+ export const inject = []
35
+
36
+ const CATALOG_REFRESH_MS = 6 * 60 * 60 * 1000
37
+ const CATALOG_STALE_MS = 5 * 60 * 1000
38
+
39
+ export function configReader(config) {
40
+ return () => config
41
+ }
42
+
43
+ export { NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
44
+
45
+ export function apply(ctx, config) {
46
+ let currentConfig = configReader(config)() || {}
47
+ const overrides = safeDict()
48
+ const getConfig = () => {
49
+ if (!currentConfig) return currentConfig
50
+ if (Object.isFrozen(currentConfig)) {
51
+ return new Proxy({}, {
52
+ get(_target, prop) {
53
+ const orig = Reflect.get(currentConfig, prop)
54
+ if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(currentConfig, prop)?.get) {
55
+ return orig
56
+ }
57
+ if (prop in overrides) {
58
+ return overrides[prop]
59
+ }
60
+ return orig
61
+ },
62
+ has(_target, prop) {
63
+ return prop in overrides || Reflect.has(currentConfig, prop)
64
+ },
65
+ ownKeys() {
66
+ return Array.from(new Set([...Reflect.ownKeys(currentConfig), ...Object.keys(overrides)]))
67
+ },
68
+ getOwnPropertyDescriptor(_target, prop) {
69
+ if (prop in overrides) {
70
+ return { configurable: true, enumerable: true, value: overrides[prop], writable: true }
71
+ }
72
+ const desc = Object.getOwnPropertyDescriptor(currentConfig, prop)
73
+ if (desc) {
74
+ return { configurable: true, enumerable: desc.enumerable ?? true, value: Reflect.get(currentConfig, prop) }
75
+ }
76
+ return undefined
77
+ }
78
+ })
79
+ }
80
+ return currentConfig
81
+ }
82
+ const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
83
+ let settingsApi
84
+ let adapterRegistration = null
85
+ let adapterDirectory = null
86
+
87
+ const syncProviderState = async (cfg) => {
88
+ try {
89
+ const pub = publicConfig(cfg)
90
+ if (adapterRegistration) {
91
+ adapterRegistration.replace(pub.enabled ? [PROVIDER_ID] : [])
92
+ }
93
+ } catch {
94
+ /* ignore transient settings unavailable */
95
+ }
96
+ }
97
+
98
+ let autoDiscoverInFlight = false
99
+ const triggerAutoDiscover = () => {
100
+ if (autoDiscoverInFlight) return
101
+ autoDiscoverInFlight = true
102
+ Promise.resolve(autoDiscoverPlanModels(ctx, {
103
+ live,
104
+ getSettingsApi: () => settingsApi,
105
+ syncProviderState,
106
+ }))
107
+ .catch(() => {})
108
+ .finally(() => {
109
+ autoDiscoverInFlight = false
110
+ })
111
+ }
112
+
113
+ const initial = publicConfig(live())
114
+ let activeStatsPath = resolvePath(isSafeDataPath(initial.statsPath) ? initial.statsPath : defaultStatsPath())
115
+ setStatsRetentionDays(initial.statsRetentionDays)
116
+ setPublicPriceCatalog(loadPublicPriceCache(isSafeDataPath(initial.priceCatalogPath) ? initial.priceCatalogPath : defaultPriceCatalogPath()))
117
+ let activeEventPath = resolvePath(isSafeDataPath(initial.eventLogPath) ? initial.eventLogPath : defaultEventLogPath())
118
+ loadAccountEvents(activeEventPath)
119
+ setAccountEventSink(recordAccountEvent)
120
+ loadStats(activeStatsPath)
121
+
122
+ const syncStatsStorage = (cfg) => {
123
+ const pub = publicConfig(cfg)
124
+ setStatsRetentionDays(pub.statsRetentionDays)
125
+ const priceCatalogPath = isSafeDataPath(pub.priceCatalogPath) ? pub.priceCatalogPath : defaultPriceCatalogPath()
126
+ setPublicPriceCatalog(loadPublicPriceCache(priceCatalogPath))
127
+ const nextEventPath = resolvePath(isSafeDataPath(pub.eventLogPath) ? pub.eventLogPath : defaultEventLogPath())
128
+ if (nextEventPath !== activeEventPath) {
129
+ flushAccountEvents()
130
+ activeEventPath = nextEventPath
131
+ loadAccountEvents(activeEventPath)
132
+ }
133
+ const nextPath = resolvePath(isSafeDataPath(pub.statsPath) ? pub.statsPath : defaultStatsPath())
134
+ if (nextPath !== activeStatsPath) {
135
+ switchStatsStorage(nextPath)
136
+ activeStatsPath = nextPath
137
+ }
138
+ }
139
+
140
+ const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
141
+ const getCatalogIdentity = (cfg) => {
142
+ const pub = publicConfig(cfg)
143
+ return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
144
+ }
145
+
146
+ let lastCatalogIdentity = getCatalogIdentity(live())
147
+ const resetCatalogSnapshot = () => {
148
+ try {
149
+ if (currentConfig && typeof currentConfig === 'object') {
150
+ currentConfig.dynamicModels = []
151
+ currentConfig.planSyncedAt = 0
152
+ }
153
+ } catch {
154
+ /* ignore read-only or frozen */
155
+ }
156
+ overrides.dynamicModels = []
157
+ overrides.planSyncedAt = 0
158
+ clearUsageCache()
159
+ clearProbeCache()
160
+ }
161
+
162
+ if (typeof ctx?.on === 'function') {
163
+ ctx.on('loader/volatile-update', () => {
164
+ const liveCfg = live()
165
+ const currentIdentity = getCatalogIdentity(liveCfg)
166
+ if (currentIdentity !== lastCatalogIdentity) {
167
+ lastCatalogIdentity = currentIdentity
168
+ resetCatalogSnapshot()
169
+ }
170
+ syncStatsStorage(liveCfg)
171
+ })
172
+ }
173
+
174
+ const getRevision = (svc) => {
175
+ try {
176
+ return svc?.describe?.().find((row) => row.ns === NS)?.revision
177
+ } catch {
178
+ return undefined
179
+ }
180
+ }
181
+
182
+ const applyConfigMutations = (target, source) => {
183
+ const targetConfig = typeof target === 'function' ? target() : target
184
+ if (targetConfig && typeof targetConfig === 'object') {
185
+ if (Object.isFrozen(targetConfig)) {
186
+ for (const [key, value] of Object.entries(source)) {
187
+ const orig = targetConfig[key]
188
+ if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
189
+ continue
190
+ }
191
+ setOwn(overrides, key, value)
192
+ }
193
+ return
194
+ }
195
+ for (const [key, value] of Object.entries(source)) {
196
+ try {
197
+ if (isVolatileRef(targetConfig[key])) {
198
+ // Cosmokit native Volatile references are reactive and authoritative;
199
+ // preserve the reference without overwriting with plain value.
200
+ continue
201
+ }
202
+ const desc = Object.getOwnPropertyDescriptor(targetConfig, key)
203
+ if (desc?.get && !desc.set) {
204
+ continue
205
+ }
206
+ if (desc?.set) {
207
+ targetConfig[key] = value
208
+ } else {
209
+ setOwn(targetConfig, key, value)
210
+ }
211
+ } catch {
212
+ /* ignore read-only */
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ settingsApi = {
219
+ get: () => live(),
220
+ replace: async (next) => {
221
+ const svc = ctx?.get?.('settings')
222
+ if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
223
+ throw new Error('DSH settings service is unavailable or non-writable')
224
+ }
225
+ const prevIdentity = getCatalogIdentity(live())
226
+ const parsed = Config(structuredClone(plainConfig(next)))
227
+ const nextIdentity = getCatalogIdentity(parsed)
228
+ const identityChanged = nextIdentity !== prevIdentity
229
+ if (identityChanged) {
230
+ if (!('dynamicModels' in next)) parsed.dynamicModels = []
231
+ if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
232
+ }
233
+ const payload = volatileConfig(parsed)
234
+ const rev = getRevision(svc)
235
+ if (typeof svc.replace === 'function') {
236
+ await svc.replace(NS, payload, rev)
237
+ } else {
238
+ await svc.update(NS, payload, rev)
239
+ }
240
+ if (identityChanged) {
241
+ lastCatalogIdentity = nextIdentity
242
+ resetCatalogSnapshot()
243
+ }
244
+ applyConfigMutations(currentConfig, parsed)
245
+ syncStatsStorage(live())
246
+ await syncProviderState(live())
247
+ return plainConfig(parsed)
248
+ },
249
+ update: async (patch) => {
250
+ const svc = ctx?.get?.('settings')
251
+ if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
252
+ throw new Error('DSH settings service is unavailable or non-writable')
253
+ }
254
+ const prevIdentity = getCatalogIdentity(live())
255
+ const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
256
+ const nextIdentity = getCatalogIdentity(merged)
257
+ const identityChanged = nextIdentity !== prevIdentity
258
+ if (identityChanged) {
259
+ if (!('dynamicModels' in patch)) merged.dynamicModels = []
260
+ if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
261
+ }
262
+ const payload = volatileConfig(merged)
263
+ const rev = getRevision(svc)
264
+ if (typeof svc.update === 'function') {
265
+ await svc.update(NS, payload, rev)
266
+ } else {
267
+ await svc.replace(NS, payload, rev)
268
+ }
269
+ if (identityChanged) {
270
+ lastCatalogIdentity = nextIdentity
271
+ resetCatalogSnapshot()
272
+ }
273
+ applyConfigMutations(currentConfig, merged)
274
+ syncStatsStorage(live())
275
+ await syncProviderState(live())
276
+ return plainConfig(merged)
277
+ },
278
+
279
+ }
280
+
281
+ if (typeof ctx.effect === 'function') {
282
+ ctx.effect(() => {
283
+ return () => {
284
+ const safeStatsPath = isSafeDataPath(activeStatsPath) ? resolvePath(activeStatsPath) : defaultStatsPath()
285
+ flushStats(safeStatsPath)
286
+ flushAccountEvents()
287
+ setAccountEventSink(null)
288
+ }
289
+ }, 'dsh-clinebot: stats-storage')
290
+ }
291
+
292
+ const routeEnv = {
293
+ live,
294
+ getSettingsApi: () => settingsApi,
295
+ syncProviderState,
296
+ triggerAutoDiscover,
297
+ }
298
+
299
+ const ensureAdapter = () => {
300
+ if (typeof ctx.inject !== 'function') return
301
+ ctx.inject(['llm'], (llmCtx) => {
302
+ const llm = llmCtx?.llm || llmCtx?.get?.('llm')
303
+ if (!llm || typeof llm.registerAdapter !== 'function') return
304
+ try {
305
+ const adapter = new ClineBotAdapter({
306
+ ctx,
307
+ live,
308
+ resolveAttachments: () => ctx?.get?.('attachments') || ctx?.attachments,
309
+ logger: ctx?.logger,
310
+ })
311
+ adapterRegistration = llm.registerAdapter([PROVIDER_ID], adapter)
312
+ if (typeof llm.registerConfigurableProviders === 'function') {
313
+ adapterDirectory = llm.registerConfigurableProviders([{
314
+ provider: PROVIDER_ID,
315
+ displayName: 'ClineBot (ClinePass)',
316
+ settingsNs: NS,
317
+ settingsPath: [],
318
+ defaultBaseURL: 'https://api.cline.bot/api/v1',
319
+ }])
320
+ }
321
+ ctx.effect(() => () => {
322
+ try { adapterDirectory?.() } catch { /* explicit best-effort fallback: non-fatal by design */ }
323
+ try { adapterRegistration?.() } catch { /* explicit best-effort fallback: non-fatal by design */ }
324
+ adapterDirectory = null
325
+ adapterRegistration = null
326
+ }, 'dsh-clinebot: llm adapter')
327
+ void syncProviderState(live())
328
+ void migrateLegacyPiAiProvider(ctx)
329
+ } catch (error) {
330
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to register llm adapter: ' + (error?.message || error))
331
+ }
332
+ })
333
+ }
334
+ ensureAdapter()
335
+ applyTuiSettings(ctx, { live })
336
+
337
+ if (typeof ctx.inject === 'function') {
338
+ ctx.inject(['settings'], () => {
339
+ void migrateLegacyPiAiProvider(ctx)
340
+ })
341
+ }
342
+
343
+ syncProviderState(live())
344
+ if (typeof ctx.effect === 'function') {
345
+ ctx.effect(() => {
346
+ const timer = setTimeout(triggerAutoDiscover, 500)
347
+ const retryTimer = setTimeout(triggerAutoDiscover, 3000)
348
+ const lateRetry = setTimeout(() => {
349
+ const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
350
+ if (Date.now() - syncedAt >= CATALOG_STALE_MS) triggerAutoDiscover()
351
+ }, 15000)
352
+ const refreshTimer = setInterval(() => {
353
+ const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
354
+ if (Date.now() - syncedAt >= CATALOG_REFRESH_MS) triggerAutoDiscover()
355
+ }, 60 * 60 * 1000)
356
+ for (const handle of [timer, retryTimer, lateRetry, refreshTimer]) {
357
+ if (typeof handle.unref === 'function') handle.unref()
358
+ }
359
+ return () => {
360
+ clearTimeout(timer)
361
+ clearTimeout(retryTimer)
362
+ clearTimeout(lateRetry)
363
+ clearInterval(refreshTimer)
364
+ }
365
+ }, 'dsh-clinebot: auto-discover')
366
+ }
367
+
368
+ const registerWebSurfaces = (webCtx) => {
369
+ if (!service(webCtx, 'webServer')?.register) return
370
+ const unregisterUpdater = registerPluginUpdater(webCtx, {
371
+ endpoint: '/dsh-clinebot/update',
372
+ packageName: name,
373
+ manifestUrl: new URL('../package.json', import.meta.url),
374
+ })
375
+ if (typeof webCtx.effect === 'function') {
376
+ webCtx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
377
+ }
378
+
379
+ registerSettingsRoutes(webCtx, routeEnv)
380
+ registerAccountsRoutes(webCtx, routeEnv)
381
+ registerModelsRoutes(webCtx, routeEnv)
382
+ registerAuthRoutes(webCtx, routeEnv)
383
+ }
384
+
385
+ if (service(ctx, 'webServer')?.register) {
386
+ registerWebSurfaces(ctx)
387
+ } else if (typeof ctx.inject === 'function') {
388
+ // Headless/TUI profiles mount no web server; the adapter and slash command
389
+ // still work, and web routes appear if a server mounts later.
390
+ ctx.inject(['webServer'], registerWebSurfaces)
391
+ }
392
+
393
+ registerSlashCommand(ctx, {
394
+ live,
395
+ getSettingsApi: () => settingsApi,
396
+ syncProviderState,
397
+ })
398
+
399
+ // Native DSH WebSearchProvider over ctx.web (#233)
400
+ if (typeof ctx.inject === 'function') {
401
+ ctx.inject(['web'], (webCtx) => {
402
+ const cfg = publicConfig(live())
403
+ if (cfg.webSearch === false) return
404
+ const web = webCtx?.get?.('web')
405
+ if (web && typeof web.registerSearchProvider === 'function') {
406
+ const searchProvider = new ClineBotSearchProvider({
407
+ getBaseUrl: () => publicConfig(live()).baseUrl,
408
+ getApiKey: async () => {
409
+ const pool = await resolveAccountPool(ctx, live())
410
+ const active = pool.find((a) => a.present && a.value)
411
+ return active?.value || ''
412
+ },
413
+ })
414
+ try {
415
+ const unreg = web.registerSearchProvider(CLINEBOT_SEARCH_PROVIDER_ID, searchProvider)
416
+ if (typeof ctx.effect === 'function') {
417
+ ctx.effect(() => () => {
418
+ try { if (typeof unreg === 'function') unreg() } catch { /* explicit best-effort fallback: non-fatal by design */ }
419
+ }, 'dsh-clinebot: web search provider')
420
+ }
421
+ } catch (e) {
422
+ ctx?.logger?.warn?.('[dsh-clinebot] Failed to register web search provider: ' + (e?.message || e))
423
+ }
424
+ }
425
+ })
426
+ }
427
+ }