@botcord/daemon 0.2.91 → 0.2.93
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/dist/gateway/channels/botcord.d.ts +9 -1
- package/dist/gateway/channels/botcord.js +55 -2
- package/dist/gateway/channels/feishu.d.ts +56 -0
- package/dist/gateway/channels/feishu.js +76 -0
- package/dist/gateway/cli-resolver.d.ts +1 -0
- package/dist/gateway/cli-resolver.js +2 -0
- package/dist/gateway/dispatcher.d.ts +20 -0
- package/dist/gateway/dispatcher.js +252 -0
- package/dist/gateway/runtimes/codex.js +1 -0
- package/dist/gateway/runtimes/deepseek-tui.js +1 -0
- package/dist/gateway/runtimes/hermes-agent.js +1 -0
- package/dist/gateway/runtimes/kimi.js +1 -0
- package/dist/gateway/runtimes/ndjson-stream.js +1 -0
- package/dist/gateway/types.d.ts +8 -0
- package/dist/gateway/wait-marker.d.ts +32 -0
- package/dist/gateway/wait-marker.js +96 -0
- package/dist/gateway-control.d.ts +4 -0
- package/dist/gateway-control.js +124 -44
- package/dist/loop-risk.js +2 -0
- package/dist/system-context.js +3 -0
- package/dist/turn-text.js +5 -0
- package/package.json +3 -3
- package/src/__tests__/feishu-channel.test.ts +180 -0
- package/src/__tests__/gateway-control.test.ts +493 -0
- package/src/__tests__/system-context.test.ts +4 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +50 -0
- package/src/gateway/__tests__/dispatcher-park.test.ts +207 -0
- package/src/gateway/__tests__/dispatcher.test.ts +48 -1
- package/src/gateway/__tests__/wait-marker.test.ts +90 -0
- package/src/gateway/channels/botcord.ts +79 -5
- package/src/gateway/channels/feishu.ts +122 -0
- package/src/gateway/cli-resolver.ts +2 -0
- package/src/gateway/dispatcher.ts +292 -0
- package/src/gateway/runtimes/codex.ts +1 -0
- package/src/gateway/runtimes/deepseek-tui.ts +1 -0
- package/src/gateway/runtimes/hermes-agent.ts +1 -0
- package/src/gateway/runtimes/kimi.ts +1 -0
- package/src/gateway/runtimes/ndjson-stream.ts +1 -0
- package/src/gateway/types.ts +8 -0
- package/src/gateway/wait-marker.ts +101 -0
- package/src/gateway-control.ts +150 -48
- package/src/loop-risk.ts +1 -0
- package/src/system-context.ts +3 -0
- package/src/turn-text.ts +5 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import { type InboxMessage } from "@botcord/protocol-core";
|
|
2
|
+
import { type InboxMessage, type MessageAttachment } from "@botcord/protocol-core";
|
|
3
3
|
import type { ChannelAdapter, GatewayInboundMessage, GatewayLogger } from "../index.js";
|
|
4
4
|
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
5
5
|
export interface BotCordChannelClient {
|
|
@@ -16,9 +16,16 @@ export interface BotCordChannelClient {
|
|
|
16
16
|
has_more: boolean;
|
|
17
17
|
}>;
|
|
18
18
|
ackMessages(messageIds: string[]): Promise<void>;
|
|
19
|
+
uploadFile?(filePath: string, filename: string, contentType?: string): Promise<{
|
|
20
|
+
original_filename: string;
|
|
21
|
+
url: string;
|
|
22
|
+
content_type?: string;
|
|
23
|
+
size_bytes?: number;
|
|
24
|
+
}>;
|
|
19
25
|
sendMessage(to: string, text: string, options?: {
|
|
20
26
|
replyTo?: string;
|
|
21
27
|
topic?: string;
|
|
28
|
+
attachments?: MessageAttachment[];
|
|
22
29
|
}): Promise<{
|
|
23
30
|
hub_msg_id?: string;
|
|
24
31
|
message_id?: string;
|
|
@@ -26,6 +33,7 @@ export interface BotCordChannelClient {
|
|
|
26
33
|
sendTypedMessage?(to: string, type: "result" | "error", text: string, options?: {
|
|
27
34
|
replyTo?: string;
|
|
28
35
|
topic?: string;
|
|
36
|
+
attachments?: MessageAttachment[];
|
|
29
37
|
}): Promise<{
|
|
30
38
|
hub_msg_id?: string;
|
|
31
39
|
message_id?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import WebSocket from "ws";
|
|
2
3
|
import { BotCordClient, buildHubWebSocketUrl, defaultCredentialsFile, loadStoredCredentials, updateCredentialsToken, } from "@botcord/protocol-core";
|
|
3
4
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
@@ -25,6 +26,54 @@ function isUnclaimedAgentError(err) {
|
|
|
25
26
|
message.includes("agent_not_claimed_generic") ||
|
|
26
27
|
message.includes("agent_not_claimed"));
|
|
27
28
|
}
|
|
29
|
+
async function uploadOutboundAttachments(client, attachments, log) {
|
|
30
|
+
if (attachments.length === 0)
|
|
31
|
+
return { attachments: [], replacements: [] };
|
|
32
|
+
if (!client.uploadFile) {
|
|
33
|
+
log.warn("botcord send: outbound attachments skipped because uploadFile is unavailable", {
|
|
34
|
+
count: attachments.length,
|
|
35
|
+
});
|
|
36
|
+
return { attachments: [], replacements: [] };
|
|
37
|
+
}
|
|
38
|
+
const uploaded = [];
|
|
39
|
+
const replacements = [];
|
|
40
|
+
for (const attachment of attachments) {
|
|
41
|
+
if (!attachment.filePath) {
|
|
42
|
+
log.warn("botcord send: attachment without filePath skipped", {
|
|
43
|
+
filename: attachment.filename ?? null,
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const resp = await client.uploadFile(attachment.filePath, attachment.filename ?? basename(attachment.filePath), attachment.contentType);
|
|
49
|
+
if (attachment.sourcePath) {
|
|
50
|
+
replacements.push({ sourcePath: attachment.sourcePath, url: resp.url });
|
|
51
|
+
}
|
|
52
|
+
uploaded.push({
|
|
53
|
+
filename: resp.original_filename,
|
|
54
|
+
url: resp.url,
|
|
55
|
+
...(resp.content_type ? { content_type: resp.content_type } : {}),
|
|
56
|
+
...(typeof resp.size_bytes === "number" ? { size_bytes: resp.size_bytes } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
log.warn("botcord send: attachment upload failed; continuing without it", {
|
|
61
|
+
filename: attachment.filename ?? attachment.filePath,
|
|
62
|
+
error: err instanceof Error ? err.message : String(err),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { attachments: uploaded, replacements };
|
|
67
|
+
}
|
|
68
|
+
function rewriteUploadedAttachmentPaths(text, replacements) {
|
|
69
|
+
let out = text;
|
|
70
|
+
for (const { sourcePath, url } of replacements) {
|
|
71
|
+
if (!sourcePath || !url)
|
|
72
|
+
continue;
|
|
73
|
+
out = out.replaceAll(sourcePath, url);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
28
77
|
/** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
|
|
29
78
|
function defaultClientFactory(input) {
|
|
30
79
|
const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
|
|
@@ -774,9 +823,13 @@ export function createBotCordChannel(options) {
|
|
|
774
823
|
options.replyTo = message.replyTo;
|
|
775
824
|
if (message.threadId)
|
|
776
825
|
options.topic = message.threadId;
|
|
826
|
+
const upload = await uploadOutboundAttachments(client, message.attachments ?? [], ctx.log);
|
|
827
|
+
if (upload.attachments.length > 0)
|
|
828
|
+
options.attachments = upload.attachments;
|
|
829
|
+
const text = rewriteUploadedAttachmentPaths(message.text, upload.replacements);
|
|
777
830
|
const resp = message.type === "error" && client.sendTypedMessage
|
|
778
|
-
? await client.sendTypedMessage(message.conversationId, "error",
|
|
779
|
-
: await client.sendMessage(message.conversationId,
|
|
831
|
+
? await client.sendTypedMessage(message.conversationId, "error", text, options)
|
|
832
|
+
: await client.sendMessage(message.conversationId, text, options);
|
|
780
833
|
const providerMessageId = (resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
|
|
781
834
|
(resp && typeof resp.message_id === "string"
|
|
782
835
|
? resp.message_id
|
|
@@ -13,4 +13,60 @@ export interface FeishuChannelOptions {
|
|
|
13
13
|
stateFile?: string;
|
|
14
14
|
stateDebounceMs?: number;
|
|
15
15
|
}
|
|
16
|
+
interface FeishuEventSender {
|
|
17
|
+
sender_id?: {
|
|
18
|
+
open_id?: string;
|
|
19
|
+
user_id?: string;
|
|
20
|
+
union_id?: string;
|
|
21
|
+
};
|
|
22
|
+
sender_type?: string;
|
|
23
|
+
tenant_key?: string;
|
|
24
|
+
}
|
|
25
|
+
interface FeishuEventMessage {
|
|
26
|
+
message_id?: string;
|
|
27
|
+
root_id?: string;
|
|
28
|
+
parent_id?: string;
|
|
29
|
+
create_time?: string;
|
|
30
|
+
chat_id?: string;
|
|
31
|
+
chat_type?: string;
|
|
32
|
+
message_type?: string;
|
|
33
|
+
content?: string;
|
|
34
|
+
mentions?: Array<{
|
|
35
|
+
id?: {
|
|
36
|
+
open_id?: string;
|
|
37
|
+
user_id?: string;
|
|
38
|
+
};
|
|
39
|
+
name?: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
interface FeishuMessageEvent {
|
|
43
|
+
sender?: FeishuEventSender;
|
|
44
|
+
message?: FeishuEventMessage;
|
|
45
|
+
}
|
|
46
|
+
export interface FeishuDiscoveredChat {
|
|
47
|
+
chatId: string;
|
|
48
|
+
senderOpenId: string;
|
|
49
|
+
kind: "direct" | "group";
|
|
50
|
+
label?: string | null;
|
|
51
|
+
lastSeenAt: number;
|
|
52
|
+
}
|
|
53
|
+
export interface FeishuChatDiscoveryOptions {
|
|
54
|
+
appId: string;
|
|
55
|
+
appSecret: string;
|
|
56
|
+
domain?: FeishuDomain;
|
|
57
|
+
userOpenId: string;
|
|
58
|
+
timeoutSeconds?: number;
|
|
59
|
+
sdkOverride?: {
|
|
60
|
+
createWsClient(args: Record<string, unknown>): {
|
|
61
|
+
start(opts: unknown): unknown;
|
|
62
|
+
close(opts?: unknown): unknown;
|
|
63
|
+
};
|
|
64
|
+
createDispatcher(): {
|
|
65
|
+
register(handlers: Record<string, (data: unknown) => unknown>): void;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export declare function feishuDiscoveryChatFromEvent(event: FeishuMessageEvent, allowedSenderOpenId: string, now?: () => number): FeishuDiscoveredChat | null;
|
|
70
|
+
export declare function discoverFeishuChats(opts: FeishuChatDiscoveryOptions): Promise<FeishuDiscoveredChat[]>;
|
|
16
71
|
export declare function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter;
|
|
72
|
+
export {};
|
|
@@ -54,6 +54,82 @@ function senderLabel(event) {
|
|
|
54
54
|
const hit = mentions.find((m) => m.id?.open_id && m.id.open_id === senderOpenId);
|
|
55
55
|
return typeof hit?.name === "string" && hit.name ? hit.name : undefined;
|
|
56
56
|
}
|
|
57
|
+
export function feishuDiscoveryChatFromEvent(event, allowedSenderOpenId, now = () => Date.now()) {
|
|
58
|
+
const message = event.message;
|
|
59
|
+
const senderOpenId = event.sender?.sender_id?.open_id;
|
|
60
|
+
const chatId = message?.chat_id;
|
|
61
|
+
if (!message || !senderOpenId || !chatId)
|
|
62
|
+
return null;
|
|
63
|
+
if (senderOpenId !== allowedSenderOpenId)
|
|
64
|
+
return null;
|
|
65
|
+
const chatType = message.chat_type ?? "";
|
|
66
|
+
const kind = chatType === "p2p" ? "direct" : "group";
|
|
67
|
+
const label = senderLabel(event) ?? null;
|
|
68
|
+
return {
|
|
69
|
+
chatId,
|
|
70
|
+
senderOpenId,
|
|
71
|
+
kind,
|
|
72
|
+
label,
|
|
73
|
+
lastSeenAt: Number(message.create_time) || now(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function discoverFeishuChats(opts) {
|
|
77
|
+
const timeoutSeconds = typeof opts.timeoutSeconds === "number"
|
|
78
|
+
? Math.min(Math.max(Math.floor(opts.timeoutSeconds), 0), 10)
|
|
79
|
+
: 0;
|
|
80
|
+
const chats = new Map();
|
|
81
|
+
const sdk = Lark;
|
|
82
|
+
const dispatcher = opts.sdkOverride
|
|
83
|
+
? opts.sdkOverride.createDispatcher()
|
|
84
|
+
: new sdk.EventDispatcher({});
|
|
85
|
+
dispatcher.register({
|
|
86
|
+
"im.message.receive_v1": (data) => {
|
|
87
|
+
const discovered = feishuDiscoveryChatFromEvent(data, opts.userOpenId);
|
|
88
|
+
if (!discovered)
|
|
89
|
+
return;
|
|
90
|
+
const previous = chats.get(discovered.chatId);
|
|
91
|
+
chats.set(discovered.chatId, {
|
|
92
|
+
...previous,
|
|
93
|
+
...discovered,
|
|
94
|
+
label: discovered.label ?? previous?.label ?? null,
|
|
95
|
+
lastSeenAt: Math.max(previous?.lastSeenAt ?? 0, discovered.lastSeenAt),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const wsClientArgs = {
|
|
100
|
+
appId: opts.appId,
|
|
101
|
+
appSecret: opts.appSecret,
|
|
102
|
+
domain: sdkDomain(opts.domain),
|
|
103
|
+
loggerLevel: sdk.LoggerLevel?.info,
|
|
104
|
+
};
|
|
105
|
+
const wsClient = opts.sdkOverride
|
|
106
|
+
? opts.sdkOverride.createWsClient(wsClientArgs)
|
|
107
|
+
: new sdk.WSClient(wsClientArgs);
|
|
108
|
+
try {
|
|
109
|
+
const startFailure = Promise.resolve()
|
|
110
|
+
.then(() => wsClient.start({ eventDispatcher: dispatcher }))
|
|
111
|
+
.then(() => new Promise(() => { }), (err) => Promise.reject(err));
|
|
112
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
await Promise.race([startFailure, delay(0)]);
|
|
114
|
+
await Promise.race([startFailure, delay(timeoutSeconds * 1000)]);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
const closeResult = wsClient.close({ force: true });
|
|
119
|
+
if (closeResult &&
|
|
120
|
+
(typeof closeResult === "object" || typeof closeResult === "function") &&
|
|
121
|
+
typeof closeResult.then === "function") {
|
|
122
|
+
void Promise.resolve(closeResult).catch(() => {
|
|
123
|
+
// best effort
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// best effort
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...chats.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
132
|
+
}
|
|
57
133
|
export function createFeishuChannel(opts) {
|
|
58
134
|
const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
|
|
59
135
|
const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map(String));
|
|
@@ -63,6 +63,8 @@ export function buildCliEnv(opts) {
|
|
|
63
63
|
env.BOTCORD_HUB = opts.hubUrl;
|
|
64
64
|
if (opts.accountId)
|
|
65
65
|
env.BOTCORD_AGENT_ID = opts.accountId;
|
|
66
|
+
if (opts.waitMarkerFile)
|
|
67
|
+
env.BOTCORD_WAIT_FILE = opts.waitMarkerFile;
|
|
66
68
|
const cli = resolveBundledCliBin();
|
|
67
69
|
if (cli) {
|
|
68
70
|
const existing = opts.basePath ?? "";
|
|
@@ -211,6 +211,26 @@ export declare class Dispatcher {
|
|
|
211
211
|
*/
|
|
212
212
|
private recomposeUserTurn;
|
|
213
213
|
private runTurn;
|
|
214
|
+
/**
|
|
215
|
+
* Clear a pending re-wake timer because an external message just arrived on
|
|
216
|
+
* the queue (it supersedes the scheduled re-wake and restarts the dithering
|
|
217
|
+
* budget). No-op when the timer-fire path already nulled `q.park` — so a
|
|
218
|
+
* re-wake does NOT reset the consecutive-park counters, keeping the caps
|
|
219
|
+
* effective across re-wakes.
|
|
220
|
+
*/
|
|
221
|
+
private supersedePendingPark;
|
|
222
|
+
/**
|
|
223
|
+
* Read the park marker a group-room turn may have written via `botcord wait`
|
|
224
|
+
* and, if present and within the per-queue caps ({@link MAX_PARKS} /
|
|
225
|
+
* {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
|
|
226
|
+
* message after the (clamped) wait. A turn that ends without a marker — or in
|
|
227
|
+
* a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
|
|
228
|
+
* resets the consecutive-park counters. New messages arriving during the wait
|
|
229
|
+
* cancel it via {@link supersedePendingPark} (the agent then re-decides with
|
|
230
|
+
* fresh context). `waitMarkerFile` is the per-queue marker path resolved at
|
|
231
|
+
* dispatch (undefined when the room is not park-eligible).
|
|
232
|
+
*/
|
|
233
|
+
private maybeSchedulePark;
|
|
214
234
|
private sendReply;
|
|
215
235
|
private providerReplyTo;
|
|
216
236
|
private emitInbound;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { looksLikeRuntimeAuthFailure } from "./runtime-errors.js";
|
|
3
5
|
import { resolveRoute } from "./router.js";
|
|
4
6
|
import { sessionKey } from "./session-store.js";
|
|
7
|
+
import { clearWaitMarker, consumeWaitMarker, resolveWaitMarkerPath, MAX_WAIT_MS } from "./wait-marker.js";
|
|
5
8
|
import { truncateTextField, } from "./transcript.js";
|
|
6
9
|
const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
7
10
|
const DEFAULT_RUNTIME_AUTH_FAILURE_THRESHOLD = 3;
|
|
@@ -17,6 +20,10 @@ const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
|
|
|
17
20
|
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
18
21
|
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
19
22
|
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
23
|
+
/** Max consecutive agent-driven `botcord wait` parks on one queue before the
|
|
24
|
+
* next turn is forced to produce a real decision. Total accumulated wait is
|
|
25
|
+
* separately bounded by {@link MAX_WAIT_MS}. */
|
|
26
|
+
const MAX_PARKS = 3;
|
|
20
27
|
/**
|
|
21
28
|
* Soft cap on the total characters across raw.batch members in a merged
|
|
22
29
|
* turn. When exceeded, oldest entries are dropped (with a warn log) so the
|
|
@@ -38,6 +45,30 @@ const TYPING_DEBOUNCE_MS = 2000;
|
|
|
38
45
|
const TYPING_REFRESH_MS = 4000;
|
|
39
46
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
40
47
|
const TYPING_RECENCY_CAP = 1024;
|
|
48
|
+
const AUTO_ATTACHMENT_LIMIT = 10;
|
|
49
|
+
const AUTO_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
|
50
|
+
const AUTO_ATTACHMENT_EXTENSIONS = new Set([
|
|
51
|
+
".avif",
|
|
52
|
+
".bmp",
|
|
53
|
+
".csv",
|
|
54
|
+
".doc",
|
|
55
|
+
".docx",
|
|
56
|
+
".gif",
|
|
57
|
+
".htm",
|
|
58
|
+
".html",
|
|
59
|
+
".jpeg",
|
|
60
|
+
".jpg",
|
|
61
|
+
".pdf",
|
|
62
|
+
".png",
|
|
63
|
+
".ppt",
|
|
64
|
+
".pptx",
|
|
65
|
+
".svg",
|
|
66
|
+
".webp",
|
|
67
|
+
".xls",
|
|
68
|
+
".xlsx",
|
|
69
|
+
".zip",
|
|
70
|
+
]);
|
|
71
|
+
const REPLY_LOCAL_PATH_RE = /(^|[\s([{"'`])((?:\/|\.{1,2}\/)?(?:[\w@+.-]+\/)+[\w@+.-]+\.(?:avif|bmp|csv|docx?|gif|html?|jpe?g|pdf|png|pptx?|svg|webp|xlsx?|zip))(?=$|[\s)\]}"'`,.!?:;])/gi;
|
|
41
72
|
function transcriptBlocksVerbose() {
|
|
42
73
|
return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
|
|
43
74
|
process.env.BOTCORD_TRACE_VERBOSE === "1";
|
|
@@ -502,6 +533,9 @@ export class Dispatcher {
|
|
|
502
533
|
cancelGen: 0,
|
|
503
534
|
serialBuffer: [],
|
|
504
535
|
serialWorkerActive: false,
|
|
536
|
+
park: null,
|
|
537
|
+
parkCount: 0,
|
|
538
|
+
parkAccumMs: 0,
|
|
505
539
|
};
|
|
506
540
|
this.queues.set(key, q);
|
|
507
541
|
}
|
|
@@ -680,6 +714,7 @@ export class Dispatcher {
|
|
|
680
714
|
}
|
|
681
715
|
async runCancelPrevious(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds = []) {
|
|
682
716
|
const q = this.getQueue(queueKey);
|
|
717
|
+
this.supersedePendingPark(q);
|
|
683
718
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
684
719
|
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
685
720
|
// they resume and drop out, so only the newest message reaches runTurn.
|
|
@@ -759,6 +794,7 @@ export class Dispatcher {
|
|
|
759
794
|
*/
|
|
760
795
|
async runSerial(queueKey, route, _text, msg, channel, turnId, mergedFromTurnIds = []) {
|
|
761
796
|
const q = this.getQueue(queueKey);
|
|
797
|
+
this.supersedePendingPark(q);
|
|
762
798
|
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
763
799
|
while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
764
800
|
const dropped = q.serialBuffer.shift();
|
|
@@ -944,6 +980,20 @@ export class Dispatcher {
|
|
|
944
980
|
blocks: [],
|
|
945
981
|
};
|
|
946
982
|
q.current = slot;
|
|
983
|
+
// Agent-driven `botcord wait` is offered only in non-owner BotCord group
|
|
984
|
+
// rooms (kind === "group"). When eligible, scope the park marker per queue
|
|
985
|
+
// (concurrent group-room turns share one agent workspace) and expose its
|
|
986
|
+
// path to the CLI subprocess via `BOTCORD_WAIT_FILE`.
|
|
987
|
+
const parkEligible = isBotCordChannel(channel) &&
|
|
988
|
+
!isOwnerChatRoom(msg) &&
|
|
989
|
+
msg.conversation.kind === "group";
|
|
990
|
+
const waitMarkerFile = parkEligible
|
|
991
|
+
? resolveWaitMarkerPath(route.cwd, queueKey)
|
|
992
|
+
: undefined;
|
|
993
|
+
// Drop any stale marker so that whatever `botcord wait` writes during this
|
|
994
|
+
// turn is unambiguously from this turn (read back in `finally`).
|
|
995
|
+
if (waitMarkerFile)
|
|
996
|
+
clearWaitMarker(waitMarkerFile);
|
|
947
997
|
// Dispatched record — marks "this turn entered runtime".
|
|
948
998
|
{
|
|
949
999
|
const composedField = truncateTextField(text);
|
|
@@ -1324,6 +1374,7 @@ export class Dispatcher {
|
|
|
1324
1374
|
cwd: route.cwd,
|
|
1325
1375
|
accountId: msg.accountId,
|
|
1326
1376
|
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
1377
|
+
...(waitMarkerFile ? { waitMarkerFile } : {}),
|
|
1327
1378
|
extraArgs: route.extraArgs,
|
|
1328
1379
|
signal: controller.signal,
|
|
1329
1380
|
trustLevel,
|
|
@@ -1727,12 +1778,25 @@ export class Dispatcher {
|
|
|
1727
1778
|
if (controller.signal.aborted && !slot.timedOut) {
|
|
1728
1779
|
return;
|
|
1729
1780
|
}
|
|
1781
|
+
const attachments = (isOwnerChat && isBotCordChannel(channel)
|
|
1782
|
+
? collectOwnerChatReplyAttachments(replyText, route.cwd)
|
|
1783
|
+
: undefined) ?? [];
|
|
1784
|
+
if (attachments.length > 0) {
|
|
1785
|
+
this.log.info("dispatcher: attaching owner-chat reply artifacts", {
|
|
1786
|
+
agentId: msg.accountId,
|
|
1787
|
+
roomId: msg.conversation.id,
|
|
1788
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1789
|
+
turnId,
|
|
1790
|
+
count: attachments.length,
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1730
1793
|
const sendResult = await this.sendReply(channel, {
|
|
1731
1794
|
channel: msg.channel,
|
|
1732
1795
|
accountId: msg.accountId,
|
|
1733
1796
|
conversationId: msg.conversation.id,
|
|
1734
1797
|
threadId: msg.conversation.threadId ?? null,
|
|
1735
1798
|
text: replyText,
|
|
1799
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
1736
1800
|
replyTo: this.providerReplyTo(msg),
|
|
1737
1801
|
traceId: msg.trace?.id ?? null,
|
|
1738
1802
|
}, turnId);
|
|
@@ -1763,9 +1827,106 @@ export class Dispatcher {
|
|
|
1763
1827
|
// let our abort-checks above drop this turn silently.
|
|
1764
1828
|
if (q.current === slot)
|
|
1765
1829
|
q.current = null;
|
|
1830
|
+
// Agent-driven defer: a group-room turn may have run `botcord wait` to
|
|
1831
|
+
// park its decision. Honor it now (timer lives here, not in the runtime).
|
|
1832
|
+
this.maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile);
|
|
1766
1833
|
resolveDone();
|
|
1767
1834
|
}
|
|
1768
1835
|
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Clear a pending re-wake timer because an external message just arrived on
|
|
1838
|
+
* the queue (it supersedes the scheduled re-wake and restarts the dithering
|
|
1839
|
+
* budget). No-op when the timer-fire path already nulled `q.park` — so a
|
|
1840
|
+
* re-wake does NOT reset the consecutive-park counters, keeping the caps
|
|
1841
|
+
* effective across re-wakes.
|
|
1842
|
+
*/
|
|
1843
|
+
supersedePendingPark(q) {
|
|
1844
|
+
if (!q.park)
|
|
1845
|
+
return;
|
|
1846
|
+
clearTimeout(q.park);
|
|
1847
|
+
q.park = null;
|
|
1848
|
+
q.parkCount = 0;
|
|
1849
|
+
q.parkAccumMs = 0;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Read the park marker a group-room turn may have written via `botcord wait`
|
|
1853
|
+
* and, if present and within the per-queue caps ({@link MAX_PARKS} /
|
|
1854
|
+
* {@link MAX_WAIT_MS} total), schedule a re-wake that re-dispatches the same
|
|
1855
|
+
* message after the (clamped) wait. A turn that ends without a marker — or in
|
|
1856
|
+
* a non-deferrable room (`waitMarkerFile` unset), or aborted/timed-out —
|
|
1857
|
+
* resets the consecutive-park counters. New messages arriving during the wait
|
|
1858
|
+
* cancel it via {@link supersedePendingPark} (the agent then re-decides with
|
|
1859
|
+
* fresh context). `waitMarkerFile` is the per-queue marker path resolved at
|
|
1860
|
+
* dispatch (undefined when the room is not park-eligible).
|
|
1861
|
+
*/
|
|
1862
|
+
maybeSchedulePark(queueKey, route, msg, channel, slot, controller, waitMarkerFile) {
|
|
1863
|
+
const q = this.queues.get(queueKey);
|
|
1864
|
+
if (!q)
|
|
1865
|
+
return;
|
|
1866
|
+
if (!waitMarkerFile || slot.timedOut || controller.signal.aborted) {
|
|
1867
|
+
// Not eligible / unclean completion — never honor a marker here.
|
|
1868
|
+
if (waitMarkerFile)
|
|
1869
|
+
clearWaitMarker(waitMarkerFile);
|
|
1870
|
+
q.parkCount = 0;
|
|
1871
|
+
q.parkAccumMs = 0;
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const marker = consumeWaitMarker(waitMarkerFile);
|
|
1875
|
+
if (!marker) {
|
|
1876
|
+
q.parkCount = 0;
|
|
1877
|
+
q.parkAccumMs = 0;
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (q.parkCount >= MAX_PARKS || q.parkAccumMs >= MAX_WAIT_MS) {
|
|
1881
|
+
this.log.info("dispatcher: park request ignored — cap reached", {
|
|
1882
|
+
agentId: msg.accountId,
|
|
1883
|
+
roomId: msg.conversation.id,
|
|
1884
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1885
|
+
queueKey,
|
|
1886
|
+
parkCount: q.parkCount,
|
|
1887
|
+
parkAccumMs: q.parkAccumMs,
|
|
1888
|
+
});
|
|
1889
|
+
q.parkCount = 0;
|
|
1890
|
+
q.parkAccumMs = 0;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
const remainingBudget = MAX_WAIT_MS - q.parkAccumMs;
|
|
1894
|
+
const waitMs = Math.max(0, Math.min(marker.deadlineMs - Date.now(), remainingBudget));
|
|
1895
|
+
if (waitMs <= 0) {
|
|
1896
|
+
q.parkCount = 0;
|
|
1897
|
+
q.parkAccumMs = 0;
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
q.parkCount += 1;
|
|
1901
|
+
q.parkAccumMs += waitMs;
|
|
1902
|
+
this.log.info("dispatcher: parking group-room turn (botcord wait)", {
|
|
1903
|
+
agentId: msg.accountId,
|
|
1904
|
+
roomId: msg.conversation.id,
|
|
1905
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1906
|
+
queueKey,
|
|
1907
|
+
waitMs,
|
|
1908
|
+
parkCount: q.parkCount,
|
|
1909
|
+
reason: marker.reason ?? null,
|
|
1910
|
+
});
|
|
1911
|
+
const timer = setTimeout(() => {
|
|
1912
|
+
q.park = null;
|
|
1913
|
+
this.log.info("dispatcher: park elapsed — re-waking", {
|
|
1914
|
+
agentId: msg.accountId,
|
|
1915
|
+
roomId: msg.conversation.id,
|
|
1916
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1917
|
+
queueKey,
|
|
1918
|
+
});
|
|
1919
|
+
void this.runSerial(queueKey, route, this.recomposeUserTurn(msg), msg, channel, randomUUID()).catch((err) => {
|
|
1920
|
+
this.log.warn("dispatcher: park re-wake failed", {
|
|
1921
|
+
queueKey,
|
|
1922
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1925
|
+
}, waitMs);
|
|
1926
|
+
if (typeof timer.unref === "function")
|
|
1927
|
+
timer.unref();
|
|
1928
|
+
q.park = timer;
|
|
1929
|
+
}
|
|
1769
1930
|
async sendReply(channel, outbound, turnId) {
|
|
1770
1931
|
try {
|
|
1771
1932
|
await channel.send({ message: outbound, log: this.log });
|
|
@@ -1872,6 +2033,97 @@ export class Dispatcher {
|
|
|
1872
2033
|
function nowIso() {
|
|
1873
2034
|
return new Date().toISOString();
|
|
1874
2035
|
}
|
|
2036
|
+
function collectOwnerChatReplyAttachments(text, cwd) {
|
|
2037
|
+
const baseDir = safeRealpath(cwd);
|
|
2038
|
+
if (!baseDir)
|
|
2039
|
+
return undefined;
|
|
2040
|
+
const out = [];
|
|
2041
|
+
const seen = new Set();
|
|
2042
|
+
REPLY_LOCAL_PATH_RE.lastIndex = 0;
|
|
2043
|
+
for (const match of text.matchAll(REPLY_LOCAL_PATH_RE)) {
|
|
2044
|
+
const rawPath = match[2];
|
|
2045
|
+
if (!rawPath || looksLikeUrl(rawPath))
|
|
2046
|
+
continue;
|
|
2047
|
+
const resolved = path.isAbsolute(rawPath)
|
|
2048
|
+
? path.resolve(rawPath)
|
|
2049
|
+
: path.resolve(baseDir, rawPath);
|
|
2050
|
+
const realPath = safeRealpath(resolved);
|
|
2051
|
+
if (!realPath || seen.has(realPath) || !isPathInside(baseDir, realPath))
|
|
2052
|
+
continue;
|
|
2053
|
+
const ext = path.extname(realPath).toLowerCase();
|
|
2054
|
+
if (!AUTO_ATTACHMENT_EXTENSIONS.has(ext))
|
|
2055
|
+
continue;
|
|
2056
|
+
let size = 0;
|
|
2057
|
+
try {
|
|
2058
|
+
const stat = statSync(realPath);
|
|
2059
|
+
if (!stat.isFile())
|
|
2060
|
+
continue;
|
|
2061
|
+
size = stat.size;
|
|
2062
|
+
}
|
|
2063
|
+
catch {
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (size <= 0 || size > AUTO_ATTACHMENT_MAX_BYTES)
|
|
2067
|
+
continue;
|
|
2068
|
+
const contentType = contentTypeForExtension(ext);
|
|
2069
|
+
out.push({
|
|
2070
|
+
filePath: realPath,
|
|
2071
|
+
filename: path.basename(realPath),
|
|
2072
|
+
sourcePath: rawPath,
|
|
2073
|
+
...(contentType ? { contentType } : {}),
|
|
2074
|
+
...(contentType?.startsWith("image/") ? { kind: "image" } : { kind: "file" }),
|
|
2075
|
+
});
|
|
2076
|
+
seen.add(realPath);
|
|
2077
|
+
if (out.length >= AUTO_ATTACHMENT_LIMIT)
|
|
2078
|
+
break;
|
|
2079
|
+
}
|
|
2080
|
+
return out.length > 0 ? out : undefined;
|
|
2081
|
+
}
|
|
2082
|
+
function safeRealpath(input) {
|
|
2083
|
+
try {
|
|
2084
|
+
return realpathSync(input);
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
function isPathInside(baseDir, candidate) {
|
|
2091
|
+
const rel = path.relative(baseDir, candidate);
|
|
2092
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
2093
|
+
}
|
|
2094
|
+
function looksLikeUrl(value) {
|
|
2095
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//");
|
|
2096
|
+
}
|
|
2097
|
+
function contentTypeForExtension(ext) {
|
|
2098
|
+
switch (ext) {
|
|
2099
|
+
case ".avif":
|
|
2100
|
+
return "image/avif";
|
|
2101
|
+
case ".bmp":
|
|
2102
|
+
return "image/bmp";
|
|
2103
|
+
case ".csv":
|
|
2104
|
+
return "text/csv";
|
|
2105
|
+
case ".gif":
|
|
2106
|
+
return "image/gif";
|
|
2107
|
+
case ".htm":
|
|
2108
|
+
case ".html":
|
|
2109
|
+
return "text/html";
|
|
2110
|
+
case ".jpeg":
|
|
2111
|
+
case ".jpg":
|
|
2112
|
+
return "image/jpeg";
|
|
2113
|
+
case ".pdf":
|
|
2114
|
+
return "application/pdf";
|
|
2115
|
+
case ".png":
|
|
2116
|
+
return "image/png";
|
|
2117
|
+
case ".svg":
|
|
2118
|
+
return "image/svg+xml";
|
|
2119
|
+
case ".webp":
|
|
2120
|
+
return "image/webp";
|
|
2121
|
+
case ".zip":
|
|
2122
|
+
return "application/zip";
|
|
2123
|
+
default:
|
|
2124
|
+
return undefined;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
1875
2127
|
function buildQueueKey(msg) {
|
|
1876
2128
|
const thread = msg.conversation.threadId ?? "";
|
|
1877
2129
|
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|