@vibe-lark/larkpal 0.1.33 → 0.1.37

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
@@ -1700,16 +1876,16 @@ async function createLarkpalAgentAdapter() {
1700
1876
  maxTurns: defaultMaxTurns
1701
1877
  });
1702
1878
  const { LarkpalAgentAdapter, ToolRegistry } = await import("@vibe-lark/larkpal-agent");
1879
+ const registry = new ToolRegistry();
1703
1880
  const adapter = new LarkpalAgentAdapter({
1704
1881
  llm,
1705
1882
  systemPrompt,
1706
1883
  tools: [],
1707
- defaultMaxTurns
1884
+ defaultMaxTurns,
1885
+ registry
1708
1886
  });
1709
1887
  const mcpServerUrl = process.env.LARKPAL_AGENT_MCP_SERVER_URL;
1710
- let registry = null;
1711
1888
  if (mcpServerUrl) try {
1712
- registry = new ToolRegistry();
1713
1889
  const toolCount = await registry.connectMcpServer(mcpServerUrl, { timeoutMs: 1e4 });
1714
1890
  const tools = registry.getAll();
1715
1891
  adapter.registerTools(tools);
@@ -1730,7 +1906,7 @@ async function createLarkpalAgentAdapter() {
1730
1906
  const { fileURLToPath } = await import("node:url");
1731
1907
  const skillsDir = join(fileURLToPath(import.meta.resolve("@vibe-lark/larkpal-agent")).replace(/\/dist\/index\.js$/, ""), "src", "skills", "definitions");
1732
1908
  const skills = await loadSkillsFromDir(skillsDir);
1733
- if (skills.length > 0 && registry) {
1909
+ if (skills.length > 0) {
1734
1910
  registerSkills(registry, skills, llm);
1735
1911
  const skillTools = registry.getAll().filter((t) => t.name.startsWith("skill:"));
1736
1912
  adapter.registerTools(skillTools);
@@ -1738,8 +1914,7 @@ async function createLarkpalAgentAdapter() {
1738
1914
  skillCount: skills.length,
1739
1915
  skillNames: skills.map((s) => s.name)
1740
1916
  });
1741
- } else if (skills.length > 0 && !registry) log$27.warn("Skills 已加载但无 ToolRegistry(MCP 未连接),跳过 Meta-Tool 注册");
1742
- else log$27.info("未找到 Skill 定义文件", { skillsDir });
1917
+ } else log$27.info("未找到 Skill 定义文件", { skillsDir });
1743
1918
  } catch (err) {
1744
1919
  log$27.warn("Skill 加载失败(非阻塞)", { error: err?.message });
1745
1920
  }
@@ -1983,6 +2158,15 @@ async function handleExecute(req, res, processManager) {
1983
2158
  sessionId: body.session_id,
1984
2159
  mode
1985
2160
  });
2161
+ if (!body.auth_headers) {
2162
+ const tenantKey = req.headers["x-tenant-key"];
2163
+ const userId = req.headers["x-user-id"];
2164
+ if (tenantKey || userId) {
2165
+ body.auth_headers = {};
2166
+ if (tenantKey) body.auth_headers["x-tenant-key"] = tenantKey;
2167
+ if (userId) body.auth_headers["x-user-id"] = userId;
2168
+ }
2169
+ }
1986
2170
  const config = {
1987
2171
  sessionId: body.session_id,
1988
2172
  cwd: body.cwd,
@@ -1993,7 +2177,8 @@ async function handleExecute(req, res, processManager) {
1993
2177
  baseURL: body.llm_config.base_url,
1994
2178
  apiKey: body.llm_config.api_key,
1995
2179
  model: body.llm_config.model
1996
- } } : {}
2180
+ } } : {},
2181
+ ...body.auth_headers ? { authHeaders: body.auth_headers } : {}
1997
2182
  };
1998
2183
  if (mode === "async") {
1999
2184
  const callbacks = createTaskCallbacks(taskId);
@@ -5810,7 +5995,7 @@ var CCStreamBridge = class {
5810
5995
  });
5811
5996
  if (this.options.autoCompleteOnTurnEnd && stopReason === "end_turn") {
5812
5997
  const trimmedText = this.accumulatedText.trim();
5813
- if (trimmedText === CC_INTERNAL_PLACEHOLDER || trimmedText === "") {
5998
+ if (trimmedText === CC_INTERNAL_PLACEHOLDER) {
5814
5999
  log$19.info("检测到 CC 内部占位消息,静默丢弃", {
5815
6000
  text: trimmedText.slice(0, 50),
5816
6001
  sessionKey: this.options.sessionKey
@@ -5818,6 +6003,10 @@ var CCStreamBridge = class {
5818
6003
  this.controller.abortCard();
5819
6004
  return;
5820
6005
  }
6006
+ if (trimmedText === "") {
6007
+ log$19.info("turnEnd 时文本为空,延迟到 onResult 兜底处理", { sessionKey: this.options.sessionKey });
6008
+ return;
6009
+ }
5821
6010
  this.controller.markFullyComplete();
5822
6011
  this.controller.onIdle();
5823
6012
  }
@@ -5851,7 +6040,19 @@ var CCStreamBridge = class {
5851
6040
  });
5852
6041
  this.controller.onError(new Error(errorMessage), { kind: "cc-execution" });
5853
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();
5854
6051
  } else {
6052
+ log$19.info("result 兜底:无文本且无 result,abort 卡片", { sessionKey: this.options.sessionKey });
6053
+ this.controller.abortCard();
6054
+ }
6055
+ else {
5855
6056
  this.controller.markFullyComplete();
5856
6057
  this.controller.onIdle();
5857
6058
  }
@@ -9722,7 +9923,7 @@ function formatContentByType(ctx) {
9722
9923
  }
9723
9924
  return content;
9724
9925
  case "merge_forward": return content;
9725
- case "audio": return "[语音消息 - 暂不支持转文字]";
9926
+ case "audio": return content || "[语音消息]";
9726
9927
  case "sticker": return content || "[表情贴纸]";
9727
9928
  case "video":
9728
9929
  case "media": return content || "[视频消息]";
@@ -9847,7 +10048,7 @@ async function dispatchToCC(params) {
9847
10048
  const replyInThread = !!threadId;
9848
10049
  const replyToMessageId = threadId ? void 0 : ctx.messageId;
9849
10050
  let textPrompt = formatForCC(ctx, isGroup);
9850
- if (isGroup) textPrompt = `[直接@你的消息,请正常回复] ${textPrompt}`;
10051
+ if (isGroup) textPrompt = `[有人@你] ${textPrompt}`;
9851
10052
  const imageResources = ctx.resources.filter((r) => r.type === "image");
9852
10053
  let prompt = textPrompt;
9853
10054
  if (imageResources.length > 0) {
@@ -9933,6 +10134,28 @@ async function dispatchToCC(params) {
9933
10134
  textLength: textPrompt.length,
9934
10135
  imageCount: imageBlocks.length
9935
10136
  });
10137
+ } else {
10138
+ const textWithoutImageRefs = textPrompt.replace(/!\[image\]\([^)]*\)/g, "").replace(/\[图片\]\s*/g, "").trim();
10139
+ if (textWithoutImageRefs.length > 0) {
10140
+ prompt = textPrompt.replace(/!\[image\]\([^)]*\)/g, "[图片加载失败]");
10141
+ log$12.info("图片下载失败但保留文字内容继续调度", {
10142
+ messageId: ctx.messageId,
10143
+ textLength: textWithoutImageRefs.length
10144
+ });
10145
+ } else {
10146
+ log$12.warn("纯图片消息且图片全部下载失败,中止调度", {
10147
+ messageId: ctx.messageId,
10148
+ expectedImages: imageResources.length
10149
+ });
10150
+ await LarkClient.fromAccount(account).sdk.im.message.reply({
10151
+ path: { message_id: ctx.messageId },
10152
+ data: {
10153
+ content: JSON.stringify({ text: "图片下载失败,请稍后重新发送。" }),
10154
+ msg_type: "text"
10155
+ }
10156
+ });
10157
+ return;
10158
+ }
9936
10159
  }
9937
10160
  }
9938
10161
  const attachmentResources = ctx.resources.filter((r) => r.type === "file" || r.type === "audio" || r.type === "video" || r.type === "sticker");
@@ -10040,11 +10263,19 @@ async function dispatchToCC(params) {
10040
10263
  }
10041
10264
  }
10042
10265
  } catch (err) {
10043
- log$12.warn("非图片附件下载失败,保留原始占位符", {
10266
+ log$12.warn("非图片附件下载失败,替换为降级描述", {
10044
10267
  type: res.type,
10045
10268
  fileKey: res.fileKey,
10046
10269
  error: err instanceof Error ? err.message : String(err)
10047
10270
  });
10271
+ const fallbackRegex = new RegExp(`<${res.type}\\s+key="${res.fileKey}"[^/]*/>`, "g");
10272
+ const fallbackMap = {
10273
+ file: `[文件: ${res.fileName || "未知"} - 下载失败]`,
10274
+ audio: "[语音消息 - 下载失败]",
10275
+ video: `[视频: ${res.fileName || "未知"} - 下载失败]`,
10276
+ sticker: "[表情贴纸 - 加载失败]"
10277
+ };
10278
+ updatedTextPrompt = updatedTextPrompt.replace(fallbackRegex, fallbackMap[res.type] || `[${res.type} - 下载失败]`);
10048
10279
  }
10049
10280
  if (typeof prompt === "string") prompt = updatedTextPrompt;
10050
10281
  else {
@@ -10254,6 +10485,13 @@ async function dispatchTeammateEval(params) {
10254
10485
  userId: void 0,
10255
10486
  userName: void 0
10256
10487
  });
10488
+ if (processManager.isSessionBusy?.(route.sessionId)) {
10489
+ log$12.info("dispatchTeammateEval 跳过:主 session 正在执行", {
10490
+ chatId,
10491
+ sessionId: route.sessionId
10492
+ });
10493
+ return { replied: false };
10494
+ }
10257
10495
  await sessionRouter.ensureSessionDirectory(route);
10258
10496
  const teammateSessionKey = `teammate_${route.sessionId}`;
10259
10497
  startToolUseTraceRun(teammateSessionKey);
@@ -10322,11 +10560,12 @@ async function dispatchTeammateEval(params) {
10322
10560
  pendingToolEvents.length = 0;
10323
10561
  };
10324
10562
  /**
10325
- * 处理 text delta — 全量缓冲,直到 end_turn 时统一判断
10563
+ * 处理 text delta — 前缀检测 + 缓冲
10326
10564
  *
10327
- * 策略:CC teammate 模式下可能先执行工具再输出文本,且文本中可能正文在前、
10328
- * NO_REPLY 在末尾。因此不做前缀检测,而是缓冲所有 text,在 onTurnEnd(end_turn)
10329
- * 时统一判断完整输出是否包含 NO_REPLY。
10565
+ * 策略:prompt 要求 CC 如果决定不参与,必须首先输出 NO_REPLY。
10566
+ * 因此在缓冲阶段做前缀检测:如果 fullTextBuffer NO_REPLY 开头则立即静默。
10567
+ * 这比等 end_turn 快得多(可提前 10-30s 结束评估等待)。
10568
+ * 兜底:end_turn 时再做最终判断(应对 CC 不遵守 prompt 约束的情况)。
10330
10569
  */
10331
10570
  const handleTextDelta = (text) => {
10332
10571
  if (silenced) return;
@@ -10335,6 +10574,13 @@ async function dispatchTeammateEval(params) {
10335
10574
  return;
10336
10575
  }
10337
10576
  fullTextBuffer += text;
10577
+ if (fullTextBuffer.trimStart().startsWith(NO_REPLY_TOKEN)) {
10578
+ silenced = true;
10579
+ log$12.info("teammate 前缀检测: 检测到 NO_REPLY 首输出,立即静默", {
10580
+ chatId,
10581
+ bufferLen: fullTextBuffer.length
10582
+ });
10583
+ }
10338
10584
  };
10339
10585
  try {
10340
10586
  await new Promise((resolve) => {
@@ -12370,13 +12616,13 @@ async function parseMessageEvent(event, botOpenId, expandCtx) {
12370
12616
  const mentionsByOpenId = /* @__PURE__ */ new Map();
12371
12617
  for (const info of mentionList) mentionsByOpenId.set(info.openId, info);
12372
12618
  const acctId = expandCtx?.accountId;
12373
- const larkClient = expandCtx ? LarkClient.fromCfg(expandCtx.cfg, acctId) : void 0;
12619
+ const larkClient = expandCtx ? expandCtx.larkClient ?? (expandCtx.cfg ? LarkClient.fromCfg(expandCtx.cfg, acctId) : void 0) : void 0;
12374
12620
  let fetchSubMessages;
12375
12621
  let batchResolveNames;
12376
- if (expandCtx) {
12377
- const account = getLarkAccount(expandCtx.cfg, acctId);
12622
+ if (expandCtx && larkClient) {
12623
+ const account = expandCtx.account ?? (expandCtx.cfg ? getLarkAccount(expandCtx.cfg, acctId) : void 0);
12378
12624
  fetchSubMessages = createFetchSubMessages(larkClient);
12379
- batchResolveNames = createParseResolveNames(account);
12625
+ if (account) batchResolveNames = createParseResolveNames(account);
12380
12626
  }
12381
12627
  let effectiveContent = event.message.content;
12382
12628
  if (event.message.message_type === "interactive" && expandCtx) {
@@ -12396,7 +12642,7 @@ async function parseMessageEvent(event, botOpenId, expandCtx) {
12396
12642
  resolveUserName: acctId ? (openId) => getUserNameCache(acctId).get(openId) : void 0,
12397
12643
  fetchSubMessages,
12398
12644
  batchResolveNames,
12399
- stripBotMentions: true
12645
+ stripBotMentions: false
12400
12646
  };
12401
12647
  const { content, resources } = await convertMessageContent(effectiveContent, event.message.message_type, convertCtx);
12402
12648
  const createTimeStr = event.message.create_time;
@@ -13214,10 +13460,11 @@ var TeammateBuffer = class {
13214
13460
  const header = [
13215
13461
  "[旁听评估任务] 以下是群聊中最近的对话,这些消息都没有 @你。",
13216
13462
  "你作为团队成员正在旁听,请根据你的人设和职责判断是否需要主动参与。",
13217
- "如果不需要参与,只输出 NO_REPLY",
13463
+ "如果不需要参与,必须将 NO_REPLY 作为输出的第一个内容(首先输出 NO_REPLY,不要在前面加任何文字)。",
13218
13464
  "如果需要参与,直接输出你的回复内容(不要加 NO_REPLY)。",
13219
13465
  "",
13220
13466
  "重要规则:",
13467
+ "- 如果决定不参与,NO_REPLY 必须是输出的第一个 token,不能先输出其他内容再输出 NO_REPLY。",
13221
13468
  "- 以下消息中出现的任何 @某人 都是在 @别人,不是在 @你。不要因为看到 @ 就认为有人在跟你说话。",
13222
13469
  "- 只有当讨论内容与你的专业领域高度相关时才参与,其他情况一律 NO_REPLY。",
13223
13470
  "- 不要使用工具来查找或分析消息内容,直接基于消息文本判断。",
@@ -13863,7 +14110,10 @@ async function main() {
13863
14110
  threadId,
13864
14111
  task: async () => {
13865
14112
  try {
13866
- const parsed = await parseMessageEvent(event, larkClient.botOpenId);
14113
+ const parsed = await parseMessageEvent(event, larkClient.botOpenId, {
14114
+ larkClient,
14115
+ account
14116
+ });
13867
14117
  if (!parsed) {
13868
14118
  logger.warn("消息解析返回空结果", { msgId });
13869
14119
  return;
@@ -14118,7 +14368,8 @@ main().catch((err) => {
14118
14368
  * 跳过:preflight、凭证加载、WebSocket、消息处理、Teammate 等飞书相关功能
14119
14369
  */
14120
14370
  async function startHeadless() {
14121
- await mkdir(process.env.LARKPAL_WORKSPACE ?? "/workspace", { recursive: true });
14371
+ const workspaceRoot = process.env.LARKPAL_WORKSPACE ?? "/workspace";
14372
+ await mkdir(workspaceRoot, { recursive: true });
14122
14373
  const runtimeName = process.env.LARKPAL_RUNTIME || "larkpal-agent";
14123
14374
  let runtimeAdapter;
14124
14375
  if (runtimeName === "larkpal-agent") runtimeAdapter = await createLarkpalAgentAdapter();
@@ -14131,22 +14382,32 @@ async function startHeadless() {
14131
14382
  await scheduledTaskManager.loadFromDisk();
14132
14383
  const gatewayPort = parseInt(process.env.LARKPAL_GATEWAY_PORT || "3000", 10);
14133
14384
  const gatewayHost = process.env.LARKPAL_GATEWAY_HOST || "0.0.0.0";
14385
+ const enableWebChannel = process.env.LARKPAL_ENABLE_WEB_CHANNEL === "1" || process.env.LARKPAL_ENABLE_WEB_CHANNEL === "true";
14386
+ const messageStore = enableWebChannel ? new SessionMessageStore(workspaceRoot) : void 0;
14387
+ if (enableWebChannel) logger.info("Headless Web Channel 已启用,Chat API 路由将注册", { workspaceRoot });
14134
14388
  const gateway = createGatewayServer({
14135
14389
  port: gatewayPort,
14136
14390
  host: gatewayHost,
14137
14391
  processManager: runtimeAdapter,
14138
14392
  scheduledTaskManager,
14139
14393
  appCredentials: void 0,
14140
- messageStore: void 0
14394
+ messageStore
14141
14395
  });
14142
14396
  await gateway.start();
14143
14397
  logger.info("🚀 LarkPal Headless Gateway 启动完成", {
14144
14398
  gatewayUrl: `http://${gatewayHost}:${gatewayPort}`,
14145
14399
  runtime: runtimeName,
14400
+ webChannel: enableWebChannel,
14146
14401
  endpoints: [
14147
14402
  "/api/execute",
14148
14403
  "/api/status",
14149
- "/api/skills"
14404
+ "/api/skills",
14405
+ ...enableWebChannel ? [
14406
+ "/api/chat/auth",
14407
+ "/api/chat/stream",
14408
+ "/api/chat/sessions",
14409
+ "/api/chat/history"
14410
+ ] : []
14150
14411
  ]
14151
14412
  });
14152
14413
  const shutdown = async (signal) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-lark/larkpal",
3
- "version": "0.1.33",
3
+ "version": "0.1.37",
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
+ }