sintbot 1.4.15 → 1.4.17

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.
@@ -18,7 +18,12 @@ const { prepareC2RunnerInput } = require('./c2runnerinput')
18
18
  const { continueC2Successor } = require('./c2successorbridge')
19
19
  const { rejectC2Input } = require('./c2inputrejection')
20
20
  const progress = require('./turnprogress')
21
- const { modelContext } = require('../modelcontext')
21
+ const { groupBrain } = require('./brain')
22
+ const { modelName } = require('./modelname')
23
+ const { parseModelWords, resolveModel, effortsFor, modelChoices, fillPicks, recommendEffort } = require('./modelcmd')
24
+ const { DEFAULT_VALUE: MODEL_CARD_DEFAULT } = require('../platform/modelcard')
25
+ // A /model card answers taps for ten minutes; older cards turn read-only.
26
+ const MODEL_CARD_TTL_MS = 10 * 60 * 1000
22
27
  const { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand } = require('../runtimefacts')
23
28
  const NO_REPLY = 'NO_REPLY'
24
29
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
@@ -51,13 +56,16 @@ function cardDuration(ms) {
51
56
  const hours = Math.floor(minutes / 60)
52
57
  return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
53
58
  }
54
- function cardModelInfo(group, provider) {
55
- if (!provider) return { model: '', effort: '' }
56
- const { model } = modelContext(group, provider)
57
- const effort = provider.runner === 'codex'
58
- ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
59
+ function cardModelInfo(brain) {
60
+ if (!brain) return { model: '', effort: '' }
59
61
  const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
60
- return { model: clean(model), effort: clean(effort) }
62
+ return { model: clean(brain.model ? modelName(brain.model) : ''), effort: clean(brain.effort) }
63
+ }
64
+ // An example depth for /model replies: high when available, else another one.
65
+ const exEffortOf = (efforts, cur) => ((efforts || []).includes('high') && cur !== 'high' ? 'high' : ((efforts || []).find(e => e !== cur) || ''))
66
+ // "codex · GPT-6-Astra · high": what this group runs on its current backend.
67
+ function brainLabel(name, brain) {
68
+ return [name, brain?.model ? modelName(brain.model) : '', brain?.effort || ''].filter(Boolean).join(' · ')
61
69
  }
62
70
  function cardModelDetail(info, result) {
63
71
  // A rejected configured effort may have been retried at the model default.
@@ -90,7 +98,7 @@ const T = {
90
98
  '· /skip — 只放弃手头这件,排队的继续;之前聊的还记得',
91
99
  '· /stop — 手头和排队的全取消;之前聊的还记得',
92
100
  '· /new — 忘掉之前聊的,从头开始(要等手头任务结束)',
93
- '· /model — 本群在用的模型;换:/model 名字(要等手头任务结束,换完之前聊的不再带着)',
101
+ '· /model — 看或改本群模型:/model high 调深度、/model 型号 换型号(都接着当前对话)、/model 后端 换后端(开新对话);要等手头任务结束',
94
102
  '· /hud — 额度与本段对话的用量',
95
103
  '· /issue 一句话 — 记下问题反馈,给管理员看',
96
104
  '· /help — 这份说明',
@@ -116,11 +124,40 @@ const T = {
116
124
  issueUsage: '用法:/issue 后面跟一句话,说清哪里不对劲\n例:/issue 刚才发的图它没理我',
117
125
  issueLogged: id => `📝 已记下 #${id}`,
118
126
  issueFailed: '⚠ 没记下来 —— 麻烦再说一次,或直接在群里说',
119
- modelInfo: (current, list, example) => `【当前模型】${current}\n【可用】${list}\n换模型:发「/model 名字」,例如 /model ${example}\n⚠ 换完是新的一段对话,之前聊的内容不再带着`,
120
- modelUnknown: (want, names) => `⚠ 没有「${want}」这个模型\n【可用】${names}\n发「/model 名字」换,名字要一模一样`,
127
+ // /model replies list backends, models and depths on separate lines, with
128
+ // examples taken from this group's real choices. Any unrecognised word
129
+ // rejects the whole command and changes nothing (no fuzzy matching).
130
+ // o = { current, names, name, choices|null|undefined, efforts, exEffort, exModel, exProvider }
131
+ // choices === null: this backend's model is set in the console (third-party endpoint).
132
+ modelInfo: (o) => {
133
+ const L = [`【本群模型】${o.current}`]
134
+ L.push(`【后端】${o.names.join(' · ')}`)
135
+ if (o.choices === null) L.push(`【${o.name} 型号】到控制台改`)
136
+ else if (o.choices && o.choices.length) L.push(`【${o.name} 型号】${o.choices.map((x) => modelName(x)).join(' · ')}`)
137
+ if (o.efforts && o.efforts.length) L.push(`【思考深度】${o.efforts.join(' · ')}`)
138
+ if (o.exEffort) L.push(`· 调深度:/model ${o.exEffort}(接着当前对话)`)
139
+ if (o.exModel) L.push(`· 换型号:/model ${modelName(o.exModel)}(接着当前对话)`)
140
+ if (o.exProvider) L.push(`· 换后端:/model ${o.exProvider}(开新对话)`)
141
+ L.push('⚠ 要等手头任务结束才能改')
142
+ return L.join('\n')
143
+ },
144
+ modelUnknownWord: (word, o) => (o.choices === null
145
+ ? `⚠ 「${word}」不是可选的后端或深度(${o.name} 的型号要到控制台改)—— 一项都没改`
146
+ : `⚠ 「${word}」不是可选的后端、型号或深度 —— 一项都没改`)
147
+ + `\n【后端】${o.names.join(' · ')}` + (o.choices && o.choices.length ? `\n【${o.name} 型号】${o.choices.map((x) => modelName(x)).join(' · ')}` : '')
148
+ + (o.efforts && o.efforts.length ? `\n【思考深度】${o.efforts.join(' · ')}` : ''),
149
+ modelDup: (what, a, b) => `⚠ 一次只能选一个${what}(写了 ${a} 和 ${b})—— 一项都没改`,
150
+ modelNoCatalog: name => `⚠ 暂时取不到 ${name} 的可选型号 —— 一项都没改;稍后再试,或请管理员到控制台改`,
151
+ modelEffortUnsupported: (model, e, list) => list.length
152
+ ? `⚠ ${modelName(model) || model} 没有「${e}」这档深度 —— 一项都没改\n【可选】${list.join(' · ')}`
153
+ : `⚠ ${modelName(model) || model} 不支持调思考深度 —— 一项都没改`,
121
154
  modelSame: current => `本群已经在用 ${current},没有变化`,
122
- modelBusy: '⚠ 手头还有任务在跑,现在换会打断它 —— 等它跑完再换,或先发 /stop',
123
- modelSwitched: current => `✅ 已切到 ${current}\n⚠ 新的一段对话从这里开始,之前聊的内容不再带着`,
155
+ modelBusy: '⚠ 手头还有任务在跑 —— 等它跑完再改,或先发 /stop',
156
+ modelSwitched: (current, note, filled) => `✅ 已切到 ${current}\n⚠ 换了后端,新的一段对话从这里开始,之前聊的内容不再带着` + (note ? ` —— ${note}` : '')
157
+ + (filled ? `\n· 本群在这个后端还没选过${filled},先用推荐的;要换就 /model 加名字` : ''),
158
+ modelChanged: (current, filled) => `✅ 已改为 ${current} —— 接着当前对话,下条消息生效\n· 对话很长时新型号可能装不下,可发 /new 重开`
159
+ + (filled ? `\n· ${filled}先用推荐的` : ''),
160
+ effortSwitched: (effort, label) => `✅ 思考深度已调为 ${effort}(${label})—— 接着当前对话,下条消息生效`,
124
161
  modelSwitchFailed: '⚠ 没换成 —— 请管理员到控制台看一下',
125
162
  stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
126
163
  stopUnavailable: '当前任务正在收尾,暂时无法停止。',
@@ -257,7 +294,9 @@ class Engine {
257
294
  durableV2AssertWriterLock, durableV2Journal,
258
295
  durableV2NormalRoute = true, durableV2StreamFrames = false,
259
296
  durableV2AcquireCapacity = null, durableV2SealCapacity = null,
260
- durableV2SwitchProvider = null, durableV2UsageProbe = null, durableV2Issues = null,
297
+ durableV2SwitchProvider = null, durableV2SetBrain = null, durableV2ModelCatalog = null,
298
+ durableV2ModelCard = null,
299
+ durableV2UsageProbe = null, durableV2Issues = null,
261
300
  durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false } = options
262
301
  const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
263
302
  if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
@@ -282,6 +321,9 @@ class Engine {
282
321
  durableV2AcquireCapacity !== null && typeof durableV2AcquireCapacity !== 'function' ||
283
322
  durableV2SealCapacity !== null && !sync(durableV2SealCapacity) ||
284
323
  durableV2SwitchProvider !== null && !sync(durableV2SwitchProvider) ||
324
+ durableV2SetBrain !== null && !sync(durableV2SetBrain) ||
325
+ durableV2ModelCatalog !== null && typeof durableV2ModelCatalog !== 'function' ||
326
+ durableV2ModelCard !== null && !sync(durableV2ModelCard) ||
285
327
  durableV2UsageProbe !== null && typeof durableV2UsageProbe !== 'function' ||
286
328
  durableV2Issues !== null && typeof durableV2Issues?.add !== 'function' ||
287
329
  typeof durableV2Version !== 'string') {
@@ -291,7 +333,8 @@ class Engine {
291
333
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
292
334
  durableV2AssertWriterLock, durableV2Journal, durableV2NormalRoute,
293
335
  durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
294
- durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version,
336
+ durableV2SwitchProvider, durableV2SetBrain, durableV2ModelCatalog, durableV2ModelCard,
337
+ durableV2UsageProbe, durableV2Issues, durableV2Version,
295
338
  durableV2DataDir, durableV2ConsoleEnabled })
296
339
  this.projectMemory = options.projectMemory || null
297
340
  this.repoInfo = options.repoInfo || null
@@ -450,6 +493,11 @@ _resolveProvider(chatId, group) {
450
493
  return { name, provider: this.cfg.providers[name] }
451
494
  }
452
495
 
496
+ // The group's model and depth on the backend actually running (core/brain.js).
497
+ _brain(group, name, provider) {
498
+ return groupBrain(group, name, provider, { defaultProvider: this.cfg.defaultProvider })
499
+ }
500
+
453
501
  // Chat commands are answered before any receipt, like /stop with nothing to
454
502
  // stop: nothing enters the journal and each returns its single reply as an
455
503
  // input rejection. Commands that reset a session need the group idle and its
@@ -467,8 +515,7 @@ async _durableV2Command(msg, cmd, rest) {
467
515
  const group = this._resolveGroup(msg.chatId)
468
516
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
469
517
  const { name, provider } = this._resolveProvider(msg.chatId, group)
470
- // A group may pin its own model; the runner uses it before the provider default.
471
- const label = (n, p) => { const { model } = modelContext(group, p); return model ? `${n} · ${model}` : n }
518
+ const label = (n, p) => brainLabel(n, this._brain(group, n, p))
472
519
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
473
520
  if (cmd === '/help') {
474
521
  const isCodex = provider?.runner === 'codex'
@@ -531,38 +578,247 @@ async _durableV2Command(msg, cmd, rest) {
531
578
  return reject('unsupported-command', T.unsupportedCommandWithModel)
532
579
  }
533
580
 
534
- // /model lists models, or switches through the console's configuration
535
- // writer. Switching needs this group idle and settled, and always starts a
536
- // fresh session.
537
- _durableV2ModelCommand(msg, want, group) {
581
+ // /model: backend · model · thinking depth. Words are parsed exactly
582
+ // (core/modelcmd.js); the decision is _durableV2ApplyModel (shared with the
583
+ // model card) and the write goes through the console's configuration writer.
584
+ // Every change needs this group idle and settled; only a backend change
585
+ // starts a new conversation.
586
+ async _durableV2ModelCommand(msg, want, group) {
538
587
  const reject = (reason, notice) =>
539
588
  rejectC2Input('C2 v2 model command answered before receipt', { reason, notice })
540
- const label = (name, provider, scope = {}) => {
541
- const { model } = modelContext(scope, provider)
542
- return model ? `${name} · ${model}` : name
543
- }
544
- const available = Object.entries(this.cfg.providers || {})
545
- .filter(([, provider]) => provider && !provider.hidden &&
546
- ['claude', 'codex'].includes(provider.runner || 'claude'))
547
- const { name: currentName, provider: currentProvider } = this._resolveProvider(msg.chatId, group)
548
- const current = label(currentName, currentProvider, group)
549
- const list = available.map(([name, provider]) => label(name, provider)).join(' / ')
550
- if (!want) {
551
- const example = available.find(([name]) => name !== currentName)?.[0] || currentName
552
- return reject('model-info', T.modelInfo(current, list, example))
553
- }
554
- const hit = available.find(([name]) => name.toLowerCase() === want.toLowerCase())
555
- if (!hit) return reject('model-unknown', T.modelUnknown(want, available.map(([name]) => name).join(' / ')))
556
- if (hit[0] === currentName) return reject('model-same', T.modelSame(current))
557
- if (this._durableV2GroupBusy(msg.chatId)) return reject('model-busy', T.modelBusy)
589
+ const { name, provider } = this._resolveProvider(msg.chatId, group)
590
+ const names = this._modelNames()
591
+ const words = String(want || '').split(/\s+/).map(w => w.trim()).filter(Boolean)
592
+ if (!words.length) {
593
+ const cat = await this._modelCatalog(name, provider)
594
+ const cur = this._brain(group, name, provider)
595
+ const o = this._modelOpts(names, name, cat, cur)
596
+ const concrete = cat && Array.isArray(cat.models) ? cat.models.map(m => m.id) : []
597
+ const exModel = concrete.find(c => (o.choices || []).includes(c) && c !== cur.model) || ''
598
+ const text = T.modelInfo({ ...o, current: brainLabel(name, cur),
599
+ exEffort: exEffortOf(o.efforts, cur.effort), exModel,
600
+ exProvider: names.find(n => n !== name) || '' })
601
+ // Feishu gets a tap-to-pick card; the text above is its fallback.
602
+ let card = null
603
+ if (this.durableV2ModelCard) {
604
+ try { card = this.durableV2ModelCard(await this._modelPanel(msg.chatId, group, { issuedAt: Date.now() })) } catch (error) {
605
+ this.log(`/model card not built: ${error?.message || String(error)}`)
606
+ }
607
+ }
608
+ return rejectC2Input('C2 v2 model command answered before receipt', { reason: 'model-info', notice: text, card })
609
+ }
610
+ const sender = msg.senderName || msg.userId || 'unknown'
611
+ const pw = parseModelWords(words, names)
612
+ if (pw.error) {
613
+ this._logModelReject(msg.chatId, words.join(' '), 'dup', sender)
614
+ return reject('model-dup', T.modelDup(pw.error.what, pw.error.a, pw.error.b))
615
+ }
616
+ const out = await this._durableV2ApplyModel(msg.chatId, group, pw,
617
+ { sender, said: words.join(' '), source: 'command' })
618
+ const reason = out.kind === 'reject' ? `model-${out.reason}` : out.kind === 'model' ? 'model-changed' : `model-${out.kind}`
619
+ return reject(reason, out.text)
620
+ }
621
+
622
+ // The /model card's panel state (platform-neutral; Feishu draws it with
623
+ // src/platform/modelcard.js). "Current" is the backend actually running and
624
+ // this group's choice on it. The draft (p backend, m model, e depth) starts
625
+ // as the group's choice on that backend; unchosen items are filled with the
626
+ // catalog's recommendation, so the card always shows a concrete pick. A draft
627
+ // that differs from the current one gets a preview, which enables confirm.
628
+ async _modelPanel(chatId, group, extra = {}) {
629
+ const { name, provider } = this._resolveProvider(chatId, group)
630
+ const b = this._brain(group, name, provider)
631
+ const current = brainLabel(name, b)
632
+ const mode = extra.mode || 'edit'
633
+ if (mode !== 'edit') return { mode, current: extra.current || current, receipt: extra.receipt, issuedAt: extra.issuedAt || 0 }
634
+ const names = this._modelNames()
635
+ const d0 = extra.draft || {}
636
+ const known = extra.draft && names.includes(d0.p)
637
+ const dp = known ? d0.p : name
638
+ const dProv = this.cfg.providers[dp]
639
+ const base = this._brain(group, dp, dProv)
640
+ const third = !!(dProv && dProv.baseUrl)
641
+ const draft = known ? { p: dp, m: String(d0.m || ''), e: String(d0.e || '') } : { p: dp, m: '', e: '' }
642
+ const cat = await this._modelCatalog(dp, dProv)
643
+ const rec = cat && cat.ok !== false && cat.recommended ? cat.recommended : null
644
+ let model = null // null: a third-party endpoint, whose model is set in the console
645
+ if (!draft.e) draft.e = base.effort || ''
646
+ if (!cat || cat.models !== null) {
647
+ const choices = cat && cat.ok !== false ? (cat.models || []).map(m => m.id) : []
648
+ if (!draft.m && !third) draft.m = base.model || ''
649
+ if (!draft.m && rec && rec.model) draft.m = rec.model
650
+ const options = choices.map(v => ({ value: v, label: modelName(v) }))
651
+ if (draft.m && !choices.includes(draft.m)) options.push({ value: draft.m, label: modelName(draft.m) })
652
+ model = { options, selected: draft.m }
653
+ } else draft.m = ''
654
+ const dModel = draft.m || (third ? base.model : '')
655
+ const listed = cat && Array.isArray(cat.models) ? cat.models.find(m => m.id === dModel) : null
656
+ const noDepth = !!(listed && Array.isArray(listed.efforts) && !listed.efforts.length)
657
+ const effList = cat && !noDepth ? effortsFor(cat, dModel) : []
658
+ if (noDepth) draft.e = ''
659
+ else if (!draft.e || (effList.length && !effList.includes(draft.e))) draft.e = cat ? recommendEffort(cat, dModel) : draft.e
660
+ const dEffort = draft.e
661
+ const efforts = effList.map(e => ({ value: e, label: e, selected: e === dEffort }))
662
+ let preview = null
663
+ if (dp !== name || dModel !== b.model || (dEffort || '') !== (b.effort || '')) {
664
+ preview = { to: brainLabel(dp, { model: dModel, effort: dEffort }),
665
+ note: dp !== name ? '换了后端 —— 会开新对话' : dModel !== b.model ? '换了型号 —— 接着当前对话' : '只调深度 —— 接着当前对话' }
666
+ }
667
+ return { mode, current, draft, preview, failover: '', quota: '',
668
+ backends: names.map(n => ({ name: n, selected: n === dp })),
669
+ model, efforts, issuedAt: extra.issuedAt || 0 }
670
+ }
671
+
672
+ // A tap on the /model card. value = the callback value { f, v?, d, t },
673
+ // option = the dropdown choice, who = the operator's name.
674
+ // f = p | m | e: change only the card's draft (nothing is written)
675
+ // f = ok: apply the draft once through _durableV2ApplyModel → receipt card
676
+ // f = x: close; nothing changes, the card turns grey in place
677
+ // Returns { toast?, state? }; state is drawn into a card that replaces this one.
678
+ async handleModelCard({ chatId, who = '', value = {}, option } = {}) {
679
+ const group = this.cfg.groups && this.cfg.groups[chatId]
680
+ if (!group) return { toast: { type: 'info', content: '这个群还没绑定项目' } }
681
+ const issued = Number(value.t) || 0
682
+ const now = Date.now()
683
+ if (value.f === 'x') return { toast: { type: 'info', content: '已关闭 · 模型没变' }, state: await this._modelPanel(chatId, group, { mode: 'closed' }) }
684
+ if (group.paused) return { toast: { type: 'warning', content: '本群已暂停 —— 恢复后再改' } }
685
+ if (!issued || now - issued > MODEL_CARD_TTL_MS) {
686
+ return { toast: { type: 'info', content: '这张卡已过期 —— 发 /model 重新打开' }, state: await this._modelPanel(chatId, group, { mode: 'expired' }) }
687
+ }
688
+ const d = value.d && typeof value.d === 'object' ? { p: String(value.d.p || ''), m: String(value.d.m || ''), e: String(value.d.e || '') } : null
689
+ const norm = x => (x == null || x === MODEL_CARD_DEFAULT ? '' : String(x))
690
+ if (value.f === 'p' || value.f === 'm' || value.f === 'e') {
691
+ let draft = d
692
+ if (value.f === 'p') draft = { p: norm(value.v), m: '', e: '' }
693
+ else if (value.f === 'm') draft = { ...(d || {}), m: norm(option) }
694
+ else draft = { ...(d || {}), e: norm(value.v) }
695
+ return { state: await this._modelPanel(chatId, group, { draft, issuedAt: now }) }
696
+ }
697
+ if (value.f !== 'ok' || !d) return {}
698
+ const by = String(who || '').trim() || '有人'
699
+ const before = await this._modelPanel(chatId, group)
700
+ const { name: curName } = this._resolveProvider(chatId, group)
701
+ const target = d.p || curName
702
+ const tb = this._brain(group, target, this.cfg.providers[target])
703
+ const out = await this._durableV2ApplyModel(chatId, group, {
704
+ provider: d.p && d.p !== curName ? d.p : undefined,
705
+ model: d.m && d.m !== tb.model ? d.m : undefined,
706
+ effort: d.e && d.e !== tb.effort ? d.e : undefined },
707
+ { sender: by, said: `[模型卡] ${d.p}·${d.m || '-'}·${d.e || '-'}`, source: 'card' })
708
+ if (out.kind === 'reject' && out.reason === 'busy') return { toast: { type: 'warning', content: '手头还有任务在跑 —— 等它做完再确认,或先发 /stop' } }
709
+ if (out.kind === 'reject' || out.kind === 'failed') return { toast: { type: 'error', content: String(out.text || '').split('\n')[0].replace(/^⚠\s*/, '') } }
710
+ const g2 = (this.cfg.groups && this.cfg.groups[chatId]) || group
711
+ if (out.kind === 'same') return { toast: { type: 'info', content: '已经是这个了,没有变化' }, state: await this._modelPanel(chatId, g2, { issuedAt: now }) }
712
+ const hm = fmtStamp(now, this.tz).slice(-5)
713
+ const after = await this._modelPanel(chatId, g2)
714
+ const how = out.kind === 'switched' ? '已开新对话' : '接着当前对话'
715
+ return {
716
+ toast: { type: 'success', content: `已改为 ${after.current} · ${how}` },
717
+ state: { mode: 'receipt', current: after.current,
718
+ receipt: { line: `${by} 改为 ${after.current} · ${how} · ${hm}`, before: `之前:${before.current}` } },
719
+ }
720
+ }
721
+
722
+ _modelNames() {
723
+ return Object.entries(this.cfg.providers || {})
724
+ .filter(([, p]) => p && !p.hidden && ['claude', 'codex'].includes(p.runner || 'claude'))
725
+ .map(([n]) => n)
726
+ }
727
+ async _modelCatalog(name, provider) {
728
+ if (!this.durableV2ModelCatalog) return null
729
+ try { return await this.durableV2ModelCatalog(name, provider) } catch { return null }
730
+ }
731
+ _modelOpts(names, name, cat, brain) {
732
+ return { names, name,
733
+ choices: !cat ? undefined : cat.models === null ? null : cat.ok === false ? [] : modelChoices(cat),
734
+ efforts: cat ? effortsFor(cat, brain && brain.model) : [] }
735
+ }
736
+ _logModelReject(chatId, said, reason, sender) {
737
+ this.log(`/model rejected: ${chatId} ${JSON.stringify(String(said || '')).slice(0, 80)} ${reason} (by ${sender})`)
738
+ }
739
+
740
+ // pw = { provider?, model?, models?, effort? } from parseModelWords, or the
741
+ // model card's confirmed draft. Returns { kind, reason?, text, label?, after? }
742
+ // with kind ∈ reject | failed | same | switched | model | effort.
743
+ // Missing picks on the target backend are filled with the catalog's
744
+ // recommendation (fillPicks); a depth-only change never fills a model.
745
+ async _durableV2ApplyModel(chatId, group, pw, { sender = 'unknown', said = '', source = 'command' } = {}) {
746
+ const reject = (reason, text) => { this._logModelReject(chatId, said, reason, sender); return { kind: 'reject', reason, text } }
747
+ const { name, provider } = this._resolveProvider(chatId, group)
748
+ const names = this._modelNames()
749
+ const target = pw.provider || name
750
+ // The card's draft comes back from the platform: accept only a listed backend.
751
+ if (pw.provider && !names.includes(pw.provider)) return reject('unknown', T.modelUnknownWord(target, this._modelOpts(names, name, null, null)))
752
+ const tProv = this.cfg.providers[target]
753
+ const tBrain = this._brain(group, target, tProv)
754
+ if (pw.model === '') pw = { ...pw, model: undefined }
755
+ if (pw.effort === '') pw = { ...pw, effort: undefined }
756
+ if (pw.models) {
757
+ const mcat = await this._modelCatalog(target, tProv)
758
+ const bad = pw.models.find(w => !resolveModel(w, mcat))
759
+ if (bad) return reject('unknown', T.modelUnknownWord(bad, this._modelOpts(names, target, mcat, tBrain)))
760
+ const ids = [...new Set(pw.models.map(w => resolveModel(w, mcat)))]
761
+ if (ids.length > 1) return reject('dup', T.modelDup('型号', pw.models[0], pw.models[1]))
762
+ pw = { ...pw, model: ids[0] }
763
+ delete pw.models
764
+ }
765
+ const switching = target !== name
766
+ const wantsBrain = pw.model !== undefined || pw.effort !== undefined
767
+ const cat = wantsBrain || switching ? await this._modelCatalog(target, tProv) : null
768
+ let model
769
+ if (pw.model !== undefined) {
770
+ if (cat && cat.models === null) return reject('unknown', T.modelUnknownWord(pw.model, this._modelOpts(names, target, cat, tBrain)))
771
+ if (!cat || cat.ok === false) return reject('no-catalog', T.modelNoCatalog(target))
772
+ model = resolveModel(pw.model, cat)
773
+ if (!model) return reject('unknown', T.modelUnknownWord(pw.model, this._modelOpts(names, target, cat, tBrain)))
774
+ if (model === tBrain.model) model = undefined
775
+ }
776
+ const f = fillPicks({ switching, model, effort: pw.effort, currentModel: tBrain.model,
777
+ currentEffort: tBrain.effort, third: !!(tProv && tProv.baseUrl), cat })
778
+ model = f.model
779
+ const nextModel = model === undefined ? tBrain.model : model
780
+ let effort
781
+ if (pw.effort !== undefined) {
782
+ const effs = cat ? effortsFor(cat, nextModel) : []
783
+ const known = cat && cat.models && cat.models.find(m => m.id === nextModel)
784
+ const unsupported = known && Array.isArray(known.efforts) && !known.efforts.length
785
+ if (unsupported || (effs.length && !effs.includes(pw.effort))) {
786
+ return reject('effort', T.modelEffortUnsupported(nextModel || target, pw.effort, unsupported ? [] : effs))
787
+ }
788
+ effort = pw.effort === tBrain.effort && model === undefined && !switching ? undefined : pw.effort
789
+ } else effort = f.effort
790
+ const current = brainLabel(name, this._brain(group, name, provider))
791
+ if (!switching && model === undefined && effort === undefined) return { kind: 'same', text: T.modelSame(current), label: current }
792
+ if (this._durableV2GroupBusy(chatId)) return reject('busy', T.modelBusy)
793
+ let r
558
794
  try {
559
- this.durableV2SwitchProvider(msg.chatId, hit[0])
795
+ if (this.durableV2SetBrain) {
796
+ r = this.durableV2SetBrain(chatId, { provider: switching ? target : undefined, model, effort },
797
+ { by: sender, source })
798
+ } else if (switching && model === undefined && effort === undefined) {
799
+ this.durableV2SwitchProvider(chatId, target)
800
+ } else throw new Error('Model and depth writer unavailable')
560
801
  } catch (error) {
561
- this.log(`/model switch failed for ${msg.chatId} → ${hit[0]}: ${error?.message || String(error)}`)
562
- return reject('model-failed', /settled/.test(error?.message || '') ? T.modelBusy : T.modelSwitchFailed)
802
+ this.log(`/model failed for ${chatId} → ${target}: ${error?.message || String(error)}`)
803
+ return { kind: 'failed', reason: 'failed',
804
+ text: /settled|任务进行中|空闲/.test(error?.message || '') ? T.modelBusy : T.modelSwitchFailed }
805
+ }
806
+ const g2 = (this.cfg.groups && this.cfg.groups[chatId]) || group
807
+ const after = r?.after || this._brain(g2, target, this.cfg.providers[target])
808
+ const label = brainLabel(target, after)
809
+ this.log(`/model: ${chatId} ${name}·${tBrain.model || '-'}·${tBrain.effort || '-'} → ${target}·${after.model || '-'}·${after.effort || '-'} (by ${sender}, ${source})`)
810
+ const filled = f.filled.length ? f.filled.join('和') : ''
811
+ if (switching) {
812
+ let note = ''
813
+ try {
814
+ const opts = { full: group.permissionMode === 'bypassPermissions', isCodex: tProv?.runner === 'codex' }
815
+ const h = this.projectMemory ? this.projectMemory(group.workdir, opts) : null
816
+ if (h?.note) note = h.note
817
+ } catch {}
818
+ return { kind: 'switched', text: T.modelSwitched(label, note, filled), label, after }
563
819
  }
564
- this.log(`/model: ${msg.chatId} ${currentName} → ${hit[0]} (by ${msg.senderName || 'unknown'})`)
565
- return reject('model-switched', T.modelSwitched(label(hit[0], this.cfg.providers[hit[0]])))
820
+ if (r?.modelChanged) return { kind: 'model', text: T.modelChanged(label, filled), label, after }
821
+ return { kind: 'effort', text: T.effortSwitched(after.effort, brainLabel(target, { model: after.model })), label, after }
566
822
  }
567
823
 
568
824
  async runDurableV2Message(msg, platform) {
@@ -1559,9 +1815,10 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1559
1815
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
1560
1816
  throw new Error('C2 v2 runner provider unavailable')
1561
1817
  }
1562
- rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1818
+ rt.v2Brain = this._brain(job.group, name, provider)
1819
+ rt.v2CardModelInfo = cardModelInfo(rt.v2Brain)
1563
1820
  this._observeActivity('start', { chatId, receiptId, receiptIds: cohort.receiptIds, generation,
1564
- sessionId: this.store.getSession(chatId), model: rt.v2CardModelInfo.model,
1821
+ sessionId: this.store.getSession(chatId), model: rt.v2Brain.model, effort: rt.v2Brain.effort,
1565
1822
  runner: provider.runner, project: projectName(job.group) })
1566
1823
  rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1567
1824
  rt.v2SteerControl = null
@@ -1805,7 +2062,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1805
2062
  capTimer = timeoutDeadlineAt !== null ? setTimeout(onHardDeadline, timeoutMs) : null
1806
2063
  const input = prepareC2RunnerInput({ prompt,
1807
2064
  appendSystemPrompt: SYSTEM_APPENDIX, msg: job.msg, group: job.group,
1808
- provider, config: this.cfg,
2065
+ provider, config: this.cfg, brain: { model: rt.v2Brain.model, effort: rt.v2Brain.effort },
1809
2066
  sessionId: this.store.getSession(chatId) || null,
1810
2067
  signal: controller.signal, extraEnv: this.baseSkillEnv(chatId),
1811
2068
  onDurableEvent: event => {
@@ -2233,7 +2490,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2233
2490
  chatId: msg.chatId, groupName: group.name || msg.chatId,
2234
2491
  workdir: group.workdir, repo: group.repo || '', repoState: info?.state || '',
2235
2492
  repoOrigin: info?.origin || '未知', pushDenied: policy.pushDenied,
2236
- providerName, model: modelContext(group, provider).model,
2493
+ providerName, model: this._brain(group, providerName, provider).model,
2237
2494
  runtimeVersion: this.durableV2Version || '', runtimeCommand: runtimeCommand(),
2238
2495
  noVision: provider.vision === false, platformName: platform.chatLabel || '项目群',
2239
2496
  permissionTier: policy.tier, tz: this.tz, publicUrl: '', uiUrl: '', lanUrl: '' }),
@@ -0,0 +1,135 @@
1
+ 'use strict'
2
+ // The single change logic for a group's backend · model · thinking depth.
3
+ // The console group card, the chat /model command and the Feishu model card
4
+ // all call applyBrainChange, so "did the backend change / only the model /
5
+ // only the depth / nothing" is decided in one place.
6
+ //
7
+ // Pure: mutates the given raw configuration object; the caller writes it
8
+ // through the configuration writer (which validates, resets sessions when the
9
+ // backend changes, replaces the file atomically and applies it).
10
+
11
+ const brainMod = require('./core/brain')
12
+ const { DEFAULTS } = require('./config')
13
+
14
+ /**
15
+ * @param raw configuration object (mutated)
16
+ * @param chatId
17
+ * @param change { provider?, model?, effort? }
18
+ * provider: switch backend (omitted = keep); model / effort: string choice
19
+ * ('' = clear), omitted = keep. Choices go to the target backend's entry.
20
+ * @returns { error } | { before, after, switching, target, home, modelChanged, effortChanged, changed }
21
+ * error ∈ 'no-group' | 'unknown-provider' | 'bad-model' | 'bad-effort'
22
+ */
23
+ function applyBrainChange(raw, chatId, change = {}) {
24
+ const { provider, model, effort } = change
25
+ const g = raw && raw.groups && raw.groups[chatId]
26
+ if (!g) return { error: 'no-group' }
27
+ if (provider !== undefined && !(raw.providers || {})[provider]) return { error: 'unknown-provider' }
28
+ if (model !== undefined && model !== '' && !brainMod.isModel(model)) return { error: 'bad-model' }
29
+ if (effort !== undefined && effort !== '' && !brainMod.isEffort(effort)) return { error: 'bad-effort' }
30
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
31
+ const provAll = { ...DEFAULTS.providers, ...(raw.providers || {}) }
32
+ const home = g.provider || defName
33
+ const before = brainMod.groupBrain(g, home, provAll[home], { defaultProvider: defName })
34
+ // Legacy group.model first moves under its own backend, then the backend may change.
35
+ brainMod.migrateLegacyModel(g, defName)
36
+ const switching = provider !== undefined && provider !== home
37
+ const target = provider || home
38
+ if (model !== undefined || effort !== undefined) {
39
+ const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
40
+ const cur = { ...(brainMod.cleanPref(prefs[target]) || {}) }
41
+ if (model !== undefined) { if (model) cur.model = model; else delete cur.model }
42
+ if (effort !== undefined) { if (effort) cur.effort = effort; else delete cur.effort }
43
+ if (Object.keys(cur).length) prefs[target] = cur; else delete prefs[target]
44
+ g.providerPrefs = prefs
45
+ }
46
+ const np = brainMod.normalizePrefs(g.providerPrefs)
47
+ if (np) g.providerPrefs = np; else delete g.providerPrefs
48
+ if (switching) g.provider = provider
49
+ const after = brainMod.groupBrain(g, target, provAll[target], { defaultProvider: defName })
50
+ const modelChanged = !switching && before.model !== after.model
51
+ const effortChanged = !switching && before.effort !== after.effort
52
+ return { before, after, switching, target, home, modelChanged, effortChanged,
53
+ changed: switching || modelChanged || effortChanged }
54
+ }
55
+
56
+ // One audit row (console, chat command and card share the shape; source tells which).
57
+ function auditRow(chatId, r, { source, by }) {
58
+ const pick = (b) => ({ model: b.model || '', effort: b.effort || '' })
59
+ return { kind: 'group-model', chatId, source, by: by || '', from: { provider: r.home, ...pick(r.before) },
60
+ to: { provider: r.target, ...pick(r.after) }, switching: r.switching, modelChanged: r.modelChanged,
61
+ effortChanged: r.effortChanged, ts: Date.now() }
62
+ }
63
+
64
+ // Card-level values that move into groups: subscription cards give up model
65
+ // and depth; a third-party endpoint keeps its model (connection setting) and
66
+ // gives up depth only.
67
+ function movable(p) {
68
+ return { model: p && !p.baseUrl ? brainMod.defaultModel(p) : '', effort: p ? brainMod.defaultEffort(p) : '' }
69
+ }
70
+
71
+ /**
72
+ * Migration step one (additive, before the service starts): every group gets,
73
+ * on every visible card (and its current card even when hidden), the card's
74
+ * current default for any item it has not chosen. Card values stay in place,
75
+ * so an older version that ignores providerPrefs behaves exactly as before.
76
+ * Idempotent. Mutates raw. @returns {{ changed, groups: string[] }}
77
+ */
78
+ function fillGroupPicks(raw) {
79
+ const out = { changed: false, groups: [] }
80
+ if (!raw || typeof raw !== 'object') return out
81
+ const provs = (raw.providers && typeof raw.providers === 'object') ? raw.providers : {}
82
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
83
+ const provAll = { ...DEFAULTS.providers, ...provs }
84
+ const withDefaults = Object.keys(provs).filter((n) => { const d = movable(provs[n]); return !!(d.model || d.effort) })
85
+ for (const [id, g] of Object.entries(raw.groups || {})) {
86
+ if (!g || typeof g !== 'object' || Array.isArray(g) || id === 'oc_example_chat_id') continue
87
+ let touched = brainMod.migrateLegacyModel(g, defName)
88
+ if (withDefaults.length) {
89
+ const home = g.provider || defName
90
+ const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
91
+ const names = new Set([home, ...Object.keys(prefs)])
92
+ for (const n of Object.keys(provAll)) if (provAll[n] && !provAll[n].hidden) names.add(n)
93
+ for (const n of names) {
94
+ if (!withDefaults.includes(n)) continue
95
+ const d = movable(provs[n])
96
+ const cur = { ...(brainMod.cleanPref(prefs[n]) || {}) }
97
+ if (!cur.model && d.model) { cur.model = d.model; touched = true }
98
+ if (!cur.effort && d.effort) { cur.effort = d.effort; touched = true }
99
+ if (Object.keys(cur).length) prefs[n] = cur
100
+ }
101
+ const np = brainMod.normalizePrefs(prefs)
102
+ if (np) g.providerPrefs = np; else delete g.providerPrefs
103
+ }
104
+ if (touched) out.groups.push(id)
105
+ }
106
+ out.changed = out.groups.length > 0
107
+ return out
108
+ }
109
+
110
+ /**
111
+ * Migration step two (after every group connected): remove the card-level
112
+ * values that step one copied into the groups. Effective values do not change
113
+ * for any group whose picks were filled. Idempotent. Mutates raw.
114
+ * @returns {{ changed, providers: string[] }}
115
+ */
116
+ function stripCardDefaults(raw) {
117
+ const out = { changed: false, providers: [] }
118
+ const provs = raw && raw.providers && typeof raw.providers === 'object' ? raw.providers : {}
119
+ for (const [n, p] of Object.entries(provs)) {
120
+ if (!p || typeof p !== 'object') continue
121
+ const d = movable(p)
122
+ if (!d.model && !d.effort) continue
123
+ if (!p.baseUrl) {
124
+ delete p.model
125
+ if (p.runner !== 'codex' && p.env) delete p.env.ANTHROPIC_MODEL
126
+ }
127
+ delete p.reasoningEffort
128
+ if (p.env) { delete p.env.CLAUDE_CODE_EFFORT_LEVEL; if (!Object.keys(p.env).length) delete p.env }
129
+ out.providers.push(n)
130
+ }
131
+ out.changed = out.providers.length > 0
132
+ return out
133
+ }
134
+
135
+ module.exports = { applyBrainChange, auditRow, fillGroupPicks, stripCardDefaults }