im-agent 0.0.0-stage → 0.1.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/driver.ts +149 -0
  6. package/src/agents/claude/errors.ts +129 -0
  7. package/src/agents/claude/permissions.ts +227 -0
  8. package/src/agents/claude/session.ts +430 -0
  9. package/src/agents/codex/account.ts +92 -0
  10. package/src/agents/codex/app-server.ts +346 -0
  11. package/src/agents/codex/driver.ts +183 -0
  12. package/src/agents/codex/errors.ts +158 -0
  13. package/src/agents/codex/protocol.ts +284 -0
  14. package/src/agents/codex/refusals.ts +62 -0
  15. package/src/agents/codex/session.ts +738 -0
  16. package/src/channels/feishu/bot.ts +609 -0
  17. package/src/channels/feishu/cards.ts +309 -0
  18. package/src/channels/feishu/client.ts +185 -0
  19. package/src/channels/feishu/envelope.ts +112 -0
  20. package/src/channels/feishu/index.ts +52 -0
  21. package/src/channels/feishu/message.ts +115 -0
  22. package/src/channels/feishu/register.ts +171 -0
  23. package/src/channels/feishu/turn-stream.ts +98 -0
  24. package/src/channels/web/api.ts +505 -0
  25. package/src/channels/web/auth.ts +204 -0
  26. package/src/channels/web/http.ts +118 -0
  27. package/src/channels/web/server.ts +363 -0
  28. package/src/cli/doctor.ts +39 -0
  29. package/src/cli/import.ts +155 -0
  30. package/src/cli/pair.ts +35 -0
  31. package/src/cli/passwd.ts +15 -0
  32. package/src/cli/service.ts +117 -0
  33. package/src/core/agent-env.ts +11 -0
  34. package/src/core/commands.ts +99 -0
  35. package/src/core/config.ts +227 -0
  36. package/src/core/dedup.ts +16 -0
  37. package/src/core/hub.ts +933 -0
  38. package/src/core/interactions.ts +261 -0
  39. package/src/core/pairing.ts +156 -0
  40. package/src/core/store.ts +174 -0
  41. package/src/core/types.ts +159 -0
  42. package/src/core/uploads.ts +200 -0
  43. package/src/home.ts +36 -0
  44. package/src/main.ts +68 -0
  45. package/web/app.js +1939 -0
  46. package/web/icon.svg +6 -0
  47. package/web/index.html +23 -0
  48. package/web/manifest.webmanifest +13 -0
  49. package/web/style.css +1267 -0
  50. package/web/sw.js +5 -0
@@ -0,0 +1,115 @@
1
+ // 飞书消息解析:文字 + 附件引用(还没下载)。
2
+ import type { AttachmentKind } from "../../core/types.js";
3
+
4
+ export type AttachmentRef = { kind: AttachmentKind; key: string; name?: string; durationMs?: number };
5
+ export type ParsedMessage = { text: string; attachments: AttachmentRef[] };
6
+
7
+ const UNSUPPORTED_TYPES: Record<string, string> = {
8
+ sticker: "表情包",
9
+ merge_forward: "合并转发",
10
+ interactive: "卡片",
11
+ share_chat: "群名片",
12
+ share_user: "个人名片",
13
+ location: "位置",
14
+ todo: "任务",
15
+ vote: "投票",
16
+ };
17
+
18
+ export function unsupportedTypeLabel(messageType: string | undefined): string {
19
+ return (messageType && UNSUPPORTED_TYPES[messageType]) ?? "这种";
20
+ }
21
+
22
+ /** 不认识的消息类型返回 null,调用方据此回一句「不支持」。 */
23
+ export function parseMessage(message: { message_type?: string; content?: string }): ParsedMessage | null {
24
+ let content: any;
25
+ try {
26
+ content = JSON.parse(message.content ?? "{}");
27
+ } catch {
28
+ return null;
29
+ }
30
+ switch (message.message_type) {
31
+ case "text":
32
+ return { text: String(content.text ?? ""), attachments: [] };
33
+ case "image":
34
+ return content.image_key ? { text: "", attachments: [{ kind: "image", key: content.image_key }] } : null;
35
+ case "file":
36
+ return content.file_key
37
+ ? { text: "", attachments: [{ kind: "file", key: content.file_key, name: content.file_name }] }
38
+ : null;
39
+ case "audio":
40
+ return content.file_key
41
+ ? {
42
+ text: "",
43
+ attachments: [{ kind: "audio", key: content.file_key, durationMs: Number(content.duration) || undefined }],
44
+ }
45
+ : null;
46
+ case "media":
47
+ return content.file_key
48
+ ? {
49
+ text: "",
50
+ attachments: [
51
+ {
52
+ kind: "video",
53
+ key: content.file_key,
54
+ name: content.file_name,
55
+ durationMs: Number(content.duration) || undefined,
56
+ },
57
+ ],
58
+ }
59
+ : null;
60
+ case "post":
61
+ return parsePost(content);
62
+ default:
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** 富文本:文字按原顺序拼起来,图片和视频在原位置留个占位(「看第二张图」才对得上号)。 */
68
+ function parsePost(content: any): ParsedMessage | null {
69
+ const body = Array.isArray(content?.content)
70
+ ? content
71
+ : Object.values(content ?? {}).find((v: any) => Array.isArray(v?.content));
72
+ if (!body) return null;
73
+ const attachments: AttachmentRef[] = [];
74
+ const lines: string[] = [];
75
+ if ((body as any).title) lines.push(String((body as any).title));
76
+ const count = (kind: AttachmentKind) => attachments.filter((a) => a.kind === kind).length;
77
+ for (const paragraph of (body as any).content as any[][]) {
78
+ if (!Array.isArray(paragraph)) continue;
79
+ let line = "";
80
+ for (const el of paragraph) {
81
+ switch (el?.tag) {
82
+ case "text":
83
+ case "md":
84
+ line += el.text ?? "";
85
+ break;
86
+ case "a":
87
+ line += el.text && el.href && el.text !== el.href ? `[${el.text}](${el.href})` : (el.href ?? el.text ?? "");
88
+ break;
89
+ case "at":
90
+ line += el.user_id ?? "";
91
+ break;
92
+ case "code_block":
93
+ line += `\n\`\`\`${el.language ?? ""}\n${el.text ?? ""}\n\`\`\`\n`;
94
+ break;
95
+ case "emotion":
96
+ line += el.emoji_type ? `[${el.emoji_type}]` : "";
97
+ break;
98
+ case "img":
99
+ if (el.image_key) {
100
+ attachments.push({ kind: "image", key: el.image_key });
101
+ line += `[图片${count("image")}]`;
102
+ }
103
+ break;
104
+ case "media":
105
+ if (el.file_key) {
106
+ attachments.push({ kind: "video", key: el.file_key });
107
+ line += `[视频${count("video")}]`;
108
+ }
109
+ break;
110
+ }
111
+ }
112
+ lines.push(line);
113
+ }
114
+ return { text: lines.join("\n"), attachments };
115
+ }
@@ -0,0 +1,171 @@
1
+ // 飞书应用注册:OAuth device-code flow。
2
+ //
3
+ // 扫码后一次拿全 appId / appSecret / 扫码人的 open_id,用户不用去开发者后台
4
+ // 手抄任何东西。协议端点与字段取自飞书 accounts 域的 app registration 接口。
5
+ import QRCode from "qrcode";
6
+
7
+ export type FeishuDomain = "feishu" | "lark";
8
+
9
+ const ACCOUNTS_URL: Record<FeishuDomain, string> = {
10
+ feishu: "https://accounts.feishu.cn",
11
+ lark: "https://accounts.larksuite.com",
12
+ };
13
+ const REGISTRATION_PATH = "/oauth/v1/app/registration";
14
+ const REQUEST_TIMEOUT_MS = 10_000;
15
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
16
+ const DEFAULT_EXPIRE_SECONDS = 600;
17
+
18
+ export type RegistrationResult = {
19
+ appId: string;
20
+ appSecret: string;
21
+ domain: FeishuDomain;
22
+ openId?: string;
23
+ };
24
+
25
+ export type BeginResult = {
26
+ deviceCode: string;
27
+ qrUrl: string;
28
+ userCode: string;
29
+ interval: number;
30
+ expireIn: number;
31
+ };
32
+
33
+ export type PollOutcome =
34
+ | { status: "success"; result: RegistrationResult }
35
+ | { status: "access_denied" }
36
+ | { status: "expired" }
37
+ | { status: "timeout" }
38
+ | { status: "error"; message: string };
39
+
40
+ type InitResponse = { nonce?: string; supported_auth_methods?: string[] };
41
+ type BeginResponse = {
42
+ device_code: string;
43
+ verification_uri: string;
44
+ verification_uri_complete: string;
45
+ user_code: string;
46
+ interval: number;
47
+ expire_in: number;
48
+ };
49
+ type PollResponse = {
50
+ client_id?: string;
51
+ client_secret?: string;
52
+ user_info?: { open_id?: string; tenant_brand?: FeishuDomain };
53
+ error?: string;
54
+ error_description?: string;
55
+ };
56
+
57
+ async function post<T>(domain: FeishuDomain, body: Record<string, string>): Promise<T> {
58
+ const res = await fetch(`${ACCOUNTS_URL[domain]}${REGISTRATION_PATH}`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
61
+ body: new URLSearchParams(body).toString(),
62
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
63
+ });
64
+ // 待授权/错误状态也会以 4xx + JSON body 返回,所以不按状态码抛错。
65
+ return (await res.json()) as T;
66
+ }
67
+
68
+ /** 第一步:确认当前环境支持 client_secret 认证。 */
69
+ export async function initRegistration(domain: FeishuDomain = "feishu"): Promise<void> {
70
+ const res = await post<InitResponse>(domain, { action: "init" });
71
+ if (!res.supported_auth_methods?.includes("client_secret")) {
72
+ throw new Error("当前环境不支持 client_secret 认证方式");
73
+ }
74
+ }
75
+
76
+ /** 第二步:开始设备码流程,拿到给用户扫的二维码地址。 */
77
+ export async function beginRegistration(domain: FeishuDomain = "feishu"): Promise<BeginResult> {
78
+ const res = await post<BeginResponse>(domain, {
79
+ action: "begin",
80
+ archetype: "PersonalAgent",
81
+ auth_method: "client_secret",
82
+ request_user_info: "open_id",
83
+ });
84
+
85
+ const url = new URL(res.verification_uri_complete);
86
+ // 只接受飞书/Lark 自家域名,避免响应被污染后把用户引到别处
87
+ if (!/(^|\.)(feishu\.cn|larksuite\.com)$/.test(url.hostname)) {
88
+ throw new Error(`拒绝非飞书域名的验证地址:${url.hostname}`);
89
+ }
90
+
91
+ return {
92
+ deviceCode: res.device_code,
93
+ qrUrl: url.toString(),
94
+ userCode: res.user_code,
95
+ interval: Number.isFinite(res.interval) ? res.interval : DEFAULT_POLL_INTERVAL_SECONDS,
96
+ expireIn: Number.isFinite(res.expire_in) ? res.expire_in : DEFAULT_EXPIRE_SECONDS,
97
+ };
98
+ }
99
+
100
+ const sleep = (seconds: number) =>
101
+ new Promise((resolve) => setTimeout(resolve, Math.max(1, seconds) * 1000));
102
+
103
+ /** 第三步:轮询直到用户在手机上确认。会根据 tenant_brand 自动切换 feishu/lark 域。 */
104
+ export async function pollRegistration(params: {
105
+ deviceCode: string;
106
+ interval: number;
107
+ expireIn: number;
108
+ domain?: FeishuDomain;
109
+ onTick?: (secondsLeft: number) => void;
110
+ }): Promise<PollOutcome> {
111
+ let domain: FeishuDomain = params.domain ?? "feishu";
112
+ let interval = params.interval;
113
+ let domainSwitched = false;
114
+ const deadline = Date.now() + params.expireIn * 1000;
115
+
116
+ while (Date.now() < deadline) {
117
+ let res: PollResponse;
118
+ try {
119
+ res = await post<PollResponse>(domain, {
120
+ action: "poll",
121
+ device_code: params.deviceCode,
122
+ tp: "ob_cli_app",
123
+ });
124
+ } catch {
125
+ await sleep(interval); // 网络抖动,继续轮询
126
+ continue;
127
+ }
128
+
129
+ // 租户在 Lark 侧时自动换域重试一次
130
+ if (!domainSwitched && res.user_info?.tenant_brand === "lark") {
131
+ domain = "lark";
132
+ domainSwitched = true;
133
+ continue;
134
+ }
135
+
136
+ if (res.client_id && res.client_secret) {
137
+ return {
138
+ status: "success",
139
+ result: {
140
+ appId: res.client_id,
141
+ appSecret: res.client_secret,
142
+ domain,
143
+ ...(res.user_info?.open_id ? { openId: res.user_info.open_id } : {}),
144
+ },
145
+ };
146
+ }
147
+
148
+ if (res.error === "slow_down") interval += 5;
149
+ else if (res.error === "access_denied") return { status: "access_denied" };
150
+ else if (res.error === "expired_token") return { status: "expired" };
151
+ else if (res.error && res.error !== "authorization_pending") {
152
+ return { status: "error", message: `${res.error}: ${res.error_description ?? "未知错误"}` };
153
+ }
154
+
155
+ params.onTick?.(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
156
+ await sleep(interval);
157
+ }
158
+
159
+ return { status: "timeout" };
160
+ }
161
+
162
+ /** 终端里打印二维码。必须无边框输出,加了装饰框会破坏识别。 */
163
+ export async function printQrCode(url: string): Promise<void> {
164
+ const output = await QRCode.toString(url, { type: "terminal", small: true });
165
+ process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
166
+ }
167
+
168
+ /** 给 Web 页面用的二维码(SVG 字符串)。 */
169
+ export async function qrSvg(url: string): Promise<string> {
170
+ return QRCode.toString(url, { type: "svg", margin: 1, width: 240 });
171
+ }
@@ -0,0 +1,98 @@
1
+ // 一轮对话的输出编排:
2
+ // 过程(思考 / 工具调用)→ 实时刷新的过程卡
3
+ // 最终回复 → 普通消息,可复制可引用,不套卡片
4
+ //
5
+ // 全程串行执行。曾经踩过的坑:过程卡的 send 还没 await 完成,收尾就到了,
6
+ // 于是多发一张卡,而先前那张永远停在中间态。
7
+ export type TurnStreamDeps = {
8
+ sendCard: (card: unknown) => Promise<string | undefined>;
9
+ updateCard: (messageId: string, card: unknown) => Promise<boolean>;
10
+ renderProgress: (steps: string[], done: boolean, footer?: string) => unknown;
11
+ /** 发送最终回复(普通文本消息) */
12
+ sendReply: (text: string) => Promise<unknown>;
13
+ onError?: (err: unknown) => void;
14
+ };
15
+
16
+ export type TurnStreamOptions = {
17
+ minIntervalMs?: number;
18
+ maxEdits?: number;
19
+ now?: () => number;
20
+ };
21
+
22
+ export class TurnStream {
23
+ private steps: string[] = [];
24
+ private text = "";
25
+ private messageId: string | undefined;
26
+ private edits = 0;
27
+ private lastEdit = 0;
28
+ private chain: Promise<void> = Promise.resolve();
29
+
30
+ private readonly minIntervalMs: number;
31
+ private readonly maxEdits: number;
32
+ private readonly now: () => number;
33
+
34
+ constructor(
35
+ private readonly deps: TurnStreamDeps,
36
+ options: TurnStreamOptions = {},
37
+ ) {
38
+ this.minIntervalMs = options.minIntervalMs ?? 2000;
39
+ this.maxEdits = options.maxEdits ?? 15;
40
+ this.now = options.now ?? (() => Date.now());
41
+ }
42
+
43
+ private enqueue(fn: () => Promise<void>): void {
44
+ this.chain = this.chain.then(fn).catch((err) => this.deps.onError?.(err));
45
+ }
46
+
47
+ /** 一条过程信息(思考片段 / 工具调用)。 */
48
+ addStep(step: string): void {
49
+ this.steps.push(step);
50
+ this.enqueue(async () => {
51
+ const now = this.now();
52
+ if (now - this.lastEdit < this.minIntervalMs || this.edits >= this.maxEdits) return;
53
+ this.lastEdit = now;
54
+ const card = this.deps.renderProgress(this.steps, false);
55
+ if (!this.messageId) {
56
+ this.messageId = await this.deps.sendCard(card);
57
+ return;
58
+ }
59
+ this.edits += 1;
60
+ await this.deps.updateCard(this.messageId, card);
61
+ });
62
+ }
63
+
64
+ /** 回复正文片段,累积起来备用。 */
65
+ addText(text: string): void {
66
+ this.text += text;
67
+ }
68
+
69
+ /**
70
+ * 收尾:过程卡定格为完成态,然后把最终回复作为普通消息发出去。
71
+ * 整轮没有过程时不会留下任何卡片 —— 简单问答就该只有一条回复。
72
+ */
73
+ finish(finalText: string, footer?: string): void {
74
+ this.enqueue(async () => {
75
+ if (this.messageId) {
76
+ await this.deps.updateCard(
77
+ this.messageId,
78
+ this.deps.renderProgress(this.steps, true, footer),
79
+ );
80
+ }
81
+ const reply = finalText.trim() || this.text.trim();
82
+ if (reply) await this.deps.sendReply(reply);
83
+ this.reset();
84
+ });
85
+ }
86
+
87
+ private reset(): void {
88
+ this.steps = [];
89
+ this.text = "";
90
+ this.messageId = undefined;
91
+ this.edits = 0;
92
+ this.lastEdit = 0;
93
+ }
94
+
95
+ async drain(): Promise<void> {
96
+ await this.chain;
97
+ }
98
+ }