imtoagent 0.2.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 (47) hide show
  1. package/README.md +234 -0
  2. package/bin/imtoagent +453 -0
  3. package/index.ts +1129 -0
  4. package/modules/agent/claude-adapter.ts +258 -0
  5. package/modules/agent/claude.ts +160 -0
  6. package/modules/agent/codex-adapter.ts +232 -0
  7. package/modules/agent/codex-exec-server.ts +513 -0
  8. package/modules/agent/codex.ts +275 -0
  9. package/modules/agent/opencode-adapter.ts +308 -0
  10. package/modules/agent/opencode.ts +247 -0
  11. package/modules/bot-context.ts +26 -0
  12. package/modules/capabilities.ts +189 -0
  13. package/modules/cli/setup.ts +424 -0
  14. package/modules/core/config.ts +275 -0
  15. package/modules/core/error.ts +124 -0
  16. package/modules/core/index.ts +39 -0
  17. package/modules/core/runtime.ts +282 -0
  18. package/modules/core/session.ts +256 -0
  19. package/modules/core/stats.ts +92 -0
  20. package/modules/core/types.ts +250 -0
  21. package/modules/im/feishu.ts +731 -0
  22. package/modules/im/telegram.ts +639 -0
  23. package/modules/im/wechat.ts +1094 -0
  24. package/modules/im/wecom.ts +603 -0
  25. package/modules/media/feishu-inbound-adapter.ts +108 -0
  26. package/modules/media/index.ts +27 -0
  27. package/modules/media/media-store.ts +273 -0
  28. package/modules/media/resolver.ts +178 -0
  29. package/modules/media/telegram-inbound-adapter.ts +124 -0
  30. package/modules/media/types.ts +76 -0
  31. package/modules/prompt-builder.ts +123 -0
  32. package/modules/proxy/anthropic-proxy.ts +1083 -0
  33. package/modules/proxy/codex-proxy.ts +657 -0
  34. package/modules/rate-limiter.ts +58 -0
  35. package/modules/types.ts +144 -0
  36. package/modules/utils/backend-check.ts +121 -0
  37. package/modules/utils/paths.ts +218 -0
  38. package/package.json +53 -0
  39. package/scripts/postinstall.ts +70 -0
  40. package/templates/config.template.json +57 -0
  41. package/templates/opencode.template.json +28 -0
  42. package/templates/providers.template.json +19 -0
  43. package/templates/soul.template/identity.md +6 -0
  44. package/templates/soul.template/profile.md +11 -0
  45. package/templates/soul.template/rules.md +7 -0
  46. package/templates/soul.template/skills.md +3 -0
  47. package/templates/soul.template/workspace.md +4 -0
@@ -0,0 +1,247 @@
1
+ // OpenCode Agent 模块
2
+ // 对接 opencode serve HTTP API,通过 :18899 Anthropic Proxy 调 Provider
3
+ //
4
+ // 设计:薄模块 — oc serve 管理 session/工具/provider,Gateway 只做 IM 翻译
5
+
6
+ import type { AgentContext, SessionData } from '../types';
7
+ import { parseToBlocks } from '../capabilities';
8
+ import { resolveCapabilities, buildSystemPrompt } from '../prompt-builder';
9
+ import { calculateCost } from '../proxy/anthropic-proxy';
10
+ import * as path from 'path';
11
+ import * as fs from 'fs';
12
+ import { getDataDir } from '../utils/paths';
13
+
14
+ // ================================================================
15
+ // 配置(从 config.json 读取)
16
+ // ================================================================
17
+
18
+ interface OpenCodeConfig {
19
+ serverUrl: string;
20
+ defaultModel: { providerID: string; modelID: string };
21
+ }
22
+
23
+ let _ocConfig: OpenCodeConfig | null = null;
24
+
25
+ export function initOpenCodeConfig(cfg: OpenCodeConfig) {
26
+ _ocConfig = cfg;
27
+ }
28
+
29
+ function getOcConfig(): OpenCodeConfig {
30
+ if (!_ocConfig) {
31
+ try {
32
+
33
+ const raw = JSON.parse(fs.readFileSync(path.join(getDataDir(), 'config.json'), 'utf-8'));
34
+ const oc = raw.opencode || {};
35
+ _ocConfig = {
36
+ serverUrl: oc.serverUrl || 'http://localhost:4096',
37
+ defaultModel: oc.defaultModel || { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' },
38
+ };
39
+ } catch {
40
+ _ocConfig = {
41
+ serverUrl: 'http://localhost:4096',
42
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' },
43
+ };
44
+ }
45
+ }
46
+ return _ocConfig;
47
+ }
48
+
49
+ const OC_SERVER_URL = () => getOcConfig().serverUrl;
50
+ const OC_DEFAULT_MODEL = () => getOcConfig().defaultModel;
51
+
52
+ // ================================================================
53
+ // OpenCode Server HTTP 客户端
54
+ // ================================================================
55
+
56
+ interface OcMessagePart {
57
+ type: string;
58
+ text?: string;
59
+ tool_call?: { name: string; arguments: Record<string, any> };
60
+ tool_result?: { content: string };
61
+ }
62
+
63
+ interface OcMessage {
64
+ info: { id: string; role: string; model?: string };
65
+ parts: OcMessagePart[];
66
+ }
67
+
68
+ async function ocCreateSession(title: string): Promise<string> {
69
+ const ac = new AbortController();
70
+ const timer = setTimeout(() => ac.abort(), 300_000);
71
+ const res = await fetch(`${OC_SERVER_URL()}/session`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ title }),
75
+ signal: ac.signal,
76
+ }).finally(() => clearTimeout(timer));
77
+ if (!res.ok) throw new Error(`oc create session: ${res.status} ${await res.text()}`);
78
+ const data = await res.json();
79
+ return data.id;
80
+ }
81
+
82
+ async function ocSendPrompt(
83
+ sessionId: string,
84
+ initialText: string,
85
+ system?: string,
86
+ onTool?: (name: string, args: Record<string, any>) => void
87
+ ): Promise<{ response: string }> {
88
+ const MAX_TURNS = 50;
89
+ const TURN_TIMEOUT = 300_000;
90
+ const startTime = Date.now();
91
+ const MAX_DURATION = 600_000; // 10 分钟总超时
92
+
93
+ let promptText = initialText;
94
+ let accumulatedResponse = '';
95
+ let turn = 0;
96
+
97
+ while (turn < MAX_TURNS) {
98
+ if (Date.now() - startTime > MAX_DURATION) {
99
+ console.error('[OpenCode] 任务总超时 (10min)');
100
+ break;
101
+ }
102
+ turn++;
103
+
104
+ const body: any = {
105
+ model: OC_DEFAULT_MODEL(),
106
+ parts: [{ type: 'text', text: promptText }],
107
+ };
108
+ if (turn === 1 && system) body.system = system;
109
+
110
+ const ac = new AbortController();
111
+ const timer = setTimeout(() => ac.abort(), TURN_TIMEOUT);
112
+ const res = await fetch(`${OC_SERVER_URL()}/session/${sessionId}/message`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify(body),
116
+ signal: ac.signal,
117
+ }).finally(() => clearTimeout(timer));
118
+ if (!res.ok) throw new Error(`oc send prompt: ${res.status} ${await res.text()}`);
119
+
120
+ const data: OcMessage = await res.json();
121
+ let hasToolCall = false;
122
+
123
+ for (const part of data.parts || []) {
124
+ if (part.type === 'text' && part.text) {
125
+ accumulatedResponse += (accumulatedResponse ? '\n' : '') + part.text;
126
+ } else if (part.type === 'tool_call' && part.tool_call) {
127
+ hasToolCall = true;
128
+ if (onTool) onTool(part.tool_call.name, part.tool_call.arguments);
129
+ }
130
+ }
131
+
132
+ // 纯文本回复或没有 tool_call → 任务完成
133
+ if (!hasToolCall) break;
134
+
135
+ // 有 tool_call,继续推进(空 prompt)
136
+ promptText = '继续';
137
+ }
138
+
139
+ return { response: accumulatedResponse };
140
+ }
141
+
142
+ async function ocDeleteSession(sessionId: string): Promise<void> {
143
+ await fetch(`${OC_SERVER_URL()}/session/${sessionId}`, { method: 'DELETE' }).catch(() => {});
144
+ }
145
+
146
+ async function ocHealthCheck(): Promise<boolean> {
147
+ try {
148
+ const res = await fetch(`${OC_SERVER_URL()}/global/health`);
149
+ return res.ok;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ================================================================
156
+ // Agent 模块类
157
+ // ================================================================
158
+ export class OpenCodeAgentModule {
159
+ private ctx: AgentContext;
160
+
161
+ constructor(ctx: AgentContext) {
162
+ this.ctx = ctx;
163
+ }
164
+
165
+ async handleMessage(chatId: string, text: string, session: SessionData) {
166
+ const ctx = this.ctx;
167
+ console.log(`[${ctx.name}] OpenCode chat=${chatId.slice(-8)}`);
168
+
169
+ // 工具回调
170
+ const onTool = (name: string, args: Record<string, any>) => {
171
+ const summary = args.command || args.cmd || args.query || JSON.stringify(args).slice(0, 80);
172
+ ctx.addToolLog(chatId, { name, summary });
173
+ };
174
+
175
+ try {
176
+ // ① Plan 模式处理
177
+ let effectiveText = text;
178
+ if (session.codexMode === 'plan') {
179
+ effectiveText = `[模式: 先计划后执行] 请先制定一个清晰的计划,等我确认后再执行。用户请求: ${text}`;
180
+ }
181
+
182
+ // ② 清理标记
183
+ const shouldClear = session.startFresh;
184
+ session.startFresh = false;
185
+
186
+ // ③ 获取或创建 OpenCode session
187
+ if (shouldClear || !session.ocSessionId) {
188
+ if (session.ocSessionId) {
189
+ await ocDeleteSession(session.ocSessionId);
190
+ console.log(`[${ctx.name}] 已清除 oc session=${session.ocSessionId.slice(-8)}`);
191
+ }
192
+ session.ocSessionId = await ocCreateSession(chatId);
193
+ console.log(`[${ctx.name}] 新建 oc session=${session.ocSessionId.slice(-8)}`);
194
+ }
195
+
196
+ // ④ 发送进度提示
197
+ await ctx.sendProgress(chatId, '💭 思考中...');
198
+
199
+ // ④.⑤ 构建系统提示词
200
+ const systemPrompt = buildSystemPrompt({
201
+ imModule: ctx.imModule || null,
202
+ botName: ctx.name,
203
+ });
204
+ console.log(`[${ctx.name}] 📝 system prompt built (${systemPrompt.length} chars, bot=${ctx.name})`);
205
+
206
+ // ⑤ 发送 prompt(多轮循环)
207
+ const { response } = await ocSendPrompt(
208
+ session.ocSessionId,
209
+ effectiveText,
210
+ systemPrompt,
211
+ onTool,
212
+ );
213
+
214
+ // ⑥ 刷新工具日志
215
+ ctx.flushToolLog(chatId);
216
+
217
+ // ⑦ 输出
218
+ if (response) {
219
+ await ctx.sendFormattedReply(chatId, response);
220
+ } else {
221
+ await ctx.reply(chatId, '✅ 已完成');
222
+ }
223
+
224
+ // ⑧ 统计
225
+ const { sharedState } = await import('../proxy/anthropic-proxy');
226
+ const lastUsage = (sharedState as any).lastCallUsage;
227
+ if (lastUsage && (lastUsage.inputTokens > 0 || lastUsage.outputTokens > 0)) {
228
+ const cost = calculateCost(ctx.activeModel, lastUsage.inputTokens, lastUsage.outputTokens);
229
+ ctx.accumulateStats(session, { ...lastUsage, costUSD: cost });
230
+ await ctx.sendProgress(chatId,
231
+ `输入 ${lastUsage.inputTokens.toLocaleString()} Token\n输出 ${lastUsage.outputTokens.toLocaleString()} Token\n费用 $${cost.toFixed(4)}`);
232
+ }
233
+
234
+ // ⑨ 持久化会话
235
+ ctx.persistSession(chatId, session);
236
+
237
+ } catch (err: any) {
238
+ console.error(`[${ctx.name}] OpenCode 错误: ${err.message}`);
239
+ await ctx.reply(chatId, `⚠️ OpenCode 出错:${err.message}`);
240
+ }
241
+ }
242
+
243
+ /** 健康检查 */
244
+ async healthCheck(): Promise<boolean> {
245
+ return ocHealthCheck();
246
+ }
247
+ }
@@ -0,0 +1,26 @@
1
+ // Bot 上下文 — 网关与代理之间的动态上下文传递
2
+ // 同一进程内共享,网关在 spawn CLI 前设置当前 bot,代理在处理请求时读取
3
+
4
+ import type { IMCapabilities } from './types';
5
+ import type { ModelAliases } from './proxy/anthropic-proxy';
6
+
7
+ export type { ModelAliases };
8
+
9
+ export interface BotContextData {
10
+ botName: string;
11
+ caps: IMCapabilities | null;
12
+ /** Bot 级别的模型别名(/model 命令修改后传入,优先级高于全局 config.json) */
13
+ modelAliases?: ModelAliases;
14
+ }
15
+
16
+ let _currentBot: BotContextData | null = null;
17
+
18
+ /** 网关调用:在 handleMessage 前设置当前 bot */
19
+ export function setCurrentBot(ctx: BotContextData | null) {
20
+ _currentBot = ctx;
21
+ }
22
+
23
+ /** 代理调用:读取当前 bot 上下文 */
24
+ export function getCurrentBot(): BotContextData | null {
25
+ return _currentBot;
26
+ }
@@ -0,0 +1,189 @@
1
+ // IM 能力 → Agent Prompt + 输出解析
2
+ // Agent 产出文本 → 网关解析为结构化块 → IM 原生渲染
3
+
4
+ import type { IMCapabilities } from './types';
5
+
6
+ export type UnifiedBlock =
7
+ | { type: 'text'; content: string }
8
+ | { type: 'code_block'; code: string; language: string; title?: string }
9
+ | { type: 'image'; url: string; alt?: string }
10
+ | { type: 'card'; title: string; content: string; color?: string; buttons?: { label: string; url?: string }[] }
11
+ | { type: 'table'; headers: string[]; rows: string[][]; caption?: string }
12
+ | { type: 'file'; url: string; filename: string }
13
+ | { type: 'audio'; url: string; filename: string; duration?: number }
14
+ | { type: 'divider' };
15
+
16
+ // ================================================================
17
+ // System Prompt:告诉 Agent 可用的输出格式
18
+ // ================================================================
19
+ // 设计原则:
20
+ // 只告诉 Agent 它能通过 markdown 语法表达的能力。
21
+ // IMCapabilities 里 capability=true 但 parseToBlocks 没有对应语法
22
+ // → 不生成提示词,避免 Agent 误以为能输出。
23
+ // ================================================================
24
+
25
+ export function buildCapabilityPrompt(caps: IMCapabilities): string {
26
+ const lines: string[] = [];
27
+
28
+ // 总览
29
+ lines.push('## IM 客户端环境');
30
+ lines.push('你通过飞书(Lark)即时通讯与用户对话。你的回复会被网关解析为飞书原生消息格式。');
31
+ lines.push('');
32
+
33
+ // 文本限制
34
+ lines.push(`**文本限制**:单条消息最多 ${caps.maxTextLength} 字符,超长会自动截断。`);
35
+
36
+ // ========== 只能生成 parseToBlocks 支持的能力 ==========
37
+
38
+ // 代码 — ``` 语法
39
+ if (caps.codeBlock) {
40
+ lines.push('**代码输出**:当输出代码时,使用标准 markdown 代码块(\\```语言\\n代码\\n\\```)。');
41
+ lines.push('⚠️ 注意:飞书对代码块的渲染有限,长代码建议使用折叠面板或分段输出,避免单条消息过长。');
42
+ }
43
+
44
+ // 图片 — ![]() 语法
45
+ if (caps.imageSend) {
46
+ lines.push('**图片**:可以使用 markdown 图片语法 `![描述](URL)` 发送图片。支持本地 file:// 路径(如图表截图)和远程 URL。网关会自动渲染,无需额外上传步骤。');
47
+ }
48
+
49
+ // 表格 + 卡片 — | 语法(需要 cardMessage 容器来渲染)
50
+ if (caps.cardMessage) {
51
+ lines.push('**表格**:可以使用标准 markdown 表格语法来展示结构化数据。');
52
+ lines.push('```');
53
+ lines.push('| 列A | 列B |');
54
+ lines.push('| --- | --- |');
55
+ lines.push('| 数据1 | 数据2 |');
56
+ lines.push('```');
57
+ lines.push('**卡片消息**:可以使用富文本卡片(多块内容会自动组合为一张卡片消息)。');
58
+ }
59
+
60
+ // 文件发送 — fileSend + 本地路径语法
61
+ if (caps.fileSend) {
62
+ lines.push('**文件发送**:如果你生成了文件(如图表、CSV、代码文件等),在回复中直接使用以下语法即可发送,网关会自动完成上传和投递,你不需要调用任何额外工具:');
63
+ lines.push('`📎 [文件名](file:///本地绝对路径)`');
64
+ lines.push('例如:`📎 [分析结果.csv](file:///tmp/result.csv)`');
65
+ }
66
+
67
+
68
+ // 语音发送 — audioSend + 本地路径语法
69
+ if (caps.audioSend) {
70
+ lines.push('**语音/音频**:如果你生成了音频文件(如语音合成、录音等),在回复中直接使用以下语法即可发送,网关会自动处理:');
71
+ lines.push('`🎙️ [文件名](file:///本地绝对路径)`');
72
+ lines.push('例如:`🎙️ [语音播报.mp3](file:///tmp/tts-output.mp3)`');
73
+ }
74
+
75
+ // 注:buttonAction 有 IM 能力但无 markdown 语法,不生成提示词
76
+
77
+ lines.push('');
78
+ lines.push('### 行为规则');
79
+ lines.push('- 不要在回复中提及或尝试调用 lark-cli、feishu 等第三方上传工具——网关会自动解析 📎 和 ![图片]() 语法并完成发送');
80
+ lines.push('- **每次修改/创建/删除文件后,必须简要汇报结果**(如"已修改 xxx.ts,修复了 YYY 问题"),不要默默完成就结束');
81
+ lines.push('- 任务完成后用一两句话总结做了什么');
82
+ lines.push('');
83
+ lines.push('### 格式转换规则');
84
+ lines.push('- 你的回复会被按 markdown 格式解析为多个块(文本、代码、图片、卡片等)');
85
+ lines.push('- 每个块会被渲染为对应的飞书原生元素');
86
+ lines.push('- 不要提及这些技术细节,直接使用对应格式即可');
87
+
88
+ return lines.join('\n');
89
+ }
90
+
91
+ // ================================================================
92
+ // 输出解析:Agent 文本 → UnifiedBlock[]
93
+ // ================================================================
94
+
95
+ export function parseToBlocks(text: string, caps: IMCapabilities): UnifiedBlock[] {
96
+ const blocks: UnifiedBlock[] = [];
97
+
98
+ // 构建所有匹配模式
99
+ type MatchDef = { regex: RegExp; make: (m: RegExpExecArray) => UnifiedBlock };
100
+ const patterns: MatchDef[] = [];
101
+ if (caps.codeBlock) {
102
+ patterns.push({
103
+ regex: /```(\w*)\n([\s\S]*?)```/g,
104
+ make: (m) => ({ type: 'code_block', code: m[2].trim(), language: m[1] || '' }),
105
+ });
106
+ }
107
+ if (caps.audioSend) {
108
+ patterns.push({
109
+ regex: /🎙️\s*\[([^\]]*)\]\((file:\/\/[^)]+)\)/g,
110
+ make: (m) => ({ type: 'audio', url: m[2], filename: m[1] }),
111
+ });
112
+ }
113
+ if (caps.imageSend) {
114
+ patterns.push({
115
+ regex: /!\[([^\]]*)\]\(([^)]+)\)/g,
116
+ make: (m) => ({ type: 'image', alt: m[1], url: m[2] }),
117
+ });
118
+ }
119
+ if (caps.fileSend) {
120
+ patterns.push({
121
+ regex: /📎\s*\[([^\]]*)\]\((file:\/\/[^)]+)\)/g,
122
+ make: (m) => ({ type: 'file', url: m[2], filename: m[1] }),
123
+ });
124
+ }
125
+
126
+ // 表格:仅在有 cardMessage 能力时解析
127
+ // 匹配 markdown 表格:表头行 | 分隔行 | 数据行...
128
+ const tableRegex = caps.cardMessage
129
+ ? /(?:^[^\n]*\n)?\|[^|\n]+\|[^|\n]*\|\n\|[-: |]+\|\n(?:\|[^|\n]+\|[^|\n]*\|\n?)+/gm
130
+ : null;
131
+
132
+ // 第一遍:收集表格匹配位置(标记为"占位",避免被其他正则误匹配)
133
+ type TableMatch = { index: number; end: number; block: UnifiedBlock };
134
+ const tableMatches: TableMatch[] = [];
135
+ const tableMasked = text; // 保存原文本用于表格内容提取
136
+ if (tableRegex) {
137
+ tableRegex.lastIndex = 0;
138
+ let tm: RegExpExecArray | null;
139
+ while ((tm = tableRegex.exec(text)) !== null) {
140
+ const lines = tm[0].split('\n').filter(l => l.startsWith('|'));
141
+ if (lines.length < 3) continue; // 至少需要 header + separator + 1 row
142
+ const headerCells = lines[0].split('|').map(c => c.trim()).filter(Boolean);
143
+ const rows = lines.slice(2).map(l =>
144
+ l.split('|').map(c => c.trim()).filter(Boolean)
145
+ );
146
+ if (headerCells.length === 0 || rows.length === 0) continue;
147
+ tableMatches.push({
148
+ index: tm.index,
149
+ end: tm.index + tm[0].length,
150
+ block: { type: 'table', headers: headerCells, rows },
151
+ });
152
+ }
153
+ }
154
+
155
+ if (patterns.length === 0 && tableMatches.length === 0) return [{ type: 'text', content: text }];
156
+
157
+ // 收集所有匹配,按位置排序
158
+ const hits: { index: number; end: number; block: UnifiedBlock }[] = [];
159
+ for (const p of patterns) {
160
+ p.regex.lastIndex = 0;
161
+ let m: RegExpExecArray | null;
162
+ while ((m = p.regex.exec(text)) !== null) {
163
+ hits.push({ index: m.index, end: m.index + m[0].length, block: p.make(m) });
164
+ }
165
+ }
166
+ hits.push(...tableMatches);
167
+ hits.sort((a, b) => a.index - b.index);
168
+
169
+ // 去重(重叠匹配只保留第一个,表格优先因为可能更长)
170
+ const deduped: typeof hits = [];
171
+ for (const h of hits) {
172
+ if (deduped.length > 0 && h.index < deduped[deduped.length - 1].end) continue;
173
+ deduped.push(h);
174
+ }
175
+
176
+ // 按位置切分
177
+ let lastIndex = 0;
178
+ for (const h of deduped) {
179
+ const before = text.slice(lastIndex, h.index).trim();
180
+ if (before) blocks.push({ type: 'text', content: before });
181
+ blocks.push(h.block);
182
+ lastIndex = h.end;
183
+ }
184
+ const after = text.slice(lastIndex).trim();
185
+ if (after) blocks.push({ type: 'text', content: after });
186
+ if (blocks.length === 0) blocks.push({ type: 'text', content: text });
187
+
188
+ return blocks;
189
+ }