@vibe-lark/larkpal 0.1.76 → 0.1.77

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.
Files changed (2) hide show
  1. package/dist/main.mjs +390 -52
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -40,7 +40,7 @@ import * as net from "node:net";
40
40
  *
41
41
  * 本模块在启动时检测它们是否已安装,缺失时给出明确的安装指引。
42
42
  */
43
- const log$40 = larkLogger("preflight");
43
+ const log$41 = larkLogger("preflight");
44
44
  function detectCli(name) {
45
45
  try {
46
46
  const cliPath = execFileSync("which", [name], {
@@ -78,14 +78,14 @@ function detectCli(name) {
78
78
  * 缺失任一依赖时打印安装指引并抛出错误,阻止启动。
79
79
  */
80
80
  function preflight() {
81
- log$40.info("开始前置环境检查...");
81
+ log$41.info("开始前置环境检查...");
82
82
  const useCodex = (process.env.LARKPAL_RUNTIME || "claude-code") === "codex-app-server";
83
83
  const claude = detectCli("claude");
84
84
  const codex = useCodex ? detectCli(process.env.LARKPAL_CODEX_BIN || "codex") : void 0;
85
85
  const larkCli = detectCli("lark-cli");
86
86
  const status = (dep) => dep.installed ? `✅ ${dep.name} (${dep.version ?? "unknown"}) → ${dep.path}` : `❌ ${dep.name} 未安装`;
87
- log$40.info(` ${status(useCodex && codex ? codex : claude)}`);
88
- log$40.info(` ${status(larkCli)}`);
87
+ log$41.info(` ${status(useCodex && codex ? codex : claude)}`);
88
+ log$41.info(` ${status(larkCli)}`);
89
89
  const missing = [];
90
90
  if (useCodex) {
91
91
  if (!codex?.installed) missing.push([
@@ -121,10 +121,10 @@ function preflight() {
121
121
  "",
122
122
  "请安装缺失的依赖后重新启动 LarkPal。"
123
123
  ].join("\n");
124
- log$40.error(msg);
124
+ log$41.error(msg);
125
125
  throw new Error("前置环境检查未通过,缺少必要依赖");
126
126
  }
127
- log$40.info("前置环境检查通过 ✓");
127
+ log$41.info("前置环境检查通过 ✓");
128
128
  return {
129
129
  claude,
130
130
  codex,
@@ -149,7 +149,7 @@ function preflight() {
149
149
  * 同时生成新版 (~/.lark-cli/config.json) 和旧版 (~/.config/lark/config.json)
150
150
  * 格式的配置文件,保证 lark-cli 无论通过哪种路径读取都能正常工作。
151
151
  */
152
- const log$39 = larkLogger("config/ensure-lark-cli");
152
+ const log$40 = larkLogger("config/ensure-lark-cli");
153
153
  /** 新版 lark-cli 配置路径 */
154
154
  const NEW_CONFIG_DIR = join(homedir(), ".lark-cli");
155
155
  const NEW_CONFIG_PATH = join(NEW_CONFIG_DIR, "config.json");
@@ -166,19 +166,19 @@ function ensureLarkCliConfig() {
166
166
  const appId = process.env.LARK_APP_ID;
167
167
  const appSecret = process.env.LARK_APP_SECRET;
168
168
  if (!appId || !appSecret) {
169
- log$39.info("环境变量凭证未设置,跳过 lark-cli 配置同步");
169
+ log$40.info("环境变量凭证未设置,跳过 lark-cli 配置同步");
170
170
  return;
171
171
  }
172
172
  const hasNewConfig = existsSync(NEW_CONFIG_PATH);
173
173
  const hasLegacyConfig = existsSync(LEGACY_CONFIG_PATH);
174
174
  if (hasNewConfig && hasLegacyConfig) {
175
- log$39.info("lark-cli 配置文件已存在,跳过同步", {
175
+ log$40.info("lark-cli 配置文件已存在,跳过同步", {
176
176
  newConfig: NEW_CONFIG_PATH,
177
177
  legacyConfig: LEGACY_CONFIG_PATH
178
178
  });
179
179
  return;
180
180
  }
181
- log$39.info("检测到环境变量凭证但 lark-cli 配置文件缺失,开始同步", {
181
+ log$40.info("检测到环境变量凭证但 lark-cli 配置文件缺失,开始同步", {
182
182
  appId,
183
183
  hasNewConfig,
184
184
  hasLegacyConfig
@@ -197,9 +197,9 @@ function ensureLarkCliConfig() {
197
197
  encoding: "utf-8",
198
198
  mode: 384
199
199
  });
200
- log$39.info("已生成新版 lark-cli 配置文件", { path: NEW_CONFIG_PATH });
200
+ log$40.info("已生成新版 lark-cli 配置文件", { path: NEW_CONFIG_PATH });
201
201
  } catch (err) {
202
- log$39.warn("生成新版 lark-cli 配置文件失败", {
202
+ log$40.warn("生成新版 lark-cli 配置文件失败", {
203
203
  path: NEW_CONFIG_PATH,
204
204
  error: err instanceof Error ? err.message : String(err)
205
205
  });
@@ -218,9 +218,9 @@ function ensureLarkCliConfig() {
218
218
  encoding: "utf-8",
219
219
  mode: 384
220
220
  });
221
- log$39.info("已生成旧版 lark-cli 配置文件", { path: LEGACY_CONFIG_PATH });
221
+ log$40.info("已生成旧版 lark-cli 配置文件", { path: LEGACY_CONFIG_PATH });
222
222
  } catch (err) {
223
- log$39.warn("生成旧版 lark-cli 配置文件失败", {
223
+ log$40.warn("生成旧版 lark-cli 配置文件失败", {
224
224
  path: LEGACY_CONFIG_PATH,
225
225
  error: err instanceof Error ? err.message : String(err)
226
226
  });
@@ -305,6 +305,22 @@ const DEFAULT_SETTINGS = {
305
305
  url: "http://localhost:3000/hooks/stop",
306
306
  timeout: 5
307
307
  }] }],
308
+ PreToolUse: [{
309
+ matcher: "Read",
310
+ hooks: [{
311
+ type: "http",
312
+ url: "http://localhost:3000/hooks/pre-tool-use",
313
+ timeout: 5
314
+ }]
315
+ }],
316
+ PostToolUse: [{
317
+ matcher: "Read",
318
+ hooks: [{
319
+ type: "http",
320
+ url: "http://localhost:3000/hooks/post-tool-use",
321
+ timeout: 5
322
+ }]
323
+ }],
308
324
  SessionEnd: [{ hooks: [{
309
325
  type: "http",
310
326
  url: "http://localhost:3000/hooks/session-end",
@@ -441,7 +457,7 @@ async function ensureDefaults() {
441
457
  * - github: { type: "api_key", token }
442
458
  * - custom-api: { type: "http_bearer", base_url, token }
443
459
  */
444
- const log$38 = larkLogger("user/credential-vault");
460
+ const log$39 = larkLogger("user/credential-vault");
445
461
  var CredentialVault = class {
446
462
  credentialPath;
447
463
  encryptionKey;
@@ -453,12 +469,12 @@ var CredentialVault = class {
453
469
  if (keyEnv) {
454
470
  this.encryptionKey = keyEnv.length === 64 ? Buffer.from(keyEnv, "hex") : Buffer.from(keyEnv, "base64");
455
471
  if (this.encryptionKey.length !== 32) {
456
- log$38.error("LARKPAL_ENCRYPTION_KEY 长度不正确,需要 32 字节", { actualLength: this.encryptionKey.length });
472
+ log$39.error("LARKPAL_ENCRYPTION_KEY 长度不正确,需要 32 字节", { actualLength: this.encryptionKey.length });
457
473
  this.encryptionKey = null;
458
- } else log$38.info("凭证加密已启用");
474
+ } else log$39.info("凭证加密已启用");
459
475
  } else {
460
476
  this.encryptionKey = null;
461
- log$38.warn("LARKPAL_ENCRYPTION_KEY 未设置,凭证将以明文存储(仅限开发模式)");
477
+ log$39.warn("LARKPAL_ENCRYPTION_KEY 未设置,凭证将以明文存储(仅限开发模式)");
462
478
  }
463
479
  }
464
480
  /**
@@ -466,7 +482,7 @@ var CredentialVault = class {
466
482
  */
467
483
  async load() {
468
484
  if (!existsSync$1(this.credentialPath)) {
469
- log$38.info("凭证文件不存在,使用空凭证", { path: this.credentialPath });
485
+ log$39.info("凭证文件不存在,使用空凭证", { path: this.credentialPath });
470
486
  this.store = {};
471
487
  this.loaded = true;
472
488
  return;
@@ -475,12 +491,12 @@ var CredentialVault = class {
475
491
  const raw = await readFile$1(this.credentialPath, "utf-8");
476
492
  this.store = JSON.parse(raw);
477
493
  this.loaded = true;
478
- log$38.info("凭证文件加载完成", {
494
+ log$39.info("凭证文件加载完成", {
479
495
  path: this.credentialPath,
480
496
  credentialCount: Object.keys(this.store).length
481
497
  });
482
498
  } catch (err) {
483
- log$38.error("凭证文件加载失败", {
499
+ log$39.error("凭证文件加载失败", {
484
500
  path: this.credentialPath,
485
501
  error: err instanceof Error ? err.message : String(err)
486
502
  });
@@ -494,12 +510,12 @@ var CredentialVault = class {
494
510
  async save() {
495
511
  try {
496
512
  await writeFile$1(this.credentialPath, JSON.stringify(this.store, null, 2), "utf-8");
497
- log$38.info("凭证文件保存完成", {
513
+ log$39.info("凭证文件保存完成", {
498
514
  path: this.credentialPath,
499
515
  credentialCount: Object.keys(this.store).length
500
516
  });
501
517
  } catch (err) {
502
- log$38.error("凭证文件保存失败", {
518
+ log$39.error("凭证文件保存失败", {
503
519
  path: this.credentialPath,
504
520
  error: err instanceof Error ? err.message : String(err)
505
521
  });
@@ -521,7 +537,7 @@ var CredentialVault = class {
521
537
  */
522
538
  async setCredential(name, credential) {
523
539
  if (!this.loaded) await this.load();
524
- log$38.info("设置凭证", {
540
+ log$39.info("设置凭证", {
525
541
  name,
526
542
  fields: Object.keys(credential)
527
543
  });
@@ -537,7 +553,7 @@ var CredentialVault = class {
537
553
  if (!(name in this.store)) return false;
538
554
  delete this.store[name];
539
555
  await this.save();
540
- log$38.info("凭证已删除", { name });
556
+ log$39.info("凭证已删除", { name });
541
557
  return true;
542
558
  }
543
559
  /**
@@ -566,7 +582,7 @@ var CredentialVault = class {
566
582
  envVars[envKey] = String(fieldValue);
567
583
  }
568
584
  } catch (err) {
569
- log$38.warn("凭证解密失败,跳过", {
585
+ log$39.warn("凭证解密失败,跳过", {
570
586
  name,
571
587
  error: err instanceof Error ? err.message : String(err)
572
588
  });
@@ -623,7 +639,7 @@ var CredentialVault = class {
623
639
  * - B 用户的 CC 进程连接 B 的数据库 MCP Server
624
640
  * - 全局工具(lark-cli、文件操作等)所有用户共享
625
641
  */
626
- const log$37 = larkLogger("user/mcp-merge");
642
+ const log$38 = larkLogger("user/mcp-merge");
627
643
  /** 全局 MCP 配置路径 */
628
644
  const GLOBAL_MCP_CONFIG_PATH = path$1.join(os.homedir(), ".claude", "mcp-servers.json");
629
645
  /**
@@ -679,9 +695,9 @@ async function mergeAndWriteMcpConfig(userCtx, sessionId, cwd) {
679
695
  if (existsSync$1(GLOBAL_MCP_CONFIG_PATH)) try {
680
696
  const raw = await readFile$1(GLOBAL_MCP_CONFIG_PATH, "utf-8");
681
697
  globalConfig = JSON.parse(raw);
682
- log$37.info("全局 MCP 配置加载完成", { serverCount: Object.keys(globalConfig).length });
698
+ log$38.info("全局 MCP 配置加载完成", { serverCount: Object.keys(globalConfig).length });
683
699
  } catch (err) {
684
- log$37.warn("全局 MCP 配置文件解析失败", {
700
+ log$38.warn("全局 MCP 配置文件解析失败", {
685
701
  path: GLOBAL_MCP_CONFIG_PATH,
686
702
  error: err instanceof Error ? err.message : String(err)
687
703
  });
@@ -692,13 +708,13 @@ async function mergeAndWriteMcpConfig(userCtx, sessionId, cwd) {
692
708
  if (existsSync$1(userMcpPath)) try {
693
709
  const raw = await readFile$1(userMcpPath, "utf-8");
694
710
  userConfig = JSON.parse(raw);
695
- log$37.info("用户 MCP 配置加载完成", {
711
+ log$38.info("用户 MCP 配置加载完成", {
696
712
  userId: userCtx.userId,
697
713
  serverCount: Object.keys(userConfig).length,
698
714
  servers: Object.keys(userConfig)
699
715
  });
700
716
  } catch (err) {
701
- log$37.warn("用户 MCP 配置文件解析失败", {
717
+ log$38.warn("用户 MCP 配置文件解析失败", {
702
718
  userId: userCtx?.userId,
703
719
  path: userMcpPath,
704
720
  error: err instanceof Error ? err.message : String(err)
@@ -710,7 +726,7 @@ async function mergeAndWriteMcpConfig(userCtx, sessionId, cwd) {
710
726
  ...globalConfig,
711
727
  ...userConfig
712
728
  };
713
- log$37.info("MCP 配置合并完成", {
729
+ log$38.info("MCP 配置合并完成", {
714
730
  userId: userCtx?.userId,
715
731
  builtinServers: Object.keys(builtinConfig),
716
732
  globalServers: Object.keys(globalConfig),
@@ -719,7 +735,7 @@ async function mergeAndWriteMcpConfig(userCtx, sessionId, cwd) {
719
735
  });
720
736
  const tmpPath = path$1.join(os.tmpdir(), `mcp-${sessionId}.json`);
721
737
  await writeFile$1(tmpPath, JSON.stringify({ mcpServers: merged }, null, 2), "utf-8");
722
- log$37.info("MCP 合并配置已写入临时文件", {
738
+ log$38.info("MCP 合并配置已写入临时文件", {
723
739
  path: tmpPath,
724
740
  serverCount: Object.keys(merged).length
725
741
  });
@@ -733,7 +749,7 @@ async function cleanupMcpConfig(sessionId) {
733
749
  try {
734
750
  if (existsSync$1(tmpPath)) {
735
751
  await unlink$1(tmpPath);
736
- log$37.debug("MCP 临时配置文件已清理", { path: tmpPath });
752
+ log$38.debug("MCP 临时配置文件已清理", { path: tmpPath });
737
753
  }
738
754
  } catch {}
739
755
  }
@@ -781,7 +797,7 @@ function getToolDisplayName(toolName) {
781
797
  * - result(data) — 最终结果
782
798
  * - parseError(error, rawLine) — 解析错误(不中断流)
783
799
  */
784
- const log$36 = larkLogger("cc-runtime/stream-parser");
800
+ const log$37 = larkLogger("cc-runtime/stream-parser");
785
801
  const MAX_TOOL_RESULT_CONTENT_CHARS = 8e3;
786
802
  var CCStreamParser = class extends EventEmitter {
787
803
  toolNamesById = /* @__PURE__ */ new Map();
@@ -798,7 +814,7 @@ var CCStreamParser = class extends EventEmitter {
798
814
  try {
799
815
  msg = JSON.parse(trimmed);
800
816
  } catch (err) {
801
- log$36.warn("NDJSON 解析失败,跳过该行", {
817
+ log$37.warn("NDJSON 解析失败,跳过该行", {
802
818
  error: String(err),
803
819
  rawLine: trimmed.slice(0, 200)
804
820
  });
@@ -806,7 +822,7 @@ var CCStreamParser = class extends EventEmitter {
806
822
  return;
807
823
  }
808
824
  if (msg.parent_tool_use_id != null && msg.type !== "result") {
809
- log$36.debug("跳过 subagent 内部事件", {
825
+ log$37.debug("跳过 subagent 内部事件", {
810
826
  type: msg.type,
811
827
  parentToolUseId: msg.parent_tool_use_id
812
828
  });
@@ -829,7 +845,7 @@ var CCStreamParser = class extends EventEmitter {
829
845
  this.handleResult(msg);
830
846
  break;
831
847
  case "system":
832
- log$36.info("收到 system 消息", {
848
+ log$37.info("收到 system 消息", {
833
849
  subtype: msg.subtype,
834
850
  detail: JSON.stringify(msg).slice(0, 500)
835
851
  });
@@ -837,7 +853,7 @@ var CCStreamParser = class extends EventEmitter {
837
853
  this.emit("system", msg);
838
854
  break;
839
855
  default:
840
- log$36.debug("收到未知消息类型,已忽略", { type: msg.type });
856
+ log$37.debug("收到未知消息类型,已忽略", { type: msg.type });
841
857
  break;
842
858
  }
843
859
  }
@@ -856,14 +872,14 @@ var CCStreamParser = class extends EventEmitter {
856
872
  switch (event.type) {
857
873
  case "content_block_start": {
858
874
  const block = event.content_block;
859
- log$36.debug("content_block_start 事件", {
875
+ log$37.debug("content_block_start 事件", {
860
876
  blockType: block?.type,
861
877
  toolName: block?.name,
862
878
  toolUseId: block?.id
863
879
  });
864
880
  if (block?.type === "tool_use" && block.name) {
865
881
  this.recordToolUse(block.name, block.id);
866
- log$36.info("工具调用开始", {
882
+ log$37.info("工具调用开始", {
867
883
  toolName: block.name,
868
884
  displayName: getToolDisplayName(block.name),
869
885
  toolUseId: block.id
@@ -882,7 +898,7 @@ var CCStreamParser = class extends EventEmitter {
882
898
  case "message_delta": {
883
899
  const stopReason = event.delta?.stop_reason;
884
900
  if (stopReason) {
885
- log$36.info("轮次结束", { stopReason });
901
+ log$37.info("轮次结束", { stopReason });
886
902
  this.emit("turnEnd", stopReason);
887
903
  }
888
904
  break;
@@ -902,7 +918,7 @@ var CCStreamParser = class extends EventEmitter {
902
918
  const content = msg.message?.content;
903
919
  const stopReason = msg.message?.stop_reason;
904
920
  const blockTypes = Array.isArray(content) ? content.map((b) => `${b.type}${b.name ? ":" + b.name : ""}`).join(", ") : "none";
905
- log$36.info("收到完整 assistant 消息", {
921
+ log$37.info("收到完整 assistant 消息", {
906
922
  model: msg.message?.model,
907
923
  stopReason,
908
924
  contentBlocks: content?.length,
@@ -911,7 +927,7 @@ var CCStreamParser = class extends EventEmitter {
911
927
  if (Array.isArray(content)) {
912
928
  for (const block of content) if (block.type === "tool_use" && block.name) {
913
929
  this.recordToolUse(block.name, block.id);
914
- log$36.info("从 assistant 消息提取工具调用", {
930
+ log$37.info("从 assistant 消息提取工具调用", {
915
931
  toolName: block.name,
916
932
  displayName: getToolDisplayName(block.name),
917
933
  toolUseId: block.id
@@ -931,7 +947,7 @@ var CCStreamParser = class extends EventEmitter {
931
947
  if (!Array.isArray(content)) return;
932
948
  for (const item of content) if (item.type === "tool_result" && item.tool_use_id) {
933
949
  const result = this.buildToolResultSummary(item, msg.toolUseResult);
934
- log$36.debug("工具结果返回", {
950
+ log$37.debug("工具结果返回", {
935
951
  toolUseId: item.tool_use_id,
936
952
  toolName: result.toolName,
937
953
  isError: result.isError,
@@ -947,7 +963,7 @@ var CCStreamParser = class extends EventEmitter {
947
963
  recordToolUse(toolName, toolUseId) {
948
964
  if (toolUseId) this.toolNamesById.set(toolUseId, toolName);
949
965
  if (this.declaredTools && !this.declaredTools.has(toolName)) {
950
- log$36.warn("模型请求了未声明工具", {
966
+ log$37.warn("模型请求了未声明工具", {
951
967
  toolName,
952
968
  toolUseId
953
969
  });
@@ -974,7 +990,7 @@ var CCStreamParser = class extends EventEmitter {
974
990
  * 处理 tool_progress 消息 — 工具执行进度
975
991
  */
976
992
  handleToolProgress(msg) {
977
- log$36.debug("工具执行进度", {
993
+ log$37.debug("工具执行进度", {
978
994
  toolName: msg.tool_name,
979
995
  elapsed: msg.elapsed_time_seconds
980
996
  });
@@ -984,7 +1000,7 @@ var CCStreamParser = class extends EventEmitter {
984
1000
  * 处理 result 消息 — CC 执行完成的最终结果
985
1001
  */
986
1002
  handleResult(msg) {
987
- log$36.info("CC 执行完成", {
1003
+ log$37.info("CC 执行完成", {
988
1004
  subtype: msg.subtype,
989
1005
  isError: msg.is_error,
990
1006
  durationMs: msg.duration_ms,
@@ -1048,6 +1064,189 @@ function tryParseJson$1(value) {
1048
1064
  }
1049
1065
  }
1050
1066
  //#endregion
1067
+ //#region src/cc-runtime/transcript-sanitizer.ts
1068
+ const log$36 = larkLogger("cc-runtime/transcript-sanitizer");
1069
+ const ARTIFACT_DIR = "larkpal-artifacts";
1070
+ const MAX_READ_BYTES = readPositiveIntEnv$1("LARKPAL_TRANSCRIPT_SANITIZER_MAX_BYTES", 50 * 1024 * 1024);
1071
+ async function sanitizeClaudeTranscriptFile(filePath) {
1072
+ const result = {
1073
+ changed: false,
1074
+ lines: 0,
1075
+ sanitizedImageBlocks: 0,
1076
+ artifacts: 0
1077
+ };
1078
+ const fileStat = await stat(filePath);
1079
+ if (fileStat.size > MAX_READ_BYTES) {
1080
+ log$36.warn("跳过过大的 Claude transcript sanitizer", {
1081
+ filePath,
1082
+ size: fileStat.size
1083
+ });
1084
+ return result;
1085
+ }
1086
+ const raw = await readFile(filePath, "utf-8");
1087
+ const hasTrailingNewline = raw.endsWith("\n");
1088
+ const lines = raw.split("\n");
1089
+ if (hasTrailingNewline) lines.pop();
1090
+ result.lines = lines.length;
1091
+ const out = [];
1092
+ const artifactRoot = join(dirname(filePath), ARTIFACT_DIR);
1093
+ for (const line of lines) {
1094
+ if (!line.trim()) {
1095
+ out.push(line);
1096
+ continue;
1097
+ }
1098
+ if (!mightContainImagePayload(line)) {
1099
+ out.push(line);
1100
+ continue;
1101
+ }
1102
+ let entry;
1103
+ try {
1104
+ entry = JSON.parse(line);
1105
+ } catch {
1106
+ out.push(line);
1107
+ continue;
1108
+ }
1109
+ const lineStats = await sanitizeTranscriptEntry(entry, artifactRoot);
1110
+ if (lineStats.sanitizedImageBlocks > 0) {
1111
+ result.changed = true;
1112
+ result.sanitizedImageBlocks += lineStats.sanitizedImageBlocks;
1113
+ result.artifacts += lineStats.artifacts;
1114
+ out.push(JSON.stringify(entry));
1115
+ } else out.push(line);
1116
+ }
1117
+ if (!result.changed) return result;
1118
+ const tempPath = `${filePath}.larkpal-sanitized-${process.pid}-${Date.now()}`;
1119
+ await writeFile(tempPath, out.join("\n") + (hasTrailingNewline ? "\n" : ""), "utf-8");
1120
+ await rename(tempPath, filePath);
1121
+ log$36.info("Claude transcript 图片结果已清理", {
1122
+ filePath,
1123
+ ...result
1124
+ });
1125
+ return result;
1126
+ }
1127
+ function isTranscriptSanitizerEnabled() {
1128
+ const value = process.env.LARKPAL_TRANSCRIPT_SANITIZER;
1129
+ return value !== "0" && value !== "false";
1130
+ }
1131
+ async function sanitizeTranscriptEntry(entry, artifactRoot) {
1132
+ let sanitizedImageBlocks = 0;
1133
+ let artifacts = 0;
1134
+ const message = asObject$1(entry.message);
1135
+ const content = message ? message.content : void 0;
1136
+ if (!Array.isArray(content)) return {
1137
+ sanitizedImageBlocks,
1138
+ artifacts
1139
+ };
1140
+ for (const block of content) {
1141
+ const toolResult = asObject$1(block);
1142
+ if (!toolResult || toolResult.type !== "tool_result") continue;
1143
+ const toolUseId = typeof toolResult.tool_use_id === "string" ? toolResult.tool_use_id : void 0;
1144
+ const replacement = await sanitizeToolResultContent(toolResult.content, artifactRoot, toolUseId);
1145
+ if (!replacement) continue;
1146
+ toolResult.content = replacement.content;
1147
+ sanitizedImageBlocks += replacement.imageBlocks;
1148
+ artifacts += replacement.artifacts;
1149
+ const toolUseResult = asObject$1(entry.toolUseResult);
1150
+ if (toolUseResult?.type === "image") {
1151
+ const file = asObject$1(toolUseResult.file);
1152
+ if (file && typeof file.base64 === "string") {
1153
+ file.artifactUri = replacement.metadata[0]?.artifactUri;
1154
+ file.base64 = "[redacted by LarkPal: image artifact externalized]";
1155
+ }
1156
+ }
1157
+ }
1158
+ return {
1159
+ sanitizedImageBlocks,
1160
+ artifacts
1161
+ };
1162
+ }
1163
+ async function sanitizeToolResultContent(content, artifactRoot, toolUseId) {
1164
+ if (!Array.isArray(content)) return null;
1165
+ const metadata = [];
1166
+ let imageBlocks = 0;
1167
+ let artifacts = 0;
1168
+ for (let i = 0; i < content.length; i++) {
1169
+ const block = asObject$1(content[i]);
1170
+ if (!isBase64ImageBlock(block)) continue;
1171
+ imageBlocks++;
1172
+ const artifact = await writeImageArtifact(artifactRoot, block, toolUseId, i);
1173
+ metadata.push(artifact);
1174
+ artifacts++;
1175
+ }
1176
+ if (imageBlocks === 0) return null;
1177
+ return {
1178
+ content: buildReplacementText(metadata),
1179
+ imageBlocks,
1180
+ artifacts,
1181
+ metadata
1182
+ };
1183
+ }
1184
+ async function writeImageArtifact(artifactRoot, block, toolUseId, index) {
1185
+ const source = asObject$1(block.source);
1186
+ const data = typeof source?.data === "string" ? source.data : "";
1187
+ const mimeType = typeof source?.media_type === "string" ? source.media_type : void 0;
1188
+ const artifactId = `${toolUseId ?? randomUUID()}-${index}`;
1189
+ const artifactPath = join(artifactRoot, `${artifactId}.json`);
1190
+ const artifactUri = `artifact://claude-transcript/${artifactId}`;
1191
+ const dimensions = formatDimensions(block);
1192
+ await mkdir(artifactRoot, { recursive: true });
1193
+ await writeFile(artifactPath, JSON.stringify({
1194
+ artifactUri,
1195
+ originalToolName: "Read",
1196
+ toolUseId,
1197
+ mimeType,
1198
+ dimensions,
1199
+ originalSize: data.length,
1200
+ sourcePath: typeof block.filePath === "string" ? block.filePath : void 0,
1201
+ base64: data
1202
+ }, null, 2), {
1203
+ encoding: "utf-8",
1204
+ flag: "w"
1205
+ });
1206
+ return {
1207
+ artifactUri,
1208
+ originalToolName: "Read",
1209
+ toolUseId,
1210
+ mimeType,
1211
+ dimensions,
1212
+ originalSize: data.length,
1213
+ sourcePath: typeof block.filePath === "string" ? block.filePath : void 0,
1214
+ summary: "Rendered image/PDF page preview externalized by LarkPal transcript sanitizer."
1215
+ };
1216
+ }
1217
+ function buildReplacementText(metadata) {
1218
+ return JSON.stringify({
1219
+ larkpalSanitized: true,
1220
+ reason: "Previous image tool result was externalized to avoid replaying base64 image payloads into provider requests.",
1221
+ artifacts: metadata
1222
+ });
1223
+ }
1224
+ function isBase64ImageBlock(value) {
1225
+ if (!value || value.type !== "image") return false;
1226
+ const source = asObject$1(value.source);
1227
+ return source?.type === "base64" && typeof source.data === "string";
1228
+ }
1229
+ function formatDimensions(block) {
1230
+ const dimensions = asObject$1(block.dimensions);
1231
+ if (!dimensions) return void 0;
1232
+ const width = dimensions.displayWidth ?? dimensions.originalWidth;
1233
+ const height = dimensions.displayHeight ?? dimensions.originalHeight;
1234
+ if (typeof width !== "number" || typeof height !== "number") return void 0;
1235
+ return `${width}x${height}`;
1236
+ }
1237
+ function mightContainImagePayload(line) {
1238
+ return line.includes("\"tool_result\"") && line.includes("\"type\":\"image\"") && line.includes("\"base64\"");
1239
+ }
1240
+ function asObject$1(value) {
1241
+ return typeof value === "object" && value != null ? value : void 0;
1242
+ }
1243
+ function readPositiveIntEnv$1(name, fallback) {
1244
+ const raw = process.env[name];
1245
+ if (!raw) return fallback;
1246
+ const parsed = Number.parseInt(raw, 10);
1247
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1248
+ }
1249
+ //#endregion
1051
1250
  //#region src/cc-runtime/process-manager.ts
1052
1251
  /**
1053
1252
  * CC 进程管理器 — 按 session_id 管理 Claude Code 常驻进程的生命周期
@@ -1268,7 +1467,16 @@ var SessionProcessManager = class {
1268
1467
  const { sessionId, cwd, prompt } = config;
1269
1468
  const isEphemeral = config.transcriptMode === "ephemeral";
1270
1469
  const claudeSessionId = isEphemeral ? v4() : v5(sessionId, LARKPAL_SESSION_NAMESPACE);
1271
- const isResuming = !isEphemeral && (opts?._forceResume || this.ccSessionExists(cwd, claudeSessionId));
1470
+ const transcriptPath = this.getCCSessionFilePath(cwd, claudeSessionId);
1471
+ const transcriptExists = existsSync(transcriptPath);
1472
+ const isResuming = !isEphemeral && (opts?._forceResume || transcriptExists);
1473
+ if (isResuming && transcriptExists && isTranscriptSanitizerEnabled()) await sanitizeClaudeTranscriptFile(transcriptPath).catch((err) => {
1474
+ log$35.warn("Claude transcript sanitizer 失败,继续启动进程", {
1475
+ sessionId,
1476
+ transcriptPath,
1477
+ error: err instanceof Error ? err.message : String(err)
1478
+ });
1479
+ });
1272
1480
  const args = [
1273
1481
  "-p",
1274
1482
  "--input-format",
@@ -1817,12 +2025,10 @@ var SessionProcessManager = class {
1817
2025
  */
1818
2026
  ccSessionExists(cwd, claudeSessionId) {
1819
2027
  try {
1820
- const cwdEncoded = this.encodeCwdForCC(cwd);
1821
- const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${claudeSessionId}.jsonl`);
2028
+ const sessionFile = this.getCCSessionFilePath(cwd, claudeSessionId);
1822
2029
  const exists = existsSync(sessionFile);
1823
2030
  log$35.debug("检查 CC session 文件", {
1824
2031
  cwd,
1825
- cwdEncoded,
1826
2032
  claudeSessionId,
1827
2033
  sessionFile,
1828
2034
  exists
@@ -1833,6 +2039,10 @@ var SessionProcessManager = class {
1833
2039
  return false;
1834
2040
  }
1835
2041
  }
2042
+ getCCSessionFilePath(cwd, claudeSessionId) {
2043
+ const cwdEncoded = this.encodeCwdForCC(cwd);
2044
+ return join(homedir(), ".claude", "projects", cwdEncoded, `${claudeSessionId}.jsonl`);
2045
+ }
1836
2046
  };
1837
2047
  //#endregion
1838
2048
  //#region src/runtime/claude-code-adapter.ts
@@ -6996,6 +7206,76 @@ async function runBatchTasks(taskConfigs, concurrency, processManager) {
6996
7206
  logger$6.info("批量任务全部完成", { totalTasks: taskConfigs.length });
6997
7207
  }
6998
7208
  //#endregion
7209
+ //#region src/cc-runtime/image-read-guard.ts
7210
+ const DEFAULT_MAX_IMAGE_READS_PER_RUN = 4;
7211
+ const IMAGE_EXTENSIONS = new Set([
7212
+ ".png",
7213
+ ".jpg",
7214
+ ".jpeg",
7215
+ ".gif",
7216
+ ".webp",
7217
+ ".bmp",
7218
+ ".tif",
7219
+ ".tiff"
7220
+ ]);
7221
+ const imageReadCounts = /* @__PURE__ */ new Map();
7222
+ function evaluateImageReadGuard(body) {
7223
+ const limit = getImageReadLimit();
7224
+ const filePath = getReadFilePath(body);
7225
+ if (limit <= 0 || !filePath || !isImagePath(filePath)) return {
7226
+ allowed: true,
7227
+ count: 0,
7228
+ limit,
7229
+ filePath
7230
+ };
7231
+ const key = getRunKey(body);
7232
+ const count = (imageReadCounts.get(key) ?? 0) + 1;
7233
+ imageReadCounts.set(key, count);
7234
+ if (count <= limit) return {
7235
+ allowed: true,
7236
+ count,
7237
+ limit,
7238
+ filePath
7239
+ };
7240
+ return {
7241
+ allowed: false,
7242
+ count,
7243
+ limit,
7244
+ filePath,
7245
+ reason: `LarkPal blocked image Read #${count}; max image reads per run is ${limit}. Use text extraction or start a fresh run/session before reading more rendered page images.`
7246
+ };
7247
+ }
7248
+ function resetImageReadGuard(key) {
7249
+ if (key) {
7250
+ imageReadCounts.delete(key);
7251
+ return;
7252
+ }
7253
+ imageReadCounts.clear();
7254
+ }
7255
+ function getImageReadLimit() {
7256
+ const raw = process.env.LARKPAL_CC_MAX_IMAGE_READS_PER_RUN;
7257
+ if (!raw) return DEFAULT_MAX_IMAGE_READS_PER_RUN;
7258
+ const parsed = Number.parseInt(raw, 10);
7259
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_IMAGE_READS_PER_RUN;
7260
+ }
7261
+ function getReadFilePath(body) {
7262
+ const input = asObject(body.tool_input);
7263
+ const value = input?.file_path ?? input?.path;
7264
+ return typeof value === "string" ? value : void 0;
7265
+ }
7266
+ function getRunKey(body) {
7267
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : void 0;
7268
+ const sessionId = typeof body.session_id === "string" ? body.session_id : void 0;
7269
+ const cwd = typeof body.cwd === "string" ? body.cwd : void 0;
7270
+ return transcriptPath ?? sessionId ?? cwd ?? "global";
7271
+ }
7272
+ function isImagePath(filePath) {
7273
+ return IMAGE_EXTENSIONS.has(extname(filePath).toLowerCase());
7274
+ }
7275
+ function asObject(value) {
7276
+ return typeof value === "object" && value != null ? value : void 0;
7277
+ }
7278
+ //#endregion
6999
7279
  //#region src/gateway/hooks-handler.ts
7000
7280
  /**
7001
7281
  * HTTP Hooks 接收路由
@@ -7028,6 +7308,63 @@ function createHooksRouter() {
7028
7308
  });
7029
7309
  res.json({ received: true });
7030
7310
  });
7311
+ router.post("/pre-tool-use", (req, res) => {
7312
+ const body = req.body;
7313
+ const decision = evaluateImageReadGuard(body);
7314
+ logger$5.info("hook received: pre-tool-use", {
7315
+ method: req.method,
7316
+ url: req.originalUrl,
7317
+ sessionId: body.session_id,
7318
+ toolName: body.tool_name,
7319
+ filePath: decision.filePath,
7320
+ count: decision.count,
7321
+ limit: decision.limit,
7322
+ allowed: decision.allowed
7323
+ });
7324
+ if (decision.allowed) {
7325
+ res.json({ continue: true });
7326
+ return;
7327
+ }
7328
+ res.json({
7329
+ continue: true,
7330
+ hookSpecificOutput: {
7331
+ hookEventName: "PreToolUse",
7332
+ permissionDecision: "deny",
7333
+ permissionDecisionReason: decision.reason
7334
+ }
7335
+ });
7336
+ });
7337
+ router.post("/post-tool-use", async (req, res) => {
7338
+ const body = req.body;
7339
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : typeof body.transcriptPath === "string" ? body.transcriptPath : void 0;
7340
+ logger$5.info("hook received: post-tool-use", {
7341
+ method: req.method,
7342
+ url: req.originalUrl,
7343
+ sessionId: body.session_id,
7344
+ toolName: body.tool_name,
7345
+ transcriptPath
7346
+ });
7347
+ if (!transcriptPath || !isTranscriptSanitizerEnabled()) {
7348
+ res.json({ continue: true });
7349
+ return;
7350
+ }
7351
+ try {
7352
+ const result = await sanitizeClaudeTranscriptFile(transcriptPath);
7353
+ res.json({
7354
+ continue: true,
7355
+ hookSpecificOutput: result.changed ? {
7356
+ hookEventName: "PostToolUse",
7357
+ additionalContext: `LarkPal externalized ${result.sanitizedImageBlocks} image tool result(s) from the persisted Claude transcript to reduce future replay payload.`
7358
+ } : void 0
7359
+ });
7360
+ } catch (err) {
7361
+ logger$5.warn("post-tool-use transcript sanitizer failed", {
7362
+ transcriptPath,
7363
+ error: err instanceof Error ? err.message : String(err)
7364
+ });
7365
+ res.json({ continue: true });
7366
+ }
7367
+ });
7031
7368
  router.post("/session-end", (req, res) => {
7032
7369
  const body = req.body;
7033
7370
  logger$5.info("hook received: session-end", {
@@ -7036,6 +7373,7 @@ function createHooksRouter() {
7036
7373
  sessionId: body.session_id,
7037
7374
  body
7038
7375
  });
7376
+ resetImageReadGuard(typeof body.transcript_path === "string" ? body.transcript_path : void 0);
7039
7377
  res.json({ received: true });
7040
7378
  });
7041
7379
  router.post("/notification", (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-lark/larkpal",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "description": "LarkPal - Lark/Feishu bot service",
5
5
  "type": "module",
6
6
  "main": "./dist/main.mjs",