opencode-feishu-plugin 0.2.6 → 0.2.7

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.
package/README.en.md CHANGED
@@ -402,9 +402,21 @@ Without this relay, any clarifying question would stall the Feishu session forev
402
402
  | `topicStatusInTitle` | boolean | `false` | Add a status emoji prefix to the root card title (e.g. `🟡 session name`). Off by default: the topic name shows in the sidebar, and flipping it would be noisy |
403
403
  | `topicStatusThrottleMs` | number | `1000` | Min root-card status refresh interval (clamped 500–10000); patched only when the kind changes |
404
404
  | `cardMaxTables` | number | `4` | Max markdown tables kept per card (clamped 1–5); tables beyond it are degraded **cumulatively per card** into fenced code blocks (no content lost) to avoid Feishu 400 `code=230099` |
405
+ | `runnerCardMaxTools` | number | `12` | Max tool blocks kept on the run card (1–50); older ones collapse into "…omitted N tool calls" |
406
+ | `runnerCardTextMax` | number | `2048` | Per-text-block character cap on the run card (512–8192) |
407
+ | `finalAnswerMinChars` | number | `600` | Final answers at least this long are sent **as their own card/file** (`0` disables splitting) |
408
+ | `finalAnswerFileMinBytes` | number | `20480` | Final answers at least this many bytes are delivered as a `.md` file (8192–102400) |
405
409
  | `keepalive` | boolean | `true` | **Location keep-alive**: periodically emits activity so OpenCode does not evict the idle Location after 60 minutes (which unloads the plugin and closes the Feishu long connection) |
406
410
  | `keepaliveIntervalMs` | number | `1200000` | Keep-alive interval (default 20 min, clamped 5–45); must stay well below OpenCode's hardcoded 60-minute TTL |
407
411
 
412
+ ### Long answers (run-card slimming + separate final answer)
413
+
414
+ A long turn (dozens of tool calls) can push a single run card to its limits (28KB / 200 elements), triggering degradation and **dropping the oldest blocks** — the card looks "full" and earlier content disappears. Default strategy:
415
+
416
+ 1. **The run card is progress-only**: keep the last `runnerCardMaxTools` (default 12) tool blocks, collapsing older ones into "…omitted N tool calls"; each text block is capped at `runnerCardTextMax` (default 2KB).
417
+ 2. **The final answer is sent separately**: when a turn ends and the trailing text is at least `finalAnswerMinChars` (default 600 chars), it is sent as its own "✅ 完整回答" card; the run card keeps only a short notice.
418
+ 3. **Very long answers become files**: at least `finalAnswerFileMinBytes` (default 20KB) → delivered as a `.md` file (preview/download), never truncated.
419
+
408
420
  ### Location keep-alive (on by default)
409
421
 
410
422
  OpenCode **evicts idle Locations**, which unloads plugins and closes the Feishu long connection:
package/README.md CHANGED
@@ -263,6 +263,10 @@ agent 调 `question` 等 form 类交互时,插件把它转成飞书卡片:
263
263
  | `topicStatusInTitle` | boolean | `false` | 是否在根卡标题加状态 emoji 前缀 |
264
264
  | `topicStatusThrottleMs` | number | `1000` | 根卡状态刷新最小间隔(500–10000) |
265
265
  | `cardMaxTables` | number | `4` | 单卡最多保留的 markdown 表格数(1–5);超出按整卡累计降级为围栏代码块,避免飞书 400 `code=230099` |
266
+ | `runnerCardMaxTools` | number | `12` | 运行卡最多保留的工具块数(1–50);更早的合并为「已省略前 N 次工具调用」 |
267
+ | `runnerCardTextMax` | number | `2048` | 运行卡单个文本块字符上限(512–8192) |
268
+ | `finalAnswerMinChars` | number | `600` | 最终回答 ≥ 该长度即**单独成卡/成文件**(0 = 关闭拆分) |
269
+ | `finalAnswerFileMinBytes` | number | `20480` | 最终回答 ≥ 该字节数转为 `.md` 文件发送(8192–102400) |
266
270
  | `keepalive` | boolean | `true` | **位置保活**:周期性向 opencode 发一次活动,阻止 60 分钟空闲回收 location(会关掉飞书长连接、机器人失联) |
267
271
  | `keepaliveIntervalMs` | number | `1200000` | 保活间隔(默认 20 分钟,夹取 5–45);必须显著小于 opencode 硬编码的 60 分钟 TTL |
268
272
 
@@ -284,6 +288,16 @@ permission.evaluate (插件 hook) permission.asked (事件流)
284
288
  - **三重单人边界**:可用范围「仅本人」+ 不申请群权限 + 代码层 open_id 白名单。
285
289
  - **`always` 语义**:仅当请求带 `save[]` 时才持久化,否则等价于「允许一次」。
286
290
 
291
+ ### 长回答处理(运行卡瘦身 + 最终答案独立)
292
+
293
+ 长任务(几十次工具调用)会把单张运行卡撑到上限(28KB / 200 元素),触发降级与**丢弃最旧块**——用户会看到"卡被撑满、前面内容消失"。默认策略:
294
+
295
+ 1. **运行卡只做进度**:最多保留最近 `runnerCardMaxTools`(默认 12)个工具块,更早的合并为「…已省略前 N 次工具调用」;单个文本块上限 `runnerCardTextMax`(默认 2KB)。
296
+ 2. **最终答案独立发送**:一轮结束时末尾文本 ≥ `finalAnswerMinChars`(默认 600 字符)→ 单独发一张「✅ 完整回答」卡(不与被工具噪声塞满的运行卡抢空间);运行卡内只留「完整回答已单独发送」提示。
297
+ 3. **超长转文件**:最终回答 ≥ `finalAnswerFileMinBytes`(默认 20KB)→ 作为 `.md` 文件发送(可预览/下载),内容不截断、不丢失。
298
+
299
+ > 想要"完整轨迹保留、不省略工具调用":把 `runnerCardMaxTools` 调到足够大并接受卡片消息变多(或提高 `finalAnswerMinChars` 降低拆分频率)。
300
+
287
301
  ### 位置保活(防空闲失联,默认开启)
288
302
 
289
303
  opencode 会**回收空闲的 location**,这会连带卸载插件、关闭飞书长连接:
package/dist/index.js CHANGED
@@ -144025,6 +144025,10 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144025
144025
  const topicStatusThrottleMs = clamp4(asNumber(merged.topicStatusThrottleMs, 1e3), 500, 1e4);
144026
144026
  const cardMaxTables = clamp4(asNumber(merged.cardMaxTables, 4), 1, 5);
144027
144027
  const keepalive = asBoolean(merged.keepalive, true);
144028
+ const runnerCardMaxTools = clamp4(asNumber(merged.runnerCardMaxTools, 12), 1, 50);
144029
+ const runnerCardTextMax = clamp4(asNumber(merged.runnerCardTextMax, 2048), 512, 8192);
144030
+ const finalAnswerMinChars = clamp4(asNumber(merged.finalAnswerMinChars, 600), 0, 2e4);
144031
+ const finalAnswerFileMinBytes = clamp4(asNumber(merged.finalAnswerFileMinBytes, 20 * 1024), 8192, 102400);
144028
144032
  const keepaliveIntervalMs = clamp4(
144029
144033
  asNumber(merged.keepaliveIntervalMs, 20 * 60 * 1e3),
144030
144034
  5 * 60 * 1e3,
@@ -144081,7 +144085,11 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144081
144085
  topicStatusThrottleMs,
144082
144086
  cardMaxTables,
144083
144087
  keepalive,
144084
- keepaliveIntervalMs
144088
+ keepaliveIntervalMs,
144089
+ runnerCardMaxTools,
144090
+ runnerCardTextMax,
144091
+ finalAnswerMinChars,
144092
+ finalAnswerFileMinBytes
144085
144093
  };
144086
144094
  }
144087
144095
  function loadConfigFile(env, deps, warnings) {
@@ -145173,6 +145181,29 @@ function createFeishuSender(client, log2, options2 = {}) {
145173
145181
  return void 0;
145174
145182
  }
145175
145183
  },
145184
+ async sendFile(chatId, fileName, content, replyToMessageId) {
145185
+ try {
145186
+ const uploaded = await client.im.file.create({
145187
+ data: { file_type: "stream", file_name: fileName, file: content }
145188
+ });
145189
+ const fileKey = uploaded?.file_key ?? "";
145190
+ if (!fileKey) return { ok: false, error: "missing file_key" };
145191
+ const payload = { msg_type: "file", content: JSON.stringify({ file_key: fileKey }) };
145192
+ const res = replyToMessageId ? await client.im.message.reply({ path: { message_id: replyToMessageId }, data: payload }) : await client.im.message.create({
145193
+ params: { receive_id_type: "chat_id" },
145194
+ data: { receive_id: chatId, ...payload }
145195
+ });
145196
+ if (res?.code && res.code !== 0) {
145197
+ log2.warn("\u53D1\u9001\u6587\u4EF6\u5931\u8D25", { chatId, fileName, code: res.code, msg: res.msg });
145198
+ return { ok: false, error: `code=${res.code} msg=${res.msg ?? ""}` };
145199
+ }
145200
+ const messageId = res?.data?.message_id ?? "";
145201
+ return messageId ? { ok: true, messageId } : { ok: true };
145202
+ } catch (err2) {
145203
+ log2.warn("\u53D1\u9001\u6587\u4EF6\u5F02\u5E38", { chatId, fileName, error: describeLarkError(err2) });
145204
+ return { ok: false, error: describeLarkError(err2) };
145205
+ }
145206
+ },
145176
145207
  async deleteMessage(messageId) {
145177
145208
  if (!messageId) return { ok: false, error: "missing messageId" };
145178
145209
  try {
@@ -145885,6 +145916,8 @@ function reduce6(state, event) {
145885
145916
  status: "error",
145886
145917
  output: event.output
145887
145918
  }));
145919
+ case "final.separated":
145920
+ return { ...state, finalSeparated: true };
145888
145921
  default:
145889
145922
  return state;
145890
145923
  }
@@ -146061,6 +146094,14 @@ function closeCodeFences(text) {
146061
146094
  if (fences && fences.length % 2 !== 0) return `${text}${CODE_FENCE}`;
146062
146095
  return text;
146063
146096
  }
146097
+ function buildFinalAnswerCard(text) {
146098
+ return {
146099
+ schema: "2.0",
146100
+ config: { update_multi: true },
146101
+ header: { title: { tag: "plain_text", content: "\u2705 \u5B8C\u6574\u56DE\u7B54" }, template: "green" },
146102
+ body: { elements: [{ tag: "markdown", content: truncateCardContent(text, 24 * 1024) }] }
146103
+ };
146104
+ }
146064
146105
 
146065
146106
  // src/feishu/run-renderer.ts
146066
146107
  var COLLAPSE_TOOL_THRESHOLD = 3;
@@ -146068,11 +146109,30 @@ var HEADER_SUMMARY_MAX = 80;
146068
146109
  var TOOL_INPUT_MAX = 600;
146069
146110
  var OUTPUT_FIRST_LINE_MAX = 200;
146070
146111
  var TEXT_ELEMENT_MAX = 8 * 1024;
146112
+ var DEFAULT_MAX_TOOLS = 12;
146113
+ var DEFAULT_TEXT_MAX = 2 * 1024;
146071
146114
  function renderRunCard(state, stopValue, options2 = {}) {
146072
146115
  const elements = [];
146073
- for (const group of groupBlocks(state.blocks)) {
146116
+ const maxTools = options2.maxTools ?? DEFAULT_MAX_TOOLS;
146117
+ const textMax = options2.textMax ?? DEFAULT_TEXT_MAX;
146118
+ const capped = capToolBlocks(state.blocks, maxTools);
146119
+ if (capped.omitted > 0) {
146120
+ elements.push(note(`\u2026\u5DF2\u7701\u7565\u524D ${capped.omitted} \u6B21\u5DE5\u5177\u8C03\u7528\uFF08\u5B8C\u6574\u8BB0\u5F55\u89C1 opencode \u4F1A\u8BDD\uFF09`));
146121
+ }
146122
+ const groups = groupBlocks(capped.blocks);
146123
+ const lastTextIndex = groups.reduce(
146124
+ (acc, g, i) => g.kind === "text" && g.content.trim() ? i : acc,
146125
+ -1
146126
+ );
146127
+ for (let i = 0; i < groups.length; i += 1) {
146128
+ const group = groups[i];
146074
146129
  if (group.kind === "text") {
146075
- if (group.content.trim()) elements.push(markdown(truncateCardContent(group.content, TEXT_ELEMENT_MAX)));
146130
+ if (!group.content.trim()) continue;
146131
+ if (state.finalSeparated && i === lastTextIndex) {
146132
+ elements.push(note("\u2705 \u5B8C\u6574\u56DE\u7B54\u5DF2\u5355\u72EC\u53D1\u9001\uFF08\u89C1\u4E0B\u65B9\u5361\u7247 / \u9644\u4EF6\uFF09"));
146133
+ continue;
146134
+ }
146135
+ elements.push(markdown(truncateCardContent(group.content, Math.min(textMax, TEXT_ELEMENT_MAX))));
146076
146136
  } else {
146077
146137
  elements.push(...renderToolGroup(group.tools, state.terminal !== "running"));
146078
146138
  }
@@ -146109,6 +146169,20 @@ function renderRunCard(state, stopValue, options2 = {}) {
146109
146169
  }
146110
146170
  return enforceSize(guarded);
146111
146171
  }
146172
+ function capToolBlocks(blocks, maxTools) {
146173
+ const total2 = blocks.reduce((n, b) => n + (b.kind === "tool" ? 1 : 0), 0);
146174
+ if (maxTools <= 0 || total2 <= maxTools) return { blocks: [...blocks], omitted: 0 };
146175
+ let toDrop = total2 - maxTools;
146176
+ const out = [];
146177
+ for (const block of blocks) {
146178
+ if (block.kind === "tool" && toDrop > 0) {
146179
+ toDrop -= 1;
146180
+ continue;
146181
+ }
146182
+ out.push(block);
146183
+ }
146184
+ return { blocks: out, omitted: total2 - maxTools };
146185
+ }
146112
146186
  function groupBlocks(blocks) {
146113
146187
  const groups = [];
146114
146188
  let toolBuf = [];
@@ -146295,6 +146369,19 @@ function byteLength(value3) {
146295
146369
  }
146296
146370
 
146297
146371
  // src/feishu/run-controller.ts
146372
+ function trailingText(blocks) {
146373
+ const parts = [];
146374
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
146375
+ const block = blocks[i];
146376
+ if (block.kind === "tool") {
146377
+ if (parts.length > 0) break;
146378
+ continue;
146379
+ }
146380
+ if (block.content.trim()) parts.unshift(block.content);
146381
+ else if (parts.length > 0) break;
146382
+ }
146383
+ return parts.join("\n\n").trim();
146384
+ }
146298
146385
  function createRunController(deps) {
146299
146386
  const sessions = /* @__PURE__ */ new Map();
146300
146387
  const throttlers = /* @__PURE__ */ new Map();
@@ -146312,6 +146399,8 @@ function createRunController(deps) {
146312
146399
  };
146313
146400
  const renderCard = (sessionID, state) => renderRunCard(state, deps.buildStopValue?.(sessionID), {
146314
146401
  maxTables: deps.cardMaxTables,
146402
+ maxTools: deps.runnerCardMaxTools,
146403
+ textMax: deps.runnerCardTextMax,
146315
146404
  onLimit: (report) => {
146316
146405
  deps.log.warn("\u8FD0\u884C\u5361\u5185\u5BB9\u8D85\u9650\uFF0C\u5DF2\u964D\u7EA7", {
146317
146406
  sessionID,
@@ -146389,7 +146478,8 @@ function createRunController(deps) {
146389
146478
  state,
146390
146479
  chain: Promise.resolve(),
146391
146480
  finalized: false,
146392
- queuedAt: Date.now()
146481
+ queuedAt: Date.now(),
146482
+ ...input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}
146393
146483
  };
146394
146484
  if (input.delivery === "queue") {
146395
146485
  runs.queued.push(card);
@@ -146419,7 +146509,29 @@ function createRunController(deps) {
146419
146509
  }
146420
146510
  const card = runs.active;
146421
146511
  if (!card) return;
146422
- if (event.type === "execution.succeeded" || event.type === "execution.failed") {
146512
+ if (event.type === "execution.succeeded") {
146513
+ const text = trailingText(card.state.blocks);
146514
+ const long = deps.sendFinalAnswer !== void 0 && deps.finalAnswer !== void 0 && text.length >= deps.finalAnswer.minChars;
146515
+ if (long) {
146516
+ card.state = reduce6(reduce6(card.state, { type: "final.separated" }), event);
146517
+ finalize2(card);
146518
+ runs.active = void 0;
146519
+ void deps.sendFinalAnswer({
146520
+ sessionID,
146521
+ chatId: card.chatId,
146522
+ ...card.replyToMessageId ? { replyToMessageId: card.replyToMessageId } : {},
146523
+ text
146524
+ }).catch((err2) => {
146525
+ deps.log.warn("\u6700\u7EC8\u7B54\u6848\u53D1\u9001\u5931\u8D25", { sessionID, error: errorMessage(err2) });
146526
+ });
146527
+ return;
146528
+ }
146529
+ update2(card, event, true);
146530
+ finalize2(card);
146531
+ runs.active = void 0;
146532
+ return;
146533
+ }
146534
+ if (event.type === "execution.failed") {
146423
146535
  update2(card, event, true);
146424
146536
  finalize2(card);
146425
146537
  runs.active = void 0;
@@ -151194,9 +151306,38 @@ async function start(ctx, config, log2, logSink) {
151194
151306
  enabled: config.stream,
151195
151307
  throttleMs: config.streamThrottleMs,
151196
151308
  cardMaxTables: config.cardMaxTables,
151309
+ runnerCardMaxTools: config.runnerCardMaxTools,
151310
+ runnerCardTextMax: config.runnerCardTextMax,
151311
+ finalAnswer: { minChars: config.finalAnswerMinChars },
151312
+ // 长回答单独成卡/成文件:运行卡只留「完整回答已单独发送」提示。
151313
+ sendFinalAnswer: (input) => sendFinalAnswer(input),
151197
151314
  // 每次 patch 重签强停 token(`stop` 在下方定义,闭包运行时才求值)。
151198
151315
  buildStopValue: (sessionID) => stop.buildStopValue(sessionID)
151199
151316
  });
151317
+ async function sendFinalAnswer(input) {
151318
+ const bytes = Buffer.byteLength(input.text, "utf8");
151319
+ if (bytes > config.finalAnswerFileMinBytes) {
151320
+ const fileName = `opencode-${input.sessionID.slice(-6)}.md`;
151321
+ const res2 = await sender.sendFile(
151322
+ input.chatId,
151323
+ fileName,
151324
+ Buffer.from(input.text, "utf8"),
151325
+ input.replyToMessageId
151326
+ );
151327
+ if (res2.ok) {
151328
+ log2.info("\u6700\u7EC8\u7B54\u6848\u5DF2\u8F6C\u4E3A\u6587\u4EF6\u53D1\u9001", { sessionID: input.sessionID, bytes, fileName });
151329
+ return;
151330
+ }
151331
+ log2.warn("\u6700\u7EC8\u7B54\u6848\u8F6C\u6587\u4EF6\u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u5361\u7247", { sessionID: input.sessionID, error: res2.error ?? "unknown" });
151332
+ }
151333
+ const card = buildFinalAnswerCard(input.text);
151334
+ const res = input.replyToMessageId ? await sender.replyCard(input.replyToMessageId, card) : await sender.sendCard(input.chatId, card);
151335
+ if (!res.ok) {
151336
+ log2.warn("\u6700\u7EC8\u7B54\u6848\u5361\u7247\u53D1\u9001\u5931\u8D25", { sessionID: input.sessionID, error: res.error ?? "unknown" });
151337
+ return;
151338
+ }
151339
+ log2.info("\u6700\u7EC8\u7B54\u6848\u5DF2\u5355\u72EC\u6210\u5361", { sessionID: input.sessionID, bytes });
151340
+ }
151200
151341
  const recovery = createSessionRecovery({
151201
151342
  log: log2,
151202
151343
  resolveLink: (sessionID) => sessionMap.resolveBySession(sessionID),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Feishu/Lark plugin for OpenCode V2 — single-user topics as sessions, in-card permission approvals, streaming cards. Minimal permissions, no public endpoint.",
5
5
  "type": "module",
6
6
  "main": "index.js",