ds-bot 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/client.js +1013 -371
- package/index.js +3 -1
- package/package.json +1 -1
- package/src/host/channel.js +14 -5
- package/src/host/dsh-update.js +170 -0
- package/src/host/groups.js +2 -0
- package/src/host/models.js +65 -8
- package/src/host/parts.js +18 -2
- package/src/host/permissions.js +42 -0
- package/src/host/roster.js +14 -1
- package/src/host/update.js +68 -40
- package/src/host/usage.js +26 -4
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
|
|
6
6
|
import * as diagnostics from './src/host/diagnostics.js'
|
|
7
7
|
import * as store from './src/host/store.js'
|
|
8
8
|
import * as models from './src/host/models.js'
|
|
9
|
+
import * as permissions from './src/host/permissions.js'
|
|
9
10
|
import * as turns from './src/host/turns.js'
|
|
10
11
|
import * as delivery from './src/host/delivery.js'
|
|
11
12
|
import * as roster from './src/host/roster.js'
|
|
@@ -23,6 +24,7 @@ import * as connectors from './src/host/connectors.js'
|
|
|
23
24
|
import * as schedules from './src/host/schedules.js'
|
|
24
25
|
import * as usage from './src/host/usage.js'
|
|
25
26
|
import * as update from './src/host/update.js'
|
|
27
|
+
import * as dshUpdate from './src/host/dsh-update.js'
|
|
26
28
|
import * as channel from './src/host/channel.js'
|
|
27
29
|
|
|
28
30
|
export { COLORS } from './src/host/constants.js'
|
|
@@ -34,7 +36,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
|
|
|
34
36
|
// the functions other modules call; they call them through `rt` at run time, never during
|
|
35
37
|
// install. Listeners run and tools are listed in registration order, so this order holds:
|
|
36
38
|
// usage meters model requests after every listener that rewrites them.
|
|
37
|
-
const MODULES = [diagnostics, store, models, turns, delivery, roster, agents, prompt, tools, chatIndex, groups, memory, parts, images, secrets, connectors, schedules, browser, usage, update, channel]
|
|
39
|
+
const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, agents, prompt, tools, chatIndex, groups, memory, parts, images, secrets, connectors, schedules, browser, usage, update, dshUpdate, channel]
|
|
38
40
|
|
|
39
41
|
export function apply(ctx, config = {}) {
|
|
40
42
|
const home = config.home ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'bot')
|
package/package.json
CHANGED
package/src/host/channel.js
CHANGED
|
@@ -27,7 +27,7 @@ export function install(rt) {
|
|
|
27
27
|
const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } })
|
|
28
28
|
// Models come from DSH, not state.json, so a provider added in Harness settings
|
|
29
29
|
// changes the view without a new revision.
|
|
30
|
-
const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.ref).join(',')}|${rt.cachedDefault() ?? ''}`
|
|
30
|
+
const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.reasoning ? `${entry.ref}:${JSON.stringify(entry.reasoning)}` : entry.ref).join(',')}|${rt.cachedDefault() ?? ''}`
|
|
31
31
|
const view = () => ({
|
|
32
32
|
revision: state.revision,
|
|
33
33
|
workspaceId: state.workspaceId ?? null,
|
|
@@ -55,6 +55,7 @@ export function install(rt) {
|
|
|
55
55
|
colors: [...COLORS, ...EXTRA_COLORS],
|
|
56
56
|
models: rt.cachedCatalog(),
|
|
57
57
|
defaultModel: rt.cachedDefault() ?? null,
|
|
58
|
+
permissions: rt.permissionCatalog?.() ?? null,
|
|
58
59
|
modelsKey: modelsKey(),
|
|
59
60
|
prefs: state.prefs,
|
|
60
61
|
// Whether a Desktop window should serve Bot browser reads (see browser.js).
|
|
@@ -70,7 +71,7 @@ export function install(rt) {
|
|
|
70
71
|
const live = () => ({ activity: rt.activityView(), memoryNews: rt.memoryNews(), memoryReviews: rt.memoryReviews(), connectors: rt.connectorsView() })
|
|
71
72
|
// What a host that only reads the team may still answer.
|
|
72
73
|
// Updates change the dsh profile, not the team, so a reading host may run them too.
|
|
73
|
-
const READS = new Set(['state', 'models', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install'])
|
|
74
|
+
const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars'])
|
|
74
75
|
|
|
75
76
|
function setPrefs(body) {
|
|
76
77
|
if (body.theme !== undefined) {
|
|
@@ -107,6 +108,8 @@ export function install(rt) {
|
|
|
107
108
|
case 'models':
|
|
108
109
|
await rt.catalog({ fresh: true })
|
|
109
110
|
return ok({ modelsKey: modelsKey() })
|
|
111
|
+
case 'efforts':
|
|
112
|
+
return ok(await rt.reasoningOf(String(body.ref ?? '')) ?? { efforts: [] })
|
|
110
113
|
case 'duplicate-bot':
|
|
111
114
|
await rt.load()
|
|
112
115
|
return ok(await rt.duplicateBot(body.id))
|
|
@@ -129,7 +132,7 @@ export function install(rt) {
|
|
|
129
132
|
return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
|
|
130
133
|
case 'create-bot': {
|
|
131
134
|
await rt.bootstrap()
|
|
132
|
-
const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model })
|
|
135
|
+
const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort })
|
|
133
136
|
return ok(bot)
|
|
134
137
|
}
|
|
135
138
|
case 'update-bot':
|
|
@@ -259,10 +262,16 @@ export function install(rt) {
|
|
|
259
262
|
}, AbortSignal.timeout(30_000))
|
|
260
263
|
return ok(true)
|
|
261
264
|
}
|
|
262
|
-
case 'update-check':
|
|
263
|
-
|
|
265
|
+
case 'update-check': {
|
|
266
|
+
const fresh = body.fresh === true
|
|
267
|
+
return ok({ ...(await rt.updateCheck({ fresh })), dsh: (await rt.dshUpdateCheck?.({ fresh })) ?? null })
|
|
268
|
+
}
|
|
264
269
|
case 'update-install':
|
|
265
270
|
return ok(rt.updateStart())
|
|
271
|
+
case 'dsh-update-install':
|
|
272
|
+
return ok(rt.dshUpdateStart?.())
|
|
273
|
+
case 'repo-stars':
|
|
274
|
+
return ok(await rt.repoStars())
|
|
266
275
|
// The Desktop window's long poll for read_browser, and its answers.
|
|
267
276
|
case 'browser-wait': {
|
|
268
277
|
if (rt.browserChannel === undefined) return fail('Bot browsers are off on this host', 'bot/browser-off')
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// DSH updates: a released DS Bot may name the DSH release it was verified
|
|
2
|
+
// against (`dsBot.verifiedDsh` in package.json); a release that names none
|
|
3
|
+
// offers nothing. The newest released bot wins: offering that version and
|
|
4
|
+
// nothing newer keeps the update path away from DSH releases we have not
|
|
5
|
+
// checked. A DSH installed from npm swaps with `npm i -g`; a packaged app's
|
|
6
|
+
// runtime lives inside its bundle and only its own updater moves it, to the
|
|
7
|
+
// newest release it finds, so the client offers it only when that release is
|
|
8
|
+
// the verified one.
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
CHECK_TTL_MS, cleanVersion, encodeName, fetchJson, installSpecOf,
|
|
15
|
+
newer, registryPlan, repoSlug, sourceOf,
|
|
16
|
+
} from './update.js'
|
|
17
|
+
|
|
18
|
+
const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
19
|
+
const APP_BOOT = '@deepseek-ai/dsh-app-boot'
|
|
20
|
+
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
|
21
|
+
|
|
22
|
+
export function install(rt) {
|
|
23
|
+
const { ctx, config } = rt
|
|
24
|
+
const enabled = config.updateCheck !== false
|
|
25
|
+
|
|
26
|
+
const status = {
|
|
27
|
+
current: null, verified: null, kind: 'npm', blockedBy: null,
|
|
28
|
+
checkedAt: 0, phase: 'idle', installed: null, error: null,
|
|
29
|
+
}
|
|
30
|
+
let target = null
|
|
31
|
+
let checking = null
|
|
32
|
+
let running = null
|
|
33
|
+
|
|
34
|
+
// A newer `minBot` than the running bot gates the offer: the bot goes first.
|
|
35
|
+
const blocked = () => status.blockedBy !== null && newer(status.blockedBy, PKG.version)
|
|
36
|
+
|
|
37
|
+
const view = () => ({
|
|
38
|
+
current: status.current, verified: status.verified, kind: status.kind, blockedBy: status.blockedBy,
|
|
39
|
+
checkedAt: status.checkedAt, phase: status.phase, installed: status.installed, error: status.error,
|
|
40
|
+
// Nothing is offered without a current version to compare against.
|
|
41
|
+
available: status.phase !== 'installed' && target !== null && status.current !== null
|
|
42
|
+
&& status.verified !== null && status.verified !== status.current && !blocked(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// The version of the DSH that is running us: the app-boot export, or — where
|
|
46
|
+
// that import cannot see it — the nearest @deepseek-ai package.json above the
|
|
47
|
+
// entry script.
|
|
48
|
+
async function currentDsh() {
|
|
49
|
+
try {
|
|
50
|
+
const loader = ctx.get?.('loader')
|
|
51
|
+
const loaded = await loader?.import?.(APP_BOOT)
|
|
52
|
+
const appBoot = loader?.unwrapExports?.(loaded) ?? loaded?.default ?? loaded
|
|
53
|
+
const version = cleanVersion(appBoot?.getDshRuntimeVersion?.())
|
|
54
|
+
if (version) return version
|
|
55
|
+
} catch { /* the export is not visible here: read the install instead */ }
|
|
56
|
+
let dir = dirname(String(process.argv[1] ?? ''))
|
|
57
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
58
|
+
try {
|
|
59
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
60
|
+
if (manifest?.name?.startsWith?.('@deepseek-ai/')) {
|
|
61
|
+
const version = cleanVersion(manifest.version)
|
|
62
|
+
if (version) return version
|
|
63
|
+
}
|
|
64
|
+
} catch { /* not a package directory */ }
|
|
65
|
+
const parent = dirname(dir)
|
|
66
|
+
if (parent === dir) break
|
|
67
|
+
dir = parent
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A bundled package manager means the runtime came with the app and only the
|
|
73
|
+
// app's own updater can move it.
|
|
74
|
+
const installKind = () =>
|
|
75
|
+
typeof ctx.get?.('profileContext')?.packageManager?.command === 'string' ? 'app' : 'npm'
|
|
76
|
+
|
|
77
|
+
// The newest released DS Bot's `dsBot` block, through the same channel the bot
|
|
78
|
+
// itself updates from: its registry, or the public repo's latest release.
|
|
79
|
+
async function releasedDeclaration(source) {
|
|
80
|
+
if (source.kind === 'registry') {
|
|
81
|
+
for (const registry of await registryPlan(config, ctx.get?.('pluginManager'))) {
|
|
82
|
+
try {
|
|
83
|
+
const manifest = await fetchJson(`${registry}${encodeName(PKG.name)}/latest`)
|
|
84
|
+
if (manifest?.dsBot?.verifiedDsh) return manifest.dsBot
|
|
85
|
+
} catch { /* unreachable or not published there: ask the next one */ }
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
const repo = repoSlug()
|
|
90
|
+
if (!repo) return null
|
|
91
|
+
try {
|
|
92
|
+
const release = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
93
|
+
const tag = cleanVersion(release?.tag_name)
|
|
94
|
+
if (!tag) return null
|
|
95
|
+
const manifest = await fetchJson(`https://raw.githubusercontent.com/${repo}/${release.tag_name}/ds-bot/package.json`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
96
|
+
return manifest?.dsBot?.verifiedDsh ? manifest.dsBot : null
|
|
97
|
+
} catch { return null }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function runCheck() {
|
|
101
|
+
try {
|
|
102
|
+
status.current = await currentDsh()
|
|
103
|
+
status.kind = installKind()
|
|
104
|
+
// The release the running package itself verified stays the floor: a bot
|
|
105
|
+
// older than its own declaration has nothing newer to offer.
|
|
106
|
+
const declared = [{ verifiedDsh: PKG.dsBot?.verifiedDsh }]
|
|
107
|
+
const released = await releasedDeclaration(sourceOf(await installSpecOf(ctx)))
|
|
108
|
+
if (released) declared.push(released)
|
|
109
|
+
const newestDeclared = declared
|
|
110
|
+
.filter(entry => cleanVersion(entry.verifiedDsh))
|
|
111
|
+
.sort((a, b) => (newer(cleanVersion(a.verifiedDsh), cleanVersion(b.verifiedDsh)) ? -1 : 1))[0]
|
|
112
|
+
status.verified = cleanVersion(newestDeclared?.verifiedDsh)
|
|
113
|
+
// A release may gate the DSH update on a newer bot: offer nothing until
|
|
114
|
+
// the bot itself is at least `minBot`.
|
|
115
|
+
status.blockedBy = cleanVersion(newestDeclared?.minBot)
|
|
116
|
+
target = status.current !== null && status.verified !== null && status.verified !== status.current
|
|
117
|
+
? { version: status.verified }
|
|
118
|
+
: null
|
|
119
|
+
status.error = null
|
|
120
|
+
} catch (error) {
|
|
121
|
+
rt.warn('ds-bot: dsh update check failed: %s', error?.message ?? error)
|
|
122
|
+
status.verified = null
|
|
123
|
+
status.blockedBy = null
|
|
124
|
+
target = null
|
|
125
|
+
}
|
|
126
|
+
status.checkedAt = Date.now()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read current and verified DSH versions; a fresh check ignores the last answer. */
|
|
130
|
+
async function check({ fresh = false } = {}) {
|
|
131
|
+
if (!enabled) return view()
|
|
132
|
+
if (checking) return checking
|
|
133
|
+
if (!fresh && Date.now() - status.checkedAt < CHECK_TTL_MS) return view()
|
|
134
|
+
checking = runCheck().finally(() => { checking = null })
|
|
135
|
+
await checking
|
|
136
|
+
return view()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Swap an npm-installed DSH for the verified version; the answer comes back through `check`. */
|
|
140
|
+
function start() {
|
|
141
|
+
if (status.phase === 'installing') return view()
|
|
142
|
+
if (target === null || blocked()) throw new Error('No DSH update is available')
|
|
143
|
+
if (status.kind !== 'npm') throw new Error('This DSH updates with its app')
|
|
144
|
+
const chosen = target
|
|
145
|
+
Object.assign(status, { phase: 'installing', error: null })
|
|
146
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
147
|
+
const args = ['install', '-g', `${DSH_PACKAGE}@${chosen.version}`]
|
|
148
|
+
const runner = rt.dshSpawn ?? ((command, argv, options) => spawn(command, argv, options))
|
|
149
|
+
running = runner(npm, args, { windowsHide: true })
|
|
150
|
+
let output = ''
|
|
151
|
+
running.stdout?.on?.('data', chunk => { output = `${output}${chunk}`.slice(-4000) })
|
|
152
|
+
running.stderr?.on?.('data', chunk => { output = `${output}${chunk}`.slice(-4000) })
|
|
153
|
+
const done = new Promise((resolve, reject) => {
|
|
154
|
+
running.once?.('error', reject)
|
|
155
|
+
running.once?.('close', code => (code === 0 ? resolve() : reject(new Error(`npm exited ${code}`))))
|
|
156
|
+
})
|
|
157
|
+
void done
|
|
158
|
+
.then(() => Object.assign(status, { phase: 'installed', installed: chosen.version }))
|
|
159
|
+
.catch((error) => {
|
|
160
|
+
rt.warn('ds-bot: dsh update to %s failed: %s', chosen.version, error?.message ?? error)
|
|
161
|
+
Object.assign(status, { phase: 'failed', error: `${error?.message ?? error}${output ? ` — ${output.trim().slice(-400)}` : ''}`.slice(0, 600) })
|
|
162
|
+
})
|
|
163
|
+
return view()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Whatever npm is still running when the plugin unloads dies with it.
|
|
167
|
+
ctx.effect(() => () => running?.kill?.(), 'ds-bot: dsh update install')
|
|
168
|
+
|
|
169
|
+
Object.assign(rt, { dshUpdateCheck: check, dshUpdateStart: start, dshUpdateView: view })
|
|
170
|
+
}
|
package/src/host/groups.js
CHANGED
|
@@ -117,6 +117,8 @@ export function install(rt) {
|
|
|
117
117
|
await rt.selectSessionModel(id, splitRef(ref))
|
|
118
118
|
.catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error))
|
|
119
119
|
}
|
|
120
|
+
await rt.applyPermission(member, id)
|
|
121
|
+
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
|
|
120
122
|
await rt.save()
|
|
121
123
|
return { id, brief: await rt.ownChatBrief(member) }
|
|
122
124
|
}
|
package/src/host/models.js
CHANGED
|
@@ -11,10 +11,29 @@ export function install(rt) {
|
|
|
11
11
|
|
|
12
12
|
let modelsSynced = null
|
|
13
13
|
let catalogCache = { at: 0, value: [] }
|
|
14
|
-
|
|
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
|
+
}
|
|
15
33
|
|
|
16
34
|
// A provider's model list can change in DSH settings without any event, so a picker
|
|
17
|
-
// that is about to be used asks for a `fresh` one.
|
|
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.
|
|
18
37
|
async function catalog({ fresh = false } = {}) {
|
|
19
38
|
if (!fresh && Date.now() - catalogCache.at < CATALOG_TTL_MS && catalogCache.value.length > 0) return catalogCache.value
|
|
20
39
|
const entries = []
|
|
@@ -32,6 +51,11 @@ export function install(rt) {
|
|
|
32
51
|
})
|
|
33
52
|
}
|
|
34
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
|
+
}))
|
|
35
59
|
catalogCache = { at: Date.now(), value: entries }
|
|
36
60
|
return entries
|
|
37
61
|
}
|
|
@@ -72,17 +96,43 @@ export function install(rt) {
|
|
|
72
96
|
return [bot.model, bot.appliedModel].find(ref => ref && served.has(ref)) ?? pickDefault(entries)
|
|
73
97
|
}
|
|
74
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
|
+
|
|
75
121
|
// DSH's selectModel also saves the selection as the profile default model, in the
|
|
76
122
|
// background. A Bot's model is its own, so the user's default is written back after
|
|
77
123
|
// it. Selections run one at a time and wait for the write-back: profile writes land in
|
|
78
124
|
// submission order, so the next one reads the user's default, not a Bot's.
|
|
79
125
|
let selecting = Promise.resolve()
|
|
80
|
-
function selectSessionModel(sessionId, { provider, model }) {
|
|
126
|
+
function selectSessionModel(sessionId, { provider, model, reasoningEffort }) {
|
|
81
127
|
const run = selecting.then(async () => {
|
|
82
128
|
const defaults = ctx.get?.('agentDefaultModel')
|
|
83
129
|
const before = defaults?.currentSelection?.()
|
|
84
|
-
const result = await ctx.sessionController.selectModel({
|
|
85
|
-
|
|
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)) {
|
|
86
136
|
await defaults.saveSelection(before).catch(error => rt.warn('ds-bot: the default model was not restored: %s', error))
|
|
87
137
|
}
|
|
88
138
|
return result
|
|
@@ -91,18 +141,24 @@ export function install(rt) {
|
|
|
91
141
|
return run
|
|
92
142
|
}
|
|
93
143
|
|
|
144
|
+
const sameSelection = (a, b) => a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort
|
|
145
|
+
|
|
94
146
|
// `force` re-selects even when the record says it is applied: the composer's own
|
|
95
147
|
// model picker can move the Session without telling this plugin.
|
|
96
148
|
async function applyModel(bot, { force = false } = {}) {
|
|
97
149
|
const ref = await desiredModel(bot)
|
|
98
|
-
if (ref === undefined
|
|
99
|
-
const
|
|
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 }) }
|
|
100
154
|
await selectSessionModel(rt.chatOf(bot.id), route)
|
|
101
155
|
for (const sessionId of rt.groupSessionsOf(bot.id)) {
|
|
102
156
|
await selectSessionModel(sessionId, route)
|
|
103
157
|
.catch(error => rt.warn('ds-bot: model for %s in a group not applied: %s', bot.name, error))
|
|
104
158
|
}
|
|
105
159
|
bot.appliedModel = ref
|
|
160
|
+
if (effort === undefined) delete bot.appliedEffort
|
|
161
|
+
else bot.appliedEffort = effort
|
|
106
162
|
await rt.save()
|
|
107
163
|
return ref
|
|
108
164
|
}
|
|
@@ -140,6 +196,7 @@ export function install(rt) {
|
|
|
140
196
|
|
|
141
197
|
Object.assign(rt, {
|
|
142
198
|
catalog, cachedCatalog: () => catalogCache.value, cachedDefault: () => pickDefault(catalogCache.value),
|
|
143
|
-
defaultRef, defaultModel, resolveModel, desiredModel,
|
|
199
|
+
defaultRef, defaultModel, resolveModel, desiredModel, reasoningOf, resolveEffort, desiredEffort,
|
|
200
|
+
selectSessionModel, applyModel, waitForModel, syncModels,
|
|
144
201
|
})
|
|
145
202
|
}
|
package/src/host/parts.js
CHANGED
|
@@ -107,6 +107,19 @@ export function historyEntry(event, group) {
|
|
|
107
107
|
return { answer: false, who: ours ? 'bot' : 'user', text: skipped ? '' : text, keepAnswers: !skipped || (ours && source.resume === true) }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// A note with every heading in order but some translated (e.g. "## 目标" for "## Goals")
|
|
111
|
+
// gets the headings as written, so the note is not thrown away over its headings alone.
|
|
112
|
+
// A known heading in another place (repeated or out of order) keeps the note as it is.
|
|
113
|
+
export function canonicalHeadings(note, names) {
|
|
114
|
+
const lines = note.split('\n')
|
|
115
|
+
const at = lines.flatMap((line, index) => /^##\s+\S/.test(line) ? [index] : [])
|
|
116
|
+
const heading = index => lines[index].replace(/^##\s+/, '').trim()
|
|
117
|
+
if (at.length !== names.length || at.every((index, n) => heading(index) === names[n])) return note
|
|
118
|
+
if (at.some((index, n) => names.includes(heading(index)) && heading(index) !== names[n])) return note
|
|
119
|
+
at.forEach((index, n) => { lines[index] = `## ${names[n]}` })
|
|
120
|
+
return lines.join('\n')
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
export function install(rt) {
|
|
111
124
|
const { ctx, config, state } = rt
|
|
112
125
|
|
|
@@ -301,7 +314,7 @@ export function install(rt) {
|
|
|
301
314
|
'',
|
|
302
315
|
...NOTE_SECTIONS.flatMap(([name, what]) => [`## ${name}`, `- [${what}]`, '']),
|
|
303
316
|
'Rules:',
|
|
304
|
-
'- Do not make anything up. Leave a detail out rather than guess it. Keep identifiers, quotes, and numbers exact.',
|
|
317
|
+
'- Do not make anything up. Leave a detail out rather than guess it. Keep identifiers, quotes, and numbers exact, and write every file path in full, as it first appeared: never shorten it.',
|
|
305
318
|
'- When a newer message contradicts an older one, the newer one wins; drop what is stale.',
|
|
306
319
|
'- Your latest messages travel to the fresh conversation word for word, and in your own chat so does every message the user and other Bots sent you (long ones cut). Do not copy them whole; record what they mean and what they rely on. Still put in the facts, quotes, and error messages from them that the work needs: the move after this one leaves them behind.',
|
|
307
320
|
'- If the conversation starts with a "[Handoff · part N]" message, it holds your previous note, and a "[Checkpoint N]" summary holds a condensed stretch of this part. Merge them: keep what is still true, drop what is stale, and add what is new. Nothing in them survives unless this note keeps it.',
|
|
@@ -342,7 +355,8 @@ export function install(rt) {
|
|
|
342
355
|
})) assembler.push(chunk)
|
|
343
356
|
const finish = assembler.finish
|
|
344
357
|
if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
|
|
345
|
-
const
|
|
358
|
+
const text = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
|
|
359
|
+
const note = missingSections(text).length > 0 ? canonicalHeadings(text, NOTE_SECTIONS.map(([name]) => name)) : text
|
|
346
360
|
const missing = missingSections(note)
|
|
347
361
|
if (finish.kind === 'max-tokens') {
|
|
348
362
|
problem = 'it was cut off at the output limit'
|
|
@@ -696,6 +710,8 @@ export function install(rt) {
|
|
|
696
710
|
await rt.selectSessionModel(freshId, model)
|
|
697
711
|
.catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
|
|
698
712
|
}
|
|
713
|
+
await rt.applyPermission(bot, freshId)
|
|
714
|
+
.catch(error => rt.warn('ds-bot: permission for the fresh part of %s not applied: %s', bot.name, error))
|
|
699
715
|
const segment = { sessionId: agent.id, endedAt: Date.now(), ...(tokens !== undefined ? { tokens } : {}), ...noted }
|
|
700
716
|
if (room !== undefined) {
|
|
701
717
|
room.segments ??= {}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Permissions. A Bot's permission preset is its own: kept on the Bot and set on every
|
|
2
|
+
// Session it speaks from, so a fresh part or a new group Session keeps it.
|
|
3
|
+
// DSH offers Auto review for the current Session only, so a Bot never keeps it.
|
|
4
|
+
const SESSION_ONLY = 'auto'
|
|
5
|
+
|
|
6
|
+
export function install(rt) {
|
|
7
|
+
const { ctx } = rt
|
|
8
|
+
|
|
9
|
+
const presets = () => ctx.get?.('permissionPresets')
|
|
10
|
+
|
|
11
|
+
// The presets DSH offers, or undefined when the profile has none.
|
|
12
|
+
function permissionCatalog() {
|
|
13
|
+
const service = presets()
|
|
14
|
+
if (service === undefined) return undefined
|
|
15
|
+
const { options, defaultPreset } = service.catalog()
|
|
16
|
+
return { options: options.filter(option => option.value !== SESSION_ONLY), defaultPreset }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Sets the Bot's preset on one of its Sessions; a Bot without one keeps DSH's default.
|
|
20
|
+
async function applyPermission(bot, sessionId) {
|
|
21
|
+
const service = presets()
|
|
22
|
+
if (service === undefined || !bot.permission) return
|
|
23
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
24
|
+
service.set(agent.session, bot.permission)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function setPermission(bot, name) {
|
|
28
|
+
const service = presets()
|
|
29
|
+
if (service === undefined) throw new Error('Permissions are not available in this DeepSeek Harness')
|
|
30
|
+
const wanted = String(name ?? '').trim()
|
|
31
|
+
if (wanted === SESSION_ONLY) throw new Error('Auto review applies to one Session only and cannot be a Bot\'s permission')
|
|
32
|
+
service.resolve(wanted)
|
|
33
|
+
await applyPermission({ ...bot, permission: wanted }, rt.chatOf(bot.id))
|
|
34
|
+
bot.permission = wanted
|
|
35
|
+
for (const sessionId of rt.groupSessionsOf(bot.id)) {
|
|
36
|
+
await applyPermission(bot, sessionId)
|
|
37
|
+
.catch(error => rt.warn('ds-bot: permission for %s in a group not applied: %s', bot.name, error))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Object.assign(rt, { permissionCatalog, applyPermission, setPermission })
|
|
42
|
+
}
|
package/src/host/roster.js
CHANGED
|
@@ -54,13 +54,14 @@ export function install(rt) {
|
|
|
54
54
|
|
|
55
55
|
// `pickModel` leaves the model to the user: the chat opens on a model card, and the
|
|
56
56
|
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
57
|
-
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, copyOf, pickModel = false }) {
|
|
57
|
+
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
58
58
|
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
59
59
|
if (cleanName === '') throw new Error('A Bot needs a name')
|
|
60
60
|
if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
|
|
61
61
|
const cleanAvatarValue = cleanAvatar(avatar)
|
|
62
62
|
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
63
63
|
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
64
|
+
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
64
65
|
const id = await createSession(cleanName)
|
|
65
66
|
state.bots[id] = {
|
|
66
67
|
id,
|
|
@@ -70,6 +71,7 @@ export function install(rt) {
|
|
|
70
71
|
...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
|
|
71
72
|
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
72
73
|
...(modelRef ? { model: modelRef } : {}),
|
|
74
|
+
...(effortId ? { effort: effortId } : {}),
|
|
73
75
|
instructions: String(instructions).trim(),
|
|
74
76
|
createdAt: Date.now(),
|
|
75
77
|
createdBy: createdBy ?? null,
|
|
@@ -126,6 +128,7 @@ export function install(rt) {
|
|
|
126
128
|
color: bot.color,
|
|
127
129
|
avatar: bot.avatar,
|
|
128
130
|
model: served ? bot.model : undefined,
|
|
131
|
+
effort: served ? bot.effort : undefined,
|
|
129
132
|
copyOf: bot.id,
|
|
130
133
|
})
|
|
131
134
|
}
|
|
@@ -159,6 +162,16 @@ export function install(rt) {
|
|
|
159
162
|
else bot.model = await rt.resolveModel(patch.model)
|
|
160
163
|
modelChanged = true
|
|
161
164
|
}
|
|
165
|
+
if (patch.effort !== undefined) {
|
|
166
|
+
if (patch.effort === null || patch.effort === '') delete bot.effort
|
|
167
|
+
else {
|
|
168
|
+
const ref = bot.model ?? bot.appliedModel
|
|
169
|
+
if (ref === undefined) throw new Error('Pick a model before choosing a thinking level')
|
|
170
|
+
bot.effort = await rt.resolveEffort(ref, patch.effort)
|
|
171
|
+
}
|
|
172
|
+
modelChanged = true
|
|
173
|
+
}
|
|
174
|
+
if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
|
|
162
175
|
await rt.save()
|
|
163
176
|
if (modelChanged) await rt.applyModel(bot, { force: true })
|
|
164
177
|
if (bot.model && bot.kickoff !== undefined) {
|