sintbot 1.4.11 → 1.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@ const { prepareC2RunnerInput } = require('./c2runnerinput')
18
18
  const { continueC2Successor } = require('./c2successorbridge')
19
19
  const { rejectC2Input } = require('./c2inputrejection')
20
20
  const progress = require('./turnprogress')
21
+ const { modelContext } = require('../modelcontext')
22
+ const { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand } = require('../runtimefacts')
21
23
  const NO_REPLY = 'NO_REPLY'
22
24
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
23
25
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
@@ -25,7 +27,7 @@ const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
25
27
  // the Codex read-only sandbox, 3 the fail-closed default. 0 is full access
26
28
  // and 2 an allow-listed tool set, which may still change files.
27
29
  const READ_ONLY_TIERS = Object.freeze([1, 3])
28
- const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
30
+ const DURABLE_V2_COMMANDS = new Set(GROUP_COMMANDS.filter(cmd => !['/skip', '/stop'].includes(cmd)))
29
31
  function fmtWan(n) {
30
32
  n = Number(n) || 0
31
33
  if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1).replace(/\.0$/, '')}亿`
@@ -51,8 +53,7 @@ function cardDuration(ms) {
51
53
  }
52
54
  function cardModelInfo(group, provider) {
53
55
  if (!provider) return { model: '', effort: '' }
54
- const model = group?.model || provider.model ||
55
- (provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
56
+ const { model } = modelContext(group, provider)
56
57
  const effort = provider.runner === 'codex'
57
58
  ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
58
59
  const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
@@ -224,10 +225,11 @@ function v2QueueSelection(queue) {
224
225
  function releaseSkillLines(platform) {
225
226
  const features = manifestFeatures({ ...(platform?.features || {}),
226
227
  boards: false, imagen: false, takeoverCard: false })
227
- const lines = manifestSkills({ features,
228
+ const lines = manifestSkills({ features: { ...features, localAnswers: true },
228
229
  platformName: String(platform?.chatLabel || '飞书群').replace(/群$/, '') })
229
230
  const cron = lines.findIndex(line => line.startsWith('【定时任务】'))
230
- return cron < 0 ? lines : lines.slice(0, cron)
231
+ return (cron < 0 ? lines : lines.slice(0, cron))
232
+ .map(line => line.replace(/sbot feishu/g, `${runtimeCommand()} feishu`))
231
233
  }
232
234
 
233
235
  // The group skills call `sbot feishu`. A machine can carry another `sbot` on
@@ -256,7 +258,7 @@ class Engine {
256
258
  durableV2NormalRoute = true, durableV2StreamFrames = false,
257
259
  durableV2AcquireCapacity = null, durableV2SealCapacity = null,
258
260
  durableV2SwitchProvider = null, durableV2UsageProbe = null, durableV2Issues = null,
259
- durableV2Version = '' } = options
261
+ durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false } = options
260
262
  const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
261
263
  if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
262
264
  options.receiptJournal || options.unifiedStepHost ||
@@ -289,13 +291,21 @@ class Engine {
289
291
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
290
292
  durableV2AssertWriterLock, durableV2Journal, durableV2NormalRoute,
291
293
  durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
292
- durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version })
294
+ durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version,
295
+ durableV2DataDir, durableV2ConsoleEnabled })
293
296
  this.projectMemory = options.projectMemory || null
294
297
  this.repoInfo = options.repoInfo || null
298
+ this.activity = options.activity || null
295
299
  this.tz = normalizeTz(cfg.timezone)
296
300
  this.rts = new Map()
297
301
  }
298
302
 
303
+ // Read-only diagnostics must never change admission, delivery or settlement.
304
+ _observeActivity(method, value) {
305
+ try { this.activity?.[method]?.(value) }
306
+ catch { this.log('Activity display update unavailable') }
307
+ }
308
+
299
309
  shutdown() {
300
310
  this.durableV2Runtime.close()
301
311
  let aborted = 0
@@ -426,6 +436,8 @@ baseSkillEnv(chatId) {
426
436
  const g = (this.cfg.groups || {})[chatId]
427
437
  const bin = releaseBinDir()
428
438
  return { ...(bin ? { PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter) } : {}),
439
+ SBOT_RUNTIME_ENTRY: RUNTIME_ENTRY, SBOT_RUNTIME_VERSION: this.durableV2Version || '',
440
+ ...(this.durableV2DataDir ? { SBOT_DATA_DIR: this.durableV2DataDir } : {}),
429
441
  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 } : {}) }
430
442
  }
431
443
 
@@ -456,7 +468,7 @@ async _durableV2Command(msg, cmd, rest) {
456
468
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
457
469
  const { name, provider } = this._resolveProvider(msg.chatId, group)
458
470
  // A group may pin its own model; the runner uses it before the provider default.
459
- const label = (n, p) => (group.model || p?.model) ? `${n} · ${group.model || p.model}` : n
471
+ const label = (n, p) => { const { model } = modelContext(group, p); return model ? `${n} · ${model}` : n }
460
472
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
461
473
  if (cmd === '/help') {
462
474
  const isCodex = provider?.runner === 'codex'
@@ -525,12 +537,15 @@ async _durableV2Command(msg, cmd, rest) {
525
537
  _durableV2ModelCommand(msg, want, group) {
526
538
  const reject = (reason, notice) =>
527
539
  rejectC2Input('C2 v2 model command answered before receipt', { reason, notice })
528
- const label = (name, provider) => provider?.model ? `${name} · ${provider.model}` : name
540
+ const label = (name, provider, scope = {}) => {
541
+ const { model } = modelContext(scope, provider)
542
+ return model ? `${name} · ${model}` : name
543
+ }
529
544
  const available = Object.entries(this.cfg.providers || {})
530
545
  .filter(([, provider]) => provider && !provider.hidden &&
531
- ['claude', 'codex'].includes(provider.runner))
546
+ ['claude', 'codex'].includes(provider.runner || 'claude'))
532
547
  const { name: currentName, provider: currentProvider } = this._resolveProvider(msg.chatId, group)
533
- const current = label(currentName, currentProvider)
548
+ const current = label(currentName, currentProvider, group)
534
549
  const list = available.map(([name, provider]) => label(name, provider)).join(' / ')
535
550
  if (!want) {
536
551
  const example = available.find(([name]) => name !== currentName)?.[0] || currentName
@@ -741,6 +756,7 @@ async runDurableV2Message(msg, platform) {
741
756
  } catch (error) {
742
757
  if (controller.signal.aborted && !rt.v2DispatchEffect &&
743
758
  error?.code === 'C2_CAPACITY_WAIT_CANCELLED') {
759
+ this._observeActivity('unknown', { chatId: msg.chatId })
744
760
  return { receiptId, status: 'interruptedBeforeDispatch',
745
761
  queued: true, ingressClosed: true }
746
762
  }
@@ -763,6 +779,7 @@ async runDurableV2Message(msg, platform) {
763
779
  // siblings must not dispatch in the microtask before the service sees
764
780
  // this rejection and begins its process-wide stop.
765
781
  failed = true
782
+ this._observeActivity('unknown', { chatId: msg.chatId })
766
783
  this.durableV2SealCapacity?.(error)
767
784
  throw error
768
785
  } finally {
@@ -823,6 +840,7 @@ async _runDurableV2Steer(rt, receiptId) {
823
840
  invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
824
841
  entry.accepted = accepted
825
842
  if (accepted) {
843
+ try { this.activity?.merge(chatId, identity.parentReceiptId, [receiptId], entry.landed ? 'landed' : 'accepted') } catch {}
826
844
  if (entry.landed) {
827
845
  authority.commitSteerObservation({ ...identity, type: 'landed' })
828
846
  }
@@ -1100,6 +1118,7 @@ async _drainDurableV2NormalChain(initial) {
1100
1118
  }
1101
1119
  const started = await this.startDurableV2Runner(identity)
1102
1120
  if (started.startFailureRecovered === true) {
1121
+ this._observeActivity('finish', { ...identity, status: 'not-started', err: '任务未启动,已通知群内' })
1103
1122
  settled.push({ receiptId: identity.receiptId, status: 'startupNotified' })
1104
1123
  return { receiptId: firstReceiptId, status: settled[0].status,
1105
1124
  settledReceiptIds: settled.map(item => item.receiptId),
@@ -1170,6 +1189,8 @@ async _drainDurableV2NormalChain(initial) {
1170
1189
  }
1171
1190
  for (const settledReceiptId of cancelled.cancelledReceiptIds ||
1172
1191
  [identity.receiptId]) {
1192
+ this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
1193
+ status: control === 'recall' ? 'recalled' : 'stopped' })
1173
1194
  settled.push({ receiptId: settledReceiptId,
1174
1195
  status: control === 'recall' ? 'recalled' : 'stopped',
1175
1196
  terminalEffectId: null, cancellationEventId: cancelled.eventId })
@@ -1230,6 +1251,8 @@ async _drainDurableV2NormalChain(initial) {
1230
1251
  rt.v2TurnToken = null
1231
1252
  for (const settledReceiptId of released.settledReceiptIds ||
1232
1253
  [identity.receiptId]) {
1254
+ this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
1255
+ status: 'timeout', err: '任务超过运行时限,已停止' })
1233
1256
  settled.push({ receiptId: settledReceiptId,
1234
1257
  status: 'timeout', terminalEffectId: null,
1235
1258
  cancellationEventId: released.eventId })
@@ -1361,6 +1384,9 @@ stageDurableV2Message(msg, platform) {
1361
1384
  const rt = this._rt(msg.chatId)
1362
1385
  if (!rt.v2QueueReceiptIds) rt.v2QueueReceiptIds = []
1363
1386
  rt.v2QueueReceiptIds.push(receipt.id)
1387
+ this._observeActivity('receive', { chatId: msg.chatId, receiptId: receipt.id,
1388
+ sourceMessageId: msg.msgId,
1389
+ text: msg.text, sender: msg.senderName || '', acceptedAt })
1364
1390
  return { receiptId: receipt.id, cursor: result.cursor, staged: true,
1365
1391
  ingressClosed: !!authority.blocked }
1366
1392
  }
@@ -1534,6 +1560,9 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1534
1560
  throw new Error('C2 v2 runner provider unavailable')
1535
1561
  }
1536
1562
  rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1563
+ this._observeActivity('start', { chatId, receiptId, receiptIds: cohort.receiptIds, generation,
1564
+ sessionId: this.store.getSession(chatId), model: rt.v2CardModelInfo.model,
1565
+ runner: provider.runner, project: projectName(job.group) })
1537
1566
  rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1538
1567
  rt.v2SteerControl = null
1539
1568
  rt.v2SteerEntries = new Map()
@@ -1579,6 +1608,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1579
1608
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1580
1609
  if (rt.v2RunnerGeneration !== generation) return
1581
1610
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1611
+ try { this.activity?.merge(chatId, receiptId, [entry.identity.receiptId], 'landed') } catch {}
1582
1612
  await rt.v2MoveCard?.(entry)
1583
1613
  })
1584
1614
  rt.v2SteerLandedChain = observation
@@ -1757,18 +1787,22 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1757
1787
  (!Number.isSafeInteger(timeoutDeadlineAt) || timeoutDeadlineAt <= 0)) {
1758
1788
  throw new Error('C2 v2 hard timeout deadline invalid')
1759
1789
  }
1760
- capTimer = timeoutDeadlineAt !== null
1761
- ? setTimeout(() => {
1790
+ const onHardDeadline = () => {
1762
1791
  const elapsedAt = Date.now()
1763
1792
  if (rt.v2RunnerGeneration !== generation ||
1764
1793
  rt.v2Controller !== controller || rt.v2CancelControl ||
1765
1794
  rt.v2StreamQuiescing) return
1766
- if (elapsedAt >= timeoutDeadlineAt) {
1767
- rt.v2TimeoutProof = { receiptId, turnToken, generation,
1768
- deadlineAt: timeoutDeadlineAt, elapsedAt }
1795
+ // Timers and Date.now need not tick together. An early callback (or
1796
+ // a backward wall-clock adjustment) is not proof of elapsed time.
1797
+ if (elapsedAt < timeoutDeadlineAt) {
1798
+ capTimer = setTimeout(onHardDeadline, timeoutDeadlineAt - elapsedAt)
1799
+ return
1769
1800
  }
1801
+ rt.v2TimeoutProof = { receiptId, turnToken, generation,
1802
+ deadlineAt: timeoutDeadlineAt, elapsedAt }
1770
1803
  controller.abort()
1771
- }, timeoutMs) : null
1804
+ }
1805
+ capTimer = timeoutDeadlineAt !== null ? setTimeout(onHardDeadline, timeoutMs) : null
1772
1806
  const input = prepareC2RunnerInput({ prompt,
1773
1807
  appendSystemPrompt: SYSTEM_APPENDIX, msg: job.msg, group: job.group,
1774
1808
  provider, config: this.cfg,
@@ -1777,6 +1811,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1777
1811
  onDurableEvent: event => {
1778
1812
  projection.accept(event)
1779
1813
  enqueueFrame(event)
1814
+ this._observeActivity('observe', { chatId, receiptId, generation, event,
1815
+ doing: rt.v2Doing, milestones: event.text?.includes('>>') ? progress.milestonesOf(projection.streamSnapshot().bufferText) : undefined })
1816
+ },
1817
+ onEvent: event => {
1818
+ if (event.type === 'system' && event.subtype === 'context')
1819
+ this._observeActivity('observe', { chatId, receiptId, generation, event })
1780
1820
  },
1781
1821
  onTurnAccepted: value => {
1782
1822
  const ack = capability.onTurnAccepted(value)
@@ -2049,8 +2089,14 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
2049
2089
  ...progress.milestonesOf(payload.bufferText)]
2050
2090
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
2051
2091
  .filter(m => typeof m === 'string' && m.trim())
2092
+ this._observeActivity('sending', { chatId, receiptId })
2052
2093
  const completed = await path.complete({ payload, replyText, paused,
2053
2094
  footer, footerDetail, milestones })
2095
+ this._observeActivity('finish', { chatId, receiptId,
2096
+ status: quotaFailure || closedFailure ? 'error' : payload.quiet || projected.display.silent ? 'silent' : 'answered',
2097
+ reply: redact(quotaFailure ? T.quotaFailure : replyText || '', { pii: job.group?.redactPii !== false }),
2098
+ err: quotaFailure ? T.quotaFailure : closedFailure ? terminalReplyText(payload) : '',
2099
+ tokens: rt.v2UsageDecision?.tokens || null, sessionId: projected.display.sessionId })
2054
2100
  const after = authority.snapshot()
2055
2101
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
2056
2102
  let successor = null
@@ -2180,21 +2226,25 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2180
2226
  const policy = manifestPolicy(group.permissionMode, provider.runner === 'codex',
2181
2227
  group.allowedTools, group.disallowedTools)
2182
2228
  const brand = this.cfg.brand || 'SintBot'
2229
+ const groupTools = group.platform === 'feishu' && manifestFeatures(platform.features || {}).skills
2183
2230
  const info = group.repo && this.repoInfo ? this.repoInfo(group.workdir, group.repo) : null
2184
2231
  const manifest = [
2185
2232
  ...manifestHeader({ restrictedV2: true, brand, chatLabel: platform.chatLabel || '项目群',
2186
2233
  chatId: msg.chatId, groupName: group.name || msg.chatId,
2187
2234
  workdir: group.workdir, repo: group.repo || '', repoState: info?.state || '',
2188
2235
  repoOrigin: info?.origin || '未知', pushDenied: policy.pushDenied,
2189
- providerName, model: group.model || provider.model || '',
2236
+ providerName, model: modelContext(group, provider).model,
2237
+ runtimeVersion: this.durableV2Version || '', runtimeCommand: runtimeCommand(),
2190
2238
  noVision: provider.vision === false, platformName: platform.chatLabel || '项目群',
2191
2239
  permissionTier: policy.tier, tz: this.tz, publicUrl: '', uiUrl: '', lanUrl: '' }),
2192
2240
  ...manifestContext({ restrictedV2: true, brand, isCodex: provider.runner === 'codex',
2241
+ groupCommands: GROUP_COMMANDS, groupTools,
2242
+ consoleEnabled: this.durableV2ConsoleEnabled === true,
2193
2243
  pmem: this.projectMemory ? this.projectMemory(group.workdir,
2194
2244
  { full: policy.full, isCodex: provider.runner === 'codex' }) : null,
2195
2245
  unredactedPii: group.redactPii === false,
2196
2246
  cardPinned: platform.features?.cardPinned === true, plainSend: false }),
2197
- ...releaseSkillLines(platform),
2247
+ ...(groupTools ? releaseSkillLines(platform) : []),
2198
2248
  ].join('\n')
2199
2249
  // Reinject current supported context each turn. No legacy feature menu,
2200
2250
  // external-message commands or v1 manifest refresh scheduler is included.
@@ -2269,6 +2319,7 @@ requestDurableV2Stop(chatId) {
2269
2319
  payloadHandles: this.durableV2Payloads,
2270
2320
  assertWriterLock: this.durableV2AssertWriterLock })
2271
2321
  rt.v2QueueReceiptIds = rt.v2QueueReceiptIds.filter(id => id !== receiptId)
2322
+ this._observeActivity('finish', { chatId, receiptId, status: 'recalled' })
2272
2323
  return true
2273
2324
  } catch (error) { authority?.close(); this.log(error.message); return false }
2274
2325
  }
package/src/feishucli.js CHANGED
@@ -20,7 +20,7 @@ const { redact } = require('./core/redact')
20
20
  const { fmtStamp, normalizeTz } = require('./core/time')
21
21
 
22
22
  const FEISHU_MESSAGE_ACTIONS = Object.freeze(['send-msg', 'send-notice', 'send-file', 'send-image',
23
- 'history', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
23
+ 'history', 'answers', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
24
24
 
25
25
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
26
26
  const green = (s) => `\x1b[32m${s}\x1b[0m`
@@ -44,6 +44,8 @@ function parseFeishuArgs(action, rawArgs) {
44
44
  else if (a.startsWith('--chat=')) o.chat = a.slice(7)
45
45
  else if (a === '--config') o.config = rawArgs[++i]
46
46
  else if (a.startsWith('--config=')) o.config = a.slice(9)
47
+ else if (a === '--data-dir') o.dataDir = rawArgs[++i]
48
+ else if (a.startsWith('--data-dir=')) o.dataDir = a.slice(11)
47
49
  else if (action === 'send-notice' && a === '--sub') o.subtitle = rawArgs[++i]
48
50
  else if (action === 'send-notice' && a === '--tone') o.tone = rawArgs[++i]
49
51
  else if (action === 'send-notice' && a === '--warn') o.warnings.push(rawArgs[++i])
@@ -102,6 +104,15 @@ async function runFeishuAction(args, deps = {}) {
102
104
  const out = deps.stdout || ((s) => process.stdout.write(s))
103
105
  const err = deps.stderr || ((s) => process.stderr.write(s))
104
106
  const p = opts.positionals
107
+ if (action === 'answers') {
108
+ if (!cfg.groups?.[chatId]) refuse('目标群没有绑定,不能读取本机答复记录')
109
+ const { readRecordedAnswers } = require('./recordedanswers')
110
+ const result = readRecordedAnswers({ dataDir: opts.dataDir || (deps.env || process.env).SBOT_DATA_DIR,
111
+ chatId, id: p[0] || '' })
112
+ out(JSON.stringify(result, null, 2) + '\n')
113
+ if (!result.answers.length) err('未找到本群匹配记录;可能已过保留范围或尚未落盘,不能推断原卡正文。\n')
114
+ return result
115
+ }
105
116
  // Usage errors before any network call, worded as the legacy entry did.
106
117
  let notice
107
118
  if (action === 'send-msg' && !p.join(' ').trim()) refuse('缺少消息内容 — sbot feishu send-msg "<文本>"')
@@ -200,6 +211,7 @@ function feishuUsage(cmd = 'sintbot') {
200
211
  + ' send-notice "<标题>" "<正文>" 发群通知卡(可加 --sub / --tone / --warn / --from)\n'
201
212
  + ' send-file <路径> · send-image <路径> 发文件 / 图片\n'
202
213
  + ' history [n] 拉最近 n 条(默认 20,最多 50):每条带 message_id,并标 →@某某·未@bot / →@bot\n'
214
+ + ' answers [消息或轮次ID] 只读本群本机答复记录;默认最近 10 轮,不代表完整飞书历史\n'
203
215
  + ' reply <message_id> "<文本>" 回复某条\n'
204
216
  + ' edit <message_id> "<文本>" 改自己发过的消息\n'
205
217
  + ' recall <message_id> 撤回自己发过的消息(别名 delete)\n'
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ // Configuration projection shared by chat, cards and the console. A configured
4
+ // model is not proof of what an executor ultimately selected or fell back to.
5
+ function modelContext(group = {}, provider = {}) {
6
+ group ||= {}; provider ||= {}
7
+ const environment = provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL
8
+ const model = group.model || provider.model || environment || ''
9
+ return { model, source: group.model ? 'group' : provider.model ? 'provider' : environment ? 'environment' : 'default',
10
+ actualModel: null }
11
+ }
12
+
13
+ module.exports = { modelContext }
@@ -181,9 +181,10 @@ function createCardWriter({
181
181
  client, cardId, elementId = 'answer', log = () => {}, now = () => Date.now(),
182
182
  throttleMs = 0, streamWindowMs = STREAM_WINDOW_MS, setTimer = setTimeout, clearTimer = clearTimeout,
183
183
  operationBoundary = null, operationKey = null,
184
+ initialText = '', initialSequence = 1,
184
185
  } = {}) {
185
186
  const openedAt = now()
186
- let seq = 1 // 流式与整卡更新共用的**唯一**序号分配器(混用同一张卡必须同源)
187
+ let seq = initialSequence // Includes any confirmed settings call before this writer opens.
187
188
  let streamOpen = true // 飞书侧流式模式还开着吗
188
189
  // ⚠ 「已收尾」与「已冻结」是两件事,合成一个标记会吃掉一条兜底路径:撤回失败时调用方
189
190
  // 要退一句中性收尾(engine 的 dropAckCard),而 recall 若把 finish 一并封死,那张卡就
@@ -195,8 +196,9 @@ function createCardWriter({
195
196
  let pushes = 0 // 流式帧 cardElement.content
196
197
  let fulls = 0 // 整卡更新 card.update
197
198
  let closes = 0 // 关流式 card.settings
198
- let lastSeen = '' // 最近一次收到的累积文本(判重用)
199
- let confirmedText = null // 只记录平台确认过的正文,不能把已派发当成已送达
199
+ const resumes = initialText ? 1 : 0 // Confirmed pre-writer streaming enable.
200
+ let lastSeen = initialText // Seed only after the snapshot's create/post have been confirmed.
201
+ let confirmedText = initialText || null // 只记录平台确认过的正文,不能把已派发当成已送达
200
202
  let chain = Promise.resolve()
201
203
  let finishResult = null
202
204
  let boundaryError = null
@@ -363,7 +365,7 @@ function createCardWriter({
363
365
  // ⚠ 这行必须打在**收尾之后**:收尾自己那一次调用(整卡更新 / 关流式)就发生在这个
364
366
  // 回调里。原先它打在回调外、且只报节流器的派发次数 ⇒ 降级后的整卡更新与收尾那次
365
367
  // 全在账外(实测漏计 ≈44 次/天)。注释写着「配额账可实测」,那就得真能对上账。
366
- log(`card ops: ${pushes + fulls + closes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes})`)
368
+ log(`card ops: ${pushes + fulls + closes + resumes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes}${resumes ? ` · 恢复流式 ${resumes}` : ''})`)
367
369
  }
368
370
  }, { terminal: true })
369
371
  return finishResult
@@ -371,7 +373,7 @@ function createCardWriter({
371
373
  // recall:先断掉后续流式帧再去删消息。⚠ 只冻结不收尾 —— 撤不掉时调用方还要退一句中性收尾。
372
374
  markClosed() { frozen = true; flushedRevision = -1 },
373
375
  settle: () => chain.then((result) => { if (boundaryError) throw boundaryError; return result }),
374
- stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes, streamOpen, seq }),
376
+ stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes + resumes, streamOpen, seq }),
375
377
  }
376
378
  }
377
379
 
@@ -858,9 +860,10 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
858
860
  // effect. `finish` flips streaming_mode off so the card settles.
859
861
  // All ops are serialized so the per-card `sequence` stays strictly increasing.
860
862
  async createStreamingCard(replyCtx, { title = 'sbot', operationBoundary = null,
861
- operationKey = null } = {}) {
863
+ operationKey = null, initialText = '' } = {}) {
862
864
  const chatId = replyCtx && replyCtx.chatId
863
865
  if (!chatId) return null
866
+ if (typeof initialText !== 'string') throw new TypeError('card initial text must be a string')
864
867
  if (operationBoundary && (typeof operationBoundary !== 'function' ||
865
868
  typeof operationKey !== 'string' || !operationKey ||
866
869
  Buffer.byteLength(operationKey) > 256)) {
@@ -870,8 +873,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
870
873
  // No header — the reply card shows just the answer body (no "sbot" title line).
871
874
  const cardJson = {
872
875
  schema: '2.0',
873
- config: { streaming_mode: true, summary: { content: '' }, streaming_config: { print_strategy: 'fast' } },
874
- body: { elements: [{ tag: 'markdown', content: '', element_id: ELEM }] },
876
+ config: { streaming_mode: !initialText, summary: { content: '' }, streaming_config: { print_strategy: 'fast' } },
877
+ body: { elements: [{ tag: 'markdown', content: initialText, element_id: ELEM }] },
875
878
  }
876
879
 
877
880
  let cardId
@@ -900,6 +903,22 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
900
903
  const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId,
901
904
  { operationBoundary, operationKey })
902
905
  msgId = posted
906
+ // A moved card is first delivered with its complete, static snapshot.
907
+ // Enable streaming only afterward: old text never enters the typewriter
908
+ // endpoint, and the next cumulative frame continues from this baseline.
909
+ if (initialText) {
910
+ const sequence = 1
911
+ const uuid = crypto.randomUUID()
912
+ await cardOperation(operationBoundary, operationKey, 'card.settings',
913
+ { cardId, sequence, uuid }, async () => {
914
+ const response = assertCardOk(await client.cardkit.v1.card.settings({
915
+ path: { card_id: cardId },
916
+ data: { settings: JSON.stringify({ config: { streaming_mode: true } }), sequence, uuid },
917
+ }), '恢复流式')
918
+ if (response?.code !== 0) throw new Error('恢复流式未确认:未返回 code=0')
919
+ return response
920
+ })
921
+ }
903
922
  } catch (e) {
904
923
  log('feishu card create error: ' + (e && e.message))
905
924
  if (operationBoundary) throw e
@@ -908,7 +927,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
908
927
 
909
928
  // 全部写操作交给写入器(流式窗口判断、序号分配、错误码检查都在它一处)。
910
929
  const writer = createCardWriter({ client, cardId, elementId: ELEM, log,
911
- throttleMs: cardThrottleMs, operationBoundary, operationKey })
930
+ throttleMs: cardThrottleMs, operationBoundary, operationKey,
931
+ initialText, initialSequence: initialText ? 2 : 1 })
912
932
 
913
933
  return {
914
934
  durableTarget() {
@@ -0,0 +1,27 @@
1
+ 'use strict'
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const { redact } = require('./core/redact')
5
+
6
+ // Read only the bounded observation file. Never open/rewrite execution journals
7
+ // or infer that a stored draft reached Feishu.
8
+ function readRecordedAnswers({ dataDir, chatId, id = '' }) {
9
+ if (!path.isAbsolute(dataDir || '') || !chatId) throw new Error('缺少当前服务的数据目录或群信息,无法读取本机答复记录')
10
+ const file = path.join(dataDir, 'activity-v1.json')
11
+ let data
12
+ try {
13
+ if (fs.statSync(file).size > 12 * 1024 * 1024) throw new Error('size')
14
+ data = JSON.parse(fs.readFileSync(file, 'utf8'))
15
+ if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > 256) throw new Error('format')
16
+ } catch { throw new Error('本机答复记录不存在或不可读;不能据此判断机器人没有回复') }
17
+ const rows = data.rows.filter(row => row && row.chatId === chatId && typeof row.id === 'string' &&
18
+ (!id || row.id === id || Array.isArray(row.receiptIds) && row.receiptIds.includes(id) ||
19
+ Array.isArray(row.messages) && row.messages.some(message => message?.sourceMessageId === id)))
20
+ .sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, id ? 1 : 10)
21
+ return { source: 'local-activity-v1', retention: '全服务最多保留最近 256 轮;每轮答复最多 16384 字符;旧记录可能没有消息 ID',
22
+ chatId, answers: rows.map(row => ({ id: row.id, ts: row.ts,
23
+ status: row.status, deliveryConfirmed: row.status === 'answered',
24
+ truncated: !!row.truncated, reply: redact(String(row.reply || ''), { pii: false }).slice(0, 16384),
25
+ sourceMessageIds: (Array.isArray(row.messages) ? row.messages : []).map(message => message?.sourceMessageId).filter(id => typeof id === 'string') })) }
26
+ }
27
+ module.exports = { readRecordedAnswers }
@@ -0,0 +1,11 @@
1
+ 'use strict'
2
+
3
+ // Supported surfaces of the release host. These are facts passed to the core
4
+ // renderer, not an alternative implementation of core policy.
5
+ const GROUP_COMMANDS = Object.freeze(['/help', '/status', '/model', '/new', '/hud', '/issue', '/skip', '/stop'])
6
+ const RUNTIME_ENTRY = require('node:path').join(__dirname, '..', 'bin', 'sintbot-v2.js')
7
+ const quoteShell = value => "'" + String(value).replace(/'/g, "'\\''") + "'"
8
+ function runtimeCommand(entry = RUNTIME_ENTRY, node = process.execPath) {
9
+ return `${quoteShell(node)} ${quoteShell(entry)}`
10
+ }
11
+ module.exports = { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand }