sintbot 1.4.14 → 1.4.16

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.
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
- // Console display only. Official limits and active executor windows are
3
+ // Model metadata for console display and executor override validation.
4
+ // Official limits and active executor windows are
4
5
  // separate values; neither changes the server-side capacity of a model.
5
6
  const fs = require('node:fs')
6
7
  const os = require('node:os')
@@ -38,7 +39,7 @@ function codexCatalogWindows(home = process.env.CODEX_HOME || path.join(os.homed
38
39
  const percent = model.effective_context_window_percent
39
40
  if (typeof id !== 'string' || !Number.isSafeInteger(full) || full <= 0 ||
40
41
  (percent != null && (!Number.isSafeInteger(percent) || percent < 1 || percent > 100))) continue
41
- windows.set(id, { tokens: full, usableTokens: Math.floor(full * (percent || 100) / 100) })
42
+ windows.set(id, { maxTokens: Number.isSafeInteger(model.max_context_window) && model.max_context_window >= full ? model.max_context_window : full, tokens: full, usableTokens: Math.floor(full * (percent || 100) / 100) })
42
43
  }
43
44
  return windows
44
45
  }
@@ -49,7 +50,13 @@ function windowForProvider(provider, reported, codexWindows) {
49
50
  const gateway = provider.baseUrl || provider.env?.ANTHROPIC_BASE_URL
50
51
  const official = provider.runner === 'codex' ? OPENAI_API_WINDOWS.get(model)
51
52
  : gateway ? null : CLAUDE_WINDOWS.get(model)
52
- // The catalog's context_window is the supported maximum. Runtime reports
53
+ // The catalog window is a default, not proof that an explicit override is active.
54
+ // With an override, only a real runtime sample can describe the active window.
55
+ if (provider.runner === 'codex' && provider.contextWindowTokens > 0) return {
56
+ tokens: official || null, source: official ? 'official' : 'unknown',
57
+ ...(Number.isSafeInteger(reported) && reported > 0 ? { activeTokens: reported } : {}),
58
+ }
59
+ // Runtime reports
53
60
  // may instead contain its effective (reserved) portion.
54
61
  if (catalog && (!Number.isSafeInteger(reported) || reported <= 0 ||
55
62
  reported === catalog.tokens || reported === catalog.usableTokens)) return {
@@ -9,6 +9,11 @@ const codexApp = require('./codexapp')
9
9
  const { routeBackend } = require('./privatecore')
10
10
 
11
11
  function runTurn(opts) {
12
+ // One-off console trials use the same model settings as group turns.
13
+ // Explicit turn values (including zero) remain authoritative.
14
+ const compact = Number(opts.autocompactTokens ?? opts.provider?.autocompactTokens) || 0
15
+ opts = { ...opts, autocompactTokens: compact > 0
16
+ ? Math.min(1_000_000, Math.max(100_000, Math.floor(compact))) : 0 }
12
17
  const route = routeBackend(opts && opts.provider && opts.provider.runner, opts && opts.codexApp, opts && opts.codexSteer, opts && opts.codexProjectDocFallback)
13
18
  if (route !== 'claude') {
14
19
  const codexOpts = { ...opts, bin: (opts.provider && opts.provider.bin) || 'codex' }
@@ -8,20 +8,24 @@ const { loadInstallationFromRaw, assertJournalScope, assertRunnerExecutable,
8
8
  MANIFEST, VALIDATORS, SUPPORTED_RUNNERS } = require('./c2install')
9
9
  const { C2DurableJournal } = require('./core/c2durablejournal')
10
10
  const { assertNoLegacyGroupState } = require('./c2migration')
11
+ const { applyBrainChange } = require('./groupbrain')
11
12
 
12
13
  const GLOBAL_TASK_KEYS = ['agent', 'timezone', 'brand']
13
- const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
14
+ // A group's model and depth (providerPrefs, legacy model) are not here: the
15
+ // executors resume the same session with another model of the same backend.
16
+ const RESET_KEYS = ['provider', 'workdir', 'codexApp']
14
17
  const EFFORT_ENV = 'CLAUDE_CODE_EFFORT_LEVEL'
15
18
 
16
- // What a group's session belongs to: backend, executable, endpoint, model and
17
- // the environment the backend starts with. Effort, reasoning effort, tool
18
- // quality, key, image support, visibility and order apply from the next turn.
19
- function sessionIdentity(provider, group) {
19
+ // What a group's session belongs to: backend, executable, endpoint and the
20
+ // environment the backend starts with. Model, depth, tool quality, key, image
21
+ // support, visibility and order apply from the next turn in the same session:
22
+ // both executors resume a session with another model of the same backend.
23
+ function sessionIdentity(provider) {
20
24
  if (!provider || typeof provider !== 'object') return null
21
25
  const env = { ...(provider.env && typeof provider.env === 'object' ? provider.env : {}) }
22
26
  delete env[EFFORT_ENV]
23
- return { runner: provider.runner, bin: provider.bin || '', baseUrl: provider.baseUrl || '',
24
- model: group?.model || provider.model || '', env }
27
+ delete env.ANTHROPIC_MODEL
28
+ return { runner: provider.runner, bin: provider.bin || '', baseUrl: provider.baseUrl || '', env }
25
29
  }
26
30
 
27
31
  // Synchronous validation/application around one atomic configuration write.
@@ -134,8 +138,8 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
134
138
  // A model library or default change reaches every group that runs
135
139
  // the edited provider, even though its own settings stay the same.
136
140
  const providerMoved = !isDeepStrictEqual(
137
- sessionIdentity(cfg.providers?.[effective(route.chatId, route.group, cfg)], route.group),
138
- sessionIdentity(next.cfg.providers?.[effective(route.chatId, target.group, next.cfg)], target.group))
141
+ sessionIdentity(cfg.providers?.[effective(route.chatId, route.group, cfg)]),
142
+ sessionIdentity(next.cfg.providers?.[effective(route.chatId, target.group, next.cfg)]))
139
143
  if (heldGroups.has(route.chatId)) {
140
144
  // Edits elsewhere proceed; the held group keeps its exact settings.
141
145
  if (!isDeepStrictEqual(route.group, target.group)) {
@@ -272,18 +276,25 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
272
276
  return raw
273
277
  }
274
278
 
275
- function switchGroupProvider(chatId, name) {
276
- return writeConfig(raw => {
277
- const group = raw.groups?.[chatId]
278
- if (!group) throw new Error('Unknown group')
279
- if (!cfg.providers?.[name]) throw new Error('Unknown model')
280
- group.provider = name
281
- // A pinned model id belongs to the previous provider.
282
- delete group.model
279
+ // Backend · model · depth of one group (console, /model, model card). The
280
+ // writer validates, resets the session only when the backend changes, and
281
+ // goes live. Returns the change summary; throws on refusal.
282
+ function setGroupBrain(chatId, change = {}) {
283
+ let summary = null
284
+ writeConfig(raw => {
285
+ if (!raw.groups?.[chatId]) throw new Error('Unknown group')
286
+ const result = applyBrainChange(raw, chatId, change)
287
+ if (result.error) throw new Error({ 'unknown-provider': 'Unknown model',
288
+ 'bad-model': 'Invalid model id', 'bad-effort': 'Invalid thinking depth' }[result.error] || 'Unknown group')
289
+ summary = result
283
290
  })
291
+ return summary
292
+ }
293
+ function switchGroupProvider(chatId, name) {
294
+ return setGroupBrain(chatId, { provider: name })
284
295
  }
285
296
 
286
- return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider,
297
+ return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider, setGroupBrain,
287
298
  // Groups whose session the last applied write reset (for the console).
288
299
  appliedResets: () => [...appliedResets] }
289
300
  }
package/src/c2console.js CHANGED
@@ -13,10 +13,11 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
13
13
  '/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
14
14
  '/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
15
15
  '/api/feishu/chats', '/api/feishu/chat-members', '/api/update', '/api/claude/models',
16
+ '/api/groups/model-options',
16
17
  START_PATH, CALLBACK_PATH]
17
18
  const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
18
19
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
19
- '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
20
+ '/api/groups', '/api/groups/provider', '/api/groups/brain', '/api/fs/mkdir',
20
21
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
21
22
  '/api/groups/codex-steer', '/api/groups/steer', '/api/groups/pause', '/api/groups/stop',
22
23
  // A fresh session only for an idle, settled group (same rule as /new).
@@ -38,9 +39,18 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
38
39
  // manages this exact entry (the updater refuses every other run mode).
39
40
  '/api/update/check', '/api/update/start', '/api/update/ignore']
40
41
  const SUPPORTED_RUNNERS = ['claude', 'codex']
41
- const { modelContext } = require('./modelcontext')
42
+ const { groupBrain } = require('./core/brain')
43
+ const { codexCatalogWindows, windowForProvider } = require('./agent/modelwindows')
42
44
  const { GROUP_COMMANDS } = require('./runtimefacts')
43
45
 
46
+ // What a group runs on one backend, for console rows: configured values only;
47
+ // what the executor finally selected is reported by the run itself.
48
+ function brainFields(group, name, provider, cfg) {
49
+ const b = groupBrain(group, name, provider, { defaultProvider: cfg.defaultProvider })
50
+ return { model: b.model, reasoningEffort: b.effort, groupModel: b.groupModel, groupEffort: b.groupEffort,
51
+ source: b.groupModel ? 'group' : b.defaultModel ? 'provider' : 'default', actualModel: null }
52
+ }
53
+
44
54
  function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
45
55
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
46
56
  appliedResets = () => [], now = Date.now,
@@ -222,8 +232,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
222
232
  ? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
223
233
  return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
224
234
  workdir: group.workdir || '', runner: provider.runner || 'claude',
225
- ...modelContext(group, provider),
226
- reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
235
+ ...brainFields(group, providerName, provider, cfg),
227
236
  paused: !!group.paused, ...live.groups[chatId], context }
228
237
  })
229
238
  const rows = getActivity().filter(row => (!query.chatId || row.chatId === query.chatId) && (!query.status || row.status === query.status))
@@ -249,8 +258,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
249
258
  for (const group of state.groups || []) {
250
259
  const cfg = getConfig(), source = cfg.groups?.[group.chatId] || {}
251
260
  const provider = cfg.providers?.[group.effectiveProvider || source.provider || cfg.defaultProvider]
252
- const model = modelContext(source, provider)
253
- Object.assign(group, { configuredModel: model.model, modelSource: model.source, actualModel: model.actualModel })
261
+ const name = group.effectiveProvider || source.provider || cfg.defaultProvider
262
+ const fields = brainFields(source, name, provider, cfg)
263
+ Object.assign(group, { configuredModel: fields.model, modelSource: fields.source,
264
+ actualModel: null, brain: { provider: name, model: fields.model, effort: fields.reasoningEffort,
265
+ groupModel: fields.groupModel, groupEffort: fields.groupEffort },
266
+ providerPrefs: source.providerPrefs || null,
267
+ contextWindow: provider ? windowForProvider({ ...provider, model: fields.model }, null, codexCatalogWindows()) : null })
254
268
  }
255
269
  state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
256
270
  state.leftovers = { supported: false, rows: [], error: null }
@@ -290,6 +304,8 @@ function createC2Console(options = {}) {
290
304
  throw new TypeError('C2 console requires explicit installation paths')
291
305
  }
292
306
  const facade = createC2ConsoleFacade(options)
307
+ // The process-wide model list (also used by /model), when the service has one.
308
+ facade.modelCatalog = typeof options.modelCatalog === 'function' ? options.modelCatalog : null
293
309
  const ui = createUiServer({ configPath, host, log, getConnHealth, updater: options.updater || null,
294
310
  onHostChange: options.onHostChange,
295
311
  getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
@@ -39,7 +39,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
39
39
  createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
40
40
  quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
41
41
  createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
42
- ingressWaitMs = 60_000, now = Date.now, log = () => {} } = {}) {
42
+ ingressWaitMs = 60_000, onModelCard = null, now = Date.now, log = () => {} } = {}) {
43
43
  if (!Array.isArray(routes) || !routes.length ||
44
44
  new Set(routes.map(route => route.chatId)).size !== routes.length ||
45
45
  routes.some(route => !route || typeof route.chatId !== 'string' ||
@@ -392,6 +392,26 @@ function createC2MultiGroupService({ routes, assertWriterLock,
392
392
  throw error
393
393
  }
394
394
  }
395
+ // Taps on the /model card only. They write the configuration through the
396
+ // same validated writer as the console and never touch a journal, receipt
397
+ // or session; every other card callback stays unsupported. The return
398
+ // value is the platform's synchronous answer (toast and replacement card).
399
+ function cardAction(event) {
400
+ let value = event?.action?.value
401
+ if (typeof value === 'string') { try { value = JSON.parse(value) } catch { value = null } }
402
+ if (typeof onModelCard !== 'function' || !value || value.a !== 'mc' || !allowed(event?.chatId)) {
403
+ return unsupported('card action')(event)
404
+ }
405
+ if (maintenance) return { toast: { type: 'info', content: '正在升级,稍后再点' } }
406
+ if (suspended.has(event.chatId)) return { toast: { type: 'warning', content: '本群暂停处理中,请管理员处理后再改' } }
407
+ const binding = bindings.get(event.chatId)
408
+ if (!binding) return { ignored: true }
409
+ return Promise.resolve().then(() => onModelCard({ ...event, action: { ...event.action, value } }, binding))
410
+ .catch(error => {
411
+ log(`model card ${event.chatId}: ${error?.message || String(error)}`)
412
+ return { toast: { type: 'error', content: '没改成 —— 请稍后再试' } }
413
+ })
414
+ }
395
415
  function unsupported(source) {
396
416
  return event => {
397
417
  // An event without group identity cannot safely be attributed. It has
@@ -507,7 +527,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
507
527
  transportStarted = true
508
528
  await transport.platform.start(routeMessage, { onFatal: fatal,
509
529
  onRecall: recall, onReaction: unsupported('reaction'),
510
- onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
530
+ onCardAction: cardAction, onBoardUpdated: unsupported('board update') })
511
531
  held()
512
532
  if (stopPromise) throw new Error('C2 service stopped during connection')
513
533
  return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
package/src/config.js CHANGED
@@ -25,14 +25,9 @@ const DEFAULTS = {
25
25
  // runs fully locked down (dontAsk ⇒ read-only safe set only), which is fail-closed.
26
26
  // maxTurnTimeMins defaults to 30: an uncapped runaway turn would
27
27
  // hold a concurrency slot and the group lock forever. 0 = explicitly off.
28
- // autocompactTokens:传给 Claude --autocompact 的是压缩窗口,传给 Codex
29
- // model_auto_compact_token_limit 的是请求阈值;两者均可能早于设置值触发。
30
- // 多天连跑的群会话历史只增不减(2026-08-09 实测 4 天涨到
31
- // 96 万 token 且两家 CLI 默认都没触发任何压缩),不设上限 = 每轮驮全量历史 + 闲置后整段重写缓存。
32
- // 0 = 不指定 SintBot 阈值,交还 CLI 默认压缩行为。
33
28
  // ackDelayMs:收到真人消息后迟迟没动静,先回一声「已收到」的等待毫秒数(0 = 不发;
34
29
  // engine 侧另有 == null ? 25s 兜底,直构 Engine 不经本层合并也不失默认)。
35
- agent: { bin: 'claude', permissionMode: 'dontAsk', maxTurnTimeMins: 30, maxConcurrent: 2, autocompactTokens: 400_000, ackDelayMs: 25_000 },
30
+ agent: { bin: 'claude', permissionMode: 'dontAsk', maxTurnTimeMins: 30, maxConcurrent: 2, ackDelayMs: 25_000 },
36
31
  // 时间口径(IANA 时区名):群内一切时间表述/消息时间戳/spawn 出去的 TZ 都按它,
37
32
  // 默认北京时间 —— 主机系统时区不参与(海外主机部署照样说北京时间)。
38
33
  timezone: DEFAULT_TZ,
@@ -87,6 +82,21 @@ function loadConfig(explicit) {
87
82
  return normalizeConfig(user, p)
88
83
  }
89
84
 
85
+ // Collapse an explicit legacy global compression value into model settings at
86
+ // the configuration boundary. Read-only until an authorized config write;
87
+ // preserve per-model overrides, secret references, and every unrelated field.
88
+ function migrateModelSettings(raw) {
89
+ if (!raw?.agent || !Object.hasOwn(raw.agent, 'autocompactTokens')) return raw
90
+ const n = Number(raw.agent.autocompactTokens) || 0
91
+ const compact = n > 0 ? Math.min(1_000_000, Math.max(100_000, Math.floor(n))) : 0
92
+ const agent = { ...raw.agent }
93
+ delete agent.autocompactTokens
94
+ const providers = Object.fromEntries(Object.entries({ ...DEFAULTS.providers, ...(raw.providers || {}) })
95
+ .map(([name, provider]) => [name, provider && typeof provider === 'object'
96
+ ? { ...provider, autocompactTokens: provider.autocompactTokens ?? compact } : provider]))
97
+ return { ...raw, agent, providers }
98
+ }
99
+
90
100
  // The console validates proposed JSON before replacing the live file. Keep
91
101
  // normalization identical to disk loading without mutating the proposed object
92
102
  // or changing the live redaction registry during validation.
@@ -94,7 +104,7 @@ function normalizeConfig(raw, p = null, { registerSecrets = true } = {}) {
94
104
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
95
105
  throw new TypeError('Configuration must be a JSON object')
96
106
  }
97
- const user = JSON.parse(JSON.stringify(raw))
107
+ const user = migrateModelSettings(JSON.parse(JSON.stringify(raw)))
98
108
  const cfg = {
99
109
  agent: { ...DEFAULTS.agent, ...(user.agent || {}) },
100
110
  timezone: normalizeTz(user.timezone || DEFAULTS.timezone), // 写错回退默认,不漂到主机时区
@@ -293,4 +303,4 @@ function chatPlatform(cfg, chatId) {
293
303
  return live.length === 1 ? live[0] : null
294
304
  }
295
305
 
296
- module.exports = { loadConfig, normalizeConfig, findConfigPath, defaultConfigPath, DEFAULTS, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, inferChatPlatform, normalizeGroupPlatforms, chatPlatform, PLATFORMS }
306
+ module.exports = { loadConfig, normalizeConfig, migrateModelSettings, findConfigPath, defaultConfigPath, DEFAULTS, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, inferChatPlatform, normalizeGroupPlatforms, chatPlatform, PLATFORMS }
@@ -0,0 +1,117 @@
1
+ 'use strict'
2
+ // Which model and thinking depth a group uses on one model card (backend).
3
+ // This is the single resolver: runner input, card footer, /help · /model ·
4
+ // /status, the agent manifest, the console and the activity rows all call it.
5
+ //
6
+ // Storage: groups[chatId].providerPrefs = { "<card name>": { model?, effort? } },
7
+ // kept per backend. A group that moves between backends keeps each backend's
8
+ // own choice: a model id from one backend is never passed to another, and
9
+ // switching back does not silently fall back to something else.
10
+ // A missing entry means "not chosen": the executor picks its own default.
11
+ // Legacy groups[chatId].model belongs only to the backend the group was on
12
+ // when it was written (group.provider, else defaultProvider).
13
+ //
14
+ // Only shapes are validated, never an allow list: model ids and depth names
15
+ // evolve upstream, and values travel as argv/env entries without a shell.
16
+ // A bad shape is treated as unset instead of failing the turn.
17
+
18
+ const MODEL_RE = /^[\w.\-[\]:/]{1,80}$/
19
+ const EFFORT_RE = /^[a-z]{2,16}$/
20
+
21
+ const isModel = (s) => typeof s === 'string' && MODEL_RE.test(s)
22
+ const isEffort = (s) => typeof s === 'string' && EFFORT_RE.test(s)
23
+
24
+ // Legacy card-level values. Subscription cards no longer carry them after the
25
+ // one-time migration; a third-party endpoint keeps its model as connection
26
+ // configuration (it has no model list to choose from).
27
+ function defaultModel(provider) {
28
+ const p = provider || {}
29
+ return p.model || (p.runner !== 'codex' && p.env && p.env.ANTHROPIC_MODEL) || ''
30
+ }
31
+
32
+ function defaultEffort(provider) {
33
+ const p = provider || {}
34
+ return (p.runner === 'codex' ? p.reasoningEffort : p.env && p.env.CLAUDE_CODE_EFFORT_LEVEL) || ''
35
+ }
36
+
37
+ // One entry → valid fields only; nothing valid ⇒ null.
38
+ function cleanPref(x) {
39
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return null
40
+ const o = {}
41
+ if (isModel(x.model)) o.model = x.model
42
+ if (isEffort(x.effort)) o.effort = x.effort
43
+ return Object.keys(o).length ? o : null
44
+ }
45
+
46
+ // Whole table: drop bad shapes, empty entries and (when known is given)
47
+ // entries for cards that do not exist. An empty table becomes undefined.
48
+ function normalizePrefs(prefs, known) {
49
+ if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return undefined
50
+ const out = {}
51
+ for (const [name, v] of Object.entries(prefs)) {
52
+ if (!/^[\w.-]+$/.test(name)) continue
53
+ if (known && !Object.prototype.hasOwnProperty.call(known, name)) continue
54
+ const c = cleanPref(v)
55
+ if (c) out[name] = c
56
+ }
57
+ return Object.keys(out).length ? out : undefined
58
+ }
59
+
60
+ /**
61
+ * @param group groups[chatId] (may be empty)
62
+ * @param name the card actually running this turn
63
+ * @param provider cfg.providers[name]
64
+ * @param opts.defaultProvider top-level defaultProvider, only used to decide
65
+ * which backend a legacy group.model belongs to
66
+ * @returns {{ model, effort, groupModel, groupEffort, defaultModel, defaultEffort }}
67
+ * model / effort are effective values ('' = executor default);
68
+ * groupX are the group's own choices ('' = not chosen).
69
+ */
70
+ function groupBrain(group, name, provider, { defaultProvider = '' } = {}) {
71
+ const g = group || {}
72
+ const pref = cleanPref(g.providerPrefs && typeof g.providerPrefs === 'object' ? g.providerPrefs[name] : null) || {}
73
+ const home = g.provider || defaultProvider || ''
74
+ const legacy = (!pref.model && isModel(g.model) && (!home || home === name)) ? g.model : ''
75
+ const groupModel = pref.model || legacy
76
+ const groupEffort = pref.effort || ''
77
+ const dm = defaultModel(provider)
78
+ const de = defaultEffort(provider)
79
+ return { model: groupModel || dm, effort: groupEffort || de, groupModel, groupEffort, defaultModel: dm, defaultEffort: de }
80
+ }
81
+
82
+ // Move legacy group.model under the backend it belonged to (mutates the raw
83
+ // group). Every writer that changes group.provider calls this first, so the
84
+ // legacy id never attaches itself to the new backend. Returns whether it changed.
85
+ function migrateLegacyModel(g, defaultProvider) {
86
+ if (!g || typeof g !== 'object' || g.model === undefined) return false
87
+ const home = g.provider || defaultProvider || ''
88
+ if (home && isModel(g.model)) {
89
+ const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
90
+ const cur = cleanPref(prefs[home]) || {}
91
+ if (!cur.model) prefs[home] = { ...cur, model: g.model }
92
+ g.providerPrefs = prefs
93
+ }
94
+ delete g.model
95
+ return true
96
+ }
97
+
98
+ // Active groups whose current backend lacks a choice (doctor and console use
99
+ // the same rule). A subscription card without a model and any card without a
100
+ // depth are listed. Paused groups are skipped.
101
+ // Returns [{ chatId, name, provider, lack: ['model' | 'effort'] }].
102
+ function missingPicks(cfg) {
103
+ const out = []
104
+ const provs = (cfg && cfg.providers) || {}
105
+ for (const [id, g] of Object.entries((cfg && cfg.groups) || {})) {
106
+ if (!g || typeof g !== 'object' || id === 'oc_example_chat_id' || g.paused === true) continue
107
+ const pn = g.provider || cfg.defaultProvider
108
+ const pv = provs[pn]
109
+ if (!pv) continue
110
+ const b = groupBrain(g, pn, pv, { defaultProvider: cfg.defaultProvider })
111
+ const lack = [!b.model && !pv.baseUrl ? 'model' : '', !b.effort ? 'effort' : ''].filter(Boolean)
112
+ if (lack.length) out.push({ chatId: id, name: g.name || '', provider: pn, lack })
113
+ }
114
+ return out
115
+ }
116
+
117
+ module.exports = { groupBrain, normalizePrefs, cleanPref, migrateLegacyModel, missingPicks, defaultModel, defaultEffort, isModel, isEffort, MODEL_RE, EFFORT_RE }
@@ -7,16 +7,20 @@
7
7
  const CODE = 'C2_V2_INPUT_REJECTED'
8
8
  const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
9
9
 
10
- function rejectC2Input(message, { reason, notice = null } = {}) {
10
+ function rejectC2Input(message, { reason, notice = null, card = null } = {}) {
11
11
  if (typeof message !== 'string' || !message ||
12
12
  typeof reason !== 'string' || !/^[a-z][a-z-]{0,40}$/.test(reason) ||
13
- notice !== null && (typeof notice !== 'string' || !notice)) {
13
+ notice !== null && (typeof notice !== 'string' || !notice) ||
14
+ card !== null && (typeof card !== 'object' || Array.isArray(card))) {
14
15
  throw new TypeError('C2 input rejection needs a message, reason and optional notice')
15
16
  }
16
17
  const error = new Error(message)
17
18
  error.code = CODE
18
19
  error.reason = reason
19
20
  error.notice = notice
21
+ // An optional interactive card (the /model card) sent instead of the text;
22
+ // the text stays the fallback when the card is not confirmed.
23
+ if (card) error.card = card
20
24
  return error
21
25
  }
22
26
 
@@ -33,10 +37,19 @@ async function answerC2InputRejection({ error, msg, platform, log = () => {} } =
33
37
  if (error.notice && typeof platform?.replyOnce === 'function' &&
34
38
  typeof msg?.chatId === 'string' && target?.chatId === msg.chatId &&
35
39
  FEISHU_MESSAGE_ID.test(target?.messageId || '')) {
36
- try {
37
- const response = await platform.replyOnce(target, error.notice)
38
- notified = response?.code === 0
39
- } catch {}
40
+ if (error.card && typeof platform.replyCardOnce === 'function') {
41
+ try {
42
+ const response = await platform.replyCardOnce(target, error.card)
43
+ notified = response?.code === 0
44
+ } catch {}
45
+ if (!notified) log(`input rejection card not confirmed (${error.reason}); sending text`)
46
+ }
47
+ if (!notified) {
48
+ try {
49
+ const response = await platform.replyOnce(target, error.notice)
50
+ notified = response?.code === 0
51
+ } catch {}
52
+ }
40
53
  if (!notified) log(`input rejection notice not confirmed (${error.reason})`)
41
54
  }
42
55
  return Object.freeze({ rejected: true, reason: error.reason, notified })
@@ -6,7 +6,7 @@ const { followupEnabled } = require('../agent/privatecore')
6
6
  // process-local arguments. It neither starts a child nor persists text,
7
7
  // credentials, callback handles, or environment values.
8
8
  function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider,
9
- config, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
9
+ config, brain = null, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
10
10
  onEvent = () => {}, onSteerReady, onSteerLanded, log = () => {} } = {}) {
11
11
  if (typeof prompt !== 'string' || !prompt ||
12
12
  typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
@@ -14,6 +14,8 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
14
14
  !group || typeof group.workdir !== 'string' || !group.workdir ||
15
15
  !provider || !['claude', 'codex'].includes(provider.runner) ||
16
16
  !config || !config.agent || typeof config.agent !== 'object' ||
17
+ (brain !== null && (typeof brain !== 'object' || typeof brain.model !== 'string' ||
18
+ typeof brain.effort !== 'string')) ||
17
19
  (sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
18
20
  !signal || typeof signal.aborted !== 'boolean' ||
19
21
  !extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
@@ -34,9 +36,9 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
34
36
  typeof onSteerLanded !== 'function'))) {
35
37
  throw new TypeError('C2 follow-up needs durable control callbacks')
36
38
  }
37
- // A model card can override the old global setting. Missing means legacy
38
- // configs keep their previous behavior; explicit zero delegates to the CLI.
39
- const rawCompact = Number(provider.autocompactTokens ?? config.agent.autocompactTokens) || 0
39
+ // Model settings are resolved once at the configuration boundary.
40
+ // Missing or zero leaves compaction to the executor.
41
+ const rawCompact = Number(provider.autocompactTokens) || 0
40
42
  const autocompactTokens = rawCompact > 0
41
43
  ? Math.min(1_000_000, Math.max(100_000, Math.floor(rawCompact))) : 0
42
44
  return {
@@ -47,7 +49,10 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
47
49
  steer: !isCodex && enabled,
48
50
  codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
49
51
  onSteerReady, onSteerLanded,
50
- model: group.model,
52
+ // The group's model and depth on this backend (core/brain.js); empty
53
+ // leaves the choice to the executor.
54
+ model: brain ? brain.model || undefined : group.model,
55
+ effort: brain ? brain.effort : '',
51
56
  workdir: group.workdir,
52
57
  sessionId,
53
58
  permissionMode: group.permissionMode || 'dontAsk',
@@ -0,0 +1,132 @@
1
+ 'use strict'
2
+ // 群命令 /model 的词法。纯函数 —— core 不认 runner 名:哪家有什么型号 / 深度,由 bin 注入的
3
+ // modelCatalog 给出形状 { ok, models: [{ id, efforts? }] | null, aliases: [], efforts: [] }(models 为 null = 这家
4
+ // 没有可选清单,型号只能在后台改)。
5
+ //
6
+ // 写法:`/model` 后面的每个词各自**精确**匹配一类(大小写不敏感,顺序随意,每类最多一个):
7
+ // 后端(模型卡名)· 深度(档位名)· 型号(目标后端清单里的 id 或别名)。
8
+ // 没有「默认」可回(模型卡上不再有默认型号 / 深度,⇒「默认 / default」不再是重置词,当认不出的词整条拒绝。
9
+ // ⚠ **不做模糊匹配**(口径):认不出一个词就整条拒绝、一项都不改 —— 换错模型 = 换了花的钱,
10
+ // 宁可让人重发一条也不猜;此前「只认第一个词、后面的悄悄扔掉」正是要修的坑(`/model claude sonnet` 丢了 sonnet)。
11
+ // 型号可以照显示名打:`/model opus-5.5` · `/model Opus 5.5` · `/model claude-opus-5-5` · `/model GPT-6.1 Terra` 是对应的同一个 ——
12
+ // 比对键见 core/modelname.js modelKey(大小写、claude- 前缀、点 / 连字符 / 空格不算),仍是拿清单**逐个精确比对**;
13
+ // 两个清单项归一后撞了键 ⇒ 当认不出(不替人挑)。「Opus 5.5」被空格拆成两个词:一个词后面紧跟纯版本号(5.5 / 4.8)
14
+ // 或变体((1M) / [1m])就并回一个词。
15
+
16
+ const { modelKey } = require('./modelname')
17
+
18
+ // 识别为「深度」这一类的词(能不能用再按目标后端 / 型号查)。Claude Code 的五档 + Codex 服务端认的两档低档。
19
+ const EFFORT_WORDS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
20
+ const VERSION_RE = /^\d+(?:[.-]\d+)*$/
21
+ const VARIANT_RE = /^[[(][a-z0-9]+[\])]$/i
22
+
23
+ // 「Opus 5.5 (1M)」「GPT-6.1 Terra」这类被空格拆开的显示名并回一个词:字母结尾 + 纯版本号 · 数字结尾 + 变体 ·
24
+ // 数字结尾 + 纯字母词(Codex 官方名称「GPT-6.1 Terra」照着打被拆成两个词、整条拒绝)。
25
+ // 只在型号类的词之间并:后端名 / 深度词不并、也不被并(`/model gpt-6-luna low` 的 low 仍是深度)。
26
+ function joinVersionWords(words, isOther) {
27
+ const out = []
28
+ for (const raw of words || []) {
29
+ const w = String(raw || '').trim()
30
+ if (!w) continue
31
+ const prev = out[out.length - 1]
32
+ const glue = prev !== undefined && !isOther(prev) && !isOther(w) && (
33
+ VERSION_RE.test(w) ? /[a-z]$/i.test(prev)
34
+ : VARIANT_RE.test(w) ? /\d$/.test(prev)
35
+ : /^[a-z]+$/i.test(w) && /\d$/.test(prev))
36
+ if (glue) out[out.length - 1] = prev + ' ' + w
37
+ else out.push(w)
38
+ }
39
+ return out
40
+ }
41
+
42
+ /**
43
+ * @param words 命令后面的词
44
+ * @param providerNames 群里可选的模型卡名(没隐藏的)
45
+ * @returns {{ provider?, model?, models?, effort? } | { error: { kind: 'dup', what, a, b } }}
46
+ * model 原样保留大小写(查清单时再归一成清单里的写法);型号类写了不止一个 ⇒ models(调用方先逐个认,再判重)
47
+ */
48
+ function parseModelWords(words, providerNames = []) {
49
+ const names = new Map(providerNames.map((n) => [String(n).toLowerCase(), n]))
50
+ const out = {}
51
+ const isOther = (w) => { const lw = w.toLowerCase(); return names.has(lw) || EFFORT_WORDS.includes(lw) }
52
+ for (const w of joinVersionWords(words, isOther)) {
53
+ const lw = w.toLowerCase()
54
+ if (names.has(lw)) {
55
+ const n = names.get(lw)
56
+ if (out.provider && out.provider !== n) return { error: { kind: 'dup', what: '后端', a: out.provider, b: n } }
57
+ out.provider = n
58
+ } else if (EFFORT_WORDS.includes(lw)) {
59
+ if (out.effort && out.effort !== lw) return { error: { kind: 'dup', what: '深度', a: out.effort, b: lw } }
60
+ out.effort = lw
61
+ } else {
62
+ // 型号这一类先都收下,由调用方按清单逐个认:两个词里有认不出的 ⇒ 报「认不出」(多半是打错 / 隐藏的后端名),
63
+ // 都认得出才报「一次只能选一个型号」—— 否则 `/model claude sonnet`(claude 被隐藏)会得到一句莫名其妙的「写了两个型号」
64
+ const ws = out.models || (out.models = [])
65
+ if (!ws.some((x) => x.toLowerCase() === lw)) ws.push(w)
66
+ }
67
+ }
68
+ if (out.models && out.models.length === 1) { out.model = out.models[0]; delete out.models }
69
+ return out
70
+ }
71
+
72
+ // 词 → 清单里的规范写法(id 或别名);认不出 ⇒ null。先原样比(大小写不敏感),再按比对键比 —— 键撞了不止一项 ⇒ null(不替人挑)。
73
+ // 实时清单带 aliasOf(opus → claude-opus-5-5)⇒ 别名认成它此刻指向的**具体型号**存下(钉住,CLI 升级不悄悄换代);
74
+ // 官方显示名(「GPT-6.1 Terra」)也参与比对键 —— 照卡上 / 控制台看到的名字打就认。
75
+ function resolveModel(word, cat) {
76
+ if (!word || !cat) return null
77
+ const lw = String(word).toLowerCase()
78
+ const models = (cat.models || []).map((m) => ({ id: String(m.id), name: m.name ? String(m.name) : '' }))
79
+ const ids = [...models.map((m) => m.id), ...(cat.aliases || []).map(String)]
80
+ const hit = ids.find((x) => x.toLowerCase() === lw)
81
+ if (hit) return hit
82
+ const aliasOf = cat.aliasOf && typeof cat.aliasOf === 'object' ? cat.aliasOf : {} // 别名走下面的比对键(大小写不敏感,与原样比等价)
83
+ const k = modelKey(word)
84
+ if (!k) return null
85
+ const keyed = new Set(ids.filter((x) => modelKey(x) === k))
86
+ for (const m of models) if (m.name && modelKey(m.name) === k) keyed.add(m.id)
87
+ for (const a of Object.keys(aliasOf)) if (modelKey(a) === k && ids.includes(aliasOf[a])) keyed.add(aliasOf[a])
88
+ return keyed.size === 1 ? [...keyed][0] : null
89
+ }
90
+
91
+ // 这家(某型号下)能选的深度:清单里该型号自带档位就用它的,否则用这家的通用档位
92
+ function effortsFor(cat, modelId) {
93
+ const m = cat && modelId ? (cat.models || []).find((x) => x.id === modelId) : null
94
+ return (m && Array.isArray(m.efforts) && m.efforts.length) ? m.efforts : ((cat && cat.efforts) || [])
95
+ }
96
+
97
+ // 群里列给人看的型号:别名在前(短、好打),再列完整 id
98
+ function modelChoices(cat) {
99
+ if (!cat || !Array.isArray(cat.models)) return []
100
+ return [...(cat.aliases || []), ...cat.models.map((m) => m.id)]
101
+ }
102
+
103
+ // 某型号的推荐深度:清单里它自带默认档(Codex defaultReasoningEffort)就用它的;否则这家的推荐档
104
+ // (cat.recommended.effort,Claude 系 xhigh);都不在这型号可选的档里 ⇒ high ⇒ 第一档。没有清单 ⇒ ''。
105
+ function recommendEffort(cat, modelId) {
106
+ if (!cat) return ''
107
+ const list = effortsFor(cat, modelId)
108
+ const m = modelId && Array.isArray(cat.models) ? cat.models.find((x) => x.id === modelId) : null
109
+ for (const e of [m && m.defEffort, cat.recommended && cat.recommended.effort, 'high']) if (e && list.includes(e)) return e
110
+ return list[0] || ''
111
+ }
112
+
113
+ // 补齐本群在目标后端上缺的选择(;群里 /model、模型卡、控制台群卡同一份)。没有「默认」之后,缺项不再静静跟随模型卡:
114
+ // 换后端且那一家**实际生效的**型号是空的(没选过、也没有迁移前遗留的卡上默认;第三方除外)⇒ 推荐型号;
115
+ // 换后端 / 换型号时实际生效的深度是空的、或新型号没有这一档 ⇒ 这型号的推荐档。
116
+ // 只调深度不补型号(补了就换脑、开新对话,与「只调深度接着聊」冲突)。返回 { model, effort, filled: ['型号','深度'] }。
117
+ function fillPicks({ switching = false, model, effort, currentModel = '', currentEffort = '', third = false, cat = null } = {}) {
118
+ const filled = []
119
+ const rec = cat && cat.ok !== false && cat.recommended ? cat.recommended : null
120
+ if (switching && model === undefined && !currentModel && !third && rec && rec.model) { model = rec.model; filled.push('型号') }
121
+ const next = model === undefined ? currentModel : model
122
+ if (effort === undefined && cat && cat.ok !== false && (switching || model !== undefined)) {
123
+ const effs = effortsFor(cat, next)
124
+ if (!currentEffort || (effs.length && !effs.includes(currentEffort))) {
125
+ const e = recommendEffort(cat, next)
126
+ if (e && e !== currentEffort) { effort = e; if (!currentEffort) filled.push('深度') }
127
+ }
128
+ }
129
+ return { model, effort, filled }
130
+ }
131
+
132
+ module.exports = { parseModelWords, resolveModel, effortsFor, modelChoices, recommendEffort, fillPicks, EFFORT_WORDS }