@yvhitxcel/opencode-remote 0.17.0 → 0.18.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.
- package/LICENSE +21 -0
- package/README.md +70 -1
- package/dist/cli.js +120 -9
- package/dist/core/adapter.js +12 -0
- package/dist/core/agent-registry.js +77 -0
- package/dist/core/crypto.js +80 -0
- package/dist/core/git-push.js +38 -15
- package/dist/core/handler.js +293 -0
- package/dist/core/log.js +92 -0
- package/dist/core/lru.js +98 -0
- package/dist/core/qiniu.js +2 -2
- package/dist/core/retry.js +46 -0
- package/dist/core/router.js +18 -6
- package/dist/core/state.js +190 -0
- package/dist/core/stats.js +115 -0
- package/dist/feishu/adapter.js +0 -1
- package/dist/feishu/bot.js +4 -2
- package/dist/feishu/commands.js +2 -2
- package/dist/feishu/handler.js +8 -177
- package/dist/opencode/client.js +78 -56
- package/dist/patch_spawn.js +1 -0
- package/dist/plugins/agents/claude-code/index.js +59 -51
- package/dist/plugins/agents/codex/index.js +32 -6
- package/dist/plugins/agents/copilot/index.js +32 -6
- package/dist/plugins/agents/opencode/index.js +36 -14
- package/dist/telegram/adapter.js +19 -3
- package/dist/weixin/adapter.js +37 -15
- package/dist/weixin/api.js +38 -17
- package/dist/weixin/bot.js +58 -23
- package/dist/weixin/commands.js +134 -8
- package/dist/weixin/handler.js +12 -274
- package/dist/weixin/user-adapter-map.js +11 -0
- package/package.json +5 -3
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Unified handler — platform-agnostic. Created via createHandler(deps).
|
|
2
|
+
// deps: { handleCommand, replyTo, wrapAdapterForShared }
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { splitMessage } from './notifications.js';
|
|
6
|
+
import { createSession, sendMessage as sendToOpenCode, checkConnection, shareSession, listOpenCodeSessions, resumeSession, initOpenCode, resetOpenCode } from '../opencode/client.js';
|
|
7
|
+
import { isAuthorized, hasOwner } from './auth.js';
|
|
8
|
+
import { threadHistory, threadAgent } from './state.js';
|
|
9
|
+
import { retryTransient, isTransientError } from './retry.js';
|
|
10
|
+
import { detectCommand, EXPERT_SYSTEM_PROMPT, startTypingPing } from './router.js';
|
|
11
|
+
import { hasPendingDecision, resolveDecision } from '../autonomous/decisions.js';
|
|
12
|
+
import { registry } from './registry.js';
|
|
13
|
+
import { incr, incrKey } from './stats.js';
|
|
14
|
+
|
|
15
|
+
const IDLE_MODEL_HINT_MS = 5 * 60 * 1000;
|
|
16
|
+
const threadLastActive = new Map();
|
|
17
|
+
const threadLock = new Set();
|
|
18
|
+
const THREAD_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟线程锁超时
|
|
19
|
+
|
|
20
|
+
// 定期清理超过 10 分钟的线程锁(防卡死)
|
|
21
|
+
setInterval(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const staleLock = [];
|
|
24
|
+
for (const tid of threadLock) {
|
|
25
|
+
const lastActive = threadLastActive.get(tid) || 0;
|
|
26
|
+
if (lastActive > 0 && now - lastActive > THREAD_LOCK_TIMEOUT_MS) {
|
|
27
|
+
staleLock.push(tid);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const tid of staleLock) {
|
|
31
|
+
threadLock.delete(tid);
|
|
32
|
+
threadLastActive.delete(tid);
|
|
33
|
+
console.log(`[handler] Force-unlocked stale thread ${tid.slice(0, 8)} (>${THREAD_LOCK_TIMEOUT_MS / 1000}s)`);
|
|
34
|
+
}
|
|
35
|
+
// 清理超过 1 小时的 threadLastActive 条目
|
|
36
|
+
const cutoff = now - 3600_000;
|
|
37
|
+
for (const [tid, ts] of threadLastActive) {
|
|
38
|
+
if (ts < cutoff) threadLastActive.delete(tid);
|
|
39
|
+
}
|
|
40
|
+
}, 60_000).unref?.();
|
|
41
|
+
|
|
42
|
+
export function createHandler(deps) {
|
|
43
|
+
const { handleCommand, replyTo, wrapAdapterForShared } = deps;
|
|
44
|
+
|
|
45
|
+
async function fwdToOpenCode(adapter, ctx, text, openCodeSessions, expertPrompt) {
|
|
46
|
+
adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
|
|
47
|
+
let openCodeSession = null;
|
|
48
|
+
let pendingModelHint = null;
|
|
49
|
+
|
|
50
|
+
const isShared = deps.isSharedMember ? deps.isSharedMember(ctx.threadId) : false;
|
|
51
|
+
const sharedRoom = deps.sharedRoom || { busy: false, session: null, members: [] };
|
|
52
|
+
|
|
53
|
+
if (isShared) {
|
|
54
|
+
if (sharedRoom.busy) { await adapter.reply(ctx.threadId, '⏳ 当前有人在用,请稍等...'); return; }
|
|
55
|
+
if (sharedRoom.session) { openCodeSession = sharedRoom.session; }
|
|
56
|
+
else {
|
|
57
|
+
openCodeSession = await createSession(`shared-${Date.now()}`, '共享会话');
|
|
58
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建共享会话'); return; }
|
|
59
|
+
sharedRoom.session = openCodeSession;
|
|
60
|
+
console.log(`✅ 共享会话已创建: ${openCodeSession.sessionId.slice(0, 8)}`);
|
|
61
|
+
}
|
|
62
|
+
sharedRoom.busy = true;
|
|
63
|
+
} else {
|
|
64
|
+
const lastActive = threadLastActive.get(ctx.threadId) || 0;
|
|
65
|
+
const isIdle = !expertPrompt && lastActive > 0 && (Date.now() - lastActive) > IDLE_MODEL_HINT_MS;
|
|
66
|
+
if (isIdle) pendingModelHint = true;
|
|
67
|
+
|
|
68
|
+
if (expertPrompt) {
|
|
69
|
+
openCodeSession = await createSession(`expert-${Date.now()}`, `专家评审 ${Date.now()}`);
|
|
70
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建评审会话'); return; }
|
|
71
|
+
console.log(`✅ 新建评审会话: ${openCodeSession.sessionId.slice(0, 8)}`);
|
|
72
|
+
} else {
|
|
73
|
+
openCodeSession = openCodeSessions.get(ctx.threadId);
|
|
74
|
+
if (!openCodeSession) {
|
|
75
|
+
console.log(`[fwdToOpenCode] no session, trying to resume...`);
|
|
76
|
+
try {
|
|
77
|
+
const sessions = await listOpenCodeSessions();
|
|
78
|
+
if (sessions.length > 0) {
|
|
79
|
+
const latest = sessions.sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0))[0];
|
|
80
|
+
const resumed = await resumeSession(latest.id);
|
|
81
|
+
if (resumed) { openCodeSession = resumed; openCodeSessions.set(ctx.threadId, openCodeSession); console.log(`[fwdToOpenCode] resumed session ${latest.id.slice(0, 8)}`); }
|
|
82
|
+
}
|
|
83
|
+
} catch (e) { console.log(`[fwdToOpenCode] failed to resume: ${e.message}`); }
|
|
84
|
+
if (!openCodeSession) {
|
|
85
|
+
console.log(`[fwdToOpenCode] creating new session for thread=${ctx.threadId}`);
|
|
86
|
+
openCodeSession = await createSession(ctx.threadId, `Session ${ctx.threadId}`);
|
|
87
|
+
if (!openCodeSession) { await adapter.reply(ctx.threadId, '❌ 无法创建 OpenCode 会话'); return; }
|
|
88
|
+
openCodeSessions.set(ctx.threadId, openCodeSession);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`📤 Message: → ${text}`);
|
|
95
|
+
let scopedText = text;
|
|
96
|
+
if (expertPrompt) scopedText = `${expertPrompt}\n\n${scopedText}`;
|
|
97
|
+
|
|
98
|
+
const typingPing = startTypingPing(adapter, ctx.threadId);
|
|
99
|
+
const startTs = Date.now();
|
|
100
|
+
const projectDir = globalThis.__autoProjectDir || process.cwd();
|
|
101
|
+
const inOpenchat = existsSync(`${projectDir}/bridge/bin/lab.mjs`);
|
|
102
|
+
|
|
103
|
+
const runLabStatus = async () => {
|
|
104
|
+
if (!inOpenchat) return;
|
|
105
|
+
try {
|
|
106
|
+
const out = execSync('node bridge/bin/lab.mjs status', { cwd: projectDir, encoding: 'utf8', timeout: 5000 });
|
|
107
|
+
replyTo(ctx.threadId, `⏳ ${formatLabOutput(out, 'status')}`, adapter).catch(() => {});
|
|
108
|
+
} catch (e) { replyTo(ctx.threadId, `⏳ lab status 失败: ${e.message}`, adapter).catch(() => {}); }
|
|
109
|
+
};
|
|
110
|
+
const heartbeat = setInterval(runLabStatus, 50_000);
|
|
111
|
+
|
|
112
|
+
const isConnError = (e) => /ECONNREFUSED|ECONNRESET|fetch failed|socket hang up|EHOSTUNREACH|ENETUNREACH/i.test(e?.message || '');
|
|
113
|
+
|
|
114
|
+
const result = await retryTransient(async () => {
|
|
115
|
+
try {
|
|
116
|
+
return await sendToOpenCode(openCodeSession, scopedText, {
|
|
117
|
+
idleThreshold: expertPrompt ? 30 : 10,
|
|
118
|
+
onNewContent: () => typingPing.poke(),
|
|
119
|
+
onResponseMeta: (meta) => { if (pendingModelHint && meta.modelID) pendingModelHint = `🧠 ${meta.providerID}/${meta.modelID}`; },
|
|
120
|
+
onEvent: (event) => {
|
|
121
|
+
if (event.type === 'tool.call') {
|
|
122
|
+
const props = event.properties || {};
|
|
123
|
+
const tn = props.name || props.tool_name || 'unknown';
|
|
124
|
+
const inp = props.input || {};
|
|
125
|
+
let desc = `🔧 ${tn}${inp.path ? ` 📁${inp.path}` : ''}${inp.command ? ` 💻${inp.command}` : ''}`;
|
|
126
|
+
console.log(`[→tool] ${desc}`);
|
|
127
|
+
(isShared ? [...sharedRoom.members] : [ctx.threadId]).forEach(tid => replyTo(tid, desc, adapter).catch(e => console.error('[→tool] fail:', e.message)));
|
|
128
|
+
typingPing.poke();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}, ctx.threadId);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (isConnError(e)) {
|
|
134
|
+
console.warn('[fwdToOpenCode] Connection error, reinit OpenCode...');
|
|
135
|
+
try { const fresh = await initOpenCode(); if (fresh?.client && openCodeSession) { openCodeSession.client = fresh.client; openCodeSession.server = fresh.server; } } catch (e) { console.debug('[fwdToOpenCode] reinit failed:', e.message); }
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
}, {
|
|
140
|
+
maxAttempts: 2, baseDelayMs: 2000,
|
|
141
|
+
onRetry: (err, attempt, delay) => {
|
|
142
|
+
incr('retries');
|
|
143
|
+
incrKey('errorsByCode', err.name || err.code || 'Unknown');
|
|
144
|
+
console.log(`[retry] error, attempt ${attempt}, retry in ${delay}ms: ${err.message}`);
|
|
145
|
+
adapter.reply(ctx.threadId, `⚠️ 请求失败,${Math.round(delay/1000)}s 后重试 (${attempt}/2)`).catch(() => {});
|
|
146
|
+
},
|
|
147
|
+
}).catch(e => {
|
|
148
|
+
console.error('[fwdToOpenCode] error:', e.message);
|
|
149
|
+
incrKey('errorsByCode', e.name || e.code || 'Unknown');
|
|
150
|
+
// 客户端超时 / AbortError → 自动重启 OpenCode 服务(防卡死)
|
|
151
|
+
if (/AbortError|aborted/i.test(e.message)) {
|
|
152
|
+
incr('opencodeRestarts');
|
|
153
|
+
resetOpenCode(); // 清空缓存 singleton,initOpenCode 才能真正重启
|
|
154
|
+
try { globalThis.__opencodeServer?.kill?.('SIGKILL'); } catch {}
|
|
155
|
+
setTimeout(() => initOpenCode().catch(() => {}), 1000);
|
|
156
|
+
}
|
|
157
|
+
return `❌ ${e.message || e}`;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
clearInterval(heartbeat);
|
|
161
|
+
typingPing.done();
|
|
162
|
+
|
|
163
|
+
const replyTargets = isShared ? [...sharedRoom.members] : [ctx.threadId];
|
|
164
|
+
if (isShared) sharedRoom.busy = false;
|
|
165
|
+
|
|
166
|
+
const finalText = (result || '').trim();
|
|
167
|
+
console.log(`📥 AI response (${finalText.length} chars): ${finalText.slice(0, 200)}...`);
|
|
168
|
+
const displayText = typeof pendingModelHint === 'string' ? `${pendingModelHint}\n${finalText}` : finalText;
|
|
169
|
+
|
|
170
|
+
if (!finalText || finalText.startsWith('⏰') || finalText.startsWith('❌')) {
|
|
171
|
+
if (!isShared) openCodeSessions.delete(ctx.threadId);
|
|
172
|
+
// 不替 LLM 编空响应消息:sendToOpenCode 已透传真实错误或返回空字符串
|
|
173
|
+
if (finalText) replyTargets.forEach(tid => replyTo(tid, finalText, adapter).catch(e => console.error('[reply] fail:', e.message)));
|
|
174
|
+
} else {
|
|
175
|
+
splitMessage(displayText).forEach(m => m.trim() && replyTargets.forEach(tid => replyTo(tid, m, adapter).catch(e => console.error('[reply] fail:', e.message))));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
threadLastActive.set(ctx.threadId, Date.now());
|
|
179
|
+
|
|
180
|
+
if (finalText.length > 0 && !finalText.startsWith('❌') && !finalText.startsWith('⏰')) {
|
|
181
|
+
const shareUrl = await shareSession(openCodeSession);
|
|
182
|
+
if (shareUrl) { try { await adapter.reply(ctx.threadId, `🔗 ${shareUrl}`); } catch (e) { console.error('[fwdToOpenCode] share URL reply failed:', e.message); } }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleMsg(adapter, ctx, text, openCodeSessions, platform = 'weixin') {
|
|
187
|
+
// Stats: count every incoming message
|
|
188
|
+
incr('messagesReceived');
|
|
189
|
+
|
|
190
|
+
// 阶段 1: 所有系统指令都不阻塞,先于 lock 处理
|
|
191
|
+
// - /z 调用 fwdToOpenCode(不 acquire lock)
|
|
192
|
+
// - detectCommand 命中任何命令都直接处理
|
|
193
|
+
const trimmedLower = text.trim().toLowerCase();
|
|
194
|
+
|
|
195
|
+
if (text.startsWith('/z')) {
|
|
196
|
+
const arg = text.slice(2).trim();
|
|
197
|
+
if (arg === 'off' || arg === 'reset' || arg === '关闭') { await adapter.reply(ctx.threadId, '⏹️ 自定义 prompt 已清除'); return; }
|
|
198
|
+
if (arg) {
|
|
199
|
+
await adapter.reply(ctx.threadId, `✅ 自定义专家 prompt (${arg.length}字),本消息生效`);
|
|
200
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions, arg);
|
|
201
|
+
} else {
|
|
202
|
+
await adapter.reply(ctx.threadId, '✅ 专家评审已启动');
|
|
203
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions, EXPERT_SYSTEM_PROMPT);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const detected = detectCommand(text);
|
|
209
|
+
if (detected) {
|
|
210
|
+
const cmdAdapter = deps.isSharedMember ? wrapAdapterForShared(adapter, ctx.threadId) : adapter;
|
|
211
|
+
const handled = await handleCommand(cmdAdapter, ctx, platform, detected.name, detected.arg, openCodeSessions);
|
|
212
|
+
if (handled) return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 阶段 2: 普通消息才加 lock
|
|
216
|
+
if (threadLock.has(ctx.threadId)) {
|
|
217
|
+
console.log(`[handler] thread ${ctx.threadId.slice(0, 8)} busy, skipping`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
threadLock.add(ctx.threadId);
|
|
221
|
+
try {
|
|
222
|
+
const expertTriggers = ['z', 'Z', '叫全部专家', '叫所有专家', '呼叫专家点评', '专家点评', '专家意见', 'call all experts', 'expert review', '专家会诊', '团队评审', '代码审查', '全员review', 'review all', '请专家', '叫专家', '找专家', 'expert'];
|
|
223
|
+
let expertPrompt = null;
|
|
224
|
+
|
|
225
|
+
if (expertTriggers.some(t => trimmedLower.includes(t))) expertPrompt = EXPERT_SYSTEM_PROMPT;
|
|
226
|
+
|
|
227
|
+
if (hasPendingDecision(ctx.threadId)) {
|
|
228
|
+
if (/^\d+$/.test(text.trim())) { if (resolveDecision(ctx.threadId, text.trim())) return; }
|
|
229
|
+
if (/^\/(\d+)$/.test(text.trim())) { if (resolveDecision(ctx.threadId, text.trim().slice(1))) return; }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isAuthorized(platform, ctx.userId)) {
|
|
233
|
+
incr('authRejections');
|
|
234
|
+
if (!hasOwner(platform)) { await adapter.reply(ctx.threadId, '🔐 请先发送 /start 进行安全认证'); return; }
|
|
235
|
+
if (!deps.isSharedMember?.(ctx.threadId)) { await adapter.reply(ctx.threadId, '🚫 你无权使用此 bot'); return; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const activeAgentName = threadAgent.get(ctx.threadId);
|
|
239
|
+
|
|
240
|
+
if (activeAgentName && activeAgentName !== 'opencode') {
|
|
241
|
+
const agent = registry.findAgent(activeAgentName);
|
|
242
|
+
if (agent) {
|
|
243
|
+
const available = await agent.isAvailable().catch(() => false);
|
|
244
|
+
if (available) {
|
|
245
|
+
adapter.sendTypingIndicator(ctx.threadId).catch(() => {});
|
|
246
|
+
const t0 = Date.now();
|
|
247
|
+
const hbDir = globalThis.__autoProjectDir || process.cwd();
|
|
248
|
+
const hbLab = existsSync(`${hbDir}/bridge/bin/lab.mjs`);
|
|
249
|
+
const heartbeat = setInterval(() => {
|
|
250
|
+
if (hbLab) { try { const out = execSync('node bridge/bin/lab.mjs status', { cwd: hbDir, encoding: 'utf8', timeout: 5000 }); adapter.reply(ctx.threadId, `⏳ ${activeAgentName} · ${formatLabOutput(out, 'status')}`).catch(() => {}); return; } catch (e) { adapter.reply(ctx.threadId, `⏳ ${activeAgentName} lab status 失败: ${e.message}`).catch(() => {}); return; } }
|
|
251
|
+
}, 50_000);
|
|
252
|
+
|
|
253
|
+
const history = threadHistory.get(ctx.threadId) || [];
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const response = await retryTransient(() => agent.sendPrompt(activeAgentName, text, history, { projectDir: globalThis.__autoProjectDir, threadId: ctx.threadId }), {
|
|
257
|
+
maxAttempts: 2, baseDelayMs: 2000, onRetry: (err, attempt, delay) => { adapter.reply(ctx.threadId, `⚠️ ${activeAgentName} 失败,${Math.round(delay/1000)}s 后重试 (${attempt}/2)`).catch(() => {}); },
|
|
258
|
+
});
|
|
259
|
+
clearInterval(heartbeat);
|
|
260
|
+
console.log(`📥 AI response (${(response || '').length} chars): ${(response || '').slice(0, 200)}...`);
|
|
261
|
+
history.push({ role: 'user', content: text }, { role: 'assistant', content: response || '' });
|
|
262
|
+
threadHistory.set(ctx.threadId, history);
|
|
263
|
+
splitMessage(response || '无响应').forEach(chunk => adapter.reply(ctx.threadId, chunk));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
clearInterval(heartbeat);
|
|
266
|
+
console.error(`[${activeAgentName}] ❌ ${error.message}`);
|
|
267
|
+
await adapter.reply(ctx.threadId, `❌ ${activeAgentName} 错误: ${error.message}`);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
threadAgent.delete(ctx.threadId);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const connected = await checkConnection();
|
|
276
|
+
if (!connected) { await adapter.reply(ctx.threadId, '❌ OpenCode 离线,请检查服务是否运行'); return; }
|
|
277
|
+
await fwdToOpenCode(adapter, ctx, text, openCodeSessions);
|
|
278
|
+
} finally { threadLock.delete(ctx.threadId); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { handleMessage: handleMsg, forwardToOpenCode: fwdToOpenCode };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function formatLabOutput(out, subCmd) {
|
|
285
|
+
const trimmed = (out || '').trim();
|
|
286
|
+
if (!trimmed) return '(空)';
|
|
287
|
+
try {
|
|
288
|
+
const obj = JSON.parse(trimmed);
|
|
289
|
+
if (obj && typeof obj.total === 'number') return `总计 ${obj.total} · 待 ${obj.pending || 0} · 跑 ${obj.running || 0} · 完成 ${obj.done || 0} · 失败 ${obj.failed || 0}`;
|
|
290
|
+
} catch (e) { console.debug('[formatLabOutput] Not JSON:', e.message); }
|
|
291
|
+
const oneline = trimmed.replace(/\s+/g, ' ').trim();
|
|
292
|
+
return oneline.length > 1800 ? oneline.slice(0, 1800) + '...' : oneline;
|
|
293
|
+
}
|
package/dist/core/log.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// File-based logger with daily rotation
|
|
2
|
+
// Writes to ~/.opencode-remote/logs/bot-YYYY-MM-DD.log
|
|
3
|
+
import { existsSync, mkdirSync, statSync, createWriteStream, readdirSync, unlinkSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
|
|
7
|
+
const LOG_DIR = join(homedir(), '.opencode-remote', 'logs');
|
|
8
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB per file, rotate
|
|
9
|
+
const KEEP_FILES = 7; // 保留最近 7 天
|
|
10
|
+
|
|
11
|
+
let currentDate = '';
|
|
12
|
+
let currentStream = null;
|
|
13
|
+
let currentSize = 0;
|
|
14
|
+
|
|
15
|
+
function getDate() {
|
|
16
|
+
return new Date().toISOString().slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureLogFile() {
|
|
20
|
+
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
|
21
|
+
const today = getDate();
|
|
22
|
+
if (currentDate === today && currentStream) return;
|
|
23
|
+
if (currentStream) {
|
|
24
|
+
try { currentStream.end(); } catch {}
|
|
25
|
+
}
|
|
26
|
+
currentDate = today;
|
|
27
|
+
const logPath = join(LOG_DIR, `bot-${today}.log`);
|
|
28
|
+
if (existsSync(logPath)) {
|
|
29
|
+
currentSize = statSync(logPath).size;
|
|
30
|
+
} else {
|
|
31
|
+
currentSize = 0;
|
|
32
|
+
}
|
|
33
|
+
currentStream = createWriteStream(logPath, { flags: 'a' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rotate() {
|
|
37
|
+
if (currentStream) {
|
|
38
|
+
try { currentStream.end(); } catch {}
|
|
39
|
+
currentStream = null;
|
|
40
|
+
}
|
|
41
|
+
ensureLogFile();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function format(level, msg, meta) {
|
|
45
|
+
const ts = new Date().toISOString();
|
|
46
|
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
|
47
|
+
return `${ts} [${level}] ${msg}${metaStr}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function write(level, msg, meta) {
|
|
51
|
+
try {
|
|
52
|
+
ensureLogFile();
|
|
53
|
+
const line = format(level, msg, meta);
|
|
54
|
+
if (currentSize + line.length > MAX_LOG_SIZE) {
|
|
55
|
+
rotate();
|
|
56
|
+
ensureLogFile();
|
|
57
|
+
}
|
|
58
|
+
currentStream.write(line);
|
|
59
|
+
currentSize += line.length;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// 写日志失败不能崩主进程
|
|
62
|
+
console.error('[log] Write failed:', e.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const logger = {
|
|
67
|
+
info(msg, meta) { write('INFO', msg, meta); },
|
|
68
|
+
warn(msg, meta) { write('WARN', msg, meta); },
|
|
69
|
+
error(msg, meta) { write('ERROR', msg, meta); },
|
|
70
|
+
debug(msg, meta) { write('DEBUG', msg, meta); },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function initLogger() {
|
|
74
|
+
ensureLogFile();
|
|
75
|
+
console.log(`[log] Writing to ${LOG_DIR}/bot-${getDate()}.log`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 清理旧日志
|
|
79
|
+
export function cleanOldLogs() {
|
|
80
|
+
if (!existsSync(LOG_DIR)) return;
|
|
81
|
+
const files = readdirSync(LOG_DIR);
|
|
82
|
+
const today = new Date();
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
const m = f.match(/^bot-(\d{4}-\d{2}-\d{2})\.log$/);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const fileDate = new Date(m[1]);
|
|
87
|
+
const daysAgo = Math.floor((today.getTime() - fileDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
88
|
+
if (daysAgo > KEEP_FILES) {
|
|
89
|
+
try { unlinkSync(join(LOG_DIR, f)); } catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/core/lru.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// LRU + TTL Map for session storage
|
|
2
|
+
// - Caps total size to prevent memory leaks
|
|
3
|
+
// - Evicts least recently used when over capacity
|
|
4
|
+
// - Auto-expires entries that haven't been touched in TTL ms
|
|
5
|
+
export class LRUSessionMap {
|
|
6
|
+
constructor({ maxSize = 100, ttlMs = 30 * 60 * 1000, name = 'sessions' } = {}) {
|
|
7
|
+
this.maxSize = maxSize;
|
|
8
|
+
this.ttlMs = ttlMs;
|
|
9
|
+
this.name = name;
|
|
10
|
+
this._map = new Map(); // key -> { value, lastUsed }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_isExpired(entry) {
|
|
14
|
+
return Date.now() - entry.lastUsed > this.ttlMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_evictExpired() {
|
|
18
|
+
for (const [k, v] of this._map.entries()) {
|
|
19
|
+
if (this._isExpired(v)) this._map.delete(k);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(key) {
|
|
24
|
+
const entry = this._map.get(key);
|
|
25
|
+
if (!entry) return undefined;
|
|
26
|
+
if (this._isExpired(entry)) {
|
|
27
|
+
this._map.delete(key);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
entry.lastUsed = Date.now();
|
|
31
|
+
// 移到队尾 (LRU)
|
|
32
|
+
this._map.delete(key);
|
|
33
|
+
this._map.set(key, entry);
|
|
34
|
+
return entry.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
set(key, value) {
|
|
38
|
+
if (this._map.has(key)) this._map.delete(key);
|
|
39
|
+
this._map.set(key, { value, lastUsed: Date.now() });
|
|
40
|
+
// 超上限淘汰最旧
|
|
41
|
+
while (this._map.size > this.maxSize) {
|
|
42
|
+
const oldest = this._map.keys().next().value;
|
|
43
|
+
this._map.delete(oldest);
|
|
44
|
+
console.log(`[lru:${this.name}] evicted ${oldest} (size > ${this.maxSize})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
delete(key) {
|
|
49
|
+
return this._map.delete(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
has(key) {
|
|
53
|
+
const entry = this._map.get(key);
|
|
54
|
+
if (!entry) return false;
|
|
55
|
+
if (this._isExpired(entry)) {
|
|
56
|
+
this._map.delete(key);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get size() {
|
|
63
|
+
return this._map.size;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
keys() {
|
|
67
|
+
this._evictExpired();
|
|
68
|
+
return this._map.keys();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
values() {
|
|
72
|
+
this._evictExpired();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const v of this._map.values()) out.push(v.value);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
entries() {
|
|
79
|
+
this._evictExpired();
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const [k, v] of this._map.entries()) out.push([k, v.value]);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
clear() {
|
|
86
|
+
this._map.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 定期清理 (调用方负责 setInterval)
|
|
90
|
+
cleanup() {
|
|
91
|
+
const before = this._map.size;
|
|
92
|
+
this._evictExpired();
|
|
93
|
+
const after = this._map.size;
|
|
94
|
+
if (before !== after) {
|
|
95
|
+
console.log(`[lru:${this.name}] cleaned ${before - after} expired entries`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/core/qiniu.js
CHANGED
|
@@ -238,10 +238,10 @@ export function findBuildOutputs(projectDir, maxDepth = 5) {
|
|
|
238
238
|
time: stat.mtime.getTime(),
|
|
239
239
|
relativePath: fullPath.replace(projectDir, '').replace(/^[\\\/]/, '')
|
|
240
240
|
});
|
|
241
|
-
} catch (e) {}
|
|
241
|
+
} catch (e) { console.debug('[qiniu] stat error:', e.message); }
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
-
} catch (e) {}
|
|
244
|
+
} catch (e) { console.debug('[qiniu] scan error:', e.message); }
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
for (const pattern of searchDirPatterns) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Retry helper — exponential backoff for transient errors
|
|
2
|
+
const TRANSIENT_PATTERNS = [
|
|
3
|
+
'AbortError', 'aborted',
|
|
4
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE',
|
|
5
|
+
'fetch failed', 'socket hang up',
|
|
6
|
+
'rate limit', '429', '502', '503', '504',
|
|
7
|
+
'Free usage exceeded', 'quota exceeded',
|
|
8
|
+
'retry attempt', 'retrying in',
|
|
9
|
+
'insufficient_quota', 'Payment Required',
|
|
10
|
+
'timeout', 'Timeout',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function isTransientError(err) {
|
|
14
|
+
if (!err) return false;
|
|
15
|
+
const msg = (err.message || String(err)).toLowerCase();
|
|
16
|
+
return TRANSIENT_PATTERNS.some(p => msg.includes(p.toLowerCase()));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Retry an async function with exponential backoff.
|
|
21
|
+
* Only retries on transient errors. Resolves on first success.
|
|
22
|
+
* @template T
|
|
23
|
+
* @param {() => Promise<T>} fn
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {number} [opts.maxAttempts=3]
|
|
26
|
+
* @param {number} [opts.baseDelayMs=1000]
|
|
27
|
+
* @param {number} [opts.maxDelayMs=8000]
|
|
28
|
+
* @param {(err: any, attempt: number, nextDelay: number) => void} [opts.onRetry]
|
|
29
|
+
* @returns {Promise<T>}
|
|
30
|
+
*/
|
|
31
|
+
export async function retryTransient(fn, opts = {}) {
|
|
32
|
+
const { maxAttempts = 3, baseDelayMs = 1000, maxDelayMs = 8000, onRetry } = opts;
|
|
33
|
+
let lastErr;
|
|
34
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
lastErr = err;
|
|
39
|
+
if (attempt >= maxAttempts || !isTransientError(err)) throw err;
|
|
40
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1));
|
|
41
|
+
if (onRetry) onRetry(err, attempt, delay);
|
|
42
|
+
await new Promise(r => setTimeout(r, delay));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw lastErr;
|
|
46
|
+
}
|
package/dist/core/router.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { registry } from './registry.js';
|
|
3
3
|
import { initOpenCode, checkConnection, setThreadModel, getThreadModel, getRecentModels, setRawDebug, isRawDebug } from '../opencode/client.js';
|
|
4
4
|
import { formatTaskCompletion } from './notifications.js';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
5
6
|
|
|
6
7
|
export const COMMAND_ALIASES = {
|
|
7
8
|
start: ['start'],
|
|
@@ -14,15 +15,18 @@ export const COMMAND_ALIASES = {
|
|
|
14
15
|
cx: ['cx'],
|
|
15
16
|
copilot: ['copilot'],
|
|
16
17
|
model: ['model'],
|
|
17
|
-
expert: ['expert', 'z', 'Z', 'review'],
|
|
18
18
|
raw: ['raw'],
|
|
19
19
|
think: ['think'],
|
|
20
20
|
share: ['share'],
|
|
21
|
-
|
|
21
|
+
bind: ['bind'],
|
|
22
22
|
push: ['push'],
|
|
23
23
|
who: ['who'],
|
|
24
24
|
deploy: ['deploy', 'gitpush'],
|
|
25
25
|
auto: ['auto'],
|
|
26
|
+
lab: ['lab'],
|
|
27
|
+
esc: ['esc', 'abort', 'stop'],
|
|
28
|
+
status: ['status'],
|
|
29
|
+
info: ['info', 'stats', 'health'],
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
export const EXPERT_SYSTEM_PROMPT = `你是一个专家评审系统。用户消息含触发词(z/c/叫全部专家/专家点评)时启动评审,前后可带具体问题则聚焦该问题。
|
|
@@ -120,7 +124,6 @@ const COMMAND_HELP = {
|
|
|
120
124
|
cx: '使用 Codex',
|
|
121
125
|
copilot: '使用 Copilot',
|
|
122
126
|
model: '切换模型',
|
|
123
|
-
expert: '专家评审(z/叫全部专家)',
|
|
124
127
|
raw: '开启/关闭 RAW 调试输出',
|
|
125
128
|
think: '开启/关闭思考过程显示',
|
|
126
129
|
share: '共享会话管理',
|
|
@@ -129,6 +132,10 @@ const COMMAND_HELP = {
|
|
|
129
132
|
who: '查看在线用户',
|
|
130
133
|
deploy: '推送代码到所有 Git 镜像',
|
|
131
134
|
auto: '自主开发模式',
|
|
135
|
+
lab: '实验室(状态/队列/历史等)',
|
|
136
|
+
esc: '中断当前活跃任务',
|
|
137
|
+
status: '查看 OpenCode 会话状态',
|
|
138
|
+
info: 'bot 状态/统计/版本/内存',
|
|
132
139
|
};
|
|
133
140
|
|
|
134
141
|
const COMMAND_MAP = {};
|
|
@@ -157,13 +164,16 @@ export function startTypingPing(adapter, threadId) {
|
|
|
157
164
|
|
|
158
165
|
export function getHelpText() {
|
|
159
166
|
const lines = ['📖 指令\n'];
|
|
167
|
+
const hasLab = existsSync('bridge/bin/lab.mjs');
|
|
160
168
|
const groups = [
|
|
161
169
|
['start', 'help'], // 系统
|
|
162
|
-
['reset', 'restart', 'diagnose'], // 会话
|
|
170
|
+
['reset', 'restart', 'diagnose', 'esc', 'status', 'info'], // 会话
|
|
163
171
|
['oc', 'cc', 'cx', 'copilot'], // Agent
|
|
164
172
|
['model', 'raw', 'think'], // 配置
|
|
165
173
|
['share', 'bind', 'push', 'who'], // 协作
|
|
166
|
-
|
|
174
|
+
hasLab
|
|
175
|
+
? ['deploy', 'auto', 'lab']
|
|
176
|
+
: ['deploy', 'auto'], // 专家+部署+自主
|
|
167
177
|
];
|
|
168
178
|
let first = true;
|
|
169
179
|
for (const group of groups) {
|
|
@@ -178,6 +188,7 @@ export function getHelpText() {
|
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
lines.push('');
|
|
191
|
+
lines.push('🤖 专家评审: 发"z"或"叫全部专家"即可');
|
|
181
192
|
lines.push('💬 直接发消息给 AI!');
|
|
182
193
|
return lines.join('\n');
|
|
183
194
|
}
|
|
@@ -188,7 +199,8 @@ export function detectCommand(text) {
|
|
|
188
199
|
return { name: 'help', arg: '' };
|
|
189
200
|
}
|
|
190
201
|
if (/^[.。\/]/.test(trimmed)) {
|
|
191
|
-
|
|
202
|
+
let cmd = trimmed.slice(1).trim();
|
|
203
|
+
if (cmd.startsWith('/')) cmd = cmd.slice(1).trim();
|
|
192
204
|
const parts = cmd.split(/\s+/);
|
|
193
205
|
const name = COMMAND_MAP[parts[0].toLowerCase()];
|
|
194
206
|
if (name) {
|