ds-bot 0.0.0-stage → 0.1.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.
@@ -0,0 +1,202 @@
1
+ // Models. Every Bot runs on one model of its own, fixed when it is created and changed
2
+ // only in its settings or by a Main Bot. The team's first Main Bot has none until the
3
+ // user picks one in its chat. The Session keeps the selection durably.
4
+ import { normalize, splitRef } from './text.js'
5
+
6
+ const CATALOG_TTL_MS = 10_000
7
+ const ROUTE_WAIT_MS = 30_000
8
+
9
+ export function install(rt) {
10
+ const { ctx, state } = rt
11
+
12
+ let modelsSynced = null
13
+ let catalogCache = { at: 0, value: [] }
14
+ // A model's thinking levels, resolved once and kept for a short while: they change
15
+ // only with the provider, which also drops the catalog cache.
16
+ const reasoningCache = new Map()
17
+ ctx.on?.('llm/adapters-updated', () => { catalogCache = { at: 0, value: [] }; reasoningCache.clear() })
18
+
19
+ // The thinking levels a model serves: `{efforts: [{id, name, description?}],
20
+ // defaultEffort?}`, or undefined when it has none or the provider cannot say.
21
+ async function reasoningOf(ref) {
22
+ const cached = reasoningCache.get(ref)
23
+ if (cached !== undefined && Date.now() - cached.at < CATALOG_TTL_MS) return cached.value
24
+ const route = splitRef(ref)
25
+ const info = route === undefined ? undefined : await ctx.llm.resolveModelInfo?.(route.provider, route.model).catch(() => undefined)
26
+ const value = info?.reasoning === undefined ? undefined : {
27
+ efforts: (info.reasoning.efforts ?? []).map(effort => ({ id: effort.id, name: effort.name ?? effort.id, ...(effort.description ? { description: effort.description } : {}) })),
28
+ ...(info.reasoning.defaultEffort === undefined ? {} : { defaultEffort: info.reasoning.defaultEffort }),
29
+ }
30
+ reasoningCache.set(ref, { at: Date.now(), value })
31
+ return value
32
+ }
33
+
34
+ // A provider's model list can change in DSH settings without any event, so a picker
35
+ // that is about to be used asks for a `fresh` one. Entries for models a Bot uses
36
+ // also carry their `reasoning`, so the picker can offer thinking levels.
37
+ async function catalog({ fresh = false } = {}) {
38
+ if (!fresh && Date.now() - catalogCache.at < CATALOG_TTL_MS && catalogCache.value.length > 0) return catalogCache.value
39
+ const entries = []
40
+ for (const provider of ctx.llm.listProviders()) {
41
+ let models = []
42
+ try { models = await ctx.llm.listModels(provider.id) } catch { continue }
43
+ for (const model of models) {
44
+ entries.push({
45
+ ref: `${provider.id}/${model.id}`,
46
+ provider: provider.id,
47
+ providerName: provider.name ?? provider.id,
48
+ id: model.id,
49
+ name: model.name ?? model.id,
50
+ ...(model.inputModalities ? { input: [...model.inputModalities] } : {}),
51
+ })
52
+ }
53
+ }
54
+ const used = new Set(Object.values(state.bots ?? {}).flatMap(bot => [bot.model, bot.appliedModel].filter(Boolean)))
55
+ await Promise.all(entries.filter(entry => used.has(entry.ref)).map(async (entry) => {
56
+ const reasoning = await reasoningOf(entry.ref)
57
+ if (reasoning !== undefined) entry.reasoning = reasoning
58
+ }))
59
+ catalogCache = { at: Date.now(), value: entries }
60
+ return entries
61
+ }
62
+
63
+ // The profile default model of DSH as set, served or not.
64
+ const defaultRef = () => {
65
+ const selection = ctx.get?.('agentDefaultModel')?.currentSelection?.()
66
+ return selection?.provider && selection?.model ? `${selection.provider}/${selection.model}` : undefined
67
+ }
68
+ // The profile default model of DSH, when it is served; else the first served model.
69
+ function pickDefault(entries) {
70
+ const ref = defaultRef()
71
+ return entries.find(entry => entry.ref === ref)?.ref ?? entries[0]?.ref
72
+ }
73
+ const defaultModel = async () => pickDefault(await catalog())
74
+
75
+ // A full `provider/model` ref, or a model id or name that only one provider serves.
76
+ async function resolveModel(text) {
77
+ const entries = await catalog({ fresh: true })
78
+ const wanted = String(text ?? '').trim()
79
+ const exact = entries.find(entry => entry.ref === wanted)
80
+ if (exact) return exact.ref
81
+ const key = normalize(wanted)
82
+ const named = entries.filter(entry => normalize(entry.id) === key || normalize(entry.name) === key)
83
+ if (named.length === 1) return named[0].ref
84
+ const known = entries.map(entry => entry.ref).join(', ') || 'none'
85
+ throw new Error(named.length > 1
86
+ ? `Model ${wanted} is served by more than one provider; use one of ${named.map(entry => entry.ref).join(', ')}`
87
+ : `Model ${wanted} is not available. Available models: ${known}`)
88
+ }
89
+
90
+ // A Bot with no model yet waits for the user to pick one and changes nothing. A
91
+ // model that is no longer served falls back to the last one it ran on, then the default.
92
+ async function desiredModel(bot) {
93
+ if (!bot.model) return undefined
94
+ const entries = await catalog()
95
+ const served = new Set(entries.map(entry => entry.ref))
96
+ return [bot.model, bot.appliedModel].find(ref => ref && served.has(ref)) ?? pickDefault(entries)
97
+ }
98
+
99
+ // The thinking level a Bot asked for, when the model it runs on serves it; else the
100
+ // model's own default applies. A level the model does not have is ignored, never
101
+ // sent: an explicit effort a provider does not know is a hard refusal there.
102
+ async function desiredEffort(bot, ref) {
103
+ if (bot.effort === undefined || ref === undefined) return undefined
104
+ const reasoning = await reasoningOf(ref)
105
+ return reasoning?.efforts?.some(effort => effort.id === bot.effort) ? bot.effort : undefined
106
+ }
107
+
108
+ // A thinking level id, or a name only one level of the model has, like resolveModel.
109
+ async function resolveEffort(ref, text) {
110
+ const wanted = String(text ?? '').trim()
111
+ if (wanted === '') return undefined
112
+ const reasoning = await reasoningOf(ref)
113
+ const efforts = reasoning?.efforts ?? []
114
+ if (efforts.length === 0) throw new Error(`Model ${ref} has no thinking levels`)
115
+ const key = normalize(wanted)
116
+ const named = efforts.filter(effort => normalize(effort.id) === key || normalize(effort.name) === key)
117
+ if (named.length === 1) return named[0].id
118
+ throw new Error(`Thinking level ${wanted} is not available for ${ref}. Available: ${efforts.map(effort => effort.name).join(', ')}`)
119
+ }
120
+
121
+ // DSH's selectModel also saves the selection as the profile default model, in the
122
+ // background. A Bot's model is its own, so the user's default is written back after
123
+ // it. Selections run one at a time and wait for the write-back: profile writes land in
124
+ // submission order, so the next one reads the user's default, not a Bot's.
125
+ let selecting = Promise.resolve()
126
+ function selectSessionModel(sessionId, { provider, model, reasoningEffort }) {
127
+ const run = selecting.then(async () => {
128
+ const defaults = ctx.get?.('agentDefaultModel')
129
+ const before = defaults?.currentSelection?.()
130
+ const result = await ctx.sessionController.selectModel({
131
+ sessionId, provider, model,
132
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
133
+ })
134
+ const saved = result?.selected ?? { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
135
+ if (before?.provider && before?.model && !sameSelection(before, saved)) {
136
+ await defaults.saveSelection(before).catch(error => rt.warn('ds-bot: the default model was not restored: %s', error))
137
+ }
138
+ return result
139
+ })
140
+ selecting = run.catch(() => {})
141
+ return run
142
+ }
143
+
144
+ const sameSelection = (a, b) => a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort
145
+
146
+ // `force` re-selects even when the record says it is applied: the composer's own
147
+ // model picker can move the Session without telling this plugin.
148
+ async function applyModel(bot, { force = false } = {}) {
149
+ const ref = await desiredModel(bot)
150
+ if (ref === undefined) return ref
151
+ const effort = await desiredEffort(bot, ref)
152
+ if (!force && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) return ref
153
+ const route = { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }
154
+ await selectSessionModel(rt.chatOf(bot.id), route)
155
+ for (const sessionId of rt.groupSessionsOf(bot.id)) {
156
+ await selectSessionModel(sessionId, route)
157
+ .catch(error => rt.warn('ds-bot: model for %s in a group not applied: %s', bot.name, error))
158
+ }
159
+ bot.appliedModel = ref
160
+ if (effort === undefined) delete bot.appliedEffort
161
+ else bot.appliedEffort = effort
162
+ await rt.save()
163
+ return ref
164
+ }
165
+
166
+ // A provider may register after the plugin starts; a greeting waits for its Bot's
167
+ // model instead of failing with "no adapter registered".
168
+ async function waitForModel(bot, ms = ROUTE_WAIT_MS) {
169
+ const deadline = Date.now() + ms
170
+ for (;;) {
171
+ if (!bot.model) return undefined
172
+ catalogCache = { at: 0, value: [] }
173
+ const served = new Set((await catalog()).map(entry => entry.ref))
174
+ if (served.has(bot.model) || Date.now() >= deadline) return desiredModel(bot)
175
+ await new Promise((resolve) => {
176
+ const timer = setTimeout(done, Math.min(2000, deadline - Date.now()))
177
+ const off = ctx.on?.('llm/adapters-updated', done)
178
+ function done() { clearTimeout(timer); off?.(); resolve() }
179
+ })
180
+ }
181
+ }
182
+
183
+ // Sequential, and again whenever the served models change: a provider that
184
+ // registers late still gets its Bots' models selected.
185
+ let syncedKey
186
+ const syncModels = () => {
187
+ const key = catalogCache.value.map(entry => entry.ref).join(',')
188
+ if (key !== syncedKey) { syncedKey = key; modelsSynced = modelsSynced?.then(syncAll, syncAll) ?? syncAll() }
189
+ return modelsSynced
190
+ }
191
+ async function syncAll() {
192
+ for (const bot of Object.values(state.bots)) {
193
+ await applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', bot.name, error))
194
+ }
195
+ }
196
+
197
+ Object.assign(rt, {
198
+ catalog, cachedCatalog: () => catalogCache.value, cachedDefault: () => pickDefault(catalogCache.value),
199
+ defaultRef, defaultModel, resolveModel, desiredModel, reasoningOf, resolveEffort, desiredEffort,
200
+ selectSessionModel, applyModel, waitForModel, syncModels,
201
+ })
202
+ }