agent-shell-chat 1.2.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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/bin/agent-shell.d.ts +15 -0
- package/dist/bin/agent-shell.d.ts.map +1 -0
- package/dist/bin/agent-shell.js +816 -0
- package/dist/bin/agent-shell.js.map +1 -0
- package/dist/package.json +54 -0
- package/dist/src/acp/agent-manager.d.ts +22 -0
- package/dist/src/acp/agent-manager.d.ts.map +1 -0
- package/dist/src/acp/agent-manager.js +79 -0
- package/dist/src/acp/agent-manager.js.map +1 -0
- package/dist/src/acp/client.d.ts +64 -0
- package/dist/src/acp/client.d.ts.map +1 -0
- package/dist/src/acp/client.js +265 -0
- package/dist/src/acp/client.js.map +1 -0
- package/dist/src/acp/session.d.ts +81 -0
- package/dist/src/acp/session.d.ts.map +1 -0
- package/dist/src/acp/session.js +339 -0
- package/dist/src/acp/session.js.map +1 -0
- package/dist/src/adapter/inbound.d.ts +39 -0
- package/dist/src/adapter/inbound.d.ts.map +1 -0
- package/dist/src/adapter/inbound.js +264 -0
- package/dist/src/adapter/inbound.js.map +1 -0
- package/dist/src/bridge.d.ts +115 -0
- package/dist/src/bridge.d.ts.map +1 -0
- package/dist/src/bridge.js +969 -0
- package/dist/src/bridge.js.map +1 -0
- package/dist/src/config.d.ts +155 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +265 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/inject/monitor.d.ts +24 -0
- package/dist/src/inject/monitor.d.ts.map +1 -0
- package/dist/src/inject/monitor.js +149 -0
- package/dist/src/inject/monitor.js.map +1 -0
- package/dist/src/inject/queue.d.ts +13 -0
- package/dist/src/inject/queue.d.ts.map +1 -0
- package/dist/src/inject/queue.js +35 -0
- package/dist/src/inject/queue.js.map +1 -0
- package/dist/src/inject/types.d.ts +10 -0
- package/dist/src/inject/types.d.ts.map +1 -0
- package/dist/src/inject/types.js +2 -0
- package/dist/src/inject/types.js.map +1 -0
- package/dist/src/storage/accounts.d.ts +43 -0
- package/dist/src/storage/accounts.d.ts.map +1 -0
- package/dist/src/storage/accounts.js +289 -0
- package/dist/src/storage/accounts.js.map +1 -0
- package/dist/src/storage/runtime.d.ts +23 -0
- package/dist/src/storage/runtime.d.ts.map +1 -0
- package/dist/src/storage/runtime.js +104 -0
- package/dist/src/storage/runtime.js.map +1 -0
- package/dist/src/storage/state.d.ts +17 -0
- package/dist/src/storage/state.d.ts.map +1 -0
- package/dist/src/storage/state.js +78 -0
- package/dist/src/storage/state.js.map +1 -0
- package/dist/src/telemetry/index.d.ts +33 -0
- package/dist/src/telemetry/index.d.ts.map +1 -0
- package/dist/src/telemetry/index.js +167 -0
- package/dist/src/telemetry/index.js.map +1 -0
- package/dist/src/weixin/api.d.ts +50 -0
- package/dist/src/weixin/api.d.ts.map +1 -0
- package/dist/src/weixin/api.js +90 -0
- package/dist/src/weixin/api.js.map +1 -0
- package/dist/src/weixin/auth.d.ts +26 -0
- package/dist/src/weixin/auth.d.ts.map +1 -0
- package/dist/src/weixin/auth.js +103 -0
- package/dist/src/weixin/auth.js.map +1 -0
- package/dist/src/weixin/media.d.ts +24 -0
- package/dist/src/weixin/media.d.ts.map +1 -0
- package/dist/src/weixin/media.js +64 -0
- package/dist/src/weixin/media.js.map +1 -0
- package/dist/src/weixin/monitor.d.ts +16 -0
- package/dist/src/weixin/monitor.d.ts.map +1 -0
- package/dist/src/weixin/monitor.js +113 -0
- package/dist/src/weixin/monitor.js.map +1 -0
- package/dist/src/weixin/send.d.ts +28 -0
- package/dist/src/weixin/send.d.ts.map +1 -0
- package/dist/src/weixin/send.js +162 -0
- package/dist/src/weixin/send.js.map +1 -0
- package/dist/src/weixin/types.d.ts +149 -0
- package/dist/src/weixin/types.d.ts.map +1 -0
- package/dist/src/weixin/types.js +33 -0
- package/dist/src/weixin/types.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentShellBridge β the main orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Connects WeChat's iLink long-poll to ACP agent subprocesses.
|
|
5
|
+
* One bridge = one WeChat bot account β many users β many agent sessions.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { login, loadToken } from "./weixin/auth.js";
|
|
11
|
+
import { startMonitor } from "./weixin/monitor.js";
|
|
12
|
+
import { sendImageMessage, sendFileMessage, sendTextMessage, splitText } from "./weixin/send.js";
|
|
13
|
+
import { sendTyping, getConfig } from "./weixin/api.js";
|
|
14
|
+
import { TypingStatus, MessageType } from "./weixin/types.js";
|
|
15
|
+
import { SessionManager } from "./acp/session.js";
|
|
16
|
+
import { weixinMessageToPrompt } from "./adapter/inbound.js";
|
|
17
|
+
import { BRIDGE_COMMANDS, resolveCommandAliases, resolveCommandNames } from "./config.js";
|
|
18
|
+
import { InjectionMonitor } from "./inject/monitor.js";
|
|
19
|
+
import { resolveUserTarget, updateLastActiveUser } from "./storage/state.js";
|
|
20
|
+
import { trackEvent, trackException, hashUserId } from "./telemetry/index.js";
|
|
21
|
+
const ACP_CONFIG_COMMAND = BRIDGE_COMMANDS.acpConfig;
|
|
22
|
+
const ACP_CANCEL_COMMAND = BRIDGE_COMMANDS.acpCancel;
|
|
23
|
+
const BUFFER_START_COMMAND = BRIDGE_COMMANDS.promptStart;
|
|
24
|
+
const BUFFER_DONE_COMMAND = BRIDGE_COMMANDS.promptDone;
|
|
25
|
+
const TEXT_CHUNK_LIMIT = 4000;
|
|
26
|
+
const BUFFER_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
27
|
+
const BUFFER_MAX_BLOCKS = 50;
|
|
28
|
+
const SEGMENT_SEND_MAX_ATTEMPTS = 3;
|
|
29
|
+
const SEGMENT_SEND_RETRY_BASE_MS = 300;
|
|
30
|
+
/**
|
|
31
|
+
* Minimum spacing between two consecutive outbound text messages to the
|
|
32
|
+
* same user. Each reply segment is an independent iLink API call with no
|
|
33
|
+
* ordering hint, and WeChat appears to order back-to-back bot messages by
|
|
34
|
+
* server-receive time. Without spacing, near-simultaneous sends can race
|
|
35
|
+
* and be delivered to the user out of order (see issue #38). A short delay
|
|
36
|
+
* separates their server-side timestamps and preserves order.
|
|
37
|
+
*/
|
|
38
|
+
const REPLY_SEND_SPACING_MS = 150;
|
|
39
|
+
export class AgentShellBridge {
|
|
40
|
+
config;
|
|
41
|
+
abortController = new AbortController();
|
|
42
|
+
sessionManager = null;
|
|
43
|
+
injectionMonitor = null;
|
|
44
|
+
tokenData = null;
|
|
45
|
+
stateUpdate = Promise.resolve();
|
|
46
|
+
// Per-user typing ticket cache
|
|
47
|
+
typingTickets = new Map();
|
|
48
|
+
// Timestamp (ms) at which the last text message was issued to each user,
|
|
49
|
+
// used to pace consecutive sends so they don't race and arrive reordered.
|
|
50
|
+
lastSendAt = new Map();
|
|
51
|
+
// Per-user promise chain serializing replies so concurrent sendReply calls
|
|
52
|
+
// (e.g. a command reply racing an active session flush) cannot interleave
|
|
53
|
+
// their segments and arrive out of order (issue #38).
|
|
54
|
+
sendChains = new Map();
|
|
55
|
+
// Per-user message buffer for /acp-prompt-start.../acp-prompt-done multi-part compose
|
|
56
|
+
messageBuffers = new Map();
|
|
57
|
+
// Per-user expiry timers for buffer cleanup
|
|
58
|
+
bufferTimers = new Map();
|
|
59
|
+
// Users currently flushing their buffer (between /done and enqueue).
|
|
60
|
+
// Maps userId to a promise that resolves when the flush completes, so
|
|
61
|
+
// messages arriving during the flush wait for the buffered prompt to
|
|
62
|
+
// enqueue first, preserving turn order.
|
|
63
|
+
bufferFlushing = new Map();
|
|
64
|
+
log;
|
|
65
|
+
constructor(config, log) {
|
|
66
|
+
this.config = config;
|
|
67
|
+
this.log = log ?? ((msg) => console.log(`[agent-shell] ${msg}`));
|
|
68
|
+
}
|
|
69
|
+
async start(opts) {
|
|
70
|
+
const { forceLogin, renderQrUrl } = opts ?? {};
|
|
71
|
+
// 1. Login or load token
|
|
72
|
+
if (!forceLogin) {
|
|
73
|
+
this.tokenData = loadToken(this.config.storage.dir);
|
|
74
|
+
if (this.tokenData) {
|
|
75
|
+
trackEvent("token.reused");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!this.tokenData) {
|
|
79
|
+
const loginStart = Date.now();
|
|
80
|
+
try {
|
|
81
|
+
this.tokenData = await login({
|
|
82
|
+
baseUrl: this.config.wechat.baseUrl,
|
|
83
|
+
botType: this.config.wechat.botType,
|
|
84
|
+
storageDir: this.config.storage.dir,
|
|
85
|
+
log: this.log,
|
|
86
|
+
renderQrUrl,
|
|
87
|
+
});
|
|
88
|
+
trackEvent("login.success", {
|
|
89
|
+
forced: !!forceLogin,
|
|
90
|
+
durationMs: Date.now() - loginStart,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
trackException(err, "auth");
|
|
95
|
+
trackEvent("login.failure", {
|
|
96
|
+
forced: !!forceLogin,
|
|
97
|
+
durationMs: Date.now() - loginStart,
|
|
98
|
+
errorType: err instanceof Error ? err.name : "Unknown",
|
|
99
|
+
});
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.log(`Loaded saved token (Bot: ${this.tokenData.accountId}, saved at ${this.tokenData.savedAt})`);
|
|
105
|
+
this.log(`Use --login to force re-login`);
|
|
106
|
+
}
|
|
107
|
+
// 2. Create SessionManager
|
|
108
|
+
this.sessionManager = new SessionManager({
|
|
109
|
+
agentCommand: this.config.agent.command,
|
|
110
|
+
agentArgs: this.config.agent.args,
|
|
111
|
+
agentCwd: this.config.agent.cwd,
|
|
112
|
+
agentEnv: this.config.agent.env,
|
|
113
|
+
agentPreset: this.config.agent.preset ?? "raw",
|
|
114
|
+
agentSystemPrompt: this.config.agent.systemPrompt,
|
|
115
|
+
idleTimeoutMs: this.config.session.idleTimeoutMs,
|
|
116
|
+
maxConcurrentUsers: this.config.session.maxConcurrentUsers,
|
|
117
|
+
showThoughts: this.config.agent.showThoughts,
|
|
118
|
+
showDiffs: this.config.agent.showDiffs ?? false,
|
|
119
|
+
log: this.log,
|
|
120
|
+
onReply: (userId, contextToken, text) => this.sendReply(userId, contextToken, text),
|
|
121
|
+
sendTyping: (userId, contextToken) => this.sendTypingIndicator(userId, contextToken),
|
|
122
|
+
});
|
|
123
|
+
this.sessionManager.start();
|
|
124
|
+
if (this.config.storage.injectDir && this.config.storage.stateFile) {
|
|
125
|
+
this.injectionMonitor = new InjectionMonitor({
|
|
126
|
+
injectDir: this.config.storage.injectDir,
|
|
127
|
+
log: this.log,
|
|
128
|
+
onMessage: (job) => this.enqueueInjectedMessage(job),
|
|
129
|
+
});
|
|
130
|
+
await this.injectionMonitor.start();
|
|
131
|
+
this.log(`Injection queue: ${this.config.storage.injectDir}`);
|
|
132
|
+
}
|
|
133
|
+
// 3. Start monitor loop
|
|
134
|
+
this.log("Starting message polling...");
|
|
135
|
+
await startMonitor({
|
|
136
|
+
baseUrl: this.tokenData.baseUrl,
|
|
137
|
+
token: this.tokenData.token,
|
|
138
|
+
storageDir: this.config.storage.dir,
|
|
139
|
+
abortSignal: this.abortController.signal,
|
|
140
|
+
log: this.log,
|
|
141
|
+
onMessage: (msg) => this.handleMessage(msg),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async stop() {
|
|
145
|
+
this.log("Stopping bridge...");
|
|
146
|
+
this.abortController.abort();
|
|
147
|
+
await this.injectionMonitor?.stop();
|
|
148
|
+
await this.sessionManager?.stop();
|
|
149
|
+
await this.stateUpdate.catch((err) => {
|
|
150
|
+
this.log(`Failed to flush state before stop: ${String(err)}`);
|
|
151
|
+
trackException(sanitizeStateError(err), "state");
|
|
152
|
+
});
|
|
153
|
+
this.log("Bridge stopped");
|
|
154
|
+
}
|
|
155
|
+
handleMessage(msg) {
|
|
156
|
+
// Only process user messages (not bot's own messages)
|
|
157
|
+
if (msg.message_type !== MessageType.USER)
|
|
158
|
+
return;
|
|
159
|
+
// Skip group messages (v1: direct only)
|
|
160
|
+
if (msg.group_id)
|
|
161
|
+
return;
|
|
162
|
+
const userId = msg.from_user_id;
|
|
163
|
+
const contextToken = msg.context_token;
|
|
164
|
+
if (!userId || !contextToken)
|
|
165
|
+
return;
|
|
166
|
+
this.log(`Message from ${userId}: ${this.previewMessage(msg)}`);
|
|
167
|
+
this.rememberActiveUser(userId, contextToken);
|
|
168
|
+
trackEvent("message.received", {
|
|
169
|
+
userIdHash: hashUserId(userId),
|
|
170
|
+
kind: this.messageKind(msg),
|
|
171
|
+
}, hashUserId(userId));
|
|
172
|
+
const acpConfigCommand = this.extractAcpConfigCommand(msg);
|
|
173
|
+
if (acpConfigCommand) {
|
|
174
|
+
this.handleAcpConfigCommand(acpConfigCommand, userId, contextToken).catch((err) => {
|
|
175
|
+
this.log(`Failed to handle ACP config command from ${userId}: ${String(err)}`);
|
|
176
|
+
trackException(err, "command", hashUserId(userId));
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const acpCancelCommand = this.extractAcpCancelCommand(msg);
|
|
181
|
+
if (acpCancelCommand) {
|
|
182
|
+
this.handleAcpCancelCommand(acpCancelCommand, userId, contextToken).catch((err) => {
|
|
183
|
+
this.log(`Failed to handle ACP cancel command from ${userId}: ${String(err)}`);
|
|
184
|
+
trackException(err, "command", hashUserId(userId));
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// /acp-prompt-start β enter buffering mode
|
|
189
|
+
if (this.isBufferStartCommand(msg)) {
|
|
190
|
+
this.handleBufferStart(userId, contextToken);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// /acp-prompt-done β flush buffer and send to agent
|
|
194
|
+
if (this.isBufferDoneCommand(msg)) {
|
|
195
|
+
this.handleBufferDone(userId, contextToken).catch((err) => {
|
|
196
|
+
this.log(`Failed to flush message buffer for ${userId}: ${String(err)}`);
|
|
197
|
+
trackException(err, "buffer", hashUserId(userId));
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// If user is in buffering mode, append to buffer instead of enqueuing
|
|
202
|
+
if (this.messageBuffers.has(userId)) {
|
|
203
|
+
this.appendToBuffer(msg, userId, contextToken);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Convert and enqueue β fire-and-forget (don't block the poll loop)
|
|
207
|
+
const waitForFlush = this.bufferFlushing.get(userId);
|
|
208
|
+
const enqueue = waitForFlush
|
|
209
|
+
? waitForFlush.then(() => this.enqueueMessage(msg, userId, contextToken))
|
|
210
|
+
: this.enqueueMessage(msg, userId, contextToken);
|
|
211
|
+
enqueue.catch((err) => {
|
|
212
|
+
this.log(`Failed to enqueue message from ${userId}: ${String(err)}`);
|
|
213
|
+
trackException(err, "enqueue", hashUserId(userId));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async enqueueMessage(msg, userId, contextToken) {
|
|
217
|
+
const prompt = await weixinMessageToPrompt(msg, this.config.wechat.cdnBaseUrl, this.log, this.config.storage.inboxDir);
|
|
218
|
+
await this.sessionManager.enqueue(userId, { prompt, contextToken });
|
|
219
|
+
}
|
|
220
|
+
async enqueueInjectedMessage(job) {
|
|
221
|
+
if (!this.sessionManager || !this.config.storage.stateFile) {
|
|
222
|
+
throw new Error("Bridge is not ready to process injected messages");
|
|
223
|
+
}
|
|
224
|
+
const target = await resolveUserTarget(this.config.storage.stateFile, job.target, job.contextToken);
|
|
225
|
+
const prompt = [{ type: "text", text: job.text }];
|
|
226
|
+
this.log(`[inject] enqueue ${job.id} for ${target.userId}`);
|
|
227
|
+
trackEvent("message.injected", {
|
|
228
|
+
userIdHash: hashUserId(target.userId),
|
|
229
|
+
targetKind: job.target === "last-active-user" ? "last-active-user" : "explicit",
|
|
230
|
+
}, hashUserId(target.userId));
|
|
231
|
+
await this.sessionManager.enqueueAndWait(target.userId, {
|
|
232
|
+
prompt,
|
|
233
|
+
contextToken: target.contextToken,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async handleAcpConfigCommand(command, userId, contextToken) {
|
|
237
|
+
const args = command.trim().split(/\s+/);
|
|
238
|
+
if (args.length === 1) {
|
|
239
|
+
const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
|
|
240
|
+
trackEvent("command.acp_config.view", {
|
|
241
|
+
userIdHash: hashUserId(userId),
|
|
242
|
+
hasSession: !!configOptions,
|
|
243
|
+
optionCount: configOptions?.length ?? 0,
|
|
244
|
+
}, hashUserId(userId));
|
|
245
|
+
await this.sendReply(userId, contextToken, this.formatAcpConfigList(userId));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (args[1] === "set") {
|
|
249
|
+
if (args.length < 4) {
|
|
250
|
+
await this.sendReply(userId, contextToken, this.formatAcpConfigUsage("Missing configId or value."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const configId = args[2];
|
|
254
|
+
const rawValue = args.slice(3).join(" ");
|
|
255
|
+
try {
|
|
256
|
+
const resolved = this.resolveAcpConfigValue(userId, configId, rawValue);
|
|
257
|
+
await this.sessionManager.setSessionConfigOption(userId, configId, resolved.rawValue);
|
|
258
|
+
const optionType = this.sessionManager
|
|
259
|
+
.getSessionConfigOptions(userId)
|
|
260
|
+
?.find((o) => o.id === configId)?.type;
|
|
261
|
+
trackEvent("command.acp_config.set", {
|
|
262
|
+
userIdHash: hashUserId(userId),
|
|
263
|
+
configId,
|
|
264
|
+
optionType: optionType ?? "unknown",
|
|
265
|
+
optionValue: resolved.displayValue,
|
|
266
|
+
}, hashUserId(userId));
|
|
267
|
+
await this.sendReply(userId, contextToken, `β
Updated ACP config: ${configId} = ${resolved.displayValue}\n\n${this.formatAcpConfigList(userId)}`);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
await this.sendReply(userId, contextToken, this.formatAcpConfigUsage(err instanceof Error ? err.message : String(err)));
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await this.sendReply(userId, contextToken, this.formatAcpConfigUsage(`Unknown subcommand: ${args[1]}`));
|
|
275
|
+
}
|
|
276
|
+
async handleAcpCancelCommand(command, userId, contextToken) {
|
|
277
|
+
const args = command.trim().split(/\s+/);
|
|
278
|
+
const sub = args[1]?.toLowerCase();
|
|
279
|
+
if (sub && sub !== "all") {
|
|
280
|
+
await this.sendReply(userId, contextToken, this.formatAcpCancelUsage(`Unknown subcommand: ${args[1]}`));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!this.sessionManager) {
|
|
284
|
+
await this.sendReply(userId, contextToken, this.formatAcpCancelUsage("Bridge is not ready yet."));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const drainQueue = sub === "all";
|
|
288
|
+
const result = await this.sessionManager.cancelCurrent(userId, { drainQueue });
|
|
289
|
+
trackEvent("command.acp_cancel", {
|
|
290
|
+
userIdHash: hashUserId(userId),
|
|
291
|
+
drainQueue,
|
|
292
|
+
cancelledTurn: result.cancelledTurn,
|
|
293
|
+
droppedQueueCount: result.droppedQueueCount,
|
|
294
|
+
}, hashUserId(userId));
|
|
295
|
+
await this.sendReply(userId, contextToken, this.formatAcpCancelResult(result, drainQueue));
|
|
296
|
+
}
|
|
297
|
+
formatAcpCancelResult(result, drainQueue) {
|
|
298
|
+
const lines = [];
|
|
299
|
+
if (result.cancelledTurn) {
|
|
300
|
+
lines.push("π Cancel signal sent. The current ACP turn will stop shortly.");
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
lines.push("βΉοΈ No active ACP turn to cancel.");
|
|
304
|
+
}
|
|
305
|
+
if (drainQueue && result.droppedQueueCount > 0) {
|
|
306
|
+
lines.push(`Dropped ${result.droppedQueueCount} queued message(s).`);
|
|
307
|
+
}
|
|
308
|
+
lines.push("");
|
|
309
|
+
lines.push("π‘ **Usage**");
|
|
310
|
+
lines.push(` β’ Cancel current turn: ${ACP_CANCEL_COMMAND}${this.aliasHint(ACP_CANCEL_COMMAND)}`);
|
|
311
|
+
lines.push(` β’ Cancel + drop queued msgs: ${ACP_CANCEL_COMMAND} all`);
|
|
312
|
+
return lines.join("\n");
|
|
313
|
+
}
|
|
314
|
+
formatAcpCancelUsage(error) {
|
|
315
|
+
const lines = [];
|
|
316
|
+
if (error) {
|
|
317
|
+
lines.push(`β οΈ ${error}`);
|
|
318
|
+
lines.push("");
|
|
319
|
+
}
|
|
320
|
+
lines.push("π‘ **Usage**");
|
|
321
|
+
lines.push(` β’ Cancel current turn: ${ACP_CANCEL_COMMAND}${this.aliasHint(ACP_CANCEL_COMMAND)}`);
|
|
322
|
+
lines.push(` β’ Cancel + drop queued msgs: ${ACP_CANCEL_COMMAND} all`);
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
|
325
|
+
isBufferStartCommand(msg) {
|
|
326
|
+
return this.extractBridgeCommand(msg, BUFFER_START_COMMAND) !== null;
|
|
327
|
+
}
|
|
328
|
+
isBufferDoneCommand(msg) {
|
|
329
|
+
return this.extractBridgeCommand(msg, BUFFER_DONE_COMMAND) !== null;
|
|
330
|
+
}
|
|
331
|
+
handleBufferStart(userId, contextToken) {
|
|
332
|
+
if (this.messageBuffers.has(userId)) {
|
|
333
|
+
const buffer = this.messageBuffers.get(userId);
|
|
334
|
+
this.sendReply(userId, contextToken, `π Already in buffering mode (${buffer.blocks.length} block(s) collected). Keep sending, then ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit.`).catch((err) => {
|
|
335
|
+
this.log(`Failed to send buffer active notice to ${userId}: ${String(err)}`);
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.messageBuffers.set(userId, { blocks: [], contextToken, pending: Promise.resolve(), lastUpdatedAt: Date.now() });
|
|
340
|
+
this.resetBufferTimer(userId);
|
|
341
|
+
this.log(`Buffer started for ${userId}`);
|
|
342
|
+
trackEvent("command.buffer_start", { userIdHash: hashUserId(userId) }, hashUserId(userId));
|
|
343
|
+
this.sendReply(userId, contextToken, `π Buffering mode started. Send your messages (text, images, files), then send ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit them all at once.`).catch((err) => {
|
|
344
|
+
this.log(`Failed to send buffer start confirmation to ${userId}: ${String(err)}`);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
handleBufferDone(userId, contextToken) {
|
|
348
|
+
const buffer = this.messageBuffers.get(userId);
|
|
349
|
+
if (!buffer) {
|
|
350
|
+
return this.sendReply(userId, contextToken, `β οΈ Nothing buffered. Send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} first, then send messages before ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)}.`);
|
|
351
|
+
}
|
|
352
|
+
// Remove from map immediately so new messages during the await
|
|
353
|
+
// are not appended to a stale buffer.
|
|
354
|
+
const pending = buffer.pending;
|
|
355
|
+
this.messageBuffers.delete(userId);
|
|
356
|
+
this.clearBufferTimer(userId);
|
|
357
|
+
// Register a flushing promise so messages arriving during the await
|
|
358
|
+
// queue behind the buffered prompt, preserving turn order.
|
|
359
|
+
const flushPromise = this.doFlush(userId, contextToken, buffer, pending);
|
|
360
|
+
this.bufferFlushing.set(userId, flushPromise);
|
|
361
|
+
flushPromise.finally(() => {
|
|
362
|
+
// Only clear if this is still our flush (not a newer one)
|
|
363
|
+
if (this.bufferFlushing.get(userId) === flushPromise) {
|
|
364
|
+
this.bufferFlushing.delete(userId);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
return flushPromise;
|
|
368
|
+
}
|
|
369
|
+
async doFlush(userId, contextToken, buffer, pending) {
|
|
370
|
+
// Wait for any in-flight appends to finish before reading
|
|
371
|
+
try {
|
|
372
|
+
await pending;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// A prior append failed (e.g. image download error). The chain
|
|
376
|
+
// already logged/tracked the error. Clear the buffer so the user
|
|
377
|
+
// can start fresh.
|
|
378
|
+
await this.sendReply(userId, contextToken, `β οΈ A buffered message failed to process. Buffer cleared. Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} to try again.`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Check expiry
|
|
382
|
+
if (Date.now() - buffer.lastUpdatedAt > BUFFER_TTL_MS) {
|
|
383
|
+
await this.sendReply(userId, contextToken, `β οΈ Buffer expired (10 min without activity). Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} to start over.`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (buffer.blocks.length === 0) {
|
|
387
|
+
await this.sendReply(userId, contextToken, `β οΈ Buffer is empty. Send some messages before ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)}.`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
this.log(`Buffer flushed for ${userId}: ${buffer.blocks.length} block(s)`);
|
|
391
|
+
trackEvent("command.buffer_done", {
|
|
392
|
+
userIdHash: hashUserId(userId),
|
|
393
|
+
blockCount: buffer.blocks.length,
|
|
394
|
+
}, hashUserId(userId));
|
|
395
|
+
await this.sessionManager.enqueue(userId, {
|
|
396
|
+
prompt: buffer.blocks,
|
|
397
|
+
contextToken: buffer.contextToken,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
appendToBuffer(msg, userId, contextToken) {
|
|
401
|
+
const buffer = this.messageBuffers.get(userId);
|
|
402
|
+
if (!buffer)
|
|
403
|
+
return;
|
|
404
|
+
// Chain the async conversion so /acp-prompt-done waits for all in-flight appends
|
|
405
|
+
buffer.pending = buffer.pending
|
|
406
|
+
.then(async () => {
|
|
407
|
+
// Re-check buffer still exists (could have been flushed or expired)
|
|
408
|
+
if (!this.messageBuffers.has(userId))
|
|
409
|
+
return;
|
|
410
|
+
// Check TTL
|
|
411
|
+
if (Date.now() - buffer.lastUpdatedAt > BUFFER_TTL_MS) {
|
|
412
|
+
this.messageBuffers.delete(userId);
|
|
413
|
+
this.log(`Buffer expired for ${userId}`);
|
|
414
|
+
await this.sendReply(userId, contextToken, `β οΈ Buffering timed out (10 min without activity). Please send ${BUFFER_START_COMMAND}${this.aliasHint(BUFFER_START_COMMAND)} again.`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// Check block limit
|
|
418
|
+
if (buffer.blocks.length >= BUFFER_MAX_BLOCKS) {
|
|
419
|
+
await this.sendReply(userId, contextToken, `β οΈ Buffer is full (${BUFFER_MAX_BLOCKS} blocks max). Send ${BUFFER_DONE_COMMAND}${this.aliasHint(BUFFER_DONE_COMMAND)} to submit what you have.`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const prompt = await weixinMessageToPrompt(msg, this.config.wechat.cdnBaseUrl, this.log, this.config.storage.inboxDir);
|
|
423
|
+
buffer.blocks.push(...prompt);
|
|
424
|
+
buffer.contextToken = contextToken;
|
|
425
|
+
buffer.lastUpdatedAt = Date.now();
|
|
426
|
+
this.resetBufferTimer(userId);
|
|
427
|
+
this.log(`Buffered message from ${userId}, now ${buffer.blocks.length} block(s)`);
|
|
428
|
+
});
|
|
429
|
+
buffer.pending.catch((err) => {
|
|
430
|
+
this.log(`Failed to buffer message from ${userId}: ${String(err)}`);
|
|
431
|
+
trackException(err, "buffer", hashUserId(userId));
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
resetBufferTimer(userId) {
|
|
435
|
+
this.clearBufferTimer(userId);
|
|
436
|
+
this.bufferTimers.set(userId, setTimeout(() => {
|
|
437
|
+
const buffer = this.messageBuffers.get(userId);
|
|
438
|
+
if (!buffer)
|
|
439
|
+
return;
|
|
440
|
+
this.messageBuffers.delete(userId);
|
|
441
|
+
this.bufferTimers.delete(userId);
|
|
442
|
+
this.log(`Buffer expired (timer) for ${userId}`);
|
|
443
|
+
}, BUFFER_TTL_MS));
|
|
444
|
+
}
|
|
445
|
+
clearBufferTimer(userId) {
|
|
446
|
+
const timer = this.bufferTimers.get(userId);
|
|
447
|
+
if (timer) {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
this.bufferTimers.delete(userId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
rememberActiveUser(userId, contextToken) {
|
|
453
|
+
if (!this.config.storage.stateFile)
|
|
454
|
+
return;
|
|
455
|
+
this.stateUpdate = this.stateUpdate
|
|
456
|
+
.catch(() => { })
|
|
457
|
+
.then(() => updateLastActiveUser(this.config.storage.stateFile, userId, contextToken));
|
|
458
|
+
this.stateUpdate.catch((err) => {
|
|
459
|
+
this.log(`Failed to persist last active user: ${String(err)}`);
|
|
460
|
+
trackException(sanitizeStateError(err), "state", hashUserId(userId));
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async sendReply(userId, contextToken, text) {
|
|
464
|
+
// Serialize all replies to the same user behind a per-user promise chain so
|
|
465
|
+
// that segments from separate sendReply calls cannot interleave (issue #38).
|
|
466
|
+
// The stored link swallows errors so one failed reply doesn't break the
|
|
467
|
+
// chain for the next caller, while the returned promise still propagates.
|
|
468
|
+
const previous = this.sendChains.get(userId) ?? Promise.resolve();
|
|
469
|
+
const current = previous
|
|
470
|
+
.catch(() => { })
|
|
471
|
+
.then(() => this.deliverReply(userId, contextToken, text));
|
|
472
|
+
this.sendChains.set(userId, current.catch(() => { }));
|
|
473
|
+
return current;
|
|
474
|
+
}
|
|
475
|
+
async deliverReply(userId, contextToken, text) {
|
|
476
|
+
const segments = splitText(text, TEXT_CHUNK_LIMIT);
|
|
477
|
+
const startedAt = Date.now();
|
|
478
|
+
let segmentsSent = 0;
|
|
479
|
+
let anyFailed = false;
|
|
480
|
+
for (const segment of segments) {
|
|
481
|
+
// Generate one stable idempotency key per segment *before* the retry
|
|
482
|
+
// loop so that all attempts for the same segment reuse the same
|
|
483
|
+
// client_id. The iLink gateway de-duplicates by client_id, so a retry
|
|
484
|
+
// after a transient hard error (connection reset, 5xx) will not produce
|
|
485
|
+
// a duplicate message even if the first attempt was already received.
|
|
486
|
+
const segmentClientId = `agent-shell-${crypto.randomUUID()}`;
|
|
487
|
+
let sent = false;
|
|
488
|
+
for (let attempt = 1; attempt <= SEGMENT_SEND_MAX_ATTEMPTS; attempt++) {
|
|
489
|
+
try {
|
|
490
|
+
await this.paceConsecutiveSend(userId);
|
|
491
|
+
await sendTextMessage(userId, segment, {
|
|
492
|
+
baseUrl: this.tokenData.baseUrl,
|
|
493
|
+
token: this.tokenData.token,
|
|
494
|
+
contextToken,
|
|
495
|
+
}, segmentClientId);
|
|
496
|
+
sent = true;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
trackException(err, "reply.segment", hashUserId(userId));
|
|
501
|
+
if (attempt < SEGMENT_SEND_MAX_ATTEMPTS) {
|
|
502
|
+
await new Promise((r) => setTimeout(r, SEGMENT_SEND_RETRY_BASE_MS * attempt));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (sent) {
|
|
507
|
+
segmentsSent++;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
// Log the drop but continue β a single failed segment must not
|
|
511
|
+
// prevent the remaining segments from being delivered.
|
|
512
|
+
anyFailed = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (anyFailed) {
|
|
516
|
+
trackException(new Error(`deliverReply: ${segments.length - segmentsSent}/${segments.length} segment(s) failed to send after retries`), "reply", hashUserId(userId));
|
|
517
|
+
}
|
|
518
|
+
// Auto-send media files referenced in reply text (e.g. markdown image links)
|
|
519
|
+
if (this.config.agent.cwd) {
|
|
520
|
+
await this.tryAutoSendMedia(userId, contextToken, text);
|
|
521
|
+
}
|
|
522
|
+
trackEvent("reply.sent", {
|
|
523
|
+
userIdHash: hashUserId(userId),
|
|
524
|
+
segments: segments.length,
|
|
525
|
+
segmentsSent,
|
|
526
|
+
chars: text.length,
|
|
527
|
+
durationMs: Date.now() - startedAt,
|
|
528
|
+
}, hashUserId(userId));
|
|
529
|
+
// Cancel typing indicator after reply is sent
|
|
530
|
+
this.cancelTypingIndicator(userId, contextToken).catch(() => { });
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Wait, if necessary, so that consecutive text messages to the same user
|
|
534
|
+
* are issued at least {@link REPLY_SEND_SPACING_MS} apart. This spaces
|
|
535
|
+
* out their server-receive timestamps so WeChat preserves the order the
|
|
536
|
+
* bridge sent them in, instead of racing and delivering them reversed
|
|
537
|
+
* (issue #38). Sends to different users are tracked independently and do
|
|
538
|
+
* not delay each other.
|
|
539
|
+
*/
|
|
540
|
+
async paceConsecutiveSend(userId) {
|
|
541
|
+
const last = this.lastSendAt.get(userId);
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
if (last !== undefined) {
|
|
544
|
+
const wait = REPLY_SEND_SPACING_MS - (now - last);
|
|
545
|
+
if (wait > 0) {
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
this.lastSendAt.set(userId, Date.now());
|
|
550
|
+
}
|
|
551
|
+
async cancelTypingIndicator(userId, contextToken) {
|
|
552
|
+
const ticket = await this.getTypingTicket(userId, contextToken);
|
|
553
|
+
if (!ticket)
|
|
554
|
+
return;
|
|
555
|
+
await sendTyping({
|
|
556
|
+
baseUrl: this.tokenData.baseUrl,
|
|
557
|
+
token: this.tokenData.token,
|
|
558
|
+
body: {
|
|
559
|
+
ilink_user_id: userId,
|
|
560
|
+
typing_ticket: ticket,
|
|
561
|
+
status: TypingStatus.CANCEL,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async sendTypingIndicator(userId, contextToken) {
|
|
566
|
+
try {
|
|
567
|
+
const ticket = await this.getTypingTicket(userId, contextToken);
|
|
568
|
+
if (!ticket)
|
|
569
|
+
return;
|
|
570
|
+
await sendTyping({
|
|
571
|
+
baseUrl: this.tokenData.baseUrl,
|
|
572
|
+
token: this.tokenData.token,
|
|
573
|
+
body: {
|
|
574
|
+
ilink_user_id: userId,
|
|
575
|
+
typing_ticket: ticket,
|
|
576
|
+
status: TypingStatus.TYPING,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Typing is best-effort
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async getTypingTicket(userId, contextToken) {
|
|
585
|
+
const cached = this.typingTickets.get(userId);
|
|
586
|
+
if (cached && cached.expiresAt > Date.now())
|
|
587
|
+
return cached.ticket;
|
|
588
|
+
try {
|
|
589
|
+
const resp = await getConfig({
|
|
590
|
+
baseUrl: this.tokenData.baseUrl,
|
|
591
|
+
token: this.tokenData.token,
|
|
592
|
+
ilinkUserId: userId,
|
|
593
|
+
contextToken,
|
|
594
|
+
});
|
|
595
|
+
if (resp.typing_ticket) {
|
|
596
|
+
this.typingTickets.set(userId, {
|
|
597
|
+
ticket: resp.typing_ticket,
|
|
598
|
+
expiresAt: Date.now() + 24 * 60 * 60_000, // 24h cache
|
|
599
|
+
});
|
|
600
|
+
return resp.typing_ticket;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Not critical
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
previewMessage(msg) {
|
|
609
|
+
const items = msg.item_list ?? [];
|
|
610
|
+
for (const item of items) {
|
|
611
|
+
if (item.type === 1 && item.text_item?.text) {
|
|
612
|
+
const text = item.text_item.text;
|
|
613
|
+
return text.length > 50 ? text.substring(0, 50) + "..." : text;
|
|
614
|
+
}
|
|
615
|
+
if (item.type === 2)
|
|
616
|
+
return "[image]";
|
|
617
|
+
if (item.type === 3)
|
|
618
|
+
return item.voice_item?.text ? `[voice] ${item.voice_item.text.substring(0, 30)}` : "[voice]";
|
|
619
|
+
if (item.type === 4)
|
|
620
|
+
return `[file] ${item.file_item?.file_name ?? ""}`;
|
|
621
|
+
if (item.type === 5)
|
|
622
|
+
return "[video]";
|
|
623
|
+
}
|
|
624
|
+
return "[empty]";
|
|
625
|
+
}
|
|
626
|
+
messageKind(msg) {
|
|
627
|
+
const items = msg.item_list ?? [];
|
|
628
|
+
for (const item of items) {
|
|
629
|
+
if (item.type === 1)
|
|
630
|
+
return "text";
|
|
631
|
+
if (item.type === 2)
|
|
632
|
+
return "image";
|
|
633
|
+
if (item.type === 3)
|
|
634
|
+
return "voice";
|
|
635
|
+
if (item.type === 4)
|
|
636
|
+
return "file";
|
|
637
|
+
if (item.type === 5)
|
|
638
|
+
return "video";
|
|
639
|
+
}
|
|
640
|
+
return "empty";
|
|
641
|
+
}
|
|
642
|
+
extractAcpConfigCommand(msg) {
|
|
643
|
+
return this.extractBridgeCommand(msg, ACP_CONFIG_COMMAND);
|
|
644
|
+
}
|
|
645
|
+
extractAcpCancelCommand(msg) {
|
|
646
|
+
return this.extractBridgeCommand(msg, ACP_CANCEL_COMMAND);
|
|
647
|
+
}
|
|
648
|
+
extractBridgeCommand(msg, canonical) {
|
|
649
|
+
const items = msg.item_list ?? [];
|
|
650
|
+
if (items.length !== 1)
|
|
651
|
+
return null;
|
|
652
|
+
const item = items[0];
|
|
653
|
+
if (item?.type !== 1 || !item.text_item?.text)
|
|
654
|
+
return null;
|
|
655
|
+
const text = item.text_item.text.trim();
|
|
656
|
+
const names = resolveCommandNames(canonical, this.config.commandAliases);
|
|
657
|
+
for (const name of names) {
|
|
658
|
+
// Exact match β normalize to the canonical command with no arguments.
|
|
659
|
+
// This is the only matching mode for bare-phrase aliases (no leading
|
|
660
|
+
// "/"), e.g. a voice-transcribed "εζΆ", which must match the whole
|
|
661
|
+
// message to avoid false positives.
|
|
662
|
+
if (text === name)
|
|
663
|
+
return canonical;
|
|
664
|
+
// Slash-prefixed names (the canonical command and "/"-style aliases)
|
|
665
|
+
// also support trailing arguments. Replace the matched name with the
|
|
666
|
+
// canonical command so handlers always see a single, stable token.
|
|
667
|
+
if (name.startsWith("/") && text.startsWith(`${name} `)) {
|
|
668
|
+
return canonical + text.slice(name.length);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Render a usage hint suffix listing any configured aliases for a
|
|
675
|
+
* canonical command, e.g. " (aliases: /cancel, /εζΆ)". Returns an
|
|
676
|
+
* empty string when no aliases are configured.
|
|
677
|
+
*/
|
|
678
|
+
aliasHint(canonical) {
|
|
679
|
+
const aliases = resolveCommandAliases(canonical, this.config.commandAliases);
|
|
680
|
+
return aliases.length > 0 ? ` (aliases: ${aliases.join(", ")})` : "";
|
|
681
|
+
}
|
|
682
|
+
formatAcpConfigList(userId) {
|
|
683
|
+
const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
|
|
684
|
+
if (!configOptions) {
|
|
685
|
+
return this.formatAcpConfigUsage("No active ACP session for this chat yet. Send a normal message first.");
|
|
686
|
+
}
|
|
687
|
+
if (configOptions.length === 0) {
|
|
688
|
+
return this.formatAcpConfigUsage("The current ACP agent does not expose any configurable session options.");
|
|
689
|
+
}
|
|
690
|
+
const lines = [];
|
|
691
|
+
lines.push("βοΈ **ACP Session Config**");
|
|
692
|
+
lines.push("ββββββββββββββββ");
|
|
693
|
+
for (const option of configOptions) {
|
|
694
|
+
lines.push("");
|
|
695
|
+
lines.push(`π **${option.name}** (id: \`${option.id}\`)`);
|
|
696
|
+
lines.push(` β’ Current: ${this.describeCurrentConfigValue(option)}`);
|
|
697
|
+
if (option.type === "select") {
|
|
698
|
+
lines.push(` β’ Options: ${this.listConfigOptionChoices(option).join(" | ")}`);
|
|
699
|
+
}
|
|
700
|
+
else if (option.type === "boolean") {
|
|
701
|
+
lines.push(` β’ Options: true | false`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
lines.push("");
|
|
705
|
+
lines.push("ββββββββββββββββ");
|
|
706
|
+
lines.push("π‘ **Usage**");
|
|
707
|
+
lines.push(` β’ View: ${ACP_CONFIG_COMMAND}${this.aliasHint(ACP_CONFIG_COMMAND)}`);
|
|
708
|
+
lines.push(` β’ Update: ${ACP_CONFIG_COMMAND} set <configId> <value>`);
|
|
709
|
+
return lines.join("\n");
|
|
710
|
+
}
|
|
711
|
+
formatAcpConfigUsage(error) {
|
|
712
|
+
const lines = [];
|
|
713
|
+
if (error) {
|
|
714
|
+
lines.push(`β οΈ ${error}`);
|
|
715
|
+
lines.push("");
|
|
716
|
+
}
|
|
717
|
+
lines.push("π‘ **Usage**");
|
|
718
|
+
lines.push(` β’ View: ${ACP_CONFIG_COMMAND}${this.aliasHint(ACP_CONFIG_COMMAND)}`);
|
|
719
|
+
lines.push(` β’ Update: ${ACP_CONFIG_COMMAND} set <configId> <value>`);
|
|
720
|
+
return lines.join("\n");
|
|
721
|
+
}
|
|
722
|
+
describeCurrentConfigValue(option) {
|
|
723
|
+
if (option.type === "boolean") {
|
|
724
|
+
return option.currentValue ? "true" : "false";
|
|
725
|
+
}
|
|
726
|
+
const current = this.findConfigOptionChoice(option, option.currentValue);
|
|
727
|
+
return current ? this.describeConfigChoice(current) : option.currentValue;
|
|
728
|
+
}
|
|
729
|
+
listConfigOptionChoices(option) {
|
|
730
|
+
if (option.type !== "select")
|
|
731
|
+
return [];
|
|
732
|
+
return this.flattenSelectOptions(option.options).map((choice) => this.describeConfigChoice(choice));
|
|
733
|
+
}
|
|
734
|
+
resolveAcpConfigValue(userId, configId, rawValue) {
|
|
735
|
+
const configOptions = this.sessionManager?.getSessionConfigOptions(userId);
|
|
736
|
+
if (!configOptions) {
|
|
737
|
+
throw new Error("No active ACP session for this chat yet. Send a normal message first.");
|
|
738
|
+
}
|
|
739
|
+
const option = configOptions.find((candidate) => candidate.id === configId);
|
|
740
|
+
if (!option) {
|
|
741
|
+
throw new Error(`Unknown ACP config option: ${configId}`);
|
|
742
|
+
}
|
|
743
|
+
if (option.type === "boolean") {
|
|
744
|
+
const normalized = rawValue.trim().toLowerCase();
|
|
745
|
+
if (["true", "on", "1", "yes"].includes(normalized)) {
|
|
746
|
+
return { rawValue: true, displayValue: "true" };
|
|
747
|
+
}
|
|
748
|
+
if (["false", "off", "0", "no"].includes(normalized)) {
|
|
749
|
+
return { rawValue: false, displayValue: "false" };
|
|
750
|
+
}
|
|
751
|
+
throw new Error(`Invalid boolean value for ${configId}: ${rawValue}`);
|
|
752
|
+
}
|
|
753
|
+
const candidates = this.flattenSelectOptions(option.options).filter((choice) => this.configChoiceAliases(choice).has(rawValue.trim().toLowerCase()));
|
|
754
|
+
if (candidates.length === 0) {
|
|
755
|
+
throw new Error(`Invalid value for ${configId}: ${rawValue}. Options: ${this.listConfigOptionChoices(option).join(", ")}`);
|
|
756
|
+
}
|
|
757
|
+
if (candidates.length > 1) {
|
|
758
|
+
throw new Error(`Ambiguous value for ${configId}: ${rawValue}`);
|
|
759
|
+
}
|
|
760
|
+
const match = candidates[0];
|
|
761
|
+
return {
|
|
762
|
+
rawValue: match.value,
|
|
763
|
+
displayValue: this.describeConfigChoice(match),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
flattenSelectOptions(options) {
|
|
767
|
+
if (options.length === 0)
|
|
768
|
+
return [];
|
|
769
|
+
const first = options[0];
|
|
770
|
+
if (first && "value" in first) {
|
|
771
|
+
return options;
|
|
772
|
+
}
|
|
773
|
+
return options.flatMap((group) => group.options);
|
|
774
|
+
}
|
|
775
|
+
findConfigOptionChoice(option, rawValue) {
|
|
776
|
+
return this.flattenSelectOptions(option.options).find((choice) => choice.value === rawValue);
|
|
777
|
+
}
|
|
778
|
+
configChoiceAliases(choice) {
|
|
779
|
+
const aliases = new Set();
|
|
780
|
+
aliases.add(choice.value.toLowerCase());
|
|
781
|
+
aliases.add(choice.name.toLowerCase());
|
|
782
|
+
const compactName = choice.name.toLowerCase().replace(/\s+/g, "-");
|
|
783
|
+
aliases.add(compactName);
|
|
784
|
+
const tail = this.extractConfigValueTail(choice.value);
|
|
785
|
+
if (tail)
|
|
786
|
+
aliases.add(tail.toLowerCase());
|
|
787
|
+
return aliases;
|
|
788
|
+
}
|
|
789
|
+
describeConfigChoice(choice) {
|
|
790
|
+
const tail = this.extractConfigValueTail(choice.value);
|
|
791
|
+
if (tail && tail.toLowerCase() !== choice.name.toLowerCase()) {
|
|
792
|
+
return tail;
|
|
793
|
+
}
|
|
794
|
+
return choice.value;
|
|
795
|
+
}
|
|
796
|
+
extractConfigValueTail(value) {
|
|
797
|
+
const hashIndex = value.lastIndexOf("#");
|
|
798
|
+
if (hashIndex >= 0 && hashIndex < value.length - 1) {
|
|
799
|
+
return value.slice(hashIndex + 1);
|
|
800
|
+
}
|
|
801
|
+
const slashIndex = value.lastIndexOf("/");
|
|
802
|
+
if (slashIndex >= 0 && slashIndex < value.length - 1) {
|
|
803
|
+
return value.slice(slashIndex + 1);
|
|
804
|
+
}
|
|
805
|
+
return value;
|
|
806
|
+
}
|
|
807
|
+
// ββ Auto-send media (images / files) referenced in agent reply ββββββββ
|
|
808
|
+
/**
|
|
809
|
+
* Scan the reply text for file-path references (markdown links and
|
|
810
|
+
* backtick-enclosed paths), resolve them inside the agent workspace,
|
|
811
|
+
* and send the corresponding files as WeChat media messages.
|
|
812
|
+
*
|
|
813
|
+
* Modeled after codex-wechat's `sendAssistantAttachmentsForReply()` and
|
|
814
|
+
* `extractAutoSendFilePathsFromReply()`.
|
|
815
|
+
*/
|
|
816
|
+
async tryAutoSendMedia(userId, contextToken, text) {
|
|
817
|
+
const mode = this.config.agent.autoSendMedia ?? "all";
|
|
818
|
+
if (mode === "off")
|
|
819
|
+
return;
|
|
820
|
+
const cwd = path.resolve(this.config.agent.cwd);
|
|
821
|
+
const candidates = mode === "tagged"
|
|
822
|
+
? this.extractTaggedFilePaths(text)
|
|
823
|
+
: this.extractFilePathsFromReply(text);
|
|
824
|
+
for (const relPath of candidates) {
|
|
825
|
+
const fullPath = path.resolve(cwd, relPath);
|
|
826
|
+
// Security: must stay within the agent workspace
|
|
827
|
+
if (!fullPath.startsWith(cwd + path.sep) && fullPath !== cwd) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
let buf;
|
|
831
|
+
try {
|
|
832
|
+
buf = await fs.readFile(fullPath);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
838
|
+
const imageExts = new Set([
|
|
839
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
|
|
840
|
+
]);
|
|
841
|
+
const isImage = imageExts.has(ext);
|
|
842
|
+
const opts = {
|
|
843
|
+
baseUrl: this.tokenData.baseUrl,
|
|
844
|
+
token: this.tokenData.token,
|
|
845
|
+
contextToken,
|
|
846
|
+
cdnBaseUrl: this.config.wechat.cdnBaseUrl,
|
|
847
|
+
};
|
|
848
|
+
try {
|
|
849
|
+
if (isImage) {
|
|
850
|
+
await sendImageMessage(userId, buf, opts);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
await sendFileMessage(userId, path.basename(fullPath), buf, opts);
|
|
854
|
+
}
|
|
855
|
+
this.log(`Auto-sent media: ${path.relative(cwd, fullPath)} (${buf.length} bytes)`);
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
this.log(`Failed to auto-send media ${path.relative(cwd, fullPath)}: ${String(err)}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Extract file-path candidates from reply text.
|
|
864
|
+
*
|
|
865
|
+
* Supports two patterns (matching codex-wechat):
|
|
866
|
+
* - Markdown links: `` or `[text](path)`
|
|
867
|
+
* - Backtick-enclosed paths: `` `path` ``
|
|
868
|
+
*/
|
|
869
|
+
extractFilePathsFromReply(text) {
|
|
870
|
+
const candidates = [];
|
|
871
|
+
const seen = new Set();
|
|
872
|
+
// Markdown image / link references
|
|
873
|
+
const mdLinkRe = /!?\[[^\]]*\]\(([^)\n]+)\)/g;
|
|
874
|
+
let m;
|
|
875
|
+
while ((m = mdLinkRe.exec(text)) !== null) {
|
|
876
|
+
const raw = (m[1] ?? "").trim();
|
|
877
|
+
if (raw) {
|
|
878
|
+
const cleaned = this.stripPathDecorations(raw);
|
|
879
|
+
if (cleaned && !seen.has(cleaned)) {
|
|
880
|
+
seen.add(cleaned);
|
|
881
|
+
candidates.push(cleaned);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// Backtick-enclosed paths
|
|
886
|
+
const btRe = /`([^`\n]+)`/g;
|
|
887
|
+
while ((m = btRe.exec(text)) !== null) {
|
|
888
|
+
const raw = (m[1] ?? "").trim();
|
|
889
|
+
if (raw) {
|
|
890
|
+
const cleaned = this.stripPathDecorations(raw);
|
|
891
|
+
if (cleaned && !seen.has(cleaned)) {
|
|
892
|
+
seen.add(cleaned);
|
|
893
|
+
candidates.push(cleaned);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return candidates;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Extract file-path candidates tagged with `@send:` marker.
|
|
901
|
+
*
|
|
902
|
+
* Supports patterns:
|
|
903
|
+
* - `@send:path/to/file` β explicit send marker
|
|
904
|
+
* - `πpath/to/file` β emoji send marker
|
|
905
|
+
*
|
|
906
|
+
* Only these explicitly tagged paths are returned; regular file
|
|
907
|
+
* references in code discussions are ignored.
|
|
908
|
+
*/
|
|
909
|
+
extractTaggedFilePaths(text) {
|
|
910
|
+
const candidates = [];
|
|
911
|
+
const seen = new Set();
|
|
912
|
+
// @send: marker
|
|
913
|
+
const sendRe = /@send:(\S+)/g;
|
|
914
|
+
let m;
|
|
915
|
+
while ((m = sendRe.exec(text)) !== null) {
|
|
916
|
+
const raw = (m[1] ?? "").trim();
|
|
917
|
+
if (raw && !seen.has(raw)) {
|
|
918
|
+
seen.add(raw);
|
|
919
|
+
candidates.push(raw);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// π emoji marker
|
|
923
|
+
const emojiRe = /π(\S+)/g;
|
|
924
|
+
while ((m = emojiRe.exec(text)) !== null) {
|
|
925
|
+
const raw = (m[1] ?? "").trim();
|
|
926
|
+
if (raw && !seen.has(raw)) {
|
|
927
|
+
seen.add(raw);
|
|
928
|
+
candidates.push(raw);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return candidates;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Strip decorations from a raw path candidate so it resolves cleanly:
|
|
935
|
+
* - `< >` wrapping (auto-linked bare paths on some platforms)
|
|
936
|
+
* - `#Lβ¦` line references from editor / code-review links
|
|
937
|
+
* - `file.ext:line:col` suffixes
|
|
938
|
+
*/
|
|
939
|
+
stripPathDecorations(candidate) {
|
|
940
|
+
let value = candidate.trim();
|
|
941
|
+
if (!value)
|
|
942
|
+
return "";
|
|
943
|
+
// Strip <...> wrapping
|
|
944
|
+
if (value.startsWith("<") && value.endsWith(">")) {
|
|
945
|
+
value = value.slice(1, -1).trim();
|
|
946
|
+
}
|
|
947
|
+
// Strip #L... line anchor
|
|
948
|
+
const hashIdx = value.indexOf("#L");
|
|
949
|
+
if (hashIdx >= 0) {
|
|
950
|
+
value = value.slice(0, hashIdx).trim();
|
|
951
|
+
}
|
|
952
|
+
// Strip :line or :line:col suffix on filenames
|
|
953
|
+
const lineSuffixMatch = value.match(/^(.*\.[A-Za-z0-9_-]+):\d+(?::\d+)?$/);
|
|
954
|
+
if (lineSuffixMatch) {
|
|
955
|
+
value = lineSuffixMatch[1];
|
|
956
|
+
}
|
|
957
|
+
return value;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function sanitizeStateError(err) {
|
|
961
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
962
|
+
? String(err.code)
|
|
963
|
+
: "";
|
|
964
|
+
const sanitized = new Error(code ? `State persistence failed (${code})` : "State persistence failed");
|
|
965
|
+
sanitized.name = err instanceof Error ? err.name : "Error";
|
|
966
|
+
sanitized.stack = undefined;
|
|
967
|
+
return sanitized;
|
|
968
|
+
}
|
|
969
|
+
//# sourceMappingURL=bridge.js.map
|