@actagent/feishu 2026.6.2

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 (207) hide show
  1. package/README.md +11 -0
  2. package/actagent.plugin.json +224 -0
  3. package/api.ts +33 -0
  4. package/channel-entry.ts +21 -0
  5. package/channel-plugin-api.ts +2 -0
  6. package/contract-api.ts +17 -0
  7. package/index.ts +83 -0
  8. package/legacy-state-migrations-api.ts +2 -0
  9. package/npm-shrinkwrap.json +539 -0
  10. package/package.json +64 -0
  11. package/runtime-api.ts +58 -0
  12. package/runtime-setter-api.ts +3 -0
  13. package/secret-contract-api.ts +6 -0
  14. package/security-contract-api.ts +2 -0
  15. package/session-key-api.ts +2 -0
  16. package/setup-api.ts +4 -0
  17. package/setup-entry.test.ts +33 -0
  18. package/setup-entry.ts +25 -0
  19. package/skills/feishu-doc/SKILL.md +211 -0
  20. package/skills/feishu-doc/references/block-types.md +103 -0
  21. package/skills/feishu-drive/SKILL.md +97 -0
  22. package/skills/feishu-perm/SKILL.md +119 -0
  23. package/skills/feishu-wiki/SKILL.md +113 -0
  24. package/src/accounts.test.ts +481 -0
  25. package/src/accounts.ts +380 -0
  26. package/src/agent-config.ts +22 -0
  27. package/src/app-registration.test.ts +62 -0
  28. package/src/app-registration.ts +355 -0
  29. package/src/approval-auth.test.ts +25 -0
  30. package/src/approval-auth.ts +26 -0
  31. package/src/async.test.ts +68 -0
  32. package/src/async.ts +109 -0
  33. package/src/audio-preflight.runtime.ts +10 -0
  34. package/src/bitable.test.ts +174 -0
  35. package/src/bitable.ts +781 -0
  36. package/src/bot-content.ts +488 -0
  37. package/src/bot-group-name.test.ts +148 -0
  38. package/src/bot-runtime-api.ts +13 -0
  39. package/src/bot-sender-name.test.ts +68 -0
  40. package/src/bot-sender-name.ts +137 -0
  41. package/src/bot.broadcast.test.ts +643 -0
  42. package/src/bot.card-action.test.ts +647 -0
  43. package/src/bot.checkBotMentioned.test.ts +266 -0
  44. package/src/bot.helpers.test.ts +136 -0
  45. package/src/bot.stripBotMention.test.ts +127 -0
  46. package/src/bot.test.ts +3817 -0
  47. package/src/bot.ts +1788 -0
  48. package/src/card-action.ts +515 -0
  49. package/src/card-interaction.test.ts +132 -0
  50. package/src/card-interaction.ts +160 -0
  51. package/src/card-test-helpers.ts +55 -0
  52. package/src/card-ux-approval.ts +66 -0
  53. package/src/card-ux-launcher.test.ts +126 -0
  54. package/src/card-ux-launcher.ts +136 -0
  55. package/src/card-ux-shared.ts +34 -0
  56. package/src/channel-runtime-api.ts +17 -0
  57. package/src/channel.runtime.ts +48 -0
  58. package/src/channel.test.ts +1337 -0
  59. package/src/channel.ts +1401 -0
  60. package/src/chat-schema.ts +30 -0
  61. package/src/chat.test.ts +295 -0
  62. package/src/chat.ts +198 -0
  63. package/src/client-timeout.ts +44 -0
  64. package/src/client.test.ts +463 -0
  65. package/src/client.ts +263 -0
  66. package/src/comment-dispatcher-runtime-api.ts +7 -0
  67. package/src/comment-dispatcher.test.ts +186 -0
  68. package/src/comment-dispatcher.ts +108 -0
  69. package/src/comment-handler-runtime-api.ts +4 -0
  70. package/src/comment-handler.test.ts +588 -0
  71. package/src/comment-handler.ts +304 -0
  72. package/src/comment-reaction.test.ts +139 -0
  73. package/src/comment-reaction.ts +260 -0
  74. package/src/comment-shared.test.ts +184 -0
  75. package/src/comment-shared.ts +405 -0
  76. package/src/comment-target.ts +45 -0
  77. package/src/config-schema.test.ts +327 -0
  78. package/src/config-schema.ts +338 -0
  79. package/src/conversation-id.test.ts +19 -0
  80. package/src/conversation-id.ts +199 -0
  81. package/src/dedup-migrations.test.ts +90 -0
  82. package/src/dedup-migrations.ts +103 -0
  83. package/src/dedup.test.ts +95 -0
  84. package/src/dedup.ts +304 -0
  85. package/src/dedupe-key.ts +68 -0
  86. package/src/directory.static.ts +62 -0
  87. package/src/directory.test.ts +142 -0
  88. package/src/directory.ts +125 -0
  89. package/src/doc-schema.ts +183 -0
  90. package/src/doctor.test.ts +382 -0
  91. package/src/doctor.ts +876 -0
  92. package/src/docx-batch-insert.test.ts +117 -0
  93. package/src/docx-batch-insert.ts +223 -0
  94. package/src/docx-color-text.ts +154 -0
  95. package/src/docx-table-ops.test.ts +54 -0
  96. package/src/docx-table-ops.ts +316 -0
  97. package/src/docx-types.ts +39 -0
  98. package/src/docx.account-selection.test.ts +96 -0
  99. package/src/docx.test.ts +706 -0
  100. package/src/docx.ts +1598 -0
  101. package/src/drive-schema.ts +93 -0
  102. package/src/drive.test.ts +1240 -0
  103. package/src/drive.ts +830 -0
  104. package/src/dynamic-agent.test.ts +156 -0
  105. package/src/dynamic-agent.ts +144 -0
  106. package/src/event-types.ts +46 -0
  107. package/src/external-keys.test.ts +21 -0
  108. package/src/external-keys.ts +20 -0
  109. package/src/lifecycle.test-support.ts +223 -0
  110. package/src/media.test.ts +956 -0
  111. package/src/media.ts +1106 -0
  112. package/src/mention-target.types.ts +6 -0
  113. package/src/mention.ts +115 -0
  114. package/src/message-action-contract.ts +14 -0
  115. package/src/monitor-state-runtime-api.ts +8 -0
  116. package/src/monitor-transport-runtime-api.ts +11 -0
  117. package/src/monitor.account.ts +501 -0
  118. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
  119. package/src/monitor.bot-identity.ts +87 -0
  120. package/src/monitor.bot-menu-handler.ts +164 -0
  121. package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
  122. package/src/monitor.bot-menu.test.ts +200 -0
  123. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
  124. package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
  125. package/src/monitor.cleanup.test.ts +384 -0
  126. package/src/monitor.comment-notice-handler.ts +106 -0
  127. package/src/monitor.comment.test.ts +968 -0
  128. package/src/monitor.comment.ts +1386 -0
  129. package/src/monitor.lifecycle.test.ts +5 -0
  130. package/src/monitor.message-handler.ts +346 -0
  131. package/src/monitor.reaction.test.ts +770 -0
  132. package/src/monitor.startup.test.ts +232 -0
  133. package/src/monitor.startup.ts +76 -0
  134. package/src/monitor.state.defaults.test.ts +47 -0
  135. package/src/monitor.state.ts +171 -0
  136. package/src/monitor.synthetic-error.ts +19 -0
  137. package/src/monitor.test-mocks.ts +47 -0
  138. package/src/monitor.transport.ts +451 -0
  139. package/src/monitor.ts +104 -0
  140. package/src/monitor.webhook-e2e.test.ts +284 -0
  141. package/src/monitor.webhook-security.test.ts +394 -0
  142. package/src/monitor.webhook.test-helpers.ts +138 -0
  143. package/src/outbound-runtime-api.ts +2 -0
  144. package/src/outbound.test.ts +1255 -0
  145. package/src/outbound.ts +742 -0
  146. package/src/perm-schema.ts +53 -0
  147. package/src/perm.ts +171 -0
  148. package/src/pins.ts +109 -0
  149. package/src/policy.test.ts +224 -0
  150. package/src/policy.ts +322 -0
  151. package/src/post.test.ts +106 -0
  152. package/src/post.ts +276 -0
  153. package/src/presentation-card.ts +204 -0
  154. package/src/probe.test.ts +310 -0
  155. package/src/probe.ts +181 -0
  156. package/src/processing-claims.ts +60 -0
  157. package/src/qr-terminal.ts +2 -0
  158. package/src/reactions.ts +124 -0
  159. package/src/reasoning-preview.test.ts +114 -0
  160. package/src/reasoning-preview.ts +29 -0
  161. package/src/reply-dispatcher-runtime-api.ts +8 -0
  162. package/src/reply-dispatcher.test.ts +2009 -0
  163. package/src/reply-dispatcher.ts +865 -0
  164. package/src/runtime.ts +10 -0
  165. package/src/secret-contract.ts +146 -0
  166. package/src/secret-input.ts +2 -0
  167. package/src/security-audit-shared.ts +70 -0
  168. package/src/security-audit.test.ts +60 -0
  169. package/src/security-audit.ts +2 -0
  170. package/src/send-result.ts +81 -0
  171. package/src/send-target.test.ts +87 -0
  172. package/src/send-target.ts +36 -0
  173. package/src/send.reply-fallback.test.ts +418 -0
  174. package/src/send.test.ts +661 -0
  175. package/src/send.ts +860 -0
  176. package/src/sequential-key.test.ts +73 -0
  177. package/src/sequential-key.ts +29 -0
  178. package/src/sequential-queue.test.ts +184 -0
  179. package/src/sequential-queue.ts +90 -0
  180. package/src/session-conversation.ts +42 -0
  181. package/src/session-route.ts +49 -0
  182. package/src/setup-core.ts +52 -0
  183. package/src/setup-surface.test.ts +485 -0
  184. package/src/setup-surface.ts +620 -0
  185. package/src/streaming-card.test.ts +549 -0
  186. package/src/streaming-card.ts +611 -0
  187. package/src/subagent-hooks.test.ts +632 -0
  188. package/src/subagent-hooks.ts +414 -0
  189. package/src/targets.ts +98 -0
  190. package/src/test-support/lifecycle-test-support.ts +459 -0
  191. package/src/thread-bindings.test.ts +181 -0
  192. package/src/thread-bindings.ts +332 -0
  193. package/src/tool-account-routing.test.ts +419 -0
  194. package/src/tool-account.test.ts +45 -0
  195. package/src/tool-account.ts +98 -0
  196. package/src/tool-factory-test-harness.ts +83 -0
  197. package/src/tool-result.test.ts +33 -0
  198. package/src/tool-result.ts +17 -0
  199. package/src/tools-config.test.ts +52 -0
  200. package/src/tools-config.ts +29 -0
  201. package/src/types.ts +111 -0
  202. package/src/typing.test.ts +145 -0
  203. package/src/typing.ts +215 -0
  204. package/src/wiki-schema.ts +70 -0
  205. package/src/wiki.ts +271 -0
  206. package/subagent-hooks-api.ts +22 -0
  207. package/tsconfig.json +16 -0
package/src/doctor.ts ADDED
@@ -0,0 +1,876 @@
1
+ // Feishu plugin module implements doctor behavior.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type {
6
+ ChannelDoctorAdapter,
7
+ ChannelDoctorSequenceResult,
8
+ } from "actagent/plugin-sdk/channel-contract";
9
+ import type { ACTAgentConfig } from "actagent/plugin-sdk/config-contracts";
10
+ import { normalizeAgentId } from "actagent/plugin-sdk/routing";
11
+ import {
12
+ loadSessionStore,
13
+ resolveSessionFilePath,
14
+ resolveStorePath,
15
+ updateSessionStore,
16
+ } from "actagent/plugin-sdk/session-store-runtime";
17
+ import { resolveStateDir } from "actagent/plugin-sdk/state-paths";
18
+
19
+ const FEISHU_STATE_DIR = "feishu";
20
+ const BACKUP_PREFIX = "feishu-state-repair";
21
+ const BLANK_USER_MESSAGE_REPAIR_THRESHOLD = 3;
22
+ const SESSION_FILE_INSPECTION_MAX_BYTES = 16 * 1024 * 1024;
23
+
24
+ type FeishuDoctorFinding =
25
+ | {
26
+ kind: "corrupt-state-json";
27
+ path: string;
28
+ }
29
+ | {
30
+ kind: "missing-session-transcript";
31
+ sessionKey: string;
32
+ storePath: string;
33
+ }
34
+ | {
35
+ kind: "invalid-session-transcript";
36
+ sessionKey: string;
37
+ storePath: string;
38
+ path: string;
39
+ reason: string;
40
+ }
41
+ | {
42
+ kind: "blank-user-message-run";
43
+ sessionKey: string;
44
+ storePath: string;
45
+ path: string;
46
+ count: number;
47
+ };
48
+
49
+ type FeishuSessionTarget = {
50
+ agentId: string;
51
+ storePath: string;
52
+ };
53
+
54
+ type FeishuSessionEntry = {
55
+ sessionId?: unknown;
56
+ sessionFile?: unknown;
57
+ };
58
+
59
+ type FeishuDoctorSessionEntry = {
60
+ key: string;
61
+ storePath: string;
62
+ agentId: string;
63
+ entry: FeishuSessionEntry;
64
+ };
65
+
66
+ export type FeishuDoctorInspection = {
67
+ stateDir: string;
68
+ feishuStateDir: string;
69
+ findings: FeishuDoctorFinding[];
70
+ sessionEntries: FeishuDoctorSessionEntry[];
71
+ };
72
+
73
+ export type FeishuDoctorRepairReport = {
74
+ backupDir: string;
75
+ stateDirRepairAttempted: boolean;
76
+ rebuiltStateDir: boolean;
77
+ removedSessionEntries: number;
78
+ touchedSessionStores: number;
79
+ archivedSessionArtifacts: number;
80
+ warnings: string[];
81
+ };
82
+
83
+ function timestampForPath(now = new Date()): string {
84
+ return now.toISOString().replaceAll(":", "-");
85
+ }
86
+
87
+ function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
89
+ }
90
+
91
+ function toFeishuSessionEntry(value: unknown): FeishuSessionEntry {
92
+ if (!isRecord(value)) {
93
+ return {};
94
+ }
95
+ return {
96
+ sessionId: value.sessionId,
97
+ sessionFile: value.sessionFile,
98
+ };
99
+ }
100
+
101
+ function countLabel(count: number, singular: string, plural = `${singular}s`): string {
102
+ return `${count} ${count === 1 ? singular : plural}`;
103
+ }
104
+
105
+ function existsDir(dir: string): boolean {
106
+ try {
107
+ return fs.statSync(dir).isDirectory();
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function existsFile(filePath: string): boolean {
114
+ try {
115
+ return fs.statSync(filePath).isFile();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function safeReadDir(dir: string): fs.Dirent[] {
122
+ try {
123
+ return fs.readdirSync(dir, { withFileTypes: true });
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
128
+
129
+ function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
130
+ const resolvedTarget = path.resolve(targetPath);
131
+ const resolvedRoot = path.resolve(rootPath);
132
+ const relative = path.relative(resolvedRoot, resolvedTarget);
133
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
134
+ }
135
+
136
+ function formatDisplayPath(filePath: string): string {
137
+ const home = os.homedir();
138
+ const resolved = path.resolve(filePath);
139
+ return resolved === home || resolved.startsWith(`${home}${path.sep}`)
140
+ ? `~${resolved.slice(home.length)}`
141
+ : resolved;
142
+ }
143
+
144
+ function formatFinding(finding: FeishuDoctorFinding): string {
145
+ switch (finding.kind) {
146
+ case "corrupt-state-json":
147
+ return `- Feishu local JSON state is corrupt: ${formatDisplayPath(finding.path)}`;
148
+ case "missing-session-transcript":
149
+ return `- Feishu session ${finding.sessionKey} points to a missing transcript in ${formatDisplayPath(
150
+ finding.storePath,
151
+ )}`;
152
+ case "invalid-session-transcript":
153
+ return `- Feishu session ${finding.sessionKey} has an invalid transcript (${finding.reason}): ${formatDisplayPath(
154
+ finding.path,
155
+ )}`;
156
+ case "blank-user-message-run":
157
+ return `- Feishu session ${finding.sessionKey} contains ${finding.count} blank user messages: ${formatDisplayPath(
158
+ finding.path,
159
+ )}`;
160
+ }
161
+ const exhaustive: never = finding;
162
+ return exhaustive;
163
+ }
164
+
165
+ export function isFeishuSessionStoreKey(key: string): boolean {
166
+ const normalized = key.trim().toLowerCase();
167
+ return /^agent:[^:]+:feishu(?::|$)/.test(normalized) || /^feishu(?::|$)/.test(normalized);
168
+ }
169
+
170
+ function isFeishuAcpBindingSessionKey(key: string): boolean {
171
+ return /^agent:[^:]+:acp:binding:feishu(?::|$)/.test(key.trim().toLowerCase());
172
+ }
173
+
174
+ function normalizeMetadataString(value: unknown): string {
175
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
176
+ }
177
+
178
+ function isFeishuSessionEntry(key: string, value: unknown): boolean {
179
+ if (isFeishuAcpBindingSessionKey(key)) {
180
+ return false;
181
+ }
182
+ if (isFeishuSessionStoreKey(key)) {
183
+ return true;
184
+ }
185
+ if (!isRecord(value)) {
186
+ return false;
187
+ }
188
+ if (
189
+ normalizeMetadataString(value.channel) === "feishu" ||
190
+ normalizeMetadataString(value.lastChannel) === "feishu"
191
+ ) {
192
+ return true;
193
+ }
194
+ const route = isRecord(value.route) ? value.route : null;
195
+ if (normalizeMetadataString(route?.channel) === "feishu") {
196
+ return true;
197
+ }
198
+ const deliveryContext = isRecord(value.deliveryContext) ? value.deliveryContext : null;
199
+ if (normalizeMetadataString(deliveryContext?.channel) === "feishu") {
200
+ return true;
201
+ }
202
+ const pendingDeliveryContext = isRecord(value.pendingFinalDeliveryContext)
203
+ ? value.pendingFinalDeliveryContext
204
+ : null;
205
+ if (normalizeMetadataString(pendingDeliveryContext?.channel) === "feishu") {
206
+ return true;
207
+ }
208
+ const origin = isRecord(value.origin) ? value.origin : null;
209
+ const originProvider = normalizeMetadataString(origin?.provider);
210
+ const originSurface = normalizeMetadataString(origin?.surface);
211
+ const originFrom = normalizeMetadataString(origin?.from);
212
+ return (
213
+ originProvider === "feishu" ||
214
+ originSurface.startsWith("feishu") ||
215
+ originFrom.startsWith("feishu:")
216
+ );
217
+ }
218
+
219
+ function collectConfiguredAgentIds(cfg: ACTAgentConfig): string[] {
220
+ const ids = new Set<string>();
221
+ ids.add(resolveConfiguredDefaultAgentId(cfg));
222
+ for (const agent of cfg.agents?.list ?? []) {
223
+ if (typeof agent.id === "string" && agent.id.trim()) {
224
+ ids.add(normalizeAgentId(agent.id));
225
+ }
226
+ }
227
+ return [...ids].toSorted();
228
+ }
229
+
230
+ function resolveConfiguredDefaultAgentId(cfg: ACTAgentConfig): string {
231
+ const agents = cfg.agents?.list ?? [];
232
+ const chosen = agents.find((agent) => agent?.default) ?? agents[0];
233
+ return normalizeAgentId(typeof chosen?.id === "string" && chosen.id.trim() ? chosen.id : "main");
234
+ }
235
+
236
+ function collectFeishuSessionTargets(params: {
237
+ cfg: ACTAgentConfig;
238
+ env: NodeJS.ProcessEnv;
239
+ stateDir: string;
240
+ }): FeishuSessionTarget[] {
241
+ const byStorePath = new Map<string, FeishuSessionTarget>();
242
+ const addTarget = (target: FeishuSessionTarget) => {
243
+ byStorePath.set(path.resolve(target.storePath), {
244
+ ...target,
245
+ storePath: path.resolve(target.storePath),
246
+ });
247
+ };
248
+
249
+ for (const agentId of collectConfiguredAgentIds(params.cfg)) {
250
+ addTarget({
251
+ agentId,
252
+ storePath: resolveStorePath(params.cfg.session?.store, { agentId, env: params.env }),
253
+ });
254
+ }
255
+
256
+ const agentsDir = path.join(params.stateDir, "agents");
257
+ for (const agentDir of safeReadDir(agentsDir)) {
258
+ if (!agentDir.isDirectory()) {
259
+ continue;
260
+ }
261
+ const agentId = normalizeAgentId(agentDir.name);
262
+ const storePath = path.join(agentsDir, agentDir.name, "sessions", "sessions.json");
263
+ if (existsFile(storePath)) {
264
+ addTarget({ agentId, storePath });
265
+ }
266
+ }
267
+
268
+ return [...byStorePath.values()].toSorted((left, right) =>
269
+ left.storePath.localeCompare(right.storePath),
270
+ );
271
+ }
272
+
273
+ function collectJsonFiles(rootDir: string, limit = 200): string[] {
274
+ const files: string[] = [];
275
+ const visit = (dir: string) => {
276
+ if (files.length >= limit) {
277
+ return;
278
+ }
279
+ for (const entry of safeReadDir(dir).toSorted((left, right) =>
280
+ left.name.localeCompare(right.name),
281
+ )) {
282
+ const fullPath = path.join(dir, entry.name);
283
+ if (entry.isDirectory()) {
284
+ visit(fullPath);
285
+ continue;
286
+ }
287
+ if (entry.isFile() && entry.name.endsWith(".json")) {
288
+ files.push(fullPath);
289
+ }
290
+ if (files.length >= limit) {
291
+ return;
292
+ }
293
+ }
294
+ };
295
+ if (existsDir(rootDir)) {
296
+ visit(rootDir);
297
+ }
298
+ return files;
299
+ }
300
+
301
+ function collectCorruptFeishuStateJsonFindings(feishuStateDir: string): FeishuDoctorFinding[] {
302
+ const findings: FeishuDoctorFinding[] = [];
303
+ for (const filePath of collectJsonFiles(feishuStateDir)) {
304
+ try {
305
+ JSON.parse(fs.readFileSync(filePath, "utf-8"));
306
+ } catch {
307
+ findings.push({ kind: "corrupt-state-json", path: filePath });
308
+ }
309
+ }
310
+ return findings;
311
+ }
312
+
313
+ function resolveSessionTranscriptCandidates(params: {
314
+ agentId: string;
315
+ storePath: string;
316
+ entry: FeishuSessionEntry;
317
+ }): string[] {
318
+ const candidates = new Set<string>();
319
+ const sessionsDir = path.dirname(params.storePath);
320
+ const addSafeCandidate = (candidate: string) => {
321
+ const resolved = path.isAbsolute(candidate)
322
+ ? path.resolve(candidate)
323
+ : path.resolve(sessionsDir, candidate);
324
+ if (resolved === sessionsDir || !isPathWithinRoot(resolved, sessionsDir)) {
325
+ return;
326
+ }
327
+ candidates.add(resolved);
328
+ };
329
+
330
+ if (
331
+ typeof params.entry.sessionId === "string" &&
332
+ /^[a-z0-9][a-z0-9._-]{0,127}$/i.test(params.entry.sessionId)
333
+ ) {
334
+ candidates.add(
335
+ resolveSessionFilePath(
336
+ params.entry.sessionId,
337
+ typeof params.entry.sessionFile === "string"
338
+ ? { sessionFile: params.entry.sessionFile }
339
+ : undefined,
340
+ { agentId: params.agentId, sessionsDir },
341
+ ),
342
+ );
343
+ return [...candidates].toSorted();
344
+ }
345
+
346
+ if (typeof params.entry.sessionFile === "string" && params.entry.sessionFile.trim()) {
347
+ addSafeCandidate(params.entry.sessionFile.trim());
348
+ }
349
+
350
+ return [...candidates].toSorted();
351
+ }
352
+
353
+ function isSessionHeader(value: unknown): boolean {
354
+ return isRecord(value) && value.type === "session" && typeof value.id === "string";
355
+ }
356
+
357
+ function isBlankUserMessage(value: unknown): boolean {
358
+ if (!isRecord(value) || value.type !== "message" || !isRecord(value.message)) {
359
+ return false;
360
+ }
361
+ if (value.message.role !== "user") {
362
+ return false;
363
+ }
364
+ const content = value.message.content;
365
+ if (typeof content === "string") {
366
+ return content.trim().length === 0;
367
+ }
368
+ return Array.isArray(content) && content.length === 0;
369
+ }
370
+
371
+ function isUserMessage(value: unknown): boolean {
372
+ return (
373
+ isRecord(value) &&
374
+ value.type === "message" &&
375
+ isRecord(value.message) &&
376
+ value.message.role === "user"
377
+ );
378
+ }
379
+
380
+ function inspectSessionTranscript(params: {
381
+ sessionKey: string;
382
+ storePath: string;
383
+ transcriptPath: string;
384
+ }): FeishuDoctorFinding | null {
385
+ let stat: fs.Stats;
386
+ try {
387
+ stat = fs.statSync(params.transcriptPath);
388
+ } catch {
389
+ return null;
390
+ }
391
+ if (!stat.isFile()) {
392
+ return {
393
+ kind: "invalid-session-transcript",
394
+ sessionKey: params.sessionKey,
395
+ storePath: params.storePath,
396
+ path: params.transcriptPath,
397
+ reason: "not a file",
398
+ };
399
+ }
400
+ if (stat.size > SESSION_FILE_INSPECTION_MAX_BYTES) {
401
+ return null;
402
+ }
403
+
404
+ let raw;
405
+ try {
406
+ raw = fs.readFileSync(params.transcriptPath, "utf-8");
407
+ } catch {
408
+ return {
409
+ kind: "invalid-session-transcript",
410
+ sessionKey: params.sessionKey,
411
+ storePath: params.storePath,
412
+ path: params.transcriptPath,
413
+ reason: "unreadable",
414
+ };
415
+ }
416
+
417
+ const entries: unknown[] = [];
418
+ let malformedLines = 0;
419
+ let blankUserMessageRun = 0;
420
+ let maxBlankUserMessageRun = 0;
421
+ for (const line of raw.split(/\r?\n/)) {
422
+ if (!line.trim()) {
423
+ continue;
424
+ }
425
+ try {
426
+ const entry = JSON.parse(line);
427
+ entries.push(entry);
428
+ if (isBlankUserMessage(entry)) {
429
+ blankUserMessageRun += 1;
430
+ maxBlankUserMessageRun = Math.max(maxBlankUserMessageRun, blankUserMessageRun);
431
+ } else if (isUserMessage(entry)) {
432
+ blankUserMessageRun = 0;
433
+ }
434
+ } catch {
435
+ malformedLines += 1;
436
+ }
437
+ }
438
+
439
+ if (entries.length === 0) {
440
+ return {
441
+ kind: "invalid-session-transcript",
442
+ sessionKey: params.sessionKey,
443
+ storePath: params.storePath,
444
+ path: params.transcriptPath,
445
+ reason: "empty transcript",
446
+ };
447
+ }
448
+ if (!isSessionHeader(entries[0])) {
449
+ return {
450
+ kind: "invalid-session-transcript",
451
+ sessionKey: params.sessionKey,
452
+ storePath: params.storePath,
453
+ path: params.transcriptPath,
454
+ reason: "invalid session header",
455
+ };
456
+ }
457
+ if (malformedLines > 0) {
458
+ return {
459
+ kind: "invalid-session-transcript",
460
+ sessionKey: params.sessionKey,
461
+ storePath: params.storePath,
462
+ path: params.transcriptPath,
463
+ reason: `${malformedLines} malformed JSONL line(s)`,
464
+ };
465
+ }
466
+ if (maxBlankUserMessageRun >= BLANK_USER_MESSAGE_REPAIR_THRESHOLD) {
467
+ return {
468
+ kind: "blank-user-message-run",
469
+ sessionKey: params.sessionKey,
470
+ storePath: params.storePath,
471
+ path: params.transcriptPath,
472
+ count: maxBlankUserMessageRun,
473
+ };
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function collectFeishuSessionFindings(params: {
479
+ agentId: string;
480
+ sessionKey: string;
481
+ storePath: string;
482
+ entry: FeishuSessionEntry;
483
+ }): FeishuDoctorFinding[] {
484
+ const transcriptCandidates = resolveSessionTranscriptCandidates(params);
485
+ const existing = transcriptCandidates.filter(existsFile);
486
+ if (transcriptCandidates.length > 0 && existing.length === 0) {
487
+ return [
488
+ {
489
+ kind: "missing-session-transcript",
490
+ sessionKey: params.sessionKey,
491
+ storePath: params.storePath,
492
+ },
493
+ ];
494
+ }
495
+
496
+ const findings: FeishuDoctorFinding[] = [];
497
+ for (const transcriptPath of existing) {
498
+ const finding = inspectSessionTranscript({
499
+ sessionKey: params.sessionKey,
500
+ storePath: params.storePath,
501
+ transcriptPath,
502
+ });
503
+ if (finding) {
504
+ findings.push(finding);
505
+ }
506
+ }
507
+ return findings;
508
+ }
509
+
510
+ function hasCorruptFeishuStateJsonFinding(inspection: FeishuDoctorInspection): boolean {
511
+ return inspection.findings.some((finding) => finding.kind === "corrupt-state-json");
512
+ }
513
+
514
+ function sessionEntryId(storePath: string, key: string): string {
515
+ return `${path.resolve(storePath)}\0${key}`;
516
+ }
517
+
518
+ function collectRepairSessionEntries(
519
+ inspection: FeishuDoctorInspection,
520
+ ): FeishuDoctorSessionEntry[] {
521
+ const entriesById = new Map<string, FeishuDoctorSessionEntry>();
522
+ for (const entry of inspection.sessionEntries) {
523
+ entriesById.set(sessionEntryId(entry.storePath, entry.key), entry);
524
+ }
525
+
526
+ const repairEntries: FeishuDoctorSessionEntry[] = [];
527
+ const seen = new Set<string>();
528
+ for (const finding of inspection.findings) {
529
+ if (finding.kind === "corrupt-state-json") {
530
+ continue;
531
+ }
532
+
533
+ const id = sessionEntryId(finding.storePath, finding.sessionKey);
534
+ if (seen.has(id)) {
535
+ continue;
536
+ }
537
+ const entry = entriesById.get(id);
538
+ if (entry) {
539
+ repairEntries.push(entry);
540
+ seen.add(id);
541
+ }
542
+ }
543
+
544
+ return repairEntries.toSorted(
545
+ (left, right) =>
546
+ left.storePath.localeCompare(right.storePath) || left.key.localeCompare(right.key),
547
+ );
548
+ }
549
+
550
+ export function inspectFeishuDoctorState(params: {
551
+ cfg: ACTAgentConfig;
552
+ env?: NodeJS.ProcessEnv;
553
+ }): FeishuDoctorInspection {
554
+ const env = params.env ?? process.env;
555
+ const stateDir = resolveStateDir(env, os.homedir);
556
+ const feishuStateDir = path.join(stateDir, FEISHU_STATE_DIR);
557
+ const findings: FeishuDoctorFinding[] = collectCorruptFeishuStateJsonFindings(feishuStateDir);
558
+ const sessionEntries: FeishuDoctorInspection["sessionEntries"] = [];
559
+
560
+ for (const target of collectFeishuSessionTargets({ cfg: params.cfg, env, stateDir })) {
561
+ const store = loadSessionStore(target.storePath, { skipCache: true });
562
+ for (const [key, entry] of Object.entries(store).toSorted(([left], [right]) =>
563
+ left.localeCompare(right),
564
+ )) {
565
+ if (!isFeishuSessionEntry(key, entry)) {
566
+ continue;
567
+ }
568
+ const sessionEntry = toFeishuSessionEntry(entry);
569
+ sessionEntries.push({
570
+ key,
571
+ storePath: target.storePath,
572
+ agentId: target.agentId,
573
+ entry: sessionEntry,
574
+ });
575
+ findings.push(
576
+ ...collectFeishuSessionFindings({
577
+ sessionKey: key,
578
+ storePath: target.storePath,
579
+ agentId: target.agentId,
580
+ entry: sessionEntry,
581
+ }),
582
+ );
583
+ }
584
+ }
585
+
586
+ return {
587
+ stateDir,
588
+ feishuStateDir,
589
+ findings,
590
+ sessionEntries,
591
+ };
592
+ }
593
+
594
+ function ensureBackupDir(stateDir: string, now: Date): string {
595
+ const backupDir = path.join(stateDir, "backups", `${BACKUP_PREFIX}-${timestampForPath(now)}`);
596
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
597
+ return backupDir;
598
+ }
599
+
600
+ function resolveUniquePath(candidate: string): string {
601
+ if (!fs.existsSync(candidate)) {
602
+ return candidate;
603
+ }
604
+ for (let index = 1; index < 1000; index += 1) {
605
+ const next = `${candidate}.${index}`;
606
+ if (!fs.existsSync(next)) {
607
+ return next;
608
+ }
609
+ }
610
+ throw new Error(`Unable to resolve unique path for ${candidate}`);
611
+ }
612
+
613
+ function movePathToBackup(params: {
614
+ sourcePath: string;
615
+ backupDir: string;
616
+ relativeTarget: string;
617
+ }): boolean {
618
+ if (!fs.existsSync(params.sourcePath)) {
619
+ return false;
620
+ }
621
+ const targetPath = resolveUniquePath(path.join(params.backupDir, params.relativeTarget));
622
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
623
+ fs.renameSync(params.sourcePath, targetPath);
624
+ return true;
625
+ }
626
+
627
+ function copyStoreBackup(params: { storePath: string; backupDir: string; agentId: string }) {
628
+ if (!existsFile(params.storePath)) {
629
+ return;
630
+ }
631
+ const targetPath = path.join(
632
+ params.backupDir,
633
+ "session-stores",
634
+ params.agentId,
635
+ path.basename(params.storePath),
636
+ );
637
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
638
+ fs.copyFileSync(params.storePath, resolveUniquePath(targetPath));
639
+ }
640
+
641
+ function collectSessionArtifactPaths(params: {
642
+ agentId: string;
643
+ storePath: string;
644
+ entry: FeishuSessionEntry;
645
+ }): string[] {
646
+ const artifacts = new Set<string>();
647
+ for (const transcriptPath of resolveSessionTranscriptCandidates(params)) {
648
+ artifacts.add(transcriptPath);
649
+ if (transcriptPath.endsWith(".jsonl")) {
650
+ const base = transcriptPath.slice(0, -".jsonl".length);
651
+ artifacts.add(`${base}.trajectory.jsonl`);
652
+ artifacts.add(`${base}.trajectory-path.json`);
653
+ }
654
+ }
655
+ return [...artifacts].toSorted();
656
+ }
657
+
658
+ function archiveSessionArtifacts(params: {
659
+ storePath: string;
660
+ entries: Array<{ agentId: string; entry: FeishuSessionEntry }>;
661
+ archiveTimestamp: string;
662
+ }): number {
663
+ const seen = new Set<string>();
664
+ let archived = 0;
665
+ for (const entry of params.entries) {
666
+ for (const artifactPath of collectSessionArtifactPaths({
667
+ storePath: params.storePath,
668
+ agentId: entry.agentId,
669
+ entry: entry.entry,
670
+ })) {
671
+ if (seen.has(artifactPath) || !existsFile(artifactPath)) {
672
+ continue;
673
+ }
674
+ seen.add(artifactPath);
675
+ const archivedPath = resolveUniquePath(`${artifactPath}.deleted.${params.archiveTimestamp}`);
676
+ fs.renameSync(artifactPath, archivedPath);
677
+ archived += 1;
678
+ }
679
+ }
680
+ return archived;
681
+ }
682
+
683
+ async function repairFeishuDoctorState(params: {
684
+ cfg: ACTAgentConfig;
685
+ env?: NodeJS.ProcessEnv;
686
+ now?: Date;
687
+ inspection?: FeishuDoctorInspection;
688
+ }): Promise<FeishuDoctorRepairReport> {
689
+ const env = params.env ?? process.env;
690
+ const now = params.now ?? new Date();
691
+ const inspection = params.inspection ?? inspectFeishuDoctorState({ cfg: params.cfg, env });
692
+ const backupDir = ensureBackupDir(inspection.stateDir, now);
693
+ const archiveTimestamp = timestampForPath(now);
694
+ const warnings: string[] = [];
695
+ const stateDirRepairAttempted = hasCorruptFeishuStateJsonFinding(inspection);
696
+
697
+ let rebuiltStateDir = false;
698
+ if (stateDirRepairAttempted) {
699
+ try {
700
+ rebuiltStateDir = movePathToBackup({
701
+ sourcePath: inspection.feishuStateDir,
702
+ backupDir,
703
+ relativeTarget: FEISHU_STATE_DIR,
704
+ });
705
+ fs.mkdirSync(inspection.feishuStateDir, { recursive: true, mode: 0o700 });
706
+ } catch (error) {
707
+ warnings.push(`- Failed to rebuild Feishu local state: ${String(error)}`);
708
+ }
709
+ }
710
+
711
+ const entriesByStore = new Map<
712
+ string,
713
+ {
714
+ agentId: string;
715
+ entries: Array<{ key: string; entry: FeishuSessionEntry }>;
716
+ }
717
+ >();
718
+ for (const entry of collectRepairSessionEntries(inspection)) {
719
+ const existing = entriesByStore.get(entry.storePath);
720
+ if (existing) {
721
+ existing.entries.push({ key: entry.key, entry: entry.entry });
722
+ } else {
723
+ entriesByStore.set(entry.storePath, {
724
+ agentId: entry.agentId,
725
+ entries: [{ key: entry.key, entry: entry.entry }],
726
+ });
727
+ }
728
+ }
729
+
730
+ let removedSessionEntries = 0;
731
+ let touchedSessionStores = 0;
732
+ let archivedSessionArtifacts = 0;
733
+ for (const [storePath, group] of [...entriesByStore.entries()].toSorted(([left], [right]) =>
734
+ left.localeCompare(right),
735
+ )) {
736
+ try {
737
+ copyStoreBackup({ storePath, backupDir, agentId: group.agentId });
738
+ const keys = new Set(group.entries.map((entry) => entry.key));
739
+ const removedEntries = await updateSessionStore(
740
+ storePath,
741
+ (store) => {
742
+ const removed: typeof group.entries = [];
743
+ for (const key of keys) {
744
+ if (Object.hasOwn(store, key)) {
745
+ delete store[key];
746
+ const entry = group.entries.find((candidate) => candidate.key === key);
747
+ if (entry) {
748
+ removed.push(entry);
749
+ }
750
+ }
751
+ }
752
+ return removed;
753
+ },
754
+ {
755
+ skipMaintenance: true,
756
+ },
757
+ );
758
+ const removed = removedEntries.length;
759
+ removedSessionEntries += removed;
760
+ if (removed > 0) {
761
+ touchedSessionStores += 1;
762
+ archivedSessionArtifacts += archiveSessionArtifacts({
763
+ storePath,
764
+ entries: removedEntries.map((entry) => ({
765
+ agentId: group.agentId,
766
+ entry: entry.entry,
767
+ })),
768
+ archiveTimestamp,
769
+ });
770
+ }
771
+ } catch (error) {
772
+ warnings.push(
773
+ `- Failed to archive Feishu sessions in ${formatDisplayPath(storePath)}: ${String(error)}`,
774
+ );
775
+ }
776
+ }
777
+
778
+ return {
779
+ backupDir,
780
+ stateDirRepairAttempted,
781
+ rebuiltStateDir,
782
+ removedSessionEntries,
783
+ touchedSessionStores,
784
+ archivedSessionArtifacts,
785
+ warnings,
786
+ };
787
+ }
788
+
789
+ function formatPreviewWarning(inspection: FeishuDoctorInspection): string {
790
+ const previewFindings = inspection.findings.slice(0, 5).map(formatFinding);
791
+ const remaining = inspection.findings.length - previewFindings.length;
792
+ const repairActions: string[] = [];
793
+ if (hasCorruptFeishuStateJsonFinding(inspection)) {
794
+ repairActions.push(`archive ${formatDisplayPath(inspection.feishuStateDir)}`);
795
+ }
796
+ const repairSessionEntries = collectRepairSessionEntries(inspection);
797
+ if (repairSessionEntries.length > 0) {
798
+ repairActions.push(
799
+ `archive artifacts and remove ${countLabel(
800
+ repairSessionEntries.length,
801
+ "flagged Feishu-scoped session entry",
802
+ "flagged Feishu-scoped session entries",
803
+ )}`,
804
+ );
805
+ }
806
+ const repairSummary =
807
+ repairActions.length > 0 ? repairActions.join(" and ") : "apply targeted Feishu state cleanup";
808
+ return [
809
+ "- Feishu local channel state may need repair.",
810
+ ...previewFindings,
811
+ ...(remaining > 0 ? [`- ...and ${remaining} more Feishu state finding(s).`] : []),
812
+ `- Repair will ${repairSummary}, while preserving Feishu App ID/secret config and healthy session entries.`,
813
+ '- Run "actagent doctor --fix" to rebuild Feishu local state.',
814
+ ].join("\n");
815
+ }
816
+
817
+ function formatRepairChange(report: FeishuDoctorRepairReport): string {
818
+ const stateRepairStatus = report.stateDirRepairAttempted
819
+ ? report.rebuiltStateDir
820
+ ? "yes"
821
+ : "no existing state"
822
+ : "not needed";
823
+ return [
824
+ "Feishu local state repaired.",
825
+ `- Backup dir: ${formatDisplayPath(report.backupDir)}`,
826
+ `- Rebuilt Feishu runtime state: ${stateRepairStatus}`,
827
+ `- Removed ${countLabel(
828
+ report.removedSessionEntries,
829
+ "Feishu-scoped session entry",
830
+ "Feishu-scoped session entries",
831
+ )} from ${countLabel(report.touchedSessionStores, "session store")}.`,
832
+ `- Archived ${countLabel(report.archivedSessionArtifacts, "session artifact file")}.`,
833
+ "- Preserved Feishu App ID/secret config.",
834
+ ].join("\n");
835
+ }
836
+
837
+ function hasConfiguredFeishuChannel(cfg: ACTAgentConfig): boolean {
838
+ return Boolean(cfg.channels?.feishu);
839
+ }
840
+
841
+ export async function runFeishuDoctorSequence(params: {
842
+ cfg: ACTAgentConfig;
843
+ env: NodeJS.ProcessEnv;
844
+ shouldRepair: boolean;
845
+ }): Promise<ChannelDoctorSequenceResult> {
846
+ if (!hasConfiguredFeishuChannel(params.cfg)) {
847
+ return { changeNotes: [], warningNotes: [] };
848
+ }
849
+
850
+ const inspection = inspectFeishuDoctorState({ cfg: params.cfg, env: params.env });
851
+ if (inspection.findings.length === 0) {
852
+ return { changeNotes: [], warningNotes: [] };
853
+ }
854
+
855
+ if (!params.shouldRepair) {
856
+ return {
857
+ changeNotes: [],
858
+ warningNotes: [formatPreviewWarning(inspection)],
859
+ };
860
+ }
861
+
862
+ const report = await repairFeishuDoctorState({
863
+ cfg: params.cfg,
864
+ env: params.env,
865
+ inspection,
866
+ });
867
+ return {
868
+ changeNotes: [formatRepairChange(report)],
869
+ warningNotes: report.warnings,
870
+ };
871
+ }
872
+
873
+ export const feishuDoctor: ChannelDoctorAdapter = {
874
+ runConfigSequence: async ({ cfg, env, shouldRepair }) =>
875
+ await runFeishuDoctorSequence({ cfg, env, shouldRepair }),
876
+ };