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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds-bot",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DS Bot: a team of long-lived Bots for DeepSeek Harness, with Main Bots, Bot-to-Bot messages and group chats",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -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
- return ok(await rt.updateCheck({ fresh: body.fresh === true }))
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
+ }
@@ -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
  }
@@ -11,10 +11,29 @@ export function install(rt) {
11
11
 
12
12
  let modelsSynced = null
13
13
  let catalogCache = { at: 0, value: [] }
14
- ctx.on?.('llm/adapters-updated', () => { 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
+ }
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({ sessionId, provider, model })
85
- if (before?.provider && before?.model && (before.provider !== provider || before.model !== model)) {
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 || (!force && bot.appliedModel === ref)) return ref
99
- const route = splitRef(ref)
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, selectSessionModel, applyModel, waitForModel, syncModels,
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 note = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
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
+ }
@@ -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) {