sintbot 1.4.7 → 1.4.9

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.
@@ -6,6 +6,9 @@
6
6
  // receive only the v2 decision and stable effect/receipt identities.
7
7
  const { C2DurableRuntime } = require('./c2durableruntime')
8
8
  const { C2PayloadHandles } = require('./c2payloadhandles')
9
+ const { engineTerminalStep } = require('../agent/privatecore')
10
+
11
+ const FAILURE_NOTICE = '本次任务执行失败。若已执行过工具,请先核对实际结果,再决定是否重试。'
9
12
 
10
13
  const RECEIPT_ID = /^[0-9a-f]{32}$/
11
14
  const GENERATION = /^[0-9a-f]{32}$/
@@ -23,6 +26,14 @@ function terminalReplyText(payload) {
23
26
  typeof payload.answerText !== 'string') {
24
27
  throw new TypeError('C2 terminal reply text fields invalid')
25
28
  }
29
+ // The core classifies the result; the host supplies a fixed display string.
30
+ // Neither a provider error nor unfinished assistant text is a final answer.
31
+ if (payload.source === 'result' && payload.isError === true) {
32
+ const event = { ...payload, type: 'finalize' }
33
+ delete event.receiptId; delete event.turnToken; delete event.quiet
34
+ if (engineTerminalStep({ finalized: false, outcome: null }, event)
35
+ .state.outcome.status === 'error') return FAILURE_NOTICE
36
+ }
26
37
  return (payload.answerText.trim() ? payload.answerText : payload.bufferText) ||
27
38
  payload.resultText
28
39
  }
@@ -106,7 +117,7 @@ function prepareTerminalEffectBridge({ runtime, payloadHandles, receiptId, chatI
106
117
  }
107
118
  }
108
119
  const effect = { effectId: final.effectId, receiptId, chatId,
109
- turnToken, generation, verifyGeneration }
120
+ turnToken, generation, verifyGeneration, assertWriterLock }
110
121
  if (final.type === 'quietCompletion') {
111
122
  if (replyText !== undefined || resolveTarget !== undefined ||
112
123
  sendReply !== undefined || confirmReply !== undefined) {
@@ -2,11 +2,13 @@
2
2
  // this class owns process-local payloads, runner handles and platform I/O.
3
3
  // No legacy Engine, retry/failover scheduler or v1 reducer is loaded here.
4
4
  'use strict'
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
5
7
  const { randomBytes } = require('node:crypto')
6
8
  const { isDeepStrictEqual } = require('node:util')
7
9
  const { redact } = require('./redact')
8
10
  const { fmtStamp, normalizeTz } = require('./time')
9
- const { settleUsage, manifestPolicy, manifestHeader, manifestContext,
11
+ const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
10
12
  systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
11
13
  const { taskId } = require('./enginecheckpoint')
12
14
  const { prepareCardTerminalPath } = require('./c2cardterminalpath')
@@ -37,6 +39,31 @@ function humanDuration(ms) {
37
39
  if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
38
40
  return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
39
41
  }
42
+ // The final card is an accounting line: keep seconds for short turns and
43
+ // switch to minutes/hours before a long turn becomes an unreadable number.
44
+ function cardDuration(ms) {
45
+ const seconds = Math.max(0, Math.round(ms / 1000))
46
+ if (seconds < 60) return `${seconds} 秒`
47
+ const minutes = Math.floor(seconds / 60)
48
+ if (minutes < 60) return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`
49
+ const hours = Math.floor(minutes / 60)
50
+ return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
51
+ }
52
+ function cardModelInfo(group, provider) {
53
+ if (!provider) return { model: '', effort: '' }
54
+ const model = group?.model || provider.model ||
55
+ (provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
56
+ const effort = provider.runner === 'codex'
57
+ ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
58
+ const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
59
+ return { model: clean(model), effort: clean(effort) }
60
+ }
61
+ function cardModelDetail(info, result) {
62
+ // A rejected configured effort may have been retried at the model default.
63
+ const fellBack = result?.effortFellBack || result?.result?.effortFellBack
64
+ return [info?.model, fellBack ? '' : info?.effort]
65
+ .filter(Boolean).join(' ')
66
+ }
40
67
  // Group texts name the project by its folder only, never by a machine path.
41
68
  function projectName(group) {
42
69
  return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
@@ -102,9 +129,11 @@ const T = {
102
129
  skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
103
130
  quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
104
131
  cardFooter: ({ ms, tokens }) => {
105
- const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
132
+ const parts = [`耗时 ${cardDuration(ms)}`]
106
133
  const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
107
- if (tokens) parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
134
+ if (tokens && ((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0) + (tokens.out || 0)) > 0) {
135
+ parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
136
+ }
108
137
  return parts.join(' · ')
109
138
  },
110
139
  }
@@ -134,6 +163,20 @@ function supportedV2QuotaFailure(payload, runnerResult) {
134
163
  outcome.sideEffects === false
135
164
  }
136
165
 
166
+ // Admit only an observed, closed result on this release's no-retry route.
167
+ // Error classification remains in the core; close/session/effect authority is
168
+ // checked by the terminal path before any delivery can be attempted.
169
+ function supportedV2ClosedFailure(payload, runnerResult) {
170
+ if (!payload || payload.source !== 'result' || payload.aborted ||
171
+ payload.sessionMissing || payload.transientNetwork || payload.hasQuota ||
172
+ payload.freshRetried || payload.resumedSessionCurrent ||
173
+ runnerResult?.aborted !== false) return false
174
+ const terminal = { ...payload, type: 'finalize' }
175
+ delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
176
+ return engineTerminalStep({ finalized: false, outcome: null }, terminal)
177
+ .state.outcome.status === 'error'
178
+ }
179
+
137
180
  function ordinaryV2Cohort(snapshot, jobs, binding, chatId, receiptId) {
138
181
  if (!Array.isArray(jobs) || !jobs.length ||
139
182
  jobs.some(job => !job || job.msg?.batch || job.msg?.synthetic ||
@@ -171,6 +214,39 @@ function v2QueueSelection(queue) {
171
214
  if (jobs.some(job => !job)) throw new Error('binary selected unknown queue handle')
172
215
  return { jobs, anchor: effect.anchor.slice('receipt:job:'.length) }
173
216
  }
217
+
218
+ // The group skills the release entry serves through `sbot feishu` (the same
219
+ // executable as `sintbot`): messages, notice cards, history, reply, edit,
220
+ // recall, download and reactions. Canvas, image generation and takeover cards
221
+ // are not in this release, and neither are scheduled tasks, so the cron
222
+ // section the core renders after the skills is cut off.
223
+ function releaseSkillLines(platform) {
224
+ const features = manifestFeatures({ ...(platform?.features || {}),
225
+ boards: false, imagen: false, takeoverCard: false })
226
+ const lines = manifestSkills({ features,
227
+ platformName: String(platform?.chatLabel || '飞书群').replace(/群$/, '') })
228
+ const cron = lines.findIndex(line => line.startsWith('【定时任务】'))
229
+ return cron < 0 ? lines : lines.slice(0, cron)
230
+ }
231
+
232
+ // The group skills call `sbot feishu`. A machine can carry another `sbot` on
233
+ // PATH (an older global install), so a turn puts first the bin directory whose
234
+ // `sbot` is this very entry: an npm global prefix's bin next to
235
+ // lib/node_modules/sintbot. Nothing is changed when no such directory exists
236
+ // (a source checkout, tests).
237
+ let releaseBin
238
+ function releaseBinDir(entry = path.join(__dirname, '..', '..', 'bin', 'sintbot-v2.js')) {
239
+ if (releaseBin !== undefined && arguments.length === 0) return releaseBin
240
+ let found = null
241
+ try {
242
+ const self = fs.realpathSync(entry)
243
+ const dir = path.resolve(path.dirname(self), '..', '..', '..', '..', 'bin')
244
+ if (fs.realpathSync(path.join(dir, 'sbot')) === self) found = dir
245
+ } catch {}
246
+ if (arguments.length === 0) releaseBin = found
247
+ return found
248
+ }
249
+
174
250
  class Engine {
175
251
  constructor(cfg, options = {}) {
176
252
  const { store, log = () => {}, durableV2Runtime, durableV2Payloads,
@@ -347,7 +423,9 @@ _rt(chatId) {
347
423
 
348
424
  baseSkillEnv(chatId) {
349
425
  const g = (this.cfg.groups || {})[chatId]
350
- return { SBOT_CHAT_ID: chatId, ...(g && g.platform ? { SBOT_PLATFORM: g.platform } : {}), TZ: this.tz, ...(this.cfg._path ? { SBOT_CONFIG: this.cfg._path } : {}), ...(this.uiLocalUrl ? { SBOT_UI_URL: this.uiLocalUrl } : {}) }
426
+ const bin = releaseBinDir()
427
+ return { ...(bin ? { PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter) } : {}),
428
+ SBOT_CHAT_ID: chatId, ...(g && g.platform ? { SBOT_PLATFORM: g.platform } : {}), TZ: this.tz, ...(this.cfg._path ? { SBOT_CONFIG: this.cfg._path } : {}), ...(this.uiLocalUrl ? { SBOT_UI_URL: this.uiLocalUrl } : {}) }
351
429
  }
352
430
 
353
431
  _resolveGroup(chatId) {
@@ -744,7 +822,9 @@ async _runDurableV2Steer(rt, receiptId) {
744
822
  invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
745
823
  entry.accepted = accepted
746
824
  if (accepted) {
747
- if (entry.landed) authority.commitSteerObservation({ ...identity, type: 'landed' })
825
+ if (entry.landed) {
826
+ authority.commitSteerObservation({ ...identity, type: 'landed' })
827
+ }
748
828
  return { receiptId, steered: true, accepted: true, landed: entry.landed, staged: true }
749
829
  }
750
830
  if (entry.landed) throw new Error('C2 rejected steer also landed; outcome inconsistent')
@@ -766,9 +846,20 @@ async _runDurableV2Steer(rt, receiptId) {
766
846
  return { receiptId, queued: true, staged: true, accepted: false }
767
847
  })()
768
848
  rt.v2QueueNoticePromise = work
769
- try { return await work }
849
+ let outcome
850
+ try { outcome = await work }
770
851
  catch (error) { authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error); throw error }
771
852
  finally { if (rt.v2QueueNoticePromise === work) rt.v2QueueNoticePromise = null }
853
+ try {
854
+ if (outcome.accepted && entry.landed) {
855
+ await rt.v2SteerLandedChain
856
+ await rt.v2MoveCard?.(entry)
857
+ }
858
+ return outcome
859
+ } catch (error) {
860
+ authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error)
861
+ throw error
862
+ }
772
863
  }
773
864
 
774
865
  async runDurableV2Stop(msg, platform) {
@@ -1166,7 +1257,8 @@ async _drainDurableV2NormalChain(initial) {
1166
1257
  const outcome = engineTerminalStep({ finalized: false, outcome: null },
1167
1258
  terminal).state.outcome
1168
1259
  const quotaFailure = supportedV2QuotaFailure(payload, rt.v2RunnerResult)
1169
- if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure) {
1260
+ const closedFailure = supportedV2ClosedFailure(payload, rt.v2RunnerResult)
1261
+ if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure && !closedFailure) {
1170
1262
  const classified = this.classifyDurableV2UnresolvedTurn({ ...identity,
1171
1263
  payload })
1172
1264
  const error = new Error('C2 v2 ordinary result requires fault recovery')
@@ -1177,7 +1269,7 @@ async _drainDurableV2NormalChain(initial) {
1177
1269
  if (payload.source !== 'result' || payload.sessionMissing ||
1178
1270
  (payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
1179
1271
  payload.resumedSessionCurrent ||
1180
- (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure)) {
1272
+ (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure && !closedFailure)) {
1181
1273
  authority.close()
1182
1274
  const error = new Error('C2 v2 ordinary result needs a retry or notice decision')
1183
1275
  error.code = 'C2_V2_UNRESOLVED_TURN'
@@ -1195,7 +1287,7 @@ async _drainDurableV2NormalChain(initial) {
1195
1287
  for (const settledReceiptId of completed.settledReceiptIds ||
1196
1288
  [identity.receiptId]) {
1197
1289
  settled.push({ receiptId: settledReceiptId,
1198
- status: quotaFailure ? 'error' : silent ? 'silent' : 'answered',
1290
+ status: quotaFailure || closedFailure ? 'error' : silent ? 'silent' : 'answered',
1199
1291
  terminalEffectId: completed.terminalEffectId })
1200
1292
  }
1201
1293
  if (authority.blocked) {
@@ -1399,7 +1491,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1399
1491
  rt.v2Doing = []
1400
1492
  rt.v2AutoMilestones = []
1401
1493
  rt.v2CardQuotaFailure = false
1494
+ rt.v2CardModelInfo = null
1402
1495
  rt.v2CardBinding = null
1496
+ rt.v2CardAnchor = null
1497
+ rt.v2CardJoinedNames = []
1498
+ rt.v2LastCardText = ''
1499
+ rt.v2MoveCard = null
1403
1500
  rt.v2StreamRequested = false
1404
1501
  rt.v2StreamQuiescing = false
1405
1502
  rt.v2StreamChain = Promise.resolve()
@@ -1435,6 +1532,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1435
1532
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
1436
1533
  throw new Error('C2 v2 runner provider unavailable')
1437
1534
  }
1535
+ rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1438
1536
  rt.v2SteerControl = null
1439
1537
  rt.v2SteerEntries = new Map()
1440
1538
  rt.v2SteerClosing = false
@@ -1479,6 +1577,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1479
1577
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1480
1578
  if (rt.v2RunnerGeneration !== generation) return
1481
1579
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1580
+ await rt.v2MoveCard?.(entry)
1482
1581
  })
1483
1582
  rt.v2SteerLandedChain = observation
1484
1583
  observation.catch(() => { authority.close(); controller?.abort() })
@@ -1494,6 +1593,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1494
1593
  rt.v2RunnerNotStarted = false
1495
1594
  let streamChain = Promise.resolve()
1496
1595
  let streamFailure = null
1596
+ const joinedLine = () => rt.v2CardJoinedNames.length
1597
+ ? `\n\n↪ 已加入 ${rt.v2CardJoinedNames.join('、')} 的补充` : ''
1497
1598
  // Card progress (same mechanism and wording as the legacy engine): deterministic
1498
1599
  // milestones and "recently doing" from tool calls, an early "received" card
1499
1600
  // and a pulse line. All of it is process-local card text; the card itself is
@@ -1510,6 +1611,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1510
1611
  rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
1511
1612
  rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
1512
1613
  const enqueueCardText = text => {
1614
+ rt.v2LastCardText = text
1513
1615
  rt.v2StreamRequested = true
1514
1616
  streamChain = streamChain.then(async () => {
1515
1617
  if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
@@ -1521,7 +1623,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1521
1623
  if (!rt.v2CardBinding) {
1522
1624
  const path = prepareCardTerminalPath({ runtime: authority,
1523
1625
  payloadHandles: this.durableV2Payloads, platform: job.platform,
1524
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken,
1626
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor,
1627
+ receiptId, chatId, turnToken,
1525
1628
  generation, verifyGeneration,
1526
1629
  applyLocalEffect: this.durableV2ApplyLocalEffect,
1527
1630
  assertWriterLock: this.durableV2AssertWriterLock,
@@ -1536,7 +1639,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1536
1639
  rt.v2CardBinding = { path, receiptId, turnToken,
1537
1640
  generation, opened }
1538
1641
  }
1539
- if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text)
1642
+ if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text + joinedLine())
1540
1643
  })
1541
1644
  rt.v2StreamChain = streamChain
1542
1645
  streamChain.catch(error => {
@@ -1544,6 +1647,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1544
1647
  try { controller.abort() } catch {}
1545
1648
  })
1546
1649
  }
1650
+ rt.v2MoveCard = entry => {
1651
+ if (entry.movePromise) return entry.movePromise
1652
+ const move = streamChain.then(async () => {
1653
+ if (progressClosed() || job.platform.features?.cardPinned !== true) return false
1654
+ const anchor = { receiptId: entry.identity.receiptId,
1655
+ replyCtx: entry.job.msg.replyCtx }
1656
+ const who = entry.job.msg.senderName || entry.job.msg.userId || 'user'
1657
+ if (!rt.v2CardJoinedNames.includes(who)) rt.v2CardJoinedNames.push(who)
1658
+ if (!rt.v2CardBinding) {
1659
+ rt.v2CardAnchor = anchor
1660
+ return true
1661
+ }
1662
+ if (!rt.v2CardBinding.opened) return false
1663
+ rt.v2CardBinding.path.update((rt.v2LastCardText || progress.T.ackWorking) + joinedLine())
1664
+ const moved = await rt.v2CardBinding.path.relocate(anchor)
1665
+ if (moved) rt.v2CardAnchor = anchor
1666
+ return moved
1667
+ })
1668
+ streamChain = move
1669
+ rt.v2StreamChain = move
1670
+ entry.movePromise = move
1671
+ move.catch(error => {
1672
+ streamFailure = error
1673
+ try { controller.abort() } catch {}
1674
+ })
1675
+ return move
1676
+ }
1547
1677
  const enqueueFrame = event => {
1548
1678
  if (event.type !== 'assistant') return
1549
1679
  // /status and the pulse read these; a late event from an older
@@ -1613,10 +1743,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1613
1743
  const sofar = buffer.trim()
1614
1744
  const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
1615
1745
  ? progress.renderMilestones(buffer) + '\n\n' : '')
1616
- try {
1617
- binding.path.update(body + progress.T.pulse(
1618
- progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1619
- } catch { /* a closed card never affects the turn */ }
1746
+ enqueueCardText(body + progress.T.pulse(
1747
+ progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1620
1748
  }, PULSE_MS)
1621
1749
  if (pulseTimer.unref) pulseTimer.unref()
1622
1750
  }
@@ -1835,6 +1963,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1835
1963
  const projected = rt?.v2TerminalProjection
1836
1964
  const resultText = projected?.payload?.resultText
1837
1965
  const quotaFailure = supportedV2QuotaFailure(payload, rt?.v2RunnerResult)
1966
+ const closedFailure = supportedV2ClosedFailure(payload, rt?.v2RunnerResult)
1838
1967
  if (!/^[0-9a-f]{32}$/.test(receiptId || '') ||
1839
1968
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
1840
1969
  typeof effectId !== 'string' || !effectId ||
@@ -1866,10 +1995,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1866
1995
  (!isDeepStrictEqual(payload.batchReceiptIds, cohort.receiptIds) ||
1867
1996
  payload.replyTargetReceiptId !== receiptId)) ||
1868
1997
  !isDeepStrictEqual(payload, projected.payload) ||
1869
- // Only successful answers and binary-classified, side-effect-free
1870
- // quota failures use this no-retry terminal bridge. Unknown failures
1871
- // and lost-session outcomes still need a separate recovery decision.
1872
- payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure) ||
1998
+ // A deterministic failure is delivered once, after the same durable
1999
+ // close checks as success. Retry and unknown outcomes stay sealed.
2000
+ payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
1873
2001
  payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
1874
2002
  payload.freshRetried || payload.resumedSessionCurrent ||
1875
2003
  (replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
@@ -1883,6 +2011,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1883
2011
  value.generation === generation && rt.v2RunnerGeneration === generation
1884
2012
  try {
1885
2013
  const preopened = rt.v2CardBinding
2014
+ if (closedFailure) authority.assertClosedFailureReady({ receiptId, chatId,
2015
+ turnToken, generation, verifyGeneration,
2016
+ assertWriterLock: this.durableV2AssertWriterLock })
1886
2017
  if (preopened && (preopened.receiptId !== receiptId ||
1887
2018
  preopened.turnToken !== turnToken ||
1888
2019
  preopened.generation !== generation)) {
@@ -1890,7 +2021,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1890
2021
  }
1891
2022
  const path = preopened?.path || prepareCardTerminalPath({ runtime: authority,
1892
2023
  payloadHandles: handles, platform: job.platform,
1893
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken, generation,
2024
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor, preferPlain: closedFailure,
2025
+ receiptId, chatId, turnToken, generation,
1894
2026
  verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
1895
2027
  assertWriterLock: this.durableV2AssertWriterLock,
1896
2028
  redactText: text => redact(quotaFailure
@@ -1905,6 +2037,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1905
2037
  for (const text of updates) path.update(text)
1906
2038
  const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
1907
2039
  tokens: rt.v2UsageDecision?.tokens || null })
2040
+ const footerDetail = job.platform.features?.cardQuota === true
2041
+ ? cardModelDetail(rt.v2CardModelInfo, rt.v2RunnerResult) : ''
1908
2042
  // Milestones from tool calls first, then the model's own `>> ` lines;
1909
2043
  // the card shows them in its "process" fold (display only).
1910
2044
  rt.v2CardQuotaFailure = !!quotaFailure
@@ -1913,7 +2047,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1913
2047
  ...progress.milestonesOf(payload.bufferText)]
1914
2048
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
1915
2049
  .filter(m => typeof m === 'string' && m.trim())
1916
- const completed = await path.complete({ payload, replyText, paused, footer, milestones })
2050
+ const completed = await path.complete({ payload, replyText, paused,
2051
+ footer, footerDetail, milestones })
1917
2052
  const after = authority.snapshot()
1918
2053
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
1919
2054
  let successor = null
@@ -2027,7 +2162,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2027
2162
  }
2028
2163
  rt.v2TerminalStarted = true
2029
2164
  authority.registerTerminalCallback({ receiptId, generation,
2030
- registeredAt: Date.now() })
2165
+ registeredAt: Date.now(), reuse: true })
2031
2166
  return authority.commitUnresolvedClassification({ receiptId, generation,
2032
2167
  turnToken, status, payload,
2033
2168
  verifyGeneration: value => value.chatId === chatId &&
@@ -2057,6 +2192,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2057
2192
  { full: policy.full, isCodex: provider.runner === 'codex' }) : null,
2058
2193
  unredactedPii: group.redactPii === false,
2059
2194
  cardPinned: platform.features?.cardPinned === true, plainSend: false }),
2195
+ ...releaseSkillLines(platform),
2060
2196
  ].join('\n')
2061
2197
  // Reinject current supported context each turn. No legacy feature menu,
2062
2198
  // external-message commands or v1 manifest refresh scheduler is included.
@@ -2146,4 +2282,4 @@ requestDurableV2Stop(chatId) {
2146
2282
 
2147
2283
  }
2148
2284
 
2149
- module.exports = { Engine }
2285
+ module.exports = { releaseBinDir, Engine }
@@ -0,0 +1,211 @@
1
+ 'use strict'
2
+
3
+ // Messaging actions of the `feishu` subcommand, shared by the legacy entry and
4
+ // the release entry so a group's agent gets the same skills under both:
5
+ // send-msg · send-notice · send-file · send-image · history · reply · edit ·
6
+ // recall (alias delete) · download · react. Canvas actions stay in the legacy
7
+ // entry. None of them needs the service: each call uses the bot's own app
8
+ // credentials directly.
9
+ //
10
+ // Target chat: --chat, else $SBOT_CHAT_ID (injected for every group turn).
11
+ // Configuration: --config, else $SBOT_CONFIG (also injected), else the usual
12
+ // lookup. Paths are relative to the working directory and never leave it;
13
+ // outbound text is redacted like the engine's replies (PII per group).
14
+ const fs = require('node:fs')
15
+ const config = require('./config')
16
+ const feishu = require('./platform/feishu')
17
+ const { cliNotice, deliverNotice } = require('./platform/noticesend')
18
+ const { TONES, resolveTone } = require('./platform/noticecard')
19
+ const { redact } = require('./core/redact')
20
+ const { fmtStamp, normalizeTz } = require('./core/time')
21
+
22
+ const FEISHU_MESSAGE_ACTIONS = Object.freeze(['send-msg', 'send-notice', 'send-file', 'send-image',
23
+ 'history', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
24
+
25
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
26
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
27
+
28
+ // A refusal with its own wording: callers print it as is and exit 1.
29
+ class FeishuCliError extends Error {
30
+ constructor(message) { super(message); this.name = 'FeishuCliError' }
31
+ }
32
+ const refuse = (message) => { throw new FeishuCliError(message) }
33
+
34
+ // Hand-roll option parsing (not util.parseArgs): send-msg bodies / filenames legitimately start
35
+ // with '-' (Markdown lists like "- item"), which parseArgs(strict) rejects as an unknown option —
36
+ // and the self-knowledge manifest teaches agents exactly that form. Pull out --chat/--config; the
37
+ // rest stays positional (message text / path), leading '-' and all. Canvas options are taken out
38
+ // for every action, as they always were.
39
+ function parseFeishuArgs(action, rawArgs) {
40
+ const o = { warnings: [], positionals: [] }
41
+ for (let i = 0; i < rawArgs.length; i++) {
42
+ const a = rawArgs[i]
43
+ if (a === '--chat') o.chat = rawArgs[++i]
44
+ else if (a.startsWith('--chat=')) o.chat = a.slice(7)
45
+ else if (a === '--config') o.config = rawArgs[++i]
46
+ else if (a.startsWith('--config=')) o.config = a.slice(9)
47
+ else if (action === 'send-notice' && a === '--sub') o.subtitle = rawArgs[++i]
48
+ else if (action === 'send-notice' && a === '--tone') o.tone = rawArgs[++i]
49
+ else if (action === 'send-notice' && a === '--warn') o.warnings.push(rawArgs[++i])
50
+ else if (action === 'send-notice' && a === '--from') o.from = rawArgs[++i]
51
+ else if (a === '--title') o.title = rawArgs[++i]
52
+ else if (a === '--new') o.new = true
53
+ else if (a === '--x') o.x = rawArgs[++i]
54
+ else if (a === '--y') o.y = rawArgs[++i]
55
+ else if (a === '--width') o.width = rawArgs[++i]
56
+ else if (a === '--height') o.height = rawArgs[++i]
57
+ else if (a === '--label') o.label = rawArgs[++i]
58
+ else if (a === '--raw') o.raw = true
59
+ else if (a === '--board') o.board = rawArgs[++i]
60
+ else if (a === '--beside') o.beside = rawArgs[++i] // 参照节点 id:新图放它右侧(按标注改的排版)
61
+ else if (a === '--deliver') o.deliver = true // 交付结果卡:上板 + 回填进原播报卡(单卡)/ 无原卡则新发
62
+ else if (a === '--note') o.note = rawArgs[++i] // 结果卡里的一句改动说明
63
+ else if (a === '--type') o.type = rawArgs[++i] // 图表类型(flow/mindmap/sequence/… 或中文别名)
64
+ else if (a === '--syntax') o.syntax = rawArgs[++i] // 图表语法(mermaid | plantuml;覆盖扩展名识别)
65
+ else if (a === '--as-image') o.asImage = true // 图表渲染成整张图片,否则多节点可编辑
66
+ else if (a === '--no-cascade') o.noCascade = true // board-del-node:只删点名的,不连带容器内容/导图子树
67
+ else o.positionals.push(a)
68
+ }
69
+ return o
70
+ }
71
+
72
+ // The checks every action shares, in the legacy order: Slack groups, then
73
+ // configuration, then the target chat.
74
+ function feishuContext(opts, { env = process.env, loadConfig = config.loadConfig,
75
+ findConfigPath = config.findConfigPath, applyProxyEnv = config.applyProxyEnv,
76
+ feishuClient = feishu.feishuClient, client } = {}) {
77
+ // Slack 群:引擎注入 SBOT_PLATFORM=slack。飞书这族动作在那儿一概走不通(文件上下行还没接到
78
+ // Slack,docs/SLACK.md 第 3 步)。不拦的话要么打到飞书 API 报 lark 错、要么 feishu 没配时报「未配置」——
79
+ // 两种都不说人话,agent 会转头重试。显式 --chat 指向飞书群的手工用法不拦。
80
+ if (!opts.chat && env.SBOT_PLATFORM === 'slack') refuse('本群在 Slack 上 —— sbot feishu 的动作(发文件/发图/读历史/回复/编辑/撤回/下载/表情)在 Slack 群暂不可用,Slack 侧文件上下行还没接。要给群成员看的文件或图片:把项目内相对路径与打开方式写进回复正文;不要重试本命令。')
81
+ const configPath = opts.config || env.SBOT_CONFIG
82
+ if (!findConfigPath(configPath)) refuse('feishu 未配置:找不到配置文件 — 传 --config <path> 或设置 env SBOT_CONFIG')
83
+ const cfg = loadConfig(configPath)
84
+ if (!cfg.feishu || !cfg.feishu.appId || !cfg.feishu.appSecret) refuse('feishu 未配置(sbot.config.json 缺 feishu.appId/appSecret)')
85
+ const chatId = opts.chat || env.SBOT_CHAT_ID
86
+ if (!chatId) refuse('缺少目标群 — 传 --chat <oc_...> 或设置 env SBOT_CHAT_ID')
87
+ applyProxyEnv(cfg)
88
+ // 群面出站脱敏(与 engine 同口径):PII 按群开关(redactPii:false ⇒ 手机号/邮箱明文),密钥类恒遮盖。
89
+ const red = (s) => redact(s, { pii: !(cfg.groups && cfg.groups[chatId] && cfg.groups[chatId].redactPii === false) })
90
+ return { cfg, chatId, red, client: client || feishuClient(cfg.feishu.appId, cfg.feishu.appSecret) }
91
+ }
92
+
93
+ // args = [action, ...rest]. Confirmations go to stderr; what a program reads
94
+ // (history lines, message ids) goes to stdout.
95
+ async function runFeishuAction(args, deps = {}) {
96
+ const action = args[0]
97
+ if (!FEISHU_MESSAGE_ACTIONS.includes(action)) refuse(`unknown feishu action '${action || ''}' (${FEISHU_MESSAGE_ACTIONS.filter((a) => a !== 'delete').join(' | ')})`)
98
+ const opts = parseFeishuArgs(action, args.slice(1))
99
+ const { cfg, chatId, red, client } = feishuContext(opts, deps)
100
+ const cwd = deps.cwd || process.cwd()
101
+ const now = deps.now || Date.now
102
+ const out = deps.stdout || ((s) => process.stdout.write(s))
103
+ const err = deps.stderr || ((s) => process.stderr.write(s))
104
+ const p = opts.positionals
105
+ // Usage errors before any network call, worded as the legacy entry did.
106
+ let notice
107
+ if (action === 'send-msg' && !p.join(' ').trim()) refuse('缺少消息内容 — sbot feishu send-msg "<文本>"')
108
+ if (action === 'send-notice') {
109
+ // 群通知卡:标题 + 正文(其余位置参数各占一行)+ 可选副标题 / 类别 / 注意事项 / 来源。
110
+ // 与 send-msg 的分工:这张卡是「对大家宣布一件事」,阶段性结果与定时提醒仍走 send-msg。
111
+ if (!String(p[0] || '').trim()) refuse('缺少标题 — sbot feishu send-notice "<标题>" ["<正文…>"] [--sub <副标题>] [--tone 公告|升级|提醒|故障|变更|状态] [--warn <注意事项>]… [--from <来源>]')
112
+ try {
113
+ notice = cliNotice(p, { subtitle: opts.subtitle, tone: opts.tone, warnings: opts.warnings,
114
+ from: opts.from, stamp: fmtStamp(now(), normalizeTz(cfg.timezone)) })
115
+ } catch (e) { refuse(e.message) }
116
+ }
117
+ if ((action === 'reply' || action === 'edit') && (!p[0] || !p.slice(1).join(' ').trim())) {
118
+ refuse(action === 'reply' ? '用法: sbot feishu reply <message_id> "<文本>"' : '用法: sbot feishu edit <message_id> "<文本>"(仅能编辑 bot 自己发的消息)')
119
+ }
120
+ if ((action === 'recall' || action === 'delete') && !p[0]) refuse('用法: sbot feishu recall <message_id>(仅能撤回 bot 自己发的消息)')
121
+ if (action === 'download' && (!p[0] || !p[1])) refuse('用法: sbot feishu download <message_id> <file_key> <file|image> [目标相对路径]')
122
+ if (action === 'react' && (!p[0] || !p[1])) refuse('用法: sbot feishu react <message_id> <emoji>(如 THUMBSUP / 👍)')
123
+ try {
124
+ if (action === 'send-file' || action === 'send-image') {
125
+ const abs = feishu.resolveSendPath(cwd, p[0])
126
+ if (action === 'send-file') await feishu.sendChatFile(client, chatId, abs)
127
+ else await feishu.sendChatImage(client, chatId, abs)
128
+ err(green(` ✓ 已发送${action === 'send-file' ? '文件' : '图片'}到群 `) + dim(chatId) + ' ' + p[0] + '\n')
129
+ return { action, chatId, path: abs }
130
+ }
131
+ if (action === 'send-msg') {
132
+ await feishu.sendChatText(client, chatId, red(p.join(' '))) // 出站脱敏,与 engine 群回复同口径(PII 按群)
133
+ err(green(' ✓ 已发送消息到群 ') + dim(chatId) + '\n')
134
+ return { action, chatId }
135
+ }
136
+ if (action === 'send-notice') {
137
+ // 有卡能力就发卡(逐字段脱敏);建卡被拒或没送达时退回同一份通知的文本。
138
+ const sent = await deliverNotice({ features: { noticeCard: true },
139
+ sendCard: (id, card) => feishu.sendChatCard(client, id, card),
140
+ send: (id, body) => feishu.sendChatText(client, id, body) }, chatId, notice, { redact: red, log: (m) => err(dim(' (' + m + ')') + '\n') })
141
+ if (sent.mode === 'card') {
142
+ err(green(' ✓ 已发送通知卡到群 ') + dim(chatId) + ` ${TONES[resolveTone(notice.tone)].label}「${notice.title}」\n`)
143
+ if (sent.result && sent.result.msgId) out(sent.result.msgId + '\n') // 给程序读:message_id(要撤回 / 回复它时用)
144
+ } else {
145
+ err(green(' ✓ 已发送通知到群 ') + dim(chatId) + '(文本)\n')
146
+ }
147
+ return { action, chatId, mode: sent.mode, msgId: (sent.result && sent.result.msgId) || null }
148
+ }
149
+ if (action === 'history') {
150
+ const n = p[0] ? parseInt(p[0], 10) : 20
151
+ const msgs = await feishu.fetchChatHistory(client, chatId, n)
152
+ const tz = normalizeTz(cfg.timezone)
153
+ // @ 标记(裁决在 feishu.js 的 historyAtMark,这里只消费):「@了别人未@bot」的消息按设计不触发轮次,
154
+ // 历史里不标就与真问 bot 的长得一模一样。bot 自己的 open_id 从配置来。
155
+ const botOid = String((cfg.feishu && cfg.feishu.botOpenId) || '')
156
+ const lines = msgs.map((m) => {
157
+ const who = m.senderType === 'app' ? 'bot' : (m.senderId || '?')
158
+ const body = m.text != null ? m.text : `(${m.msgType})`
159
+ return `[${m.createTimeMs ? fmtStamp(m.createTimeMs, tz) : '?'} ${who}${feishu.historyAtMark(m.mentions, botOid)}] ${body} {${m.msgId}}`
160
+ })
161
+ // 群消息内容是入站上下文(给 agent 读),不脱敏;message_id 用于后续 reply/edit/recall
162
+ out(`共 ${msgs.length} 条群消息(新→旧):\n` + (lines.join('\n') || '(空)') + '\n')
163
+ return { action, chatId, count: msgs.length }
164
+ }
165
+ if (action === 'reply' || action === 'edit') {
166
+ const msgId = p[0]
167
+ await (action === 'reply' ? feishu.replyMessage : feishu.editMessage)(client, msgId, red(p.slice(1).join(' ')))
168
+ err(green(action === 'reply' ? ' ✓ 已回复消息 ' : ' ✓ 已编辑消息 ') + dim(msgId) + '\n')
169
+ return { action, chatId, msgId }
170
+ }
171
+ if (action === 'recall' || action === 'delete') {
172
+ await feishu.recallMessage(client, p[0])
173
+ err(green(' ✓ 已撤回消息 ') + dim(p[0]) + '\n')
174
+ return { action, chatId, msgId: p[0] }
175
+ }
176
+ if (action === 'download') {
177
+ const [msgId, fileKey, type] = p
178
+ const dest = p[3] || fileKey
179
+ const abs = feishu.resolveSendPath(cwd, dest, false) // 写模式:目标文件尚不存在
180
+ await feishu.downloadResource(client, msgId, fileKey, type === 'image' ? 'image' : 'file', abs)
181
+ err(green(' ✓ 已下载到 ') + dim(dest) + '\n')
182
+ return { action, chatId, path: abs }
183
+ }
184
+ // react
185
+ await feishu.reactMessage(client, p[0], p[1])
186
+ err(green(' ✓ 已回应 ') + dim(p[0]) + ' ' + p[1] + '\n')
187
+ return { action, chatId, msgId: p[0] }
188
+ } catch (e) {
189
+ throw new FeishuCliError('feishu 操作失败:' + feishu.larkErrMsg(e))
190
+ }
191
+ }
192
+
193
+ // `--help` text of the release entry. The legacy entry keeps its own, which
194
+ // also lists the canvas actions.
195
+ function feishuUsage(cmd = 'sintbot') {
196
+ return `用法: ${cmd} feishu <动作> [参数…] [--chat <oc_…>] [--config <path>]\n`
197
+ + ' 目标群:--chat,不传取 $SBOT_CHAT_ID(群里跑时已注入);配置:--config,不传取 $SBOT_CONFIG;路径一律项目内相对路径\n'
198
+ + '\n 消息\n'
199
+ + ' send-msg "<文本>" 发文本(正文以 - 开头也照收)\n'
200
+ + ' send-notice "<标题>" "<正文>" 发群通知卡(可加 --sub / --tone / --warn / --from)\n'
201
+ + ' send-file <路径> · send-image <路径> 发文件 / 图片\n'
202
+ + ' history [n] 拉最近 n 条(默认 20,最多 50):每条带 message_id,并标 →@某某·未@bot / →@bot\n'
203
+ + ' reply <message_id> "<文本>" 回复某条\n'
204
+ + ' edit <message_id> "<文本>" 改自己发过的消息\n'
205
+ + ' recall <message_id> 撤回自己发过的消息(别名 delete)\n'
206
+ + ' download <message_id> <file_key> <file|image> [目标路径] 下载群里的附件 / 图片(目标默认取 file_key)\n'
207
+ + ' react <message_id> <表情> 表情回应(THUMBSUP 之类的枚举名,或直接给 👍)\n'
208
+ + '\n 确认走 stderr;给程序读的(history / message_id)走 stdout;失败退出码 1\n'
209
+ }
210
+
211
+ module.exports = { FEISHU_MESSAGE_ACTIONS, FeishuCliError, parseFeishuArgs, feishuContext, runFeishuAction, feishuUsage }
@@ -605,7 +605,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
605
605
  }
606
606
 
607
607
  return {
608
- features: { cardQuota: true, noticeCard: true }, // 飞书支持通知卡;企微/Slack 退回文本
608
+ features: { cardQuota: true, noticeCard: true, cardPinned: true }, // 回复卡钉在触发消息下;补话落地后可搬到新消息下
609
609
  async start(onMessage, hooks = {}) {
610
610
  if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
611
611
  if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {