@vibe-lark/larkpal 0.1.34 → 0.1.38

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/bin/larkpal.js CHANGED
File without changes
package/dist/main.mjs CHANGED
@@ -375,7 +375,8 @@ const TOOL_DISPLAY_NAMES = {
375
375
  WebSearch: "正在搜索网页",
376
376
  WebFetch: "正在获取网页内容",
377
377
  TodoWrite: "正在更新任务列表",
378
- Task: "正在执行子任务"
378
+ Task: "正在执行子任务",
379
+ _AutoCompact: "正在压缩对话历史"
379
380
  };
380
381
  /** 获取工具的中文显示名称,未知工具返回通用描述 */
381
382
  function getToolDisplayName(toolName) {
@@ -420,6 +421,13 @@ var CCStreamParser = class extends EventEmitter {
420
421
  this.emit("parseError", err, trimmed);
421
422
  return;
422
423
  }
424
+ if (msg.parent_tool_use_id != null && msg.type !== "result") {
425
+ log$32.debug("跳过 subagent 内部事件", {
426
+ type: msg.type,
427
+ parentToolUseId: msg.parent_tool_use_id
428
+ });
429
+ return;
430
+ }
423
431
  switch (msg.type) {
424
432
  case "stream_event":
425
433
  this.handleStreamEvent(msg);
@@ -524,6 +532,11 @@ var CCStreamParser = class extends EventEmitter {
524
532
  this.emit("toolUseStart", block.name, block.input ?? {});
525
533
  }
526
534
  }
535
+ const usage = msg.message?.usage;
536
+ if (usage && typeof usage.input_tokens === "number") this.emit("usage", {
537
+ input_tokens: usage.input_tokens,
538
+ output_tokens: usage.output_tokens
539
+ });
527
540
  this.emit("assistant", msg);
528
541
  }
529
542
  /**
@@ -876,6 +889,37 @@ const IDLE_TIMEOUT_MS = (() => {
876
889
  if (isNaN(seconds) || seconds < 0) return 0;
877
890
  return seconds * 1e3;
878
891
  })();
892
+ /**
893
+ * 自动压缩阈值(input_tokens 数)。
894
+ *
895
+ * 当 CC 返回的 input_tokens 超过此阈值时,在下一次用户消息发送前
896
+ * 自动执行 /compact 命令压缩对话历史。
897
+ *
898
+ * 通过 LARKPAL_CC_AUTO_COMPACT_THRESHOLD 环境变量配置,默认 50000(50K tokens)。
899
+ * 设为 0 表示禁用自动压缩。
900
+ *
901
+ * 参考数据:200K context 模型在 50K tokens 时 TTFT ≈ 7-10s(线性区间),
902
+ * 超过后延迟快速增长。压缩后目标约 20-30K tokens。
903
+ */
904
+ const AUTO_COMPACT_THRESHOLD = (() => {
905
+ const envVal = process.env.LARKPAL_CC_AUTO_COMPACT_THRESHOLD;
906
+ if (envVal === void 0 || envVal === "") return 5e4;
907
+ const tokens = parseInt(envVal, 10);
908
+ if (isNaN(tokens) || tokens < 0) return 5e4;
909
+ return tokens;
910
+ })();
911
+ /**
912
+ * CC 推理努力级别。
913
+ *
914
+ * 控制模型的 thinking budget(思维深度),影响延迟和成本:
915
+ * low — 最少思考,快速响应(简单问答)
916
+ * medium — 适度思考(推荐:平衡延迟与质量)
917
+ * high — 较深思考(默认 CC 行为)
918
+ * xhigh/max — 最大思考(复杂推理)
919
+ *
920
+ * 通过 LARKPAL_CC_EFFORT 环境变量配置,默认 medium。
921
+ */
922
+ const CC_EFFORT = process.env.LARKPAL_CC_EFFORT || "medium";
879
923
  /** 优雅关闭等待时间(5 秒) */
880
924
  const GRACEFUL_SHUTDOWN_MS = 5e3;
881
925
  var SessionProcessManager = class {
@@ -920,6 +964,13 @@ var SessionProcessManager = class {
920
964
  messageCompletionResolvers = /* @__PURE__ */ new Map();
921
965
  /** 消息级串行锁:确保同一 session 的消息按顺序处理完成 */
922
966
  sessionLocks = /* @__PURE__ */ new Map();
967
+ /**
968
+ * 最近一次 CC 响应的 input_tokens:sessionId → token 数
969
+ *
970
+ * 由 parser 的 usage 事件更新。用于在下一次 executePrompt 前判断
971
+ * 是否需要自动触发 /compact 压缩对话历史。
972
+ */
973
+ lastInputTokens = /* @__PURE__ */ new Map();
923
974
  /** 标记 session 为用户主动中断 */
924
975
  markAborted(sessionId) {
925
976
  this.abortedSessions.add(sessionId);
@@ -983,6 +1034,7 @@ var SessionProcessManager = class {
983
1034
  pid: existing.childProcess.pid,
984
1035
  prepareMs: Date.now() - t0
985
1036
  });
1037
+ await this.autoCompactIfNeeded(sessionId);
986
1038
  this.sendMessage(sessionId, config.prompt);
987
1039
  this.resetIdleTimer(sessionId);
988
1040
  await completionPromise;
@@ -1042,6 +1094,7 @@ var SessionProcessManager = class {
1042
1094
  if (config.maxTurns) args.push("--max-turns", String(config.maxTurns));
1043
1095
  if (config.maxBudgetUsd) args.push("--max-budget-usd", String(config.maxBudgetUsd));
1044
1096
  if (config.model) args.push("--model", config.model);
1097
+ args.push("--effort", CC_EFFORT);
1045
1098
  if (config.userContext && !config.userContext.isTenantIdentity) try {
1046
1099
  const mcpConfigPath = await mergeAndWriteMcpConfig(config.userContext, sessionId);
1047
1100
  if (mcpConfigPath) {
@@ -1283,6 +1336,16 @@ var SessionProcessManager = class {
1283
1336
  getCallbacks()?.onResult?.(result);
1284
1337
  this.resolveMessageCompletion(sessionId);
1285
1338
  });
1339
+ parser.on("usage", (usage) => {
1340
+ this.lastInputTokens.set(sessionId, usage.input_tokens);
1341
+ log$29.info("[auto-compact] 更新 input_tokens", {
1342
+ sessionId,
1343
+ inputTokens: usage.input_tokens,
1344
+ outputTokens: usage.output_tokens,
1345
+ threshold: AUTO_COMPACT_THRESHOLD,
1346
+ needsCompact: AUTO_COMPACT_THRESHOLD > 0 && usage.input_tokens > AUTO_COMPACT_THRESHOLD
1347
+ });
1348
+ });
1286
1349
  parser.on("parseError", (error, rawLine) => {
1287
1350
  log$29.warn("流解析错误", {
1288
1351
  sessionId,
@@ -1382,6 +1445,111 @@ var SessionProcessManager = class {
1382
1445
  });
1383
1446
  }
1384
1447
  /**
1448
+ * 检查是否需要自动压缩,如需要则执行 /compact 命令
1449
+ *
1450
+ * 判断逻辑:
1451
+ * 1. AUTO_COMPACT_THRESHOLD > 0(未禁用)
1452
+ * 2. lastInputTokens[sessionId] > threshold
1453
+ *
1454
+ * 执行方式:通过 stdin 发送 "/compact" 作为用户消息,等待 CC 返回 result。
1455
+ * compact 期间:
1456
+ * - 通过 callbacks.onToolUseStart 通知 UI 显示"正在压缩对话历史"
1457
+ * - compact 产生的文本/result 被静默回调吞掉,不泄露到用户卡片
1458
+ * - compact 完成后通过 callbacks.onToolResult 通知 UI 压缩完成
1459
+ */
1460
+ async autoCompactIfNeeded(sessionId) {
1461
+ if (AUTO_COMPACT_THRESHOLD <= 0) return;
1462
+ const lastTokens = this.lastInputTokens.get(sessionId);
1463
+ if (!lastTokens || lastTokens <= AUTO_COMPACT_THRESHOLD) return;
1464
+ log$29.info("[auto-compact] 触发自动压缩", {
1465
+ sessionId,
1466
+ lastInputTokens: lastTokens,
1467
+ threshold: AUTO_COMPACT_THRESHOLD
1468
+ });
1469
+ const tCompact = Date.now();
1470
+ const realCallbacks = this.activeCallbacks.get(sessionId);
1471
+ const compactToolUseId = `compact-${Date.now()}`;
1472
+ realCallbacks?.onToolUseStart?.("_AutoCompact", {
1473
+ inputTokens: lastTokens,
1474
+ threshold: AUTO_COMPACT_THRESHOLD
1475
+ });
1476
+ this.activeCallbacks.set(sessionId, {
1477
+ onTextDelta: () => {},
1478
+ onThinkingDelta: () => {},
1479
+ onToolUseStart: () => {},
1480
+ onToolResult: () => {},
1481
+ onToolProgress: () => {},
1482
+ onTurnEnd: () => {},
1483
+ onResult: () => {},
1484
+ onError: (err) => {
1485
+ log$29.warn("[auto-compact] compact 期间收到错误", {
1486
+ sessionId,
1487
+ error: err.message
1488
+ });
1489
+ }
1490
+ });
1491
+ try {
1492
+ await this.sendCompactCommand(sessionId);
1493
+ this.lastInputTokens.delete(sessionId);
1494
+ log$29.info("[auto-compact] 压缩完成", {
1495
+ sessionId,
1496
+ durationMs: Date.now() - tCompact
1497
+ });
1498
+ } catch (err) {
1499
+ log$29.warn("[auto-compact] 压缩失败,继续发送原始消息", {
1500
+ sessionId,
1501
+ error: err instanceof Error ? err.message : String(err),
1502
+ durationMs: Date.now() - tCompact
1503
+ });
1504
+ } finally {
1505
+ if (realCallbacks) this.activeCallbacks.set(sessionId, realCallbacks);
1506
+ realCallbacks?.onToolResult?.(compactToolUseId);
1507
+ }
1508
+ }
1509
+ /**
1510
+ * 向 CC 进程发送 /compact 命令并等待完成
1511
+ *
1512
+ * /compact 是 CC 的内置 slash command,通过 stdin 作为用户消息发送。
1513
+ * CC 会压缩对话历史并返回 result 事件表示完成。
1514
+ *
1515
+ * 超时 120 秒(compact 可能需要处理大量历史)。
1516
+ */
1517
+ sendCompactCommand(sessionId) {
1518
+ const proc = this.processes.get(sessionId);
1519
+ if (!proc || proc.status !== "running") return Promise.reject(/* @__PURE__ */ new Error(`会话 ${sessionId} 无运行中的进程,无法执行 compact`));
1520
+ if (!proc.childProcess.stdin || proc.childProcess.stdin.destroyed) return Promise.reject(/* @__PURE__ */ new Error(`会话 ${sessionId} 的 stdin 不可用,无法执行 compact`));
1521
+ const COMPACT_TIMEOUT_MS = 12e4;
1522
+ return new Promise((resolve, reject) => {
1523
+ const timer = setTimeout(() => {
1524
+ reject(/* @__PURE__ */ new Error(`/compact 超时 (${COMPACT_TIMEOUT_MS}ms)`));
1525
+ }, COMPACT_TIMEOUT_MS);
1526
+ const completionPromise = this.createMessageCompletionPromise(sessionId);
1527
+ const compactPayload = JSON.stringify({
1528
+ type: "user",
1529
+ message: {
1530
+ role: "user",
1531
+ content: "/compact"
1532
+ },
1533
+ parent_tool_use_id: null,
1534
+ uuid: v4()
1535
+ });
1536
+ log$29.info("[auto-compact] 发送 /compact 命令", {
1537
+ sessionId,
1538
+ pid: proc.childProcess.pid
1539
+ });
1540
+ proc.childProcess.stdin.write(compactPayload + "\n", (err) => {
1541
+ if (err) {
1542
+ clearTimeout(timer);
1543
+ reject(/* @__PURE__ */ new Error(`/compact stdin 写入失败: ${err.message}`));
1544
+ }
1545
+ });
1546
+ completionPromise.then(() => {
1547
+ clearTimeout(timer);
1548
+ resolve();
1549
+ });
1550
+ });
1551
+ }
1552
+ /**
1385
1553
  * 获取指定会话的最近一条用户消息 ID
1386
1554
  *
1387
1555
  * 用于构建回退按钮的 action value。
@@ -1397,6 +1565,10 @@ var SessionProcessManager = class {
1397
1565
  getSessionCwd(sessionId) {
1398
1566
  return this.sessionCwdCache.get(sessionId);
1399
1567
  }
1568
+ /** 检查指定 session 是否正在执行消息(CC 尚未返回 result) */
1569
+ isSessionBusy(sessionId) {
1570
+ return this.messageCompletionResolvers.has(sessionId);
1571
+ }
1400
1572
  /**
1401
1573
  * 为 rewind 操作临时启动一个 CC 进程
1402
1574
  *
@@ -1526,6 +1698,7 @@ var SessionProcessManager = class {
1526
1698
  this.processes.delete(sessionId);
1527
1699
  this.activeCallbacks.delete(sessionId);
1528
1700
  this.lastUserMessageIds.delete(sessionId);
1701
+ this.lastInputTokens.delete(sessionId);
1529
1702
  for (const [reqId, pending] of this.pendingControlRequests) if (pending.sessionId === sessionId) {
1530
1703
  clearTimeout(pending.timer);
1531
1704
  this.pendingControlRequests.delete(reqId);
@@ -1645,6 +1818,9 @@ var ClaudeCodeAdapter = class ClaudeCodeAdapter {
1645
1818
  lastActiveAt: proc.lastActiveAt
1646
1819
  }));
1647
1820
  }
1821
+ isSessionBusy(sessionId) {
1822
+ return this.processManager.isSessionBusy(sessionId);
1823
+ }
1648
1824
  };
1649
1825
  //#endregion
1650
1826
  //#region src/runtime/larkpal-agent-factory.ts
@@ -5819,7 +5995,7 @@ var CCStreamBridge = class {
5819
5995
  });
5820
5996
  if (this.options.autoCompleteOnTurnEnd && stopReason === "end_turn") {
5821
5997
  const trimmedText = this.accumulatedText.trim();
5822
- if (trimmedText === CC_INTERNAL_PLACEHOLDER || trimmedText === "") {
5998
+ if (trimmedText === CC_INTERNAL_PLACEHOLDER) {
5823
5999
  log$19.info("检测到 CC 内部占位消息,静默丢弃", {
5824
6000
  text: trimmedText.slice(0, 50),
5825
6001
  sessionKey: this.options.sessionKey
@@ -5827,6 +6003,10 @@ var CCStreamBridge = class {
5827
6003
  this.controller.abortCard();
5828
6004
  return;
5829
6005
  }
6006
+ if (trimmedText === "") {
6007
+ log$19.info("turnEnd 时文本为空,延迟到 onResult 兜底处理", { sessionKey: this.options.sessionKey });
6008
+ return;
6009
+ }
5830
6010
  this.controller.markFullyComplete();
5831
6011
  this.controller.onIdle();
5832
6012
  }
@@ -5860,7 +6040,19 @@ var CCStreamBridge = class {
5860
6040
  });
5861
6041
  this.controller.onError(new Error(errorMessage), { kind: "cc-execution" });
5862
6042
  }
6043
+ } else if (!this.accumulatedText.trim()) if (data.result) {
6044
+ log$19.info("result 兜底:使用 result.result 作为最终回复", {
6045
+ resultLen: data.result.length,
6046
+ sessionKey: this.options.sessionKey
6047
+ });
6048
+ this.controller.onPartialReply({ text: data.result });
6049
+ this.controller.markFullyComplete();
6050
+ this.controller.onIdle();
5863
6051
  } else {
6052
+ log$19.info("result 兜底:无文本且无 result,abort 卡片", { sessionKey: this.options.sessionKey });
6053
+ this.controller.abortCard();
6054
+ }
6055
+ else {
5864
6056
  this.controller.markFullyComplete();
5865
6057
  this.controller.onIdle();
5866
6058
  }
@@ -9731,7 +9923,7 @@ function formatContentByType(ctx) {
9731
9923
  }
9732
9924
  return content;
9733
9925
  case "merge_forward": return content;
9734
- case "audio": return "[语音消息 - 暂不支持转文字]";
9926
+ case "audio": return content || "[语音消息]";
9735
9927
  case "sticker": return content || "[表情贴纸]";
9736
9928
  case "video":
9737
9929
  case "media": return content || "[视频消息]";
@@ -9856,7 +10048,9 @@ async function dispatchToCC(params) {
9856
10048
  const replyInThread = !!threadId;
9857
10049
  const replyToMessageId = threadId ? void 0 : ctx.messageId;
9858
10050
  let textPrompt = formatForCC(ctx, isGroup);
9859
- if (isGroup) textPrompt = `[直接@你的消息,请正常回复] ${textPrompt}`;
10051
+ if (isGroup && replyInThread) textPrompt = `[群聊话题] ${textPrompt}`;
10052
+ else if (isGroup) textPrompt = `[群聊] ${textPrompt}`;
10053
+ else textPrompt = `[私聊] ${textPrompt}`;
9860
10054
  const imageResources = ctx.resources.filter((r) => r.type === "image");
9861
10055
  let prompt = textPrompt;
9862
10056
  if (imageResources.length > 0) {
@@ -9942,6 +10136,28 @@ async function dispatchToCC(params) {
9942
10136
  textLength: textPrompt.length,
9943
10137
  imageCount: imageBlocks.length
9944
10138
  });
10139
+ } else {
10140
+ const textWithoutImageRefs = textPrompt.replace(/!\[image\]\([^)]*\)/g, "").replace(/\[图片\]\s*/g, "").trim();
10141
+ if (textWithoutImageRefs.length > 0) {
10142
+ prompt = textPrompt.replace(/!\[image\]\([^)]*\)/g, "[图片加载失败]");
10143
+ log$12.info("图片下载失败但保留文字内容继续调度", {
10144
+ messageId: ctx.messageId,
10145
+ textLength: textWithoutImageRefs.length
10146
+ });
10147
+ } else {
10148
+ log$12.warn("纯图片消息且图片全部下载失败,中止调度", {
10149
+ messageId: ctx.messageId,
10150
+ expectedImages: imageResources.length
10151
+ });
10152
+ await LarkClient.fromAccount(account).sdk.im.message.reply({
10153
+ path: { message_id: ctx.messageId },
10154
+ data: {
10155
+ content: JSON.stringify({ text: "图片下载失败,请稍后重新发送。" }),
10156
+ msg_type: "text"
10157
+ }
10158
+ });
10159
+ return;
10160
+ }
9945
10161
  }
9946
10162
  }
9947
10163
  const attachmentResources = ctx.resources.filter((r) => r.type === "file" || r.type === "audio" || r.type === "video" || r.type === "sticker");
@@ -10049,11 +10265,19 @@ async function dispatchToCC(params) {
10049
10265
  }
10050
10266
  }
10051
10267
  } catch (err) {
10052
- log$12.warn("非图片附件下载失败,保留原始占位符", {
10268
+ log$12.warn("非图片附件下载失败,替换为降级描述", {
10053
10269
  type: res.type,
10054
10270
  fileKey: res.fileKey,
10055
10271
  error: err instanceof Error ? err.message : String(err)
10056
10272
  });
10273
+ const fallbackRegex = new RegExp(`<${res.type}\\s+key="${res.fileKey}"[^/]*/>`, "g");
10274
+ const fallbackMap = {
10275
+ file: `[文件: ${res.fileName || "未知"} - 下载失败]`,
10276
+ audio: "[语音消息 - 下载失败]",
10277
+ video: `[视频: ${res.fileName || "未知"} - 下载失败]`,
10278
+ sticker: "[表情贴纸 - 加载失败]"
10279
+ };
10280
+ updatedTextPrompt = updatedTextPrompt.replace(fallbackRegex, fallbackMap[res.type] || `[${res.type} - 下载失败]`);
10057
10281
  }
10058
10282
  if (typeof prompt === "string") prompt = updatedTextPrompt;
10059
10283
  else {
@@ -10263,6 +10487,13 @@ async function dispatchTeammateEval(params) {
10263
10487
  userId: void 0,
10264
10488
  userName: void 0
10265
10489
  });
10490
+ if (processManager.isSessionBusy?.(route.sessionId)) {
10491
+ log$12.info("dispatchTeammateEval 跳过:主 session 正在执行", {
10492
+ chatId,
10493
+ sessionId: route.sessionId
10494
+ });
10495
+ return { replied: false };
10496
+ }
10266
10497
  await sessionRouter.ensureSessionDirectory(route);
10267
10498
  const teammateSessionKey = `teammate_${route.sessionId}`;
10268
10499
  startToolUseTraceRun(teammateSessionKey);
@@ -10331,11 +10562,12 @@ async function dispatchTeammateEval(params) {
10331
10562
  pendingToolEvents.length = 0;
10332
10563
  };
10333
10564
  /**
10334
- * 处理 text delta — 全量缓冲,直到 end_turn 时统一判断
10565
+ * 处理 text delta — 前缀检测 + 缓冲
10335
10566
  *
10336
- * 策略:CC teammate 模式下可能先执行工具再输出文本,且文本中可能正文在前、
10337
- * NO_REPLY 在末尾。因此不做前缀检测,而是缓冲所有 text,在 onTurnEnd(end_turn)
10338
- * 时统一判断完整输出是否包含 NO_REPLY。
10567
+ * 策略:prompt 要求 CC 如果决定不参与,必须首先输出 NO_REPLY。
10568
+ * 因此在缓冲阶段做前缀检测:如果 fullTextBuffer NO_REPLY 开头则立即静默。
10569
+ * 这比等 end_turn 快得多(可提前 10-30s 结束评估等待)。
10570
+ * 兜底:end_turn 时再做最终判断(应对 CC 不遵守 prompt 约束的情况)。
10339
10571
  */
10340
10572
  const handleTextDelta = (text) => {
10341
10573
  if (silenced) return;
@@ -10344,6 +10576,13 @@ async function dispatchTeammateEval(params) {
10344
10576
  return;
10345
10577
  }
10346
10578
  fullTextBuffer += text;
10579
+ if (fullTextBuffer.trimStart().startsWith(NO_REPLY_TOKEN)) {
10580
+ silenced = true;
10581
+ log$12.info("teammate 前缀检测: 检测到 NO_REPLY 首输出,立即静默", {
10582
+ chatId,
10583
+ bufferLen: fullTextBuffer.length
10584
+ });
10585
+ }
10347
10586
  };
10348
10587
  try {
10349
10588
  await new Promise((resolve) => {
@@ -11964,10 +12203,16 @@ const log$11 = larkLogger("converters/merge-forward");
11964
12203
  */
11965
12204
  const convertMergeForward = async (_raw, ctx) => {
11966
12205
  const { accountId, messageId, resolveUserName, batchResolveNames, fetchSubMessages, convertMessageContent } = ctx;
11967
- if (!fetchSubMessages) return {
11968
- content: "<forwarded_messages/>",
11969
- resources: []
11970
- };
12206
+ if (!fetchSubMessages) {
12207
+ log$11.warn("fetchSubMessages 回调未注入,无法展开合并转发消息", {
12208
+ messageId,
12209
+ accountId
12210
+ });
12211
+ return {
12212
+ content: "<forwarded_messages/>",
12213
+ resources: []
12214
+ };
12215
+ }
11971
12216
  return {
11972
12217
  content: await expand(accountId, messageId, resolveUserName, batchResolveNames, fetchSubMessages, convertMessageContent),
11973
12218
  resources: []
@@ -11977,6 +12222,10 @@ async function expand(accountId, messageId, resolveUserName, batchResolveNames,
11977
12222
  let items;
11978
12223
  try {
11979
12224
  items = await fetchSubMessages(messageId);
12225
+ log$11.info("fetchSubMessages 成功", {
12226
+ messageId,
12227
+ itemCount: items.length
12228
+ });
11980
12229
  } catch (error) {
11981
12230
  log$11.error("fetch sub-messages failed", {
11982
12231
  messageId,
@@ -11984,8 +12233,16 @@ async function expand(accountId, messageId, resolveUserName, batchResolveNames,
11984
12233
  });
11985
12234
  return "<forwarded_messages/>";
11986
12235
  }
11987
- if (items.length === 0) return "<forwarded_messages/>";
12236
+ if (items.length === 0) {
12237
+ log$11.warn("fetchSubMessages 返回空 items", { messageId });
12238
+ return "<forwarded_messages/>";
12239
+ }
11988
12240
  const childrenMap = buildChildrenMap(items, messageId);
12241
+ log$11.info("buildChildrenMap 完成", {
12242
+ messageId,
12243
+ parentKeys: [...childrenMap.keys()],
12244
+ childCounts: [...childrenMap.entries()].map(([k, v]) => `${k}:${v.length}`)
12245
+ });
11989
12246
  const senderIds = collectSenderIds(items, messageId);
11990
12247
  if (senderIds.length > 0 && batchResolveNames) try {
11991
12248
  await batchResolveNames(senderIds);
@@ -12319,6 +12576,10 @@ async function fetchCardContent(messageId, larkClient) {
12319
12576
  */
12320
12577
  function createFetchSubMessages(larkClient) {
12321
12578
  return async (msgId) => {
12579
+ log$10.info("fetchSubMessages 请求", {
12580
+ msgId,
12581
+ url: `/open-apis/im/v1/messages/${msgId}`
12582
+ });
12322
12583
  const response = await larkClient.sdk.request({
12323
12584
  method: "GET",
12324
12585
  url: `/open-apis/im/v1/messages/${msgId}`,
@@ -12327,6 +12588,12 @@ function createFetchSubMessages(larkClient) {
12327
12588
  card_msg_content_type: "raw_card_content"
12328
12589
  }
12329
12590
  });
12591
+ log$10.info("fetchSubMessages 响应", {
12592
+ msgId,
12593
+ code: response?.code,
12594
+ msg: response?.msg,
12595
+ itemCount: response?.data?.items?.length ?? 0
12596
+ });
12330
12597
  if (response?.code !== 0) throw new Error(`API error: code=${response?.code} msg=${response?.msg}`);
12331
12598
  return response?.data?.items ?? [];
12332
12599
  };
@@ -12379,13 +12646,13 @@ async function parseMessageEvent(event, botOpenId, expandCtx) {
12379
12646
  const mentionsByOpenId = /* @__PURE__ */ new Map();
12380
12647
  for (const info of mentionList) mentionsByOpenId.set(info.openId, info);
12381
12648
  const acctId = expandCtx?.accountId;
12382
- const larkClient = expandCtx ? LarkClient.fromCfg(expandCtx.cfg, acctId) : void 0;
12649
+ const larkClient = expandCtx ? expandCtx.larkClient ?? (expandCtx.cfg ? LarkClient.fromCfg(expandCtx.cfg, acctId) : void 0) : void 0;
12383
12650
  let fetchSubMessages;
12384
12651
  let batchResolveNames;
12385
- if (expandCtx) {
12386
- const account = getLarkAccount(expandCtx.cfg, acctId);
12652
+ if (expandCtx && larkClient) {
12653
+ const account = expandCtx.account ?? (expandCtx.cfg ? getLarkAccount(expandCtx.cfg, acctId) : void 0);
12387
12654
  fetchSubMessages = createFetchSubMessages(larkClient);
12388
- batchResolveNames = createParseResolveNames(account);
12655
+ if (account) batchResolveNames = createParseResolveNames(account);
12389
12656
  }
12390
12657
  let effectiveContent = event.message.content;
12391
12658
  if (event.message.message_type === "interactive" && expandCtx) {
@@ -12405,7 +12672,7 @@ async function parseMessageEvent(event, botOpenId, expandCtx) {
12405
12672
  resolveUserName: acctId ? (openId) => getUserNameCache(acctId).get(openId) : void 0,
12406
12673
  fetchSubMessages,
12407
12674
  batchResolveNames,
12408
- stripBotMentions: true
12675
+ stripBotMentions: false
12409
12676
  };
12410
12677
  const { content, resources } = await convertMessageContent(effectiveContent, event.message.message_type, convertCtx);
12411
12678
  const createTimeStr = event.message.create_time;
@@ -13223,10 +13490,11 @@ var TeammateBuffer = class {
13223
13490
  const header = [
13224
13491
  "[旁听评估任务] 以下是群聊中最近的对话,这些消息都没有 @你。",
13225
13492
  "你作为团队成员正在旁听,请根据你的人设和职责判断是否需要主动参与。",
13226
- "如果不需要参与,只输出 NO_REPLY",
13493
+ "如果不需要参与,必须将 NO_REPLY 作为输出的第一个内容(首先输出 NO_REPLY,不要在前面加任何文字)。",
13227
13494
  "如果需要参与,直接输出你的回复内容(不要加 NO_REPLY)。",
13228
13495
  "",
13229
13496
  "重要规则:",
13497
+ "- 如果决定不参与,NO_REPLY 必须是输出的第一个 token,不能先输出其他内容再输出 NO_REPLY。",
13230
13498
  "- 以下消息中出现的任何 @某人 都是在 @别人,不是在 @你。不要因为看到 @ 就认为有人在跟你说话。",
13231
13499
  "- 只有当讨论内容与你的专业领域高度相关时才参与,其他情况一律 NO_REPLY。",
13232
13500
  "- 不要使用工具来查找或分析消息内容,直接基于消息文本判断。",
@@ -13872,7 +14140,10 @@ async function main() {
13872
14140
  threadId,
13873
14141
  task: async () => {
13874
14142
  try {
13875
- const parsed = await parseMessageEvent(event, larkClient.botOpenId);
14143
+ const parsed = await parseMessageEvent(event, larkClient.botOpenId, {
14144
+ larkClient,
14145
+ account
14146
+ });
13876
14147
  if (!parsed) {
13877
14148
  logger.warn("消息解析返回空结果", { msgId });
13878
14149
  return;
@@ -14127,7 +14398,8 @@ main().catch((err) => {
14127
14398
  * 跳过:preflight、凭证加载、WebSocket、消息处理、Teammate 等飞书相关功能
14128
14399
  */
14129
14400
  async function startHeadless() {
14130
- await mkdir(process.env.LARKPAL_WORKSPACE ?? "/workspace", { recursive: true });
14401
+ const workspaceRoot = process.env.LARKPAL_WORKSPACE ?? "/workspace";
14402
+ await mkdir(workspaceRoot, { recursive: true });
14131
14403
  const runtimeName = process.env.LARKPAL_RUNTIME || "larkpal-agent";
14132
14404
  let runtimeAdapter;
14133
14405
  if (runtimeName === "larkpal-agent") runtimeAdapter = await createLarkpalAgentAdapter();
@@ -14140,22 +14412,32 @@ async function startHeadless() {
14140
14412
  await scheduledTaskManager.loadFromDisk();
14141
14413
  const gatewayPort = parseInt(process.env.LARKPAL_GATEWAY_PORT || "3000", 10);
14142
14414
  const gatewayHost = process.env.LARKPAL_GATEWAY_HOST || "0.0.0.0";
14415
+ const enableWebChannel = process.env.LARKPAL_ENABLE_WEB_CHANNEL === "1" || process.env.LARKPAL_ENABLE_WEB_CHANNEL === "true";
14416
+ const messageStore = enableWebChannel ? new SessionMessageStore(workspaceRoot) : void 0;
14417
+ if (enableWebChannel) logger.info("Headless Web Channel 已启用,Chat API 路由将注册", { workspaceRoot });
14143
14418
  const gateway = createGatewayServer({
14144
14419
  port: gatewayPort,
14145
14420
  host: gatewayHost,
14146
14421
  processManager: runtimeAdapter,
14147
14422
  scheduledTaskManager,
14148
14423
  appCredentials: void 0,
14149
- messageStore: void 0
14424
+ messageStore
14150
14425
  });
14151
14426
  await gateway.start();
14152
14427
  logger.info("🚀 LarkPal Headless Gateway 启动完成", {
14153
14428
  gatewayUrl: `http://${gatewayHost}:${gatewayPort}`,
14154
14429
  runtime: runtimeName,
14430
+ webChannel: enableWebChannel,
14155
14431
  endpoints: [
14156
14432
  "/api/execute",
14157
14433
  "/api/status",
14158
- "/api/skills"
14434
+ "/api/skills",
14435
+ ...enableWebChannel ? [
14436
+ "/api/chat/auth",
14437
+ "/api/chat/stream",
14438
+ "/api/chat/sessions",
14439
+ "/api/chat/history"
14440
+ ] : []
14159
14441
  ]
14160
14442
  });
14161
14443
  const shutdown = async (signal) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-lark/larkpal",
3
- "version": "0.1.34",
3
+ "version": "0.1.38",
4
4
  "description": "LarkPal - Lark/Feishu bot service",
5
5
  "type": "module",
6
6
  "main": "./dist/main.mjs",
@@ -19,21 +19,9 @@
19
19
  "registry": "https://registry.npmjs.org",
20
20
  "access": "public"
21
21
  },
22
- "packageManager": "pnpm@10.32.1",
23
22
  "engines": {
24
23
  "node": ">=22"
25
24
  },
26
- "scripts": {
27
- "start": "node --env-file=.env bin/larkpal.js",
28
- "build": "tsdown",
29
- "test": "vitest run",
30
- "test:watch": "vitest",
31
- "lint": "eslint src/",
32
- "lint:fix": "eslint src/ --fix",
33
- "typecheck": "tsc --noEmit",
34
- "format": "prettier --write src/**/*.ts",
35
- "format:check": "prettier --check src/**/*.ts"
36
- },
37
25
  "dependencies": {
38
26
  "@larksuiteoapi/node-sdk": "^1.60.0",
39
27
  "@sinclair/typebox": "0.34.48",
@@ -63,5 +51,16 @@
63
51
  "typescript": "^5.9.3",
64
52
  "typescript-eslint": "^8.32.1",
65
53
  "vitest": "^4.1.1"
54
+ },
55
+ "scripts": {
56
+ "start": "node --env-file=.env bin/larkpal.js",
57
+ "build": "tsdown",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "lint": "eslint src/",
61
+ "lint:fix": "eslint src/ --fix",
62
+ "typecheck": "tsc --noEmit",
63
+ "format": "prettier --write src/**/*.ts",
64
+ "format:check": "prettier --check src/**/*.ts"
66
65
  }
67
- }
66
+ }