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.
@@ -1,9 +1,9 @@
1
- // ABI v35 adapter for the private binary decision core. Manifest assembly still stays in JS.
1
+ // ABI v36 adapter for the private binary decision core. Manifest assembly still stays in JS.
2
2
  const fs = require('node:fs')
3
3
  const path = require('node:path')
4
4
  const { createHash } = require('node:crypto')
5
5
 
6
- const EXPECTED_SHA256 = 'a7ad2bd9210906ee6f5d7893ed3d9632769b6e2dbec285c1102492e3ba1257b5'
6
+ const EXPECTED_SHA256 = 'c79f12ceb1ef79aceb0ab54cf87052127325a1d8fdfd5c02e3509fef39f6f511'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
@@ -26,7 +26,7 @@ try {
26
26
  } catch (cause) {
27
27
  throw new Error('SintBot binary core cannot start on this runtime', { cause })
28
28
  }
29
- if (core.abi_version() !== 35) throw new Error('SintBot binary core ABI mismatch')
29
+ if (core.abi_version() !== 36) throw new Error('SintBot binary core ABI mismatch')
30
30
  const encoder = new TextEncoder()
31
31
  const decoder = new TextDecoder('utf-8', { fatal: true })
32
32
 
@@ -771,6 +771,67 @@ function manifestPolicy(permissionMode, isCodex, allowedTools, disallowedTools)
771
771
  return value
772
772
  }
773
773
 
774
+ // ABI 36 JSON decisions (sintbot-core docs/ABI36_CONTRACTS.md). Each wrapper only
775
+ // checks the envelope shape the host relies on; the core owns the rules and wording.
776
+ const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
777
+
778
+ function connectManifest(basic, userName, version) {
779
+ const value = coreJson('connect_manifest', { basic: !!basic, userName: String(userName || ''), version: String(version || '') }, 'build the Feishu connect manifest')
780
+ if (!isObject(value) || !isObject(value.appPreset) || !isObject(value.addons) ||
781
+ !Array.isArray(value.addons.scopes) || !Array.isArray(value.addons.events) ||
782
+ !Array.isArray(value.addons.callbacks) || typeof value.hint !== 'string') {
783
+ throw new Error('SintBot binary core returned an invalid connect manifest')
784
+ }
785
+ return value
786
+ }
787
+
788
+ function connectPlan(step, { basic = false, status = null, code = null, msg = '', observed = {} } = {}) {
789
+ const value = coreJson('connect_plan', { step, basic: !!basic, status, code, msg: String(msg || ''), observed: isObject(observed) ? observed : {} }, `decide the connect step ${step}`)
790
+ if (!isObject(value) || !['ok', 'stop', 'wait', 'warn'].includes(value.verdict) || typeof value.notice !== 'string' ||
791
+ !(value.next === null || typeof value.next === 'string') ||
792
+ !(value.retryAfterMs === null || Number.isSafeInteger(value.retryAfterMs))) {
793
+ throw new Error('SintBot binary core returned an invalid connect decision')
794
+ }
795
+ return value
796
+ }
797
+
798
+ function connectConfig(input) {
799
+ const value = coreJson('connect_config', input, 'build the Feishu connect configuration')
800
+ if (!isObject(value) || typeof value.ok !== 'boolean' ||
801
+ (value.ok && (!isObject(value.config) || typeof value.readme !== 'string')) ||
802
+ (!value.ok && typeof value.error !== 'string')) {
803
+ throw new Error('SintBot binary core returned an invalid connect configuration')
804
+ }
805
+ return value
806
+ }
807
+
808
+ function qrMatrix(text, ec = 'M') {
809
+ const value = coreJson('qr_matrix', { text: String(text), ec }, 'encode a QR code')
810
+ if (!isObject(value) || !Number.isInteger(value.size) || !Array.isArray(value.rows) ||
811
+ value.rows.length !== value.size || value.rows.some(row => typeof row !== 'string' || row.length !== value.size)) {
812
+ throw new Error('SintBot binary core returned an invalid QR matrix')
813
+ }
814
+ return value
815
+ }
816
+
817
+ function usageParse(provider, source, status, body, now = Date.now()) {
818
+ const value = coreJson('usage_parse', { provider, source, status, body, now }, `parse ${provider} ${source} usage`)
819
+ if (!isObject(value) || typeof value.ok !== 'boolean' ||
820
+ (value.ok && !Array.isArray(value.windows)) || (!value.ok && typeof value.error !== 'string')) {
821
+ throw new Error('SintBot binary core returned an invalid usage result')
822
+ }
823
+ return value
824
+ }
825
+
826
+ function envConflicts(input) {
827
+ const value = coreJson('env_conflicts', input, 'check environment conflicts')
828
+ if (!isObject(value) || !Array.isArray(value.warnings) ||
829
+ value.warnings.some(w => !isObject(w) || typeof w.key !== 'string' || typeof w.where !== 'string' || typeof w.reason !== 'string')) {
830
+ throw new Error('SintBot binary core returned an invalid environment check')
831
+ }
832
+ return value
833
+ }
834
+
774
835
  // A trap or exhausted stack can leave the instance's own stack pointer wrong, and
775
836
  // C1 calls hold no state, so the next call simply gets a fresh instance.
776
837
  function guarded(fn) {
@@ -798,7 +859,7 @@ function followupCall(name, value) {
798
859
  function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
799
860
  function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
800
861
 
801
- const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
862
+ const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError, connectManifest, connectPlan, connectConfig, qrMatrix, usageParse, envConflicts }
802
863
  for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
803
864
 
804
865
  module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
Binary file
@@ -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,
package/src/c2install.js CHANGED
@@ -323,6 +323,6 @@ function registerAddedGroups(installation) {
323
323
  return addedRoutes.map(route => route.chatId)
324
324
  }
325
325
 
326
- module.exports = { registerAddedGroups, loadInstallation, loadInstallationFromRaw, initializeInstallation, assertPrivateDirectory,
326
+ module.exports = { registerAddedGroups, loadInstallation, loadInstallationFromRaw, initializeInstallation, installationStateDir, assertPrivateDirectory,
327
327
  assertJournalScope, assertInstallationJournals, assertRunnerExecutable, SUPPORTED_RUNNERS, MANIFEST,
328
328
  VALIDATORS }
@@ -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() } : {}) }