@agentconnect.md/daemon 1.0.0-rc.32 → 1.0.0-rc.33
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/index.js
CHANGED
|
@@ -7353,6 +7353,7 @@ const RuntimeDefSchema = object({
|
|
|
7353
7353
|
const ConfigSchema = object({
|
|
7354
7354
|
version: literal(1),
|
|
7355
7355
|
daemonId: string().optional(),
|
|
7356
|
+
webAppUrl: string().optional(),
|
|
7356
7357
|
controlPlane: object({
|
|
7357
7358
|
enabled: boolean().default(true),
|
|
7358
7359
|
url: string().optional(),
|
|
@@ -7374,11 +7375,13 @@ const ConfigSchema = object({
|
|
|
7374
7375
|
limits: object({
|
|
7375
7376
|
maxAgents: number().int().default(8),
|
|
7376
7377
|
maxConcurrentSessions: number().int().default(32),
|
|
7377
|
-
agentIdleTimeoutMs: number().int().default(9e5)
|
|
7378
|
+
agentIdleTimeoutMs: number().int().default(9e5),
|
|
7379
|
+
maxAttachmentBytes: number().int().default(8 * 1024 * 1024)
|
|
7378
7380
|
}).default({
|
|
7379
7381
|
maxAgents: 8,
|
|
7380
7382
|
maxConcurrentSessions: 32,
|
|
7381
|
-
agentIdleTimeoutMs: 9e5
|
|
7383
|
+
agentIdleTimeoutMs: 9e5,
|
|
7384
|
+
maxAttachmentBytes: 8 * 1024 * 1024
|
|
7382
7385
|
})
|
|
7383
7386
|
});
|
|
7384
7387
|
//#endregion
|
|
@@ -16877,6 +16880,7 @@ var AcpHost = class {
|
|
|
16877
16880
|
live = /* @__PURE__ */ new Set();
|
|
16878
16881
|
loadingSessions = /* @__PURE__ */ new Set();
|
|
16879
16882
|
canLoad = false;
|
|
16883
|
+
promptCaps = {};
|
|
16880
16884
|
constructor(runtime, opts) {
|
|
16881
16885
|
this.runtime = runtime;
|
|
16882
16886
|
this.opts = opts;
|
|
@@ -16917,7 +16921,13 @@ var AcpHost = class {
|
|
|
16917
16921
|
} }
|
|
16918
16922
|
});
|
|
16919
16923
|
this.canLoad = init.agentCapabilities?.loadSession ?? false;
|
|
16920
|
-
this.
|
|
16924
|
+
this.promptCaps = init.agentCapabilities?.promptCapabilities ?? {};
|
|
16925
|
+
this.opts.log?.debug(`acp: agent initialized (loadSession=${this.canLoad}, prompt caps: image=${!!this.promptCaps.image} audio=${!!this.promptCaps.audio} embeddedContext=${!!this.promptCaps.embeddedContext})`);
|
|
16926
|
+
}
|
|
16927
|
+
/** Whether the agent advertised support for a gated prompt content-block kind.
|
|
16928
|
+
* `text` and `resource_link` are baseline and always return true. */
|
|
16929
|
+
promptSupports(kind) {
|
|
16930
|
+
return Boolean(this.promptCaps[kind]);
|
|
16921
16931
|
}
|
|
16922
16932
|
async newSession(cwd, mcpServers = []) {
|
|
16923
16933
|
const res = await this.conn.agent.request(methods.agent.session.new, {
|
|
@@ -23265,6 +23275,12 @@ var LocalStore = class {
|
|
|
23265
23275
|
openSessionAgents(channel, thread) {
|
|
23266
23276
|
return this.db.prepare("SELECT agentId FROM sessions WHERE channel = ? AND thread = ? AND state != 'closed'").all(channel, thread).map((r) => r.agentId);
|
|
23267
23277
|
}
|
|
23278
|
+
/** Count non-closed sessions in (channel, thread) touched at/after `sinceTs`
|
|
23279
|
+
* (epoch ms). Used to bound unrouted-transcript growth to recently-active
|
|
23280
|
+
* threads, since there is no session-`closed` lifecycle yet. */
|
|
23281
|
+
activeSessionCountSince(channel, thread, sinceTs) {
|
|
23282
|
+
return this.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE channel = ? AND thread = ? AND state != 'closed' AND updatedAt >= ?").get(channel, thread, sinceTs)?.n ?? 0;
|
|
23283
|
+
}
|
|
23268
23284
|
getCpRouting() {
|
|
23269
23285
|
return this.db.prepare("SELECT routingEpoch, assignments, globalRules FROM cp_routing WHERE id = 1").get();
|
|
23270
23286
|
}
|
|
@@ -23281,6 +23297,67 @@ var LocalStore = class {
|
|
|
23281
23297
|
}
|
|
23282
23298
|
};
|
|
23283
23299
|
//#endregion
|
|
23300
|
+
//#region src/session/attachment-block.ts
|
|
23301
|
+
/**
|
|
23302
|
+
* Turn one Slack attachment into the richest ACP content block the agent can
|
|
23303
|
+
* accept (§9.2):
|
|
23304
|
+
* - image/* + promptCapabilities.image → an inline `image` block (base64).
|
|
23305
|
+
* - downloaded + promptCapabilities.embeddedContext → an embedded `resource`
|
|
23306
|
+
* (text bytes for text/*, else a base64 blob).
|
|
23307
|
+
* - otherwise → a baseline `resource_link` pointer (always supported), which is
|
|
23308
|
+
* also the fallback when the download failed or the agent opted out.
|
|
23309
|
+
*/
|
|
23310
|
+
function attachmentToBlock(att, bytes, supports) {
|
|
23311
|
+
const isImage = att.mimeType.startsWith("image/");
|
|
23312
|
+
if (bytes && isImage && supports("image")) return {
|
|
23313
|
+
type: "image",
|
|
23314
|
+
data: bytes.toString("base64"),
|
|
23315
|
+
mimeType: att.mimeType,
|
|
23316
|
+
uri: att.sourceUrl
|
|
23317
|
+
};
|
|
23318
|
+
if (bytes && supports("embeddedContext")) {
|
|
23319
|
+
if (att.mimeType.startsWith("text/")) return {
|
|
23320
|
+
type: "resource",
|
|
23321
|
+
resource: {
|
|
23322
|
+
text: bytes.toString("utf8"),
|
|
23323
|
+
uri: att.sourceUrl,
|
|
23324
|
+
mimeType: att.mimeType
|
|
23325
|
+
}
|
|
23326
|
+
};
|
|
23327
|
+
return {
|
|
23328
|
+
type: "resource",
|
|
23329
|
+
resource: {
|
|
23330
|
+
blob: bytes.toString("base64"),
|
|
23331
|
+
uri: att.sourceUrl,
|
|
23332
|
+
mimeType: att.mimeType
|
|
23333
|
+
}
|
|
23334
|
+
};
|
|
23335
|
+
}
|
|
23336
|
+
return {
|
|
23337
|
+
type: "resource_link",
|
|
23338
|
+
name: att.name,
|
|
23339
|
+
uri: att.sourceUrl,
|
|
23340
|
+
mimeType: att.mimeType,
|
|
23341
|
+
description: `Slack file. You cannot fetch this URL directly — call the readSlackFile tool with this uri (mimeType: ${att.mimeType}) to view its contents.`,
|
|
23342
|
+
...typeof att.size === "number" ? { size: att.size } : {}
|
|
23343
|
+
};
|
|
23344
|
+
}
|
|
23345
|
+
/** Download + convert every attachment, skipping nothing (failed/over-cap
|
|
23346
|
+
* downloads degrade to resource_link rather than being dropped). A file whose
|
|
23347
|
+
* declared size already exceeds maxBytes is never fetched. */
|
|
23348
|
+
async function buildAttachmentBlocks(attachments, deps) {
|
|
23349
|
+
const cap = deps.maxBytes ?? Infinity;
|
|
23350
|
+
return Promise.all(attachments.map(async (att) => {
|
|
23351
|
+
return attachmentToBlock(att, typeof att.size === "number" && att.size > cap ? null : await deps.download(att).catch(() => null), deps.supports);
|
|
23352
|
+
}));
|
|
23353
|
+
}
|
|
23354
|
+
/** One-line human summary of attachments for the transcript text (so §8.5
|
|
23355
|
+
* catch-up replay at least notes a file was shared, since bytes aren't stored). */
|
|
23356
|
+
function attachmentMention(attachments) {
|
|
23357
|
+
if (!attachments?.length) return "";
|
|
23358
|
+
return `[attached: ${attachments.map((a) => `${a.name} (${a.mimeType})`).join(", ")}]`;
|
|
23359
|
+
}
|
|
23360
|
+
//#endregion
|
|
23284
23361
|
//#region src/session/session-manager.ts
|
|
23285
23362
|
var SessionManager = class {
|
|
23286
23363
|
deps;
|
|
@@ -23294,14 +23371,15 @@ var SessionManager = class {
|
|
|
23294
23371
|
async handle(agentId, msg) {
|
|
23295
23372
|
const agent = this.deps.agentById(agentId);
|
|
23296
23373
|
if (!agent) throw new Error(`unknown agent ${agentId}`);
|
|
23297
|
-
const thread
|
|
23374
|
+
const { thread, ts } = transcriptCoords(msg);
|
|
23298
23375
|
const key = sessionKey(msg.platform, msg.channel, thread, agentId);
|
|
23376
|
+
const mention = attachmentMention(msg.attachments);
|
|
23299
23377
|
this.deps.store.appendTranscript({
|
|
23300
23378
|
channel: msg.channel,
|
|
23301
23379
|
thread,
|
|
23302
|
-
ts
|
|
23380
|
+
ts,
|
|
23303
23381
|
sender: msg.sender.id,
|
|
23304
|
-
text: msg.text
|
|
23382
|
+
text: mention ? `${msg.text}\n${mention}`.trim() : msg.text
|
|
23305
23383
|
});
|
|
23306
23384
|
let rec = this.deps.store.getSession(key);
|
|
23307
23385
|
const host = await this.deps.hostFor(agentId);
|
|
@@ -23349,21 +23427,44 @@ var SessionManager = class {
|
|
|
23349
23427
|
this.deps.store.upsertSession(rec);
|
|
23350
23428
|
}
|
|
23351
23429
|
}
|
|
23430
|
+
if (rec.lastDeliveredTs === null && thread !== ts && this.deps.fetchThreadHistory) {
|
|
23431
|
+
if (!this.deps.store.transcriptSince(msg.channel, thread, null).some((e) => e.ts === thread)) {
|
|
23432
|
+
const history = await this.deps.fetchThreadHistory(agentId, msg.channel, thread);
|
|
23433
|
+
for (const h of history) this.deps.store.appendTranscript({
|
|
23434
|
+
channel: msg.channel,
|
|
23435
|
+
thread,
|
|
23436
|
+
ts: h.ts,
|
|
23437
|
+
sender: h.sender,
|
|
23438
|
+
text: h.text
|
|
23439
|
+
});
|
|
23440
|
+
}
|
|
23441
|
+
}
|
|
23352
23442
|
const gap = this.deps.store.transcriptSince(msg.channel, thread, rec.lastDeliveredTs);
|
|
23353
23443
|
const blocks = [];
|
|
23354
|
-
const
|
|
23444
|
+
const allContext = gap.filter((e) => e.ts !== ts).filter((e) => e.sender !== agentId);
|
|
23445
|
+
const context = allContext.slice(-50);
|
|
23355
23446
|
if (context.length > 0) {
|
|
23447
|
+
const elided = allContext.length - context.length;
|
|
23448
|
+
const head = elided > 0 ? `(thread context you may have missed — ${elided} earlier message(s) elided)` : "(thread context you may have missed)";
|
|
23356
23449
|
const ctxText = context.map((e) => `[${e.sender}] ${e.text}`).join("\n");
|
|
23357
23450
|
blocks.push({
|
|
23358
23451
|
type: "text",
|
|
23359
|
-
text:
|
|
23452
|
+
text: `${head}\n${ctxText}`
|
|
23360
23453
|
});
|
|
23361
23454
|
}
|
|
23362
23455
|
blocks.push({
|
|
23363
23456
|
type: "text",
|
|
23364
23457
|
text: msg.text
|
|
23365
23458
|
});
|
|
23366
|
-
|
|
23459
|
+
if (msg.attachments?.length) {
|
|
23460
|
+
const attBlocks = await buildAttachmentBlocks(msg.attachments, {
|
|
23461
|
+
download: (att) => this.deps.downloadAttachment?.(agentId, att) ?? Promise.resolve(null),
|
|
23462
|
+
supports: (kind) => host.promptSupports?.(kind) ?? false,
|
|
23463
|
+
...this.deps.attachmentMaxBytes !== void 0 ? { maxBytes: this.deps.attachmentMaxBytes } : {}
|
|
23464
|
+
});
|
|
23465
|
+
blocks.push(...attBlocks);
|
|
23466
|
+
}
|
|
23467
|
+
rec.lastDeliveredTs = ts;
|
|
23367
23468
|
rec.state = "prompting";
|
|
23368
23469
|
rec.updatedAt = Date.now();
|
|
23369
23470
|
this.deps.store.upsertSession(rec);
|
|
@@ -23373,9 +23474,17 @@ var SessionManager = class {
|
|
|
23373
23474
|
};
|
|
23374
23475
|
}
|
|
23375
23476
|
};
|
|
23376
|
-
|
|
23477
|
+
/** Canonical transcript primary-key coordinates for a normalized message. Used by
|
|
23478
|
+
* BOTH the session manager and the daemon's unrouted-append path so a message
|
|
23479
|
+
* recorded from either site lands on the same (thread, ts) PK and dedups via
|
|
23480
|
+
* INSERT OR IGNORE — never a divergent double row. */
|
|
23481
|
+
function transcriptCoords(msg) {
|
|
23482
|
+
const thread = msg.thread ?? msg.msgId;
|
|
23377
23483
|
const parts = msg.msgId.split(":");
|
|
23378
|
-
return
|
|
23484
|
+
return {
|
|
23485
|
+
thread,
|
|
23486
|
+
ts: parts[parts.length - 1] ?? "0"
|
|
23487
|
+
};
|
|
23379
23488
|
}
|
|
23380
23489
|
//#endregion
|
|
23381
23490
|
//#region src/mcp/ipc.ts
|
|
@@ -23411,6 +23520,25 @@ function decodeFrames(buf, onError) {
|
|
|
23411
23520
|
}
|
|
23412
23521
|
//#endregion
|
|
23413
23522
|
//#region src/mcp/ops.ts
|
|
23523
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
23524
|
+
/** Best-effort MIME guess from a Slack file URL's extension (used when the caller
|
|
23525
|
+
* doesn't pass a mimeType hint). */
|
|
23526
|
+
function guessMimeFromUrl(url) {
|
|
23527
|
+
const ext = url.split("?")[0]?.split(".").pop()?.toLowerCase();
|
|
23528
|
+
return ext ? {
|
|
23529
|
+
png: "image/png",
|
|
23530
|
+
jpg: "image/jpeg",
|
|
23531
|
+
jpeg: "image/jpeg",
|
|
23532
|
+
gif: "image/gif",
|
|
23533
|
+
webp: "image/webp",
|
|
23534
|
+
bmp: "image/bmp",
|
|
23535
|
+
svg: "image/svg+xml",
|
|
23536
|
+
txt: "text/plain",
|
|
23537
|
+
md: "text/markdown",
|
|
23538
|
+
json: "application/json",
|
|
23539
|
+
csv: "text/csv"
|
|
23540
|
+
}[ext] : void 0;
|
|
23541
|
+
}
|
|
23414
23542
|
/**
|
|
23415
23543
|
* Execute one tool call inside the daemon and return a plain result object (the
|
|
23416
23544
|
* bridge wraps it into an MCP `CallToolResult`). Throws on bad input or a
|
|
@@ -23454,6 +23582,26 @@ async function executeTool(ctx, name, args, deps) {
|
|
|
23454
23582
|
const user = requireString(args, "user");
|
|
23455
23583
|
return await gw.getUserProfile(user);
|
|
23456
23584
|
}
|
|
23585
|
+
case "readSlackFile": {
|
|
23586
|
+
const url = requireString(args, "url");
|
|
23587
|
+
const max = deps.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
23588
|
+
const bytes = await gw.downloadFile(url, max);
|
|
23589
|
+
if (!bytes) throw new Error(`could not download the Slack file at ${url} — the bot may lack the files:read scope, or the file is inaccessible / larger than ${max} bytes`);
|
|
23590
|
+
const mimeType = optionalString(args, "mimeType") ?? guessMimeFromUrl(url) ?? "application/octet-stream";
|
|
23591
|
+
if (mimeType.startsWith("image/")) return { mcpContent: [{
|
|
23592
|
+
type: "image",
|
|
23593
|
+
data: bytes.toString("base64"),
|
|
23594
|
+
mimeType
|
|
23595
|
+
}] };
|
|
23596
|
+
if (mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "text/csv") return { mcpContent: [{
|
|
23597
|
+
type: "text",
|
|
23598
|
+
text: bytes.toString("utf8")
|
|
23599
|
+
}] };
|
|
23600
|
+
return { mcpContent: [{
|
|
23601
|
+
type: "text",
|
|
23602
|
+
text: `Downloaded ${bytes.byteLength} bytes of ${mimeType} (binary — not shown inline).`
|
|
23603
|
+
}] };
|
|
23604
|
+
}
|
|
23457
23605
|
default: throw new Error(`unknown tool: ${name}`);
|
|
23458
23606
|
}
|
|
23459
23607
|
}
|
|
@@ -23665,6 +23813,20 @@ const SLACK_TOOLS = [
|
|
|
23665
23813
|
type: "string",
|
|
23666
23814
|
description: "User ID (e.g. U0123ABC)."
|
|
23667
23815
|
} }, ["user"])
|
|
23816
|
+
},
|
|
23817
|
+
{
|
|
23818
|
+
name: "readSlackFile",
|
|
23819
|
+
description: "Fetch the contents of a file shared in Slack, using the bot credentials. You do NOT have direct network access to Slack's private file URLs (they require the bot token) — use this tool instead of curl/fetch. Pass the file's `url` (the `url_private` / `uri` from a shared attachment or resource link). Images are returned as viewable image content; text files as text. Supply `mimeType` when known for correct handling.",
|
|
23820
|
+
inputSchema: obj({
|
|
23821
|
+
url: {
|
|
23822
|
+
type: "string",
|
|
23823
|
+
description: "The file's url_private (or url_private_download) / resource-link uri."
|
|
23824
|
+
},
|
|
23825
|
+
mimeType: {
|
|
23826
|
+
type: "string",
|
|
23827
|
+
description: "Optional MIME type hint, e.g. image/png or text/plain."
|
|
23828
|
+
}
|
|
23829
|
+
}, ["url"])
|
|
23668
23830
|
}
|
|
23669
23831
|
];
|
|
23670
23832
|
SLACK_TOOLS.map((t) => t.name);
|
|
@@ -79691,9 +79853,25 @@ var import_dist = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((expor
|
|
|
79691
79853
|
});
|
|
79692
79854
|
})))(), 1);
|
|
79693
79855
|
const MENTION_RE = /<@([A-Z0-9]+)>/g;
|
|
79856
|
+
/** Map a Slack file element to a NormalizedMessage Attachment, dropping any
|
|
79857
|
+
* without a usable id + fetch URL (e.g. external/tombstoned files). Tolerates a
|
|
79858
|
+
* malformed (null / non-object) element off the wire without throwing. */
|
|
79859
|
+
function toAttachment(f) {
|
|
79860
|
+
if (!f || typeof f !== "object") return null;
|
|
79861
|
+
const sourceUrl = f.url_private_download ?? f.url_private;
|
|
79862
|
+
if (!f.id || !sourceUrl) return null;
|
|
79863
|
+
return {
|
|
79864
|
+
id: f.id,
|
|
79865
|
+
name: f.name ?? f.title ?? f.id,
|
|
79866
|
+
mimeType: f.mimetype ?? "application/octet-stream",
|
|
79867
|
+
...typeof f.size === "number" ? { size: f.size } : {},
|
|
79868
|
+
sourceUrl
|
|
79869
|
+
};
|
|
79870
|
+
}
|
|
79694
79871
|
function normalizeSlackEvent(event, ctx) {
|
|
79695
79872
|
const text = event.text ?? "";
|
|
79696
79873
|
const mentionedBots = [...text.matchAll(MENTION_RE)].map((m) => m[1]);
|
|
79874
|
+
const attachments = (event.files ?? []).map(toAttachment).filter((a) => a !== null);
|
|
79697
79875
|
return {
|
|
79698
79876
|
msgId: `slack:${event.channel}:${event.ts}`,
|
|
79699
79877
|
traceId: ctx.traceId,
|
|
@@ -79707,10 +79885,75 @@ function normalizeSlackEvent(event, ctx) {
|
|
|
79707
79885
|
},
|
|
79708
79886
|
text,
|
|
79709
79887
|
mentionedBots,
|
|
79888
|
+
...attachments.length ? { attachments } : {},
|
|
79710
79889
|
isDm: event.channel_type === "im"
|
|
79711
79890
|
};
|
|
79712
79891
|
}
|
|
79713
79892
|
//#endregion
|
|
79893
|
+
//#region src/slack/send-queue.ts
|
|
79894
|
+
/**
|
|
79895
|
+
* §9.1 SlackSendQueue: serialize outbound Slack Web API writes (chat.postMessage
|
|
79896
|
+
* / chat.update / assistant.threads.setStatus) per connection and space them by a
|
|
79897
|
+
* minimum interval so streamed edits don't trip Slack's Tier-3 rate limit
|
|
79898
|
+
* (chat.postMessage ~50 rpm). FIFO: preserves the order the converger emits.
|
|
79899
|
+
*
|
|
79900
|
+
* The first task runs immediately; each subsequent task waits until at least
|
|
79901
|
+
* `minIntervalMs` after the previous one *started*. `enqueue` resolves/rejects
|
|
79902
|
+
* with the task's own result, so callers can still await a posted message ts.
|
|
79903
|
+
*
|
|
79904
|
+
* Each task is bounded by `taskTimeoutMs`: if a single call hangs (e.g. a Slack
|
|
79905
|
+
* API stall during a socket reconnect), it is abandoned so the queue keeps moving
|
|
79906
|
+
* and a best-effort status update can never block real message delivery. The
|
|
79907
|
+
* abandoned call's promise still settles in the background; the caller just sees a
|
|
79908
|
+
* timeout rejection (which, for a progress/plan post, the daemon treats as "no ts").
|
|
79909
|
+
*/
|
|
79910
|
+
var SlackSendQueue = class {
|
|
79911
|
+
minIntervalMs;
|
|
79912
|
+
now;
|
|
79913
|
+
sleep;
|
|
79914
|
+
taskTimeoutMs;
|
|
79915
|
+
chain = Promise.resolve();
|
|
79916
|
+
lastStart = Number.NEGATIVE_INFINITY;
|
|
79917
|
+
constructor(minIntervalMs = 350, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), taskTimeoutMs = 3e4) {
|
|
79918
|
+
this.minIntervalMs = minIntervalMs;
|
|
79919
|
+
this.now = now;
|
|
79920
|
+
this.sleep = sleep;
|
|
79921
|
+
this.taskTimeoutMs = taskTimeoutMs;
|
|
79922
|
+
}
|
|
79923
|
+
enqueue(task) {
|
|
79924
|
+
const run = this.chain.then(async () => {
|
|
79925
|
+
const wait = this.minIntervalMs - (this.now() - this.lastStart);
|
|
79926
|
+
if (wait > 0) await this.sleep(wait);
|
|
79927
|
+
this.lastStart = this.now();
|
|
79928
|
+
return this.withTimeout(task());
|
|
79929
|
+
});
|
|
79930
|
+
this.chain = run.then(() => void 0, () => void 0);
|
|
79931
|
+
return run;
|
|
79932
|
+
}
|
|
79933
|
+
withTimeout(p) {
|
|
79934
|
+
if (!Number.isFinite(this.taskTimeoutMs) || this.taskTimeoutMs <= 0) return p;
|
|
79935
|
+
return new Promise((resolve, reject) => {
|
|
79936
|
+
let settled = false;
|
|
79937
|
+
const timer = setTimeout(() => {
|
|
79938
|
+
if (settled) return;
|
|
79939
|
+
settled = true;
|
|
79940
|
+
reject(/* @__PURE__ */ new Error(`SlackSendQueue: task exceeded ${this.taskTimeoutMs}ms — abandoned`));
|
|
79941
|
+
}, this.taskTimeoutMs);
|
|
79942
|
+
p.then((v) => {
|
|
79943
|
+
if (settled) return;
|
|
79944
|
+
settled = true;
|
|
79945
|
+
clearTimeout(timer);
|
|
79946
|
+
resolve(v);
|
|
79947
|
+
}, (e) => {
|
|
79948
|
+
if (settled) return;
|
|
79949
|
+
settled = true;
|
|
79950
|
+
clearTimeout(timer);
|
|
79951
|
+
reject(e);
|
|
79952
|
+
});
|
|
79953
|
+
});
|
|
79954
|
+
}
|
|
79955
|
+
};
|
|
79956
|
+
//#endregion
|
|
79714
79957
|
//#region src/slack/connection.ts
|
|
79715
79958
|
const { App, LogLevel } = import_dist.default;
|
|
79716
79959
|
/** §6.1: one Slack Socket Mode connection per unique appToken. */
|
|
@@ -79737,15 +79980,21 @@ const MEMBER_ENRICH_CAP = 50;
|
|
|
79737
79980
|
var SlackConnection = class {
|
|
79738
79981
|
deps;
|
|
79739
79982
|
app;
|
|
79983
|
+
queue;
|
|
79740
79984
|
botUserId = "";
|
|
79741
79985
|
/** The appToken this socket is keyed by (one socket per unique appToken). */
|
|
79742
79986
|
appToken;
|
|
79743
79987
|
/** The botToken this socket authenticated with (used to detect a same-appToken swap). */
|
|
79744
79988
|
botToken;
|
|
79989
|
+
botId = "";
|
|
79745
79990
|
constructor(deps, factory = (o) => new App({
|
|
79746
79991
|
token: o.token,
|
|
79747
79992
|
appToken: o.appToken,
|
|
79748
79993
|
socketMode: true,
|
|
79994
|
+
clientOptions: {
|
|
79995
|
+
timeout: 1e4,
|
|
79996
|
+
retryConfig: { retries: 2 }
|
|
79997
|
+
},
|
|
79749
79998
|
...deps.boltDebug ? { logLevel: LogLevel.DEBUG } : {}
|
|
79750
79999
|
})) {
|
|
79751
80000
|
this.deps = deps;
|
|
@@ -79755,13 +80004,15 @@ var SlackConnection = class {
|
|
|
79755
80004
|
token: deps.group.botToken,
|
|
79756
80005
|
appToken: deps.group.appToken
|
|
79757
80006
|
});
|
|
80007
|
+
this.queue = new SlackSendQueue(deps.sendIntervalMs ?? 350);
|
|
79758
80008
|
}
|
|
79759
80009
|
async start() {
|
|
79760
80010
|
const log = this.deps.log;
|
|
79761
80011
|
log?.debug("slack: auth.test → resolving bot identity (HTTPS)…");
|
|
79762
80012
|
const auth = await this.app.client.auth.test();
|
|
79763
80013
|
this.botUserId = auth.user_id ?? "";
|
|
79764
|
-
|
|
80014
|
+
this.botId = auth.bot_id ?? "";
|
|
80015
|
+
log?.debug(`slack: auth.test ok → bot user ${this.botUserId} (bot_id ${this.botId || "n/a"})`);
|
|
79765
80016
|
const deliver = (ev, kind) => {
|
|
79766
80017
|
const msg = normalizeSlackEvent(ev, { traceId: this.deps.newTraceId() });
|
|
79767
80018
|
log?.debug(`slack: inbound ${kind} ch=${msg.channel} user=${msg.sender.id} isBot=${msg.sender.isBot} isDm=${msg.isDm} mentions=[${msg.mentionedBots.join(",")}] text=${JSON.stringify(msg.text.slice(0, 80))}`);
|
|
@@ -79785,11 +80036,97 @@ var SlackConnection = class {
|
|
|
79785
80036
|
log?.debug("slack: app.start resolved → socket established");
|
|
79786
80037
|
}
|
|
79787
80038
|
async postMessage(channel, text, threadTs) {
|
|
79788
|
-
return
|
|
79789
|
-
|
|
79790
|
-
|
|
79791
|
-
|
|
79792
|
-
|
|
80039
|
+
return this.queue.enqueue(async () => {
|
|
80040
|
+
return (await this.app.client.chat.postMessage({
|
|
80041
|
+
channel,
|
|
80042
|
+
text,
|
|
80043
|
+
thread_ts: threadTs
|
|
80044
|
+
}))?.ts;
|
|
80045
|
+
});
|
|
80046
|
+
}
|
|
80047
|
+
/** Edit a previously-posted message in place (chat.update) — the §9.1 "就地更新"
|
|
80048
|
+
* primitive for the main progress / plan message. Best-effort: swallows errors. */
|
|
80049
|
+
async updateMessage(channel, ts, text) {
|
|
80050
|
+
await this.queue.enqueue(async () => {
|
|
80051
|
+
try {
|
|
80052
|
+
await this.app.client.chat.update({
|
|
80053
|
+
channel,
|
|
80054
|
+
ts,
|
|
80055
|
+
text
|
|
80056
|
+
});
|
|
80057
|
+
} catch (err) {
|
|
80058
|
+
this.deps.log?.debug(`slack: chat.update failed (ch=${channel} ts=${ts}): ${err.message}`);
|
|
80059
|
+
}
|
|
80060
|
+
});
|
|
80061
|
+
}
|
|
80062
|
+
/**
|
|
80063
|
+
* Pull a Slack thread's full history (conversations.replies, cursor-paginated)
|
|
80064
|
+
* for §8.4/§9.2 mid-thread context. Returns root + replies in Slack ts order;
|
|
80065
|
+
* best-effort (returns what it fetched, [] on error). Bot/system frames keep
|
|
80066
|
+
* their bot_id as the sender so the caller can attribute them.
|
|
80067
|
+
*/
|
|
80068
|
+
async getThreadReplies(channel, threadTs, maxMessages = 200) {
|
|
80069
|
+
const out = [];
|
|
80070
|
+
let cursor;
|
|
80071
|
+
try {
|
|
80072
|
+
do {
|
|
80073
|
+
const res = await this.app.client.conversations.replies({
|
|
80074
|
+
channel,
|
|
80075
|
+
ts: threadTs,
|
|
80076
|
+
limit: 200,
|
|
80077
|
+
...cursor ? { cursor } : {}
|
|
80078
|
+
});
|
|
80079
|
+
for (const m of res.messages ?? []) {
|
|
80080
|
+
if (!m.ts) continue;
|
|
80081
|
+
out.push({
|
|
80082
|
+
sender: m.user ?? m.bot_id ?? "unknown",
|
|
80083
|
+
ts: m.ts,
|
|
80084
|
+
text: m.text ?? "",
|
|
80085
|
+
isBot: Boolean(m.bot_id),
|
|
80086
|
+
attachments: (m.files ?? []).map(toAttachment).filter((a) => a !== null)
|
|
80087
|
+
});
|
|
80088
|
+
if (out.length >= maxMessages) return out;
|
|
80089
|
+
}
|
|
80090
|
+
cursor = res.has_more ? res.response_metadata?.next_cursor : void 0;
|
|
80091
|
+
} while (cursor);
|
|
80092
|
+
} catch (err) {
|
|
80093
|
+
this.deps.log?.debug(`slack: conversations.replies failed (ch=${channel} thread=${threadTs}): ${err.message}`);
|
|
80094
|
+
}
|
|
80095
|
+
return out;
|
|
80096
|
+
}
|
|
80097
|
+
/**
|
|
80098
|
+
* Download an auth-gated Slack file (url_private[_download]) with the bot token,
|
|
80099
|
+
* up to `maxBytes` (bounds daemon RSS + the inlined prompt frame). Returns the
|
|
80100
|
+
* bytes, or null on any failure / over-cap (best-effort — a failed or oversized
|
|
80101
|
+
* attachment degrades to a resource_link, never breaks the prompt). §9.2: bytes
|
|
80102
|
+
* stay daemon-local.
|
|
80103
|
+
*/
|
|
80104
|
+
async downloadFile(sourceUrl, maxBytes = 8 * 1024 * 1024) {
|
|
80105
|
+
try {
|
|
80106
|
+
const res = await fetch(sourceUrl, { headers: { Authorization: `Bearer ${this.deps.group.botToken}` } });
|
|
80107
|
+
if (!res.ok) {
|
|
80108
|
+
this.deps.log?.debug(`slack: downloadFile ${sourceUrl} → HTTP ${res.status}`);
|
|
80109
|
+
return null;
|
|
80110
|
+
}
|
|
80111
|
+
const declared = Number(res.headers.get("content-length"));
|
|
80112
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
80113
|
+
this.deps.log?.debug(`slack: downloadFile ${sourceUrl} skipped — ${declared} bytes > cap ${maxBytes}`);
|
|
80114
|
+
return null;
|
|
80115
|
+
}
|
|
80116
|
+
if ((res.headers.get("content-type") ?? "").includes("text/html")) {
|
|
80117
|
+
this.deps.log?.debug(`slack: downloadFile ${sourceUrl} got text/html (login page?) — treating as inaccessible`);
|
|
80118
|
+
return null;
|
|
80119
|
+
}
|
|
80120
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
80121
|
+
if (buf.byteLength > maxBytes) {
|
|
80122
|
+
this.deps.log?.debug(`slack: downloadFile ${sourceUrl} discarded — ${buf.byteLength} bytes > cap ${maxBytes}`);
|
|
80123
|
+
return null;
|
|
80124
|
+
}
|
|
80125
|
+
return buf;
|
|
80126
|
+
} catch (err) {
|
|
80127
|
+
this.deps.log?.debug(`slack: downloadFile ${sourceUrl} failed: ${err.message}`);
|
|
80128
|
+
return null;
|
|
80129
|
+
}
|
|
79793
80130
|
}
|
|
79794
80131
|
async getChannelInfo(channel) {
|
|
79795
80132
|
const c = (await this.app.client.conversations.info({ channel })).channel ?? {};
|
|
@@ -79837,22 +80174,103 @@ var SlackConnection = class {
|
|
|
79837
80174
|
* Pass status='' to clear. Never throws into dispatch.
|
|
79838
80175
|
*/
|
|
79839
80176
|
async setStatus(channel, threadTs, status, loadingMessages) {
|
|
79840
|
-
|
|
79841
|
-
|
|
79842
|
-
|
|
79843
|
-
|
|
79844
|
-
|
|
79845
|
-
|
|
79846
|
-
|
|
79847
|
-
|
|
79848
|
-
|
|
79849
|
-
|
|
80177
|
+
await this.queue.enqueue(async () => {
|
|
80178
|
+
try {
|
|
80179
|
+
await this.app.client.assistant.threads.setStatus({
|
|
80180
|
+
channel_id: channel,
|
|
80181
|
+
thread_ts: threadTs,
|
|
80182
|
+
status,
|
|
80183
|
+
...loadingMessages ? { loading_messages: loadingMessages } : {}
|
|
80184
|
+
});
|
|
80185
|
+
} catch (err) {
|
|
80186
|
+
this.deps.log?.debug(`slack: setStatus failed (ch=${channel} thread=${threadTs}): ${err.message}`);
|
|
80187
|
+
}
|
|
80188
|
+
});
|
|
79850
80189
|
}
|
|
79851
80190
|
async stop() {
|
|
79852
80191
|
await this.app.stop();
|
|
79853
80192
|
}
|
|
79854
80193
|
};
|
|
79855
80194
|
//#endregion
|
|
80195
|
+
//#region src/slack/formatter.ts
|
|
80196
|
+
/**
|
|
80197
|
+
* Last-mile Slack formatting (§9.1 / §9.3). Two pure helpers:
|
|
80198
|
+
* - markdownToMrkdwn: convert the agent's CommonMark-ish output to Slack mrkdwn.
|
|
80199
|
+
* - splitIntoSections: break a message into <= maxLen sections (Slack ~3000/section).
|
|
80200
|
+
* Both are platform-specific; the upstream convergence (ACP->IR) stays platform-agnostic.
|
|
80201
|
+
*/
|
|
80202
|
+
/** Slack section soft cap (§9.3: Slack splits at ~3000 chars per section). */
|
|
80203
|
+
const SLACK_SECTION_LIMIT = 3e3;
|
|
80204
|
+
const CODE_SENTINEL = "\0";
|
|
80205
|
+
const BOLD = "";
|
|
80206
|
+
/** Replace fenced ```code``` and inline `code` with placeholders so inline
|
|
80207
|
+
* formatting rules never fire inside them; returns the masked text + restorer. */
|
|
80208
|
+
function protectCode(input) {
|
|
80209
|
+
const spans = [];
|
|
80210
|
+
const masked = input.replace(/```[\s\S]*?```|`[^`\n]*`/g, (m) => {
|
|
80211
|
+
return `${CODE_SENTINEL}${spans.push(m) - 1}${CODE_SENTINEL}`;
|
|
80212
|
+
});
|
|
80213
|
+
const restore = (s) => s.replace(new RegExp(`${CODE_SENTINEL}(\\d+)${CODE_SENTINEL}`, "g"), (_, i) => spans[Number(i)] ?? "");
|
|
80214
|
+
return {
|
|
80215
|
+
masked,
|
|
80216
|
+
restore
|
|
80217
|
+
};
|
|
80218
|
+
}
|
|
80219
|
+
/**
|
|
80220
|
+
* Convert common Markdown to Slack mrkdwn. Handled: bold (`**`/`__` -> `*`),
|
|
80221
|
+
* italic (`*` -> `_`; bare `_` is already valid mrkdwn italic and left alone to
|
|
80222
|
+
* avoid mangling snake_case), strikethrough (`~~` -> `~`), links (`[t](u)` ->
|
|
80223
|
+
* `<u|t>`), ATX headings (`# H` -> `*H*`), and `-`/`*`/`+` bullets -> `•`. Code
|
|
80224
|
+
* spans and fences are preserved verbatim.
|
|
80225
|
+
*
|
|
80226
|
+
* Best-effort, not a full CommonMark parser: pathological overlapping emphasis
|
|
80227
|
+
* (e.g. `***a** b*`, or a lone `*` inside `**bold**`) can still render imperfectly.
|
|
80228
|
+
* Such inputs are rare in agent output and only affect cosmetics — never content.
|
|
80229
|
+
*/
|
|
80230
|
+
function markdownToMrkdwn(input) {
|
|
80231
|
+
if (!input) return input;
|
|
80232
|
+
const { masked, restore } = protectCode(input);
|
|
80233
|
+
let out = masked;
|
|
80234
|
+
out = out.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*$/gm, (_m, h) => `${BOLD}${h.replace(/\*+|__/g, "")}${BOLD}`);
|
|
80235
|
+
out = out.replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• ");
|
|
80236
|
+
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>");
|
|
80237
|
+
out = out.replace(/\*\*\*(?!\s)([^\n]+?)(?<!\s)\*\*\*/g, `${BOLD}_$1_${BOLD}`);
|
|
80238
|
+
out = out.replace(/___(?!\s)([^\n]+?)(?<!\s)___/g, `${BOLD}_$1_${BOLD}`);
|
|
80239
|
+
out = out.replace(/\*\*(?!\s)([^\n]+?)(?<!\s)\*\*/g, `${BOLD}$1${BOLD}`);
|
|
80240
|
+
out = out.replace(/__(?!\s)([^\n]+?)(?<!\s)__/g, `${BOLD}$1${BOLD}`);
|
|
80241
|
+
out = out.replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, "_$1_");
|
|
80242
|
+
out = out.split(BOLD).join("*");
|
|
80243
|
+
out = out.replace(/~~([^\n]+?)~~/g, "~$1~");
|
|
80244
|
+
return restore(out);
|
|
80245
|
+
}
|
|
80246
|
+
/**
|
|
80247
|
+
* Split text into sections no longer than maxLen, preferring line boundaries;
|
|
80248
|
+
* a single line longer than maxLen is hard-cut. Whitespace-only input yields no
|
|
80249
|
+
* sections. Content is never lost across sections.
|
|
80250
|
+
*/
|
|
80251
|
+
function splitIntoSections(text, maxLen = SLACK_SECTION_LIMIT) {
|
|
80252
|
+
if (!text.trim()) return [];
|
|
80253
|
+
if (text.length <= maxLen) return [text];
|
|
80254
|
+
const sections = [];
|
|
80255
|
+
let cur = "";
|
|
80256
|
+
const flush = () => {
|
|
80257
|
+
if (cur.length) sections.push(cur);
|
|
80258
|
+
cur = "";
|
|
80259
|
+
};
|
|
80260
|
+
for (const line of text.split("\n")) {
|
|
80261
|
+
const piece = cur.length ? `\n${line}` : line;
|
|
80262
|
+
if (cur.length + piece.length <= maxLen) {
|
|
80263
|
+
cur += piece;
|
|
80264
|
+
continue;
|
|
80265
|
+
}
|
|
80266
|
+
flush();
|
|
80267
|
+
if (line.length <= maxLen) cur = line;
|
|
80268
|
+
else for (let i = 0; i < line.length; i += maxLen) sections.push(line.slice(i, i + maxLen));
|
|
80269
|
+
}
|
|
80270
|
+
flush();
|
|
80271
|
+
return sections;
|
|
80272
|
+
}
|
|
80273
|
+
//#endregion
|
|
79856
80274
|
//#region src/slack/render.ts
|
|
79857
80275
|
const THINKING = "is thinking…";
|
|
79858
80276
|
const MAX_ACTIVITY = 10;
|
|
@@ -79861,6 +80279,15 @@ function clampLabel(s) {
|
|
|
79861
80279
|
const t = s.trim();
|
|
79862
80280
|
return t.length > MAX_LABEL ? `${t.slice(0, MAX_LABEL - 1)}…` : t;
|
|
79863
80281
|
}
|
|
80282
|
+
function planIcon(status) {
|
|
80283
|
+
if (status === "completed") return ":white_check_mark:";
|
|
80284
|
+
if (status === "in_progress") return ":hourglass_flowing_sand:";
|
|
80285
|
+
return ":white_large_square:";
|
|
80286
|
+
}
|
|
80287
|
+
/** Render an ACP plan (full entry list, resent on every update) as a compact summary. */
|
|
80288
|
+
function renderPlan(entries) {
|
|
80289
|
+
return [":clipboard: *Plan*", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
|
|
80290
|
+
}
|
|
79864
80291
|
var OutputConverger = class {
|
|
79865
80292
|
mode;
|
|
79866
80293
|
buf = "";
|
|
@@ -79869,17 +80296,24 @@ var OutputConverger = class {
|
|
|
79869
80296
|
constructor(mode) {
|
|
79870
80297
|
this.mode = mode;
|
|
79871
80298
|
}
|
|
80299
|
+
/** True while body text is buffered — the daemon uses this to (re)arm the ~2s
|
|
80300
|
+
* idle-flush timer (§9.1 text-buffer) so a long pure-text stream posts in steps. */
|
|
80301
|
+
hasBuffered() {
|
|
80302
|
+
return this.buf.trim().length > 0;
|
|
80303
|
+
}
|
|
80304
|
+
/** Flush the buffered body: markdown→mrkdwn, then split into ≤3000-char sections,
|
|
80305
|
+
* one `post` per section (§9.1/§9.3). Public so the daemon's idle timer can call it. */
|
|
80306
|
+
flushBuffered() {
|
|
80307
|
+
return this.flush();
|
|
80308
|
+
}
|
|
79872
80309
|
flush() {
|
|
79873
|
-
if (!this.buf.trim()) {
|
|
79874
|
-
this.buf = "";
|
|
79875
|
-
return [];
|
|
79876
|
-
}
|
|
79877
80310
|
const text = this.buf;
|
|
79878
80311
|
this.buf = "";
|
|
79879
|
-
return [
|
|
80312
|
+
if (!text.trim()) return [];
|
|
80313
|
+
return splitIntoSections(markdownToMrkdwn(text)).map((t) => ({
|
|
79880
80314
|
kind: "post",
|
|
79881
|
-
text
|
|
79882
|
-
}
|
|
80315
|
+
text: t
|
|
80316
|
+
}));
|
|
79883
80317
|
}
|
|
79884
80318
|
/**
|
|
79885
80319
|
* Record an activity label and build the loading-status action carrying the rolling
|
|
@@ -79921,13 +80355,12 @@ var OutputConverger = class {
|
|
|
79921
80355
|
...this.flush(),
|
|
79922
80356
|
...status,
|
|
79923
80357
|
{
|
|
79924
|
-
kind: "
|
|
79925
|
-
text:
|
|
80358
|
+
kind: "progress",
|
|
80359
|
+
text: `:thought_balloon: ${clampLabel(content?.text ?? "thinking…")}`
|
|
79926
80360
|
}
|
|
79927
80361
|
];
|
|
79928
80362
|
}
|
|
79929
|
-
|
|
79930
|
-
return status;
|
|
80363
|
+
return [...this.flush(), ...status];
|
|
79931
80364
|
}
|
|
79932
80365
|
case "tool_call":
|
|
79933
80366
|
case "tool_call_update": {
|
|
@@ -79938,28 +80371,42 @@ var OutputConverger = class {
|
|
|
79938
80371
|
...this.flush(),
|
|
79939
80372
|
...status,
|
|
79940
80373
|
{
|
|
79941
|
-
kind: "
|
|
80374
|
+
kind: "progress",
|
|
79942
80375
|
text: `:hammer_and_wrench: ${label}`
|
|
79943
80376
|
}
|
|
79944
80377
|
];
|
|
79945
80378
|
}
|
|
80379
|
+
case "plan": {
|
|
80380
|
+
const entries = update.entries ?? [];
|
|
80381
|
+
if (this.mode === "low") return [...this.flush(), ...this.pushActivity("planning…")];
|
|
80382
|
+
return [...this.flush(), {
|
|
80383
|
+
kind: "plan",
|
|
80384
|
+
text: renderPlan(entries)
|
|
80385
|
+
}];
|
|
80386
|
+
}
|
|
79946
80387
|
case "usage_update": return [];
|
|
79947
80388
|
default: return [];
|
|
79948
80389
|
}
|
|
79949
80390
|
}
|
|
80391
|
+
/**
|
|
80392
|
+
* Turn end (§9.1 stopReason): flush remaining body, clear the loading status, and
|
|
80393
|
+
* — in medium/high — append a footer linking to the Web App session detail. The
|
|
80394
|
+
* link is omitted entirely when none is configured (no `local://` placeholder).
|
|
80395
|
+
*/
|
|
79950
80396
|
onFinal(link) {
|
|
79951
80397
|
const clear = {
|
|
79952
80398
|
kind: "set-status",
|
|
79953
80399
|
text: ""
|
|
79954
80400
|
};
|
|
79955
80401
|
if (this.mode === "low") return [...this.flush(), clear];
|
|
80402
|
+
const footer = link ? [{
|
|
80403
|
+
kind: "post",
|
|
80404
|
+
text: `:white_check_mark: done — <${link}|details>`
|
|
80405
|
+
}] : [];
|
|
79956
80406
|
return [
|
|
79957
80407
|
...this.flush(),
|
|
79958
80408
|
clear,
|
|
79959
|
-
|
|
79960
|
-
kind: "post",
|
|
79961
|
-
text: `:white_check_mark: done — <${link}|details>`
|
|
79962
|
-
}
|
|
80409
|
+
...footer
|
|
79963
80410
|
];
|
|
79964
80411
|
}
|
|
79965
80412
|
};
|
|
@@ -81440,6 +81887,7 @@ function formatErr(err) {
|
|
|
81440
81887
|
return e?.stack ?? String(err);
|
|
81441
81888
|
}
|
|
81442
81889
|
const MAX_QUEUED_PER_SESSION = 10;
|
|
81890
|
+
const IDLE_FLUSH_MS = 2e3;
|
|
81443
81891
|
var Daemon = class {
|
|
81444
81892
|
opts;
|
|
81445
81893
|
store;
|
|
@@ -81530,7 +81978,8 @@ var Daemon = class {
|
|
|
81530
81978
|
ts,
|
|
81531
81979
|
sender: ctx.agentId,
|
|
81532
81980
|
text
|
|
81533
|
-
})
|
|
81981
|
+
}),
|
|
81982
|
+
maxAttachmentBytes: cfg.limits.maxAttachmentBytes
|
|
81534
81983
|
});
|
|
81535
81984
|
await this.mcp.start();
|
|
81536
81985
|
const cliEntry = process.argv[1] ?? "";
|
|
@@ -81554,7 +82003,10 @@ var Daemon = class {
|
|
|
81554
82003
|
token,
|
|
81555
82004
|
cliEntry
|
|
81556
82005
|
});
|
|
81557
|
-
}
|
|
82006
|
+
},
|
|
82007
|
+
downloadAttachment: (agentId, att) => this.replyConnFor(agentId)?.downloadFile?.(att.sourceUrl, this.cfg.limits.maxAttachmentBytes) ?? Promise.resolve(null),
|
|
82008
|
+
attachmentMaxBytes: cfg.limits.maxAttachmentBytes,
|
|
82009
|
+
fetchThreadHistory: (agentId, channel, threadTs) => this.fetchThreadHistory(agentId, channel, threadTs)
|
|
81558
82010
|
});
|
|
81559
82011
|
this.scheduler = new Scheduler({
|
|
81560
82012
|
onFire: (agentId, msg) => void this.dispatch(agentId, msg).catch((err) => this.log.error(`cron dispatch failed for agent "${agentId}": ${formatErr(err)}`)),
|
|
@@ -81786,12 +82238,63 @@ var Daemon = class {
|
|
|
81786
82238
|
}
|
|
81787
82239
|
const result = routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
|
|
81788
82240
|
if (!result) {
|
|
82241
|
+
this.recordUnrouted(msg);
|
|
81789
82242
|
this.log.debug(`routing: dropped message in ch=${msg.channel} (no agent matched — not a mention of a known bot, not a subscribed 'all' channel, not a thread/DM hit)`);
|
|
81790
82243
|
return;
|
|
81791
82244
|
}
|
|
81792
82245
|
this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
|
|
81793
82246
|
this.dispatch(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
|
|
81794
82247
|
}
|
|
82248
|
+
/** All of our own connections' bot identities (both `U…` user ids and `B…`
|
|
82249
|
+
* bot_ids), so we recognize self-echoes regardless of which field Slack sends. */
|
|
82250
|
+
ownBotIdentities() {
|
|
82251
|
+
const ids = /* @__PURE__ */ new Set();
|
|
82252
|
+
for (const conn of this.connByIntegration.values()) {
|
|
82253
|
+
if (conn.botUserId) ids.add(conn.botUserId);
|
|
82254
|
+
if (conn.botId) ids.add(conn.botId);
|
|
82255
|
+
}
|
|
82256
|
+
return ids;
|
|
82257
|
+
}
|
|
82258
|
+
/** Record an unrouted inbound message into the transcript iff a session is
|
|
82259
|
+
* *recently active* in its thread (§8.5 catch-up). Skips our own bot's echoes
|
|
82260
|
+
* (recordOutbound already logs those under the agentId). The recency gate bounds
|
|
82261
|
+
* transcript growth to threads with live work — without it, a thread that ever
|
|
82262
|
+
* held a session would record forever (no session-`closed` lifecycle yet). */
|
|
82263
|
+
recordUnrouted(msg) {
|
|
82264
|
+
if (this.ownBotIdentities().has(msg.sender.id)) return;
|
|
82265
|
+
const { thread, ts } = transcriptCoords(msg);
|
|
82266
|
+
const sinceTs = Date.now() - this.cfg.limits.agentIdleTimeoutMs;
|
|
82267
|
+
const recentlyActive = this.store.activeSessionCountSince(msg.channel, thread, sinceTs) > 0;
|
|
82268
|
+
const inFlight = [...this.pending.values()].some((p) => p.channel === msg.channel && p.statusThread === thread);
|
|
82269
|
+
if (!recentlyActive && !inFlight) return;
|
|
82270
|
+
this.store.appendTranscript({
|
|
82271
|
+
channel: msg.channel,
|
|
82272
|
+
thread,
|
|
82273
|
+
ts,
|
|
82274
|
+
sender: msg.sender.id,
|
|
82275
|
+
text: msg.text
|
|
82276
|
+
});
|
|
82277
|
+
this.log.debug(`transcript: recorded unrouted msg ch=${msg.channel} thread=${thread} ts=${ts} (live session)`);
|
|
82278
|
+
}
|
|
82279
|
+
/**
|
|
82280
|
+
* Pull real Slack thread history for a cold mid-thread @ (§8.4/§9.2), relabeling
|
|
82281
|
+
* THIS agent's own past bot frames to its agentId (so the §8.5 own-message filter
|
|
82282
|
+
* still suppresses them) and folding any attachment metadata into the text (so
|
|
82283
|
+
* cold replay matches the live transcript's `[attached: …]` mention).
|
|
82284
|
+
*/
|
|
82285
|
+
async fetchThreadHistory(agentId, channel, threadTs) {
|
|
82286
|
+
const conn = this.replyConnFor(agentId);
|
|
82287
|
+
if (!conn?.getThreadReplies) return [];
|
|
82288
|
+
const ours = new Set([conn.botUserId, conn.botId].filter(Boolean));
|
|
82289
|
+
return (await conn.getThreadReplies(channel, threadTs)).map((r) => {
|
|
82290
|
+
const mention = attachmentMention(r.attachments);
|
|
82291
|
+
return {
|
|
82292
|
+
sender: r.isBot && ours.has(r.sender) ? agentId : r.sender,
|
|
82293
|
+
ts: r.ts,
|
|
82294
|
+
text: mention ? `${r.text}\n${mention}`.trim() : r.text
|
|
82295
|
+
};
|
|
82296
|
+
});
|
|
82297
|
+
}
|
|
81795
82298
|
queued = /* @__PURE__ */ new Map();
|
|
81796
82299
|
/**
|
|
81797
82300
|
* Handle an in-conversation control command. Resolves the target agent via the
|
|
@@ -81885,36 +82388,87 @@ var Daemon = class {
|
|
|
81885
82388
|
const statusThread = msg.thread ?? msg.msgId;
|
|
81886
82389
|
replyConn?.setStatus(msg.channel, statusThread, wasRunning ? "is thinking…" : "is starting up…");
|
|
81887
82390
|
const { sessionId, blocks } = await this.sessions.handle(agentId, msg);
|
|
81888
|
-
|
|
82391
|
+
const p = {
|
|
81889
82392
|
conv,
|
|
81890
82393
|
channel: msg.channel,
|
|
81891
82394
|
thread: msg.thread,
|
|
81892
|
-
|
|
81893
|
-
|
|
82395
|
+
statusThread,
|
|
82396
|
+
conn: replyConn,
|
|
82397
|
+
applyChain: Promise.resolve()
|
|
82398
|
+
};
|
|
82399
|
+
this.pending.set(sessionId, p);
|
|
81894
82400
|
try {
|
|
81895
82401
|
const host = await this.ensureHostAsync(agentId);
|
|
81896
82402
|
if (!wasRunning) replyConn?.setStatus(msg.channel, statusThread, "is thinking…");
|
|
81897
82403
|
await host.prompt(sessionId, blocks);
|
|
81898
|
-
|
|
82404
|
+
this.clearIdle(p);
|
|
82405
|
+
const link = this.cfg.webAppUrl ? `${this.cfg.webAppUrl.replace(/\/$/, "")}/sessions/${sessionId}` : void 0;
|
|
82406
|
+
for (const action of conv.onFinal(link)) this.enqueueApply(p, action);
|
|
82407
|
+
await p.applyChain;
|
|
81899
82408
|
} finally {
|
|
82409
|
+
this.clearIdle(p);
|
|
81900
82410
|
this.pending.delete(sessionId);
|
|
81901
82411
|
}
|
|
81902
82412
|
this.flushQueued(agentId, sessionId, integrationId);
|
|
81903
82413
|
}
|
|
81904
|
-
/**
|
|
81905
|
-
*
|
|
81906
|
-
|
|
81907
|
-
|
|
81908
|
-
|
|
81909
|
-
|
|
82414
|
+
/**
|
|
82415
|
+
* Apply one converger action against the session's Slack connection:
|
|
82416
|
+
* - set-status → assistant.threads.setStatus (best-effort; '' clears)
|
|
82417
|
+
* - post → a new thread message
|
|
82418
|
+
* - progress / plan → the single in-place message of that kind, posted once
|
|
82419
|
+
* then chat.update-ed (§9.1 就地更新). The first post's ts is remembered on `p`.
|
|
82420
|
+
*/
|
|
82421
|
+
async applyAction(p, action) {
|
|
82422
|
+
const conn = p.conn;
|
|
82423
|
+
if (!conn) return;
|
|
82424
|
+
switch (action.kind) {
|
|
82425
|
+
case "set-status":
|
|
82426
|
+
if (p.statusThread) await conn.setStatus(p.channel, p.statusThread, action.text, action.loadingMessages);
|
|
82427
|
+
return;
|
|
82428
|
+
case "post":
|
|
82429
|
+
await conn.postMessage(p.channel, action.text, p.thread);
|
|
82430
|
+
return;
|
|
82431
|
+
case "progress":
|
|
82432
|
+
if (p.progressTs) await conn.updateMessage(p.channel, p.progressTs, action.text);
|
|
82433
|
+
else if (!p.progressAttempted) {
|
|
82434
|
+
p.progressAttempted = true;
|
|
82435
|
+
p.progressTs = await conn.postMessage(p.channel, action.text, p.thread);
|
|
82436
|
+
}
|
|
82437
|
+
return;
|
|
82438
|
+
case "plan":
|
|
82439
|
+
if (p.planTs) await conn.updateMessage(p.channel, p.planTs, action.text);
|
|
82440
|
+
else if (!p.planAttempted) {
|
|
82441
|
+
p.planAttempted = true;
|
|
82442
|
+
p.planTs = await conn.postMessage(p.channel, action.text, p.thread);
|
|
82443
|
+
}
|
|
82444
|
+
return;
|
|
81910
82445
|
}
|
|
81911
|
-
|
|
82446
|
+
}
|
|
82447
|
+
/** Serialize action application per session so in-place edits never race on the
|
|
82448
|
+
* remembered message ts (two concurrent `progress` actions both posting). */
|
|
82449
|
+
enqueueApply(p, action) {
|
|
82450
|
+
p.applyChain = p.applyChain.then(() => this.applyAction(p, action).catch((err) => this.log.error(`slack apply failed: ${formatErr(err)}`)));
|
|
82451
|
+
}
|
|
82452
|
+
/** (Re)arm the ~2s idle-flush timer when body text is buffered (§9.1 text-buffer):
|
|
82453
|
+
* a long pure-text stream posts in steps instead of all at turn end. */
|
|
82454
|
+
armIdle(p) {
|
|
82455
|
+
this.clearIdle(p);
|
|
82456
|
+
if (!p.conv.hasBuffered()) return;
|
|
82457
|
+
p.idleTimer = setTimeout(() => {
|
|
82458
|
+
p.idleTimer = void 0;
|
|
82459
|
+
for (const action of p.conv.flushBuffered()) this.enqueueApply(p, action);
|
|
82460
|
+
}, IDLE_FLUSH_MS);
|
|
82461
|
+
}
|
|
82462
|
+
clearIdle(p) {
|
|
82463
|
+
if (p.idleTimer) clearTimeout(p.idleTimer);
|
|
82464
|
+
p.idleTimer = void 0;
|
|
81912
82465
|
}
|
|
81913
82466
|
pending = /* @__PURE__ */ new Map();
|
|
81914
82467
|
onAcpUpdate(_agentId, sessionId, update) {
|
|
81915
82468
|
const p = this.pending.get(sessionId);
|
|
81916
82469
|
if (!p) return;
|
|
81917
|
-
for (const action of p.conv.onUpdate(update)) this.
|
|
82470
|
+
for (const action of p.conv.onUpdate(update)) this.enqueueApply(p, action);
|
|
82471
|
+
this.armIdle(p);
|
|
81918
82472
|
}
|
|
81919
82473
|
async ensureHostAsync(agentId) {
|
|
81920
82474
|
const host = this.ensureHost(agentId, this.cfg);
|
|
@@ -82302,7 +82856,7 @@ program.command("run").description("Run the daemon in the foreground").action(as
|
|
|
82302
82856
|
}
|
|
82303
82857
|
});
|
|
82304
82858
|
program.command("mcp-bridge", { hidden: true }).description("internal: stdio MCP bridge to the running daemon").action(async () => {
|
|
82305
|
-
const { runBridge } = await import("./bridge-
|
|
82859
|
+
const { runBridge } = await import("./bridge-hZMpl3k6.js");
|
|
82306
82860
|
await runBridge();
|
|
82307
82861
|
});
|
|
82308
82862
|
const controller = () => resolveController({ root: program.opts().root });
|