@sentry/junior 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +91 -0
- package/bin/junior.mjs +148 -0
- package/dist/app/layout.d.ts +8 -0
- package/dist/app/layout.js +8 -0
- package/dist/bot-DLML4Z7F.js +3108 -0
- package/dist/channel-HJO33DGJ.js +18 -0
- package/dist/chunk-4RBEYCOG.js +12 -0
- package/dist/chunk-7E56WM6K.js +7303 -0
- package/dist/chunk-BBOVH5RF.js +520 -0
- package/dist/chunk-GDNDYMGX.js +333 -0
- package/dist/chunk-MM3YNA4F.js +203 -0
- package/dist/chunk-OD6TOSY4.js +95 -0
- package/dist/chunk-ZA2IDPVG.js +39 -0
- package/dist/chunk-ZBFSIN6G.js +323 -0
- package/dist/client-3GAEMIQ3.js +10 -0
- package/dist/handlers/health.d.ts +3 -0
- package/dist/handlers/health.js +6 -0
- package/dist/handlers/queue-callback.d.ts +3 -0
- package/dist/handlers/queue-callback.js +272 -0
- package/dist/handlers/router.d.ts +9 -0
- package/dist/handlers/router.js +53 -0
- package/dist/handlers/webhooks.d.ts +8 -0
- package/dist/handlers/webhooks.js +7 -0
- package/dist/instrumentation.d.ts +6 -0
- package/dist/instrumentation.js +49 -0
- package/dist/next-config.d.ts +14 -0
- package/dist/next-config.js +108 -0
- package/dist/route-XLYK6CKP.js +309 -0
- package/package.json +80 -0
|
@@ -0,0 +1,3108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GEN_AI_PROVIDER_NAME,
|
|
3
|
+
buildSlackOutputMessage,
|
|
4
|
+
completeObject,
|
|
5
|
+
completeText,
|
|
6
|
+
ensureBlockSpacing,
|
|
7
|
+
escapeXml,
|
|
8
|
+
generateAssistantReply,
|
|
9
|
+
getOAuthProviderConfig,
|
|
10
|
+
getUserTokenStore,
|
|
11
|
+
isExplicitChannelPostIntent,
|
|
12
|
+
isPluginProvider,
|
|
13
|
+
isRetryableTurnError,
|
|
14
|
+
publishAppHomeView,
|
|
15
|
+
shouldEmitDevAgentTrace,
|
|
16
|
+
startOAuthFlow,
|
|
17
|
+
truncateStatusText
|
|
18
|
+
} from "./chunk-7E56WM6K.js";
|
|
19
|
+
import {
|
|
20
|
+
claimQueueIngressDedup,
|
|
21
|
+
getStateAdapter,
|
|
22
|
+
hasQueueIngressDedup
|
|
23
|
+
} from "./chunk-ZBFSIN6G.js";
|
|
24
|
+
import {
|
|
25
|
+
listThreadReplies
|
|
26
|
+
} from "./chunk-MM3YNA4F.js";
|
|
27
|
+
import {
|
|
28
|
+
botConfig,
|
|
29
|
+
downloadPrivateSlackFile,
|
|
30
|
+
getSlackBotToken,
|
|
31
|
+
getSlackClient,
|
|
32
|
+
getSlackClientId,
|
|
33
|
+
getSlackClientSecret,
|
|
34
|
+
getSlackSigningSecret,
|
|
35
|
+
isDmChannel
|
|
36
|
+
} from "./chunk-GDNDYMGX.js";
|
|
37
|
+
import {
|
|
38
|
+
logError,
|
|
39
|
+
logException,
|
|
40
|
+
logInfo,
|
|
41
|
+
logWarn,
|
|
42
|
+
setSpanAttributes,
|
|
43
|
+
setTags,
|
|
44
|
+
toOptionalString,
|
|
45
|
+
withContext,
|
|
46
|
+
withSpan
|
|
47
|
+
} from "./chunk-BBOVH5RF.js";
|
|
48
|
+
|
|
49
|
+
// src/chat/bot.ts
|
|
50
|
+
import { Chat as Chat2 } from "chat";
|
|
51
|
+
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
52
|
+
|
|
53
|
+
// src/chat/chat-background-patch.ts
|
|
54
|
+
import { Chat } from "chat";
|
|
55
|
+
|
|
56
|
+
// src/chat/conversation-state.ts
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return Boolean(value) && typeof value === "object";
|
|
59
|
+
}
|
|
60
|
+
function toOptionalString2(value) {
|
|
61
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
62
|
+
}
|
|
63
|
+
function toOptionalNumber(value) {
|
|
64
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
65
|
+
}
|
|
66
|
+
function coerceRole(value) {
|
|
67
|
+
return value === "assistant" || value === "system" || value === "user" ? value : "user";
|
|
68
|
+
}
|
|
69
|
+
function coerceAuthor(value) {
|
|
70
|
+
if (!isRecord(value)) return void 0;
|
|
71
|
+
const author = {
|
|
72
|
+
fullName: toOptionalString2(value.fullName),
|
|
73
|
+
userId: toOptionalString2(value.userId),
|
|
74
|
+
userName: toOptionalString2(value.userName)
|
|
75
|
+
};
|
|
76
|
+
if (typeof value.isBot === "boolean") {
|
|
77
|
+
author.isBot = value.isBot;
|
|
78
|
+
}
|
|
79
|
+
if (!author.fullName && !author.userId && !author.userName && author.isBot === void 0) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
return author;
|
|
83
|
+
}
|
|
84
|
+
function coerceMessageMeta(value) {
|
|
85
|
+
if (!isRecord(value)) return void 0;
|
|
86
|
+
const meta = {};
|
|
87
|
+
if (typeof value.explicitMention === "boolean") {
|
|
88
|
+
meta.explicitMention = value.explicitMention;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value.replied === "boolean") {
|
|
91
|
+
meta.replied = value.replied;
|
|
92
|
+
}
|
|
93
|
+
if (typeof value.skippedReason === "string" && value.skippedReason.trim().length > 0) {
|
|
94
|
+
meta.skippedReason = value.skippedReason;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value.slackTs === "string" && value.slackTs.trim().length > 0) {
|
|
97
|
+
meta.slackTs = value.slackTs;
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(value.imageFileIds)) {
|
|
100
|
+
const imageFileIds = value.imageFileIds.filter(
|
|
101
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
102
|
+
);
|
|
103
|
+
if (imageFileIds.length > 0) {
|
|
104
|
+
meta.imageFileIds = imageFileIds;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof value.imagesHydrated === "boolean") {
|
|
108
|
+
meta.imagesHydrated = value.imagesHydrated;
|
|
109
|
+
}
|
|
110
|
+
if (meta.explicitMention === void 0 && meta.replied === void 0 && meta.skippedReason === void 0 && meta.slackTs === void 0 && meta.imageFileIds === void 0 && meta.imagesHydrated === void 0) {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
return meta;
|
|
114
|
+
}
|
|
115
|
+
function defaultConversationState() {
|
|
116
|
+
const nowMs = Date.now();
|
|
117
|
+
return {
|
|
118
|
+
schemaVersion: 1,
|
|
119
|
+
messages: [],
|
|
120
|
+
compactions: [],
|
|
121
|
+
backfill: {},
|
|
122
|
+
processing: {},
|
|
123
|
+
stats: {
|
|
124
|
+
estimatedContextTokens: 0,
|
|
125
|
+
totalMessageCount: 0,
|
|
126
|
+
compactedMessageCount: 0,
|
|
127
|
+
updatedAtMs: nowMs
|
|
128
|
+
},
|
|
129
|
+
vision: {
|
|
130
|
+
byFileId: {}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function coerceThreadConversationState(value) {
|
|
135
|
+
if (!isRecord(value)) {
|
|
136
|
+
return defaultConversationState();
|
|
137
|
+
}
|
|
138
|
+
const root = value;
|
|
139
|
+
const rawConversation = isRecord(root.conversation) ? root.conversation : {};
|
|
140
|
+
const base = defaultConversationState();
|
|
141
|
+
const rawMessages = Array.isArray(rawConversation.messages) ? rawConversation.messages : [];
|
|
142
|
+
const messages = [];
|
|
143
|
+
for (const item of rawMessages) {
|
|
144
|
+
if (!isRecord(item)) continue;
|
|
145
|
+
const id = toOptionalString2(item.id);
|
|
146
|
+
const text = toOptionalString2(item.text);
|
|
147
|
+
const createdAtMs = toOptionalNumber(item.createdAtMs);
|
|
148
|
+
if (!id || !text || !createdAtMs) continue;
|
|
149
|
+
messages.push({
|
|
150
|
+
id,
|
|
151
|
+
role: coerceRole(item.role),
|
|
152
|
+
text,
|
|
153
|
+
createdAtMs,
|
|
154
|
+
author: coerceAuthor(item.author),
|
|
155
|
+
meta: coerceMessageMeta(item.meta)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const rawCompactions = Array.isArray(rawConversation.compactions) ? rawConversation.compactions : [];
|
|
159
|
+
const compactions = [];
|
|
160
|
+
for (const item of rawCompactions) {
|
|
161
|
+
if (!isRecord(item)) continue;
|
|
162
|
+
const id = toOptionalString2(item.id);
|
|
163
|
+
const summary = toOptionalString2(item.summary);
|
|
164
|
+
const createdAtMs = toOptionalNumber(item.createdAtMs);
|
|
165
|
+
if (!id || !summary || !createdAtMs) continue;
|
|
166
|
+
const coveredMessageIds = Array.isArray(item.coveredMessageIds) ? item.coveredMessageIds.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
|
|
167
|
+
compactions.push({
|
|
168
|
+
id,
|
|
169
|
+
summary,
|
|
170
|
+
createdAtMs,
|
|
171
|
+
coveredMessageIds
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const rawBackfill = isRecord(rawConversation.backfill) ? rawConversation.backfill : {};
|
|
175
|
+
const backfill = {
|
|
176
|
+
completedAtMs: toOptionalNumber(rawBackfill.completedAtMs),
|
|
177
|
+
source: rawBackfill.source === "recent_messages" || rawBackfill.source === "thread_fetch" ? rawBackfill.source : void 0
|
|
178
|
+
};
|
|
179
|
+
const rawProcessing = isRecord(rawConversation.processing) ? rawConversation.processing : {};
|
|
180
|
+
const processing = {
|
|
181
|
+
activeTurnId: toOptionalString2(rawProcessing.activeTurnId),
|
|
182
|
+
lastCompletedAtMs: toOptionalNumber(rawProcessing.lastCompletedAtMs)
|
|
183
|
+
};
|
|
184
|
+
const rawStats = isRecord(rawConversation.stats) ? rawConversation.stats : {};
|
|
185
|
+
const stats = {
|
|
186
|
+
estimatedContextTokens: toOptionalNumber(rawStats.estimatedContextTokens) ?? base.stats.estimatedContextTokens,
|
|
187
|
+
totalMessageCount: toOptionalNumber(rawStats.totalMessageCount) ?? messages.length,
|
|
188
|
+
compactedMessageCount: toOptionalNumber(rawStats.compactedMessageCount) ?? 0,
|
|
189
|
+
updatedAtMs: toOptionalNumber(rawStats.updatedAtMs) ?? base.stats.updatedAtMs
|
|
190
|
+
};
|
|
191
|
+
const rawVision = isRecord(rawConversation.vision) ? rawConversation.vision : {};
|
|
192
|
+
const rawVisionByFileId = isRecord(rawVision.byFileId) ? rawVision.byFileId : {};
|
|
193
|
+
const byFileId = {};
|
|
194
|
+
for (const [fileId, value2] of Object.entries(rawVisionByFileId)) {
|
|
195
|
+
if (typeof fileId !== "string" || fileId.trim().length === 0) continue;
|
|
196
|
+
if (!isRecord(value2)) continue;
|
|
197
|
+
const summary = toOptionalString2(value2.summary);
|
|
198
|
+
const analyzedAtMs = toOptionalNumber(value2.analyzedAtMs);
|
|
199
|
+
if (!summary || !analyzedAtMs) continue;
|
|
200
|
+
byFileId[fileId] = {
|
|
201
|
+
summary,
|
|
202
|
+
analyzedAtMs
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: 1,
|
|
207
|
+
messages,
|
|
208
|
+
compactions,
|
|
209
|
+
backfill,
|
|
210
|
+
processing,
|
|
211
|
+
stats,
|
|
212
|
+
vision: {
|
|
213
|
+
backfillCompletedAtMs: toOptionalNumber(rawVision.backfillCompletedAtMs),
|
|
214
|
+
byFileId
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function buildConversationStatePatch(conversation) {
|
|
219
|
+
return {
|
|
220
|
+
conversation: {
|
|
221
|
+
...conversation,
|
|
222
|
+
schemaVersion: 1,
|
|
223
|
+
stats: {
|
|
224
|
+
...conversation.stats,
|
|
225
|
+
totalMessageCount: conversation.messages.length,
|
|
226
|
+
updatedAtMs: Date.now()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/chat/routing/subscribed-decision.ts
|
|
233
|
+
import { z } from "zod";
|
|
234
|
+
var replyDecisionSchema = z.object({
|
|
235
|
+
should_reply: z.boolean().describe("Whether Junior should respond to this thread message."),
|
|
236
|
+
confidence: z.number().min(0).max(1).describe("Classifier confidence from 0 to 1."),
|
|
237
|
+
reason: z.string().max(160).optional().describe("Short reason for the decision.")
|
|
238
|
+
});
|
|
239
|
+
var ROUTER_CONFIDENCE_THRESHOLD = 0.72;
|
|
240
|
+
var ACK_REGEXES = [
|
|
241
|
+
/^(thanks|thank you|thx|ty|tysm|much appreciated)[!. ]*$/i,
|
|
242
|
+
/^(ok|okay|k|got it|sgtm|lgtm|sounds good|works for me|works|done|resolved|perfect|great|nice|cool)[!. ]*$/i,
|
|
243
|
+
/^(\+1|\+\+|ack|roger|copy that)[!. ]*$/i,
|
|
244
|
+
/^(:[a-z0-9_+-]+:|[\p{Extended_Pictographic}\uFE0F\u200D])+[!. ]*$/u
|
|
245
|
+
];
|
|
246
|
+
var QUESTION_PREFIX_RE = /^(what|why|how|when|where|which|who|can|could|would|should|do|does|did|is|are|was|were|will)\b/i;
|
|
247
|
+
var FOLLOW_UP_REF_RE = /\b(you|your|that|this|it|above|previous|earlier|last|just\s+said)\b/i;
|
|
248
|
+
function tokenizeForOverlap(value) {
|
|
249
|
+
return value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 4);
|
|
250
|
+
}
|
|
251
|
+
function getLastAssistantLine(conversationContext) {
|
|
252
|
+
if (!conversationContext) return void 0;
|
|
253
|
+
const lines = conversationContext.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
254
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
255
|
+
const line = lines[index];
|
|
256
|
+
if (line.startsWith("[assistant]")) {
|
|
257
|
+
return line;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return void 0;
|
|
261
|
+
}
|
|
262
|
+
function isLikelyAcknowledgment(text) {
|
|
263
|
+
const trimmed = text.trim();
|
|
264
|
+
if (!trimmed) return false;
|
|
265
|
+
if (trimmed.includes("?")) return false;
|
|
266
|
+
for (const regex of ACK_REGEXES) {
|
|
267
|
+
if (regex.test(trimmed)) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
function isLikelyAssistantDirectedFollowUp(text, conversationContext) {
|
|
274
|
+
const trimmed = text.trim();
|
|
275
|
+
if (!trimmed) return false;
|
|
276
|
+
const isQuestion = trimmed.includes("?") || QUESTION_PREFIX_RE.test(trimmed);
|
|
277
|
+
if (!isQuestion) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const lastAssistantLine = getLastAssistantLine(conversationContext);
|
|
281
|
+
if (!lastAssistantLine) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if (FOLLOW_UP_REF_RE.test(trimmed)) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
const questionTokens = tokenizeForOverlap(trimmed);
|
|
288
|
+
const assistantTokens = new Set(tokenizeForOverlap(lastAssistantLine));
|
|
289
|
+
for (const token of questionTokens) {
|
|
290
|
+
if (assistantTokens.has(token)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
function buildRouterSystemPrompt(botUserName, conversationContext) {
|
|
297
|
+
return [
|
|
298
|
+
"You are a message router for a Slack assistant named Junior in a subscribed Slack thread.",
|
|
299
|
+
"Decide whether Junior should reply to the latest message.",
|
|
300
|
+
"Default to should_reply=false unless the user is clearly asking Junior for help or follow-up.",
|
|
301
|
+
"",
|
|
302
|
+
"Reply should be true only when the user is clearly asking Junior a question, requesting help,",
|
|
303
|
+
"or when a direct follow-up is contextually aimed at Junior's previous response in the thread context.",
|
|
304
|
+
"",
|
|
305
|
+
"Reply should be false for side conversations between humans, acknowledgements (thanks, +1),",
|
|
306
|
+
"status chatter, or messages not seeking assistant input.",
|
|
307
|
+
"Junior must not participate in casual banter.",
|
|
308
|
+
"If uncertain, set should_reply=false and use low confidence.",
|
|
309
|
+
"",
|
|
310
|
+
"Return JSON with should_reply, confidence, and a short reason. Do not return any extra keys.",
|
|
311
|
+
"",
|
|
312
|
+
`<assistant-name>${escapeXml(botUserName)}</assistant-name>`,
|
|
313
|
+
`<thread-context>${escapeXml(conversationContext?.trim() || "[none]")}</thread-context>`
|
|
314
|
+
].join("\n");
|
|
315
|
+
}
|
|
316
|
+
async function decideSubscribedThreadReply(args) {
|
|
317
|
+
const text = args.input.text.trim();
|
|
318
|
+
const rawText = args.input.rawText.trim();
|
|
319
|
+
if (args.input.isExplicitMention) {
|
|
320
|
+
return { shouldReply: true, reason: "explicit_mention" /* ExplicitMention */ };
|
|
321
|
+
}
|
|
322
|
+
if (!text && !args.input.hasAttachments) {
|
|
323
|
+
return { shouldReply: false, reason: "empty_message" /* EmptyMessage */ };
|
|
324
|
+
}
|
|
325
|
+
if (!text && args.input.hasAttachments) {
|
|
326
|
+
return { shouldReply: true, reason: "attachment_only" /* AttachmentOnly */ };
|
|
327
|
+
}
|
|
328
|
+
if (isLikelyAcknowledgment(text)) {
|
|
329
|
+
return { shouldReply: false, reason: "acknowledgment" /* Acknowledgment */ };
|
|
330
|
+
}
|
|
331
|
+
if (isLikelyAssistantDirectedFollowUp(text, args.input.conversationContext)) {
|
|
332
|
+
return { shouldReply: true, reason: "follow_up_question" /* FollowUpQuestion */ };
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const result = await args.completeObject({
|
|
336
|
+
modelId: args.modelId,
|
|
337
|
+
schema: replyDecisionSchema,
|
|
338
|
+
maxTokens: 120,
|
|
339
|
+
temperature: 0,
|
|
340
|
+
system: buildRouterSystemPrompt(args.botUserName, args.input.conversationContext),
|
|
341
|
+
prompt: rawText,
|
|
342
|
+
metadata: {
|
|
343
|
+
modelId: args.modelId,
|
|
344
|
+
threadId: args.input.context.threadId ?? "",
|
|
345
|
+
channelId: args.input.context.channelId ?? "",
|
|
346
|
+
requesterId: args.input.context.requesterId ?? "",
|
|
347
|
+
runId: args.input.context.runId ?? ""
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
const parsed = replyDecisionSchema.parse(result.object);
|
|
351
|
+
const reason = parsed.reason?.trim() || "classifier";
|
|
352
|
+
if (!parsed.should_reply) {
|
|
353
|
+
return {
|
|
354
|
+
shouldReply: false,
|
|
355
|
+
reason: "side_conversation" /* SideConversation */,
|
|
356
|
+
reasonDetail: reason
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (parsed.confidence < ROUTER_CONFIDENCE_THRESHOLD) {
|
|
360
|
+
return {
|
|
361
|
+
shouldReply: false,
|
|
362
|
+
reason: "low_confidence" /* LowConfidence */,
|
|
363
|
+
reasonDetail: `${parsed.confidence.toFixed(2)}: ${reason}`
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
shouldReply: true,
|
|
368
|
+
reason: "llm_classifier" /* Classifier */,
|
|
369
|
+
reasonDetail: reason
|
|
370
|
+
};
|
|
371
|
+
} catch (error) {
|
|
372
|
+
args.logClassifierFailure(error, args.input);
|
|
373
|
+
return {
|
|
374
|
+
shouldReply: false,
|
|
375
|
+
reason: "classifier_error" /* ClassifierError */
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/chat/slack-user.ts
|
|
381
|
+
var USER_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
382
|
+
var userCache = /* @__PURE__ */ new Map();
|
|
383
|
+
function readFromCache(userId) {
|
|
384
|
+
const hit = userCache.get(userId);
|
|
385
|
+
if (!hit) return null;
|
|
386
|
+
if (hit.expiresAt < Date.now()) {
|
|
387
|
+
userCache.delete(userId);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
return hit.value;
|
|
391
|
+
}
|
|
392
|
+
function writeToCache(userId, value) {
|
|
393
|
+
userCache.set(userId, {
|
|
394
|
+
value,
|
|
395
|
+
expiresAt: Date.now() + USER_CACHE_TTL_MS
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
async function lookupSlackUser(userId) {
|
|
399
|
+
if (!userId) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
const cached = readFromCache(userId);
|
|
403
|
+
if (cached) {
|
|
404
|
+
return cached;
|
|
405
|
+
}
|
|
406
|
+
const token = getSlackBotToken();
|
|
407
|
+
if (!token) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const response = await fetch(`https://slack.com/api/users.info?user=${encodeURIComponent(userId)}`, {
|
|
412
|
+
headers: {
|
|
413
|
+
authorization: `Bearer ${token}`
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
logWarn(
|
|
418
|
+
"slack_user_lookup_failed",
|
|
419
|
+
{},
|
|
420
|
+
{
|
|
421
|
+
"enduser.id": userId,
|
|
422
|
+
"http.response.status_code": response.status
|
|
423
|
+
},
|
|
424
|
+
"Slack user lookup request failed"
|
|
425
|
+
);
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const payload = await response.json();
|
|
429
|
+
if (!payload.ok || !payload.user) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
const userName = payload.user.name?.trim() || void 0;
|
|
433
|
+
const fullName = payload.user.profile?.display_name?.trim() || payload.user.profile?.real_name?.trim() || payload.user.real_name?.trim() || void 0;
|
|
434
|
+
const result = {
|
|
435
|
+
userName,
|
|
436
|
+
fullName
|
|
437
|
+
};
|
|
438
|
+
writeToCache(userId, result);
|
|
439
|
+
return result;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
logWarn(
|
|
442
|
+
"slack_user_lookup_failed",
|
|
443
|
+
{},
|
|
444
|
+
{
|
|
445
|
+
"enduser.id": userId,
|
|
446
|
+
"error.message": error instanceof Error ? error.message : String(error)
|
|
447
|
+
},
|
|
448
|
+
"Slack user lookup failed with exception"
|
|
449
|
+
);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/chat/runtime/deps.ts
|
|
455
|
+
var defaultBotDeps = {
|
|
456
|
+
completeObject,
|
|
457
|
+
completeText,
|
|
458
|
+
downloadPrivateSlackFile,
|
|
459
|
+
generateAssistantReply,
|
|
460
|
+
listThreadReplies,
|
|
461
|
+
lookupSlackUser
|
|
462
|
+
};
|
|
463
|
+
var botDeps = defaultBotDeps;
|
|
464
|
+
function setBotDepsForTests(overrides) {
|
|
465
|
+
botDeps = {
|
|
466
|
+
...defaultBotDeps,
|
|
467
|
+
...overrides
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function resetBotDepsForTests() {
|
|
471
|
+
botDeps = defaultBotDeps;
|
|
472
|
+
}
|
|
473
|
+
function getBotDeps() {
|
|
474
|
+
return botDeps;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/chat/runtime/subscribed-routing.ts
|
|
478
|
+
async function shouldReplyInSubscribedThread(args) {
|
|
479
|
+
const decision = await decideSubscribedThreadReply({
|
|
480
|
+
botUserName: botConfig.userName,
|
|
481
|
+
modelId: botConfig.fastModelId,
|
|
482
|
+
input: args,
|
|
483
|
+
completeObject: (input) => getBotDeps().completeObject(input),
|
|
484
|
+
logClassifierFailure: (error, input) => {
|
|
485
|
+
logWarn(
|
|
486
|
+
"subscribed_reply_classifier_failed",
|
|
487
|
+
{
|
|
488
|
+
slackThreadId: input.context.threadId,
|
|
489
|
+
slackUserId: input.context.requesterId,
|
|
490
|
+
slackChannelId: input.context.channelId,
|
|
491
|
+
runId: input.context.runId,
|
|
492
|
+
assistantUserName: botConfig.userName,
|
|
493
|
+
modelId: botConfig.fastModelId
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
"error.message": error instanceof Error ? error.message : String(error)
|
|
497
|
+
},
|
|
498
|
+
"Subscribed-thread reply classifier failed; skipping reply"
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
const reason = decision.reasonDetail ? `${decision.reason}:${decision.reasonDetail}` : decision.reason;
|
|
503
|
+
return {
|
|
504
|
+
shouldReply: decision.shouldReply,
|
|
505
|
+
reason
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/chat/slack-context.ts
|
|
510
|
+
function toOptionalString3(value) {
|
|
511
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
512
|
+
}
|
|
513
|
+
function parseSlackThreadId(threadId) {
|
|
514
|
+
const normalizedThreadId = toOptionalString3(threadId);
|
|
515
|
+
if (!normalizedThreadId) {
|
|
516
|
+
return void 0;
|
|
517
|
+
}
|
|
518
|
+
const parts = normalizedThreadId.split(":");
|
|
519
|
+
if (parts.length !== 3 || parts[0] !== "slack") {
|
|
520
|
+
return void 0;
|
|
521
|
+
}
|
|
522
|
+
const channelId = toOptionalString3(parts[1]);
|
|
523
|
+
const threadTs = toOptionalString3(parts[2]);
|
|
524
|
+
if (!channelId || !threadTs) {
|
|
525
|
+
return void 0;
|
|
526
|
+
}
|
|
527
|
+
return { channelId, threadTs };
|
|
528
|
+
}
|
|
529
|
+
function resolveSlackChannelIdFromThreadId(threadId) {
|
|
530
|
+
return parseSlackThreadId(threadId)?.channelId;
|
|
531
|
+
}
|
|
532
|
+
function resolveSlackChannelIdFromMessage(message) {
|
|
533
|
+
const messageChannelId = toOptionalString3(message.channelId);
|
|
534
|
+
if (messageChannelId) {
|
|
535
|
+
return messageChannelId;
|
|
536
|
+
}
|
|
537
|
+
const raw = message.raw;
|
|
538
|
+
if (raw && typeof raw === "object") {
|
|
539
|
+
const rawChannel = toOptionalString3(raw.channel);
|
|
540
|
+
if (rawChannel) {
|
|
541
|
+
return rawChannel;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const threadId = toOptionalString3(message.threadId);
|
|
545
|
+
return resolveSlackChannelIdFromThreadId(threadId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/chat/runtime/thread-context.ts
|
|
549
|
+
function escapeRegExp(value) {
|
|
550
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
551
|
+
}
|
|
552
|
+
function stripLeadingBotMention(text, options = {}) {
|
|
553
|
+
if (!text.trim()) return text;
|
|
554
|
+
let next = text;
|
|
555
|
+
if (options.stripLeadingSlackMentionToken) {
|
|
556
|
+
next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim();
|
|
557
|
+
}
|
|
558
|
+
const mentionByNameRe = new RegExp(`^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`, "i");
|
|
559
|
+
next = next.replace(mentionByNameRe, "").trim();
|
|
560
|
+
const mentionByLabeledEntityRe = new RegExp(
|
|
561
|
+
`^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`,
|
|
562
|
+
"i"
|
|
563
|
+
);
|
|
564
|
+
next = next.replace(mentionByLabeledEntityRe, "").trim();
|
|
565
|
+
return next;
|
|
566
|
+
}
|
|
567
|
+
function getThreadId(thread, _message) {
|
|
568
|
+
return toOptionalString(thread.id);
|
|
569
|
+
}
|
|
570
|
+
function getRunId(thread, message) {
|
|
571
|
+
return toOptionalString(thread.runId) ?? toOptionalString(message.runId);
|
|
572
|
+
}
|
|
573
|
+
function getChannelId(thread, message) {
|
|
574
|
+
return thread.channelId ?? resolveSlackChannelIdFromMessage(message);
|
|
575
|
+
}
|
|
576
|
+
function getThreadTs(threadId) {
|
|
577
|
+
return parseSlackThreadId(threadId)?.threadTs;
|
|
578
|
+
}
|
|
579
|
+
function getMessageTs(message) {
|
|
580
|
+
const directTs = toOptionalString(message.ts);
|
|
581
|
+
if (directTs) {
|
|
582
|
+
return directTs;
|
|
583
|
+
}
|
|
584
|
+
const raw = message.raw;
|
|
585
|
+
if (!raw || typeof raw !== "object") {
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
const rawRecord = raw;
|
|
589
|
+
return toOptionalString(rawRecord.ts) ?? toOptionalString(rawRecord.event_ts) ?? toOptionalString(rawRecord.message?.ts);
|
|
590
|
+
}
|
|
591
|
+
function getSlackApiErrorCode(error) {
|
|
592
|
+
if (!error || typeof error !== "object") {
|
|
593
|
+
return void 0;
|
|
594
|
+
}
|
|
595
|
+
const candidate = error;
|
|
596
|
+
if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) {
|
|
597
|
+
return candidate.data.error;
|
|
598
|
+
}
|
|
599
|
+
if (typeof candidate.code === "string" && candidate.code.trim().length > 0) {
|
|
600
|
+
return candidate.code;
|
|
601
|
+
}
|
|
602
|
+
return void 0;
|
|
603
|
+
}
|
|
604
|
+
function isSlackTitlePermissionError(error) {
|
|
605
|
+
const code = getSlackApiErrorCode(error);
|
|
606
|
+
return code === "no_permission" || code === "missing_scope" || code === "not_allowed_token_type";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/chat/chat-background-patch.ts
|
|
610
|
+
var PATCH_FLAG = /* @__PURE__ */ Symbol.for("junior.chat.backgroundPatch");
|
|
611
|
+
var QUEUE_INGRESS_DEDUP_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
612
|
+
function nonEmptyString(value) {
|
|
613
|
+
if (typeof value !== "string") return void 0;
|
|
614
|
+
const trimmed = value.trim();
|
|
615
|
+
return trimmed || void 0;
|
|
616
|
+
}
|
|
617
|
+
function buildRoutingConversationContext(conversation) {
|
|
618
|
+
if (conversation.messages.length === 0 && conversation.compactions.length === 0) {
|
|
619
|
+
return void 0;
|
|
620
|
+
}
|
|
621
|
+
const lines = [];
|
|
622
|
+
if (conversation.compactions.length > 0) {
|
|
623
|
+
lines.push("<thread-compactions>");
|
|
624
|
+
for (const [index, compaction] of conversation.compactions.entries()) {
|
|
625
|
+
lines.push(
|
|
626
|
+
[
|
|
627
|
+
`summary_${index + 1}:`,
|
|
628
|
+
compaction.summary,
|
|
629
|
+
`covered_messages: ${compaction.coveredMessageIds.length}`,
|
|
630
|
+
`created_at: ${new Date(compaction.createdAtMs).toISOString()}`
|
|
631
|
+
].join(" ")
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
lines.push("</thread-compactions>");
|
|
635
|
+
lines.push("");
|
|
636
|
+
}
|
|
637
|
+
lines.push("<thread-transcript>");
|
|
638
|
+
for (const message of conversation.messages) {
|
|
639
|
+
const displayName = message.author?.fullName || message.author?.userName || message.role;
|
|
640
|
+
lines.push(`[${message.role}] ${displayName}: ${message.text}`);
|
|
641
|
+
}
|
|
642
|
+
lines.push("</thread-transcript>");
|
|
643
|
+
return lines.join("\n");
|
|
644
|
+
}
|
|
645
|
+
function serializeMessageForQueue(message) {
|
|
646
|
+
const candidate = message;
|
|
647
|
+
if (typeof candidate.toJSON === "function") {
|
|
648
|
+
return candidate.toJSON();
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
_type: "chat:Message",
|
|
652
|
+
...message
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function serializeThreadForQueue(thread) {
|
|
656
|
+
const candidate = thread;
|
|
657
|
+
if (typeof candidate.toJSON === "function") {
|
|
658
|
+
return candidate.toJSON();
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
_type: "chat:Thread",
|
|
662
|
+
...thread
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function normalizeIncomingSlackThreadId(threadId, message) {
|
|
666
|
+
if (!threadId.startsWith("slack:")) {
|
|
667
|
+
return threadId;
|
|
668
|
+
}
|
|
669
|
+
if (!message || typeof message !== "object") {
|
|
670
|
+
return threadId;
|
|
671
|
+
}
|
|
672
|
+
const raw = message.raw;
|
|
673
|
+
if (!raw || typeof raw !== "object") {
|
|
674
|
+
return threadId;
|
|
675
|
+
}
|
|
676
|
+
const channelId = nonEmptyString(raw.channel);
|
|
677
|
+
const threadTs = nonEmptyString(raw.thread_ts) ?? nonEmptyString(raw.ts);
|
|
678
|
+
if (!channelId || !threadTs) {
|
|
679
|
+
return threadId;
|
|
680
|
+
}
|
|
681
|
+
return `slack:${channelId}:${threadTs}`;
|
|
682
|
+
}
|
|
683
|
+
function buildQueueIngressDedupKey(normalizedThreadId, messageId) {
|
|
684
|
+
return `${normalizedThreadId}:${messageId}`;
|
|
685
|
+
}
|
|
686
|
+
function determineThreadMessageKind(args) {
|
|
687
|
+
if (args.isSubscribed) {
|
|
688
|
+
return "subscribed_message";
|
|
689
|
+
}
|
|
690
|
+
if (args.isMention) {
|
|
691
|
+
return "new_mention";
|
|
692
|
+
}
|
|
693
|
+
return void 0;
|
|
694
|
+
}
|
|
695
|
+
var defaultQueueRoutingDeps = {
|
|
696
|
+
hasDedup: (key) => hasQueueIngressDedup(key),
|
|
697
|
+
markDedup: (key, ttlMs) => claimQueueIngressDedup(key, ttlMs),
|
|
698
|
+
getIsSubscribed: (threadId) => getStateAdapter().isSubscribed(threadId),
|
|
699
|
+
logInfo,
|
|
700
|
+
logWarn,
|
|
701
|
+
enqueueThreadMessage: async (payload, dedupKey) => {
|
|
702
|
+
const { enqueueThreadMessage } = await import("./client-3GAEMIQ3.js");
|
|
703
|
+
return await enqueueThreadMessage(payload, {
|
|
704
|
+
idempotencyKey: dedupKey
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
shouldReplyInSubscribedThread: async ({ message, normalizedThreadId, thread }) => {
|
|
708
|
+
const rawText = message.text;
|
|
709
|
+
const text = stripLeadingBotMention(rawText, {
|
|
710
|
+
stripLeadingSlackMentionToken: Boolean(message.isMention)
|
|
711
|
+
});
|
|
712
|
+
const conversation = coerceThreadConversationState(await thread.state);
|
|
713
|
+
const conversationContext = buildRoutingConversationContext(conversation);
|
|
714
|
+
const channelId = getChannelId(thread, message);
|
|
715
|
+
const runId = getRunId(thread, message);
|
|
716
|
+
return await shouldReplyInSubscribedThread({
|
|
717
|
+
rawText,
|
|
718
|
+
text,
|
|
719
|
+
conversationContext,
|
|
720
|
+
hasAttachments: message.attachments.length > 0,
|
|
721
|
+
isExplicitMention: Boolean(message.isMention),
|
|
722
|
+
context: {
|
|
723
|
+
threadId: normalizedThreadId,
|
|
724
|
+
requesterId: message.author.userId,
|
|
725
|
+
channelId,
|
|
726
|
+
runId
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
},
|
|
730
|
+
addProcessingReaction: async ({ channelId, timestamp }) => {
|
|
731
|
+
const { addReactionToMessage } = await import("./channel-HJO33DGJ.js");
|
|
732
|
+
await addReactionToMessage({
|
|
733
|
+
channelId,
|
|
734
|
+
timestamp,
|
|
735
|
+
emoji: "eyes"
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
removeProcessingReaction: async ({ channelId, timestamp }) => {
|
|
739
|
+
const { removeReactionFromMessage } = await import("./channel-HJO33DGJ.js");
|
|
740
|
+
await removeReactionFromMessage({
|
|
741
|
+
channelId,
|
|
742
|
+
timestamp,
|
|
743
|
+
emoji: "eyes"
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
async function routeIncomingMessageToQueue(args) {
|
|
748
|
+
const deps = args.deps ?? defaultQueueRoutingDeps;
|
|
749
|
+
const { adapter, runtime } = args;
|
|
750
|
+
const message = args.message;
|
|
751
|
+
if (!message || typeof message !== "object") {
|
|
752
|
+
return "ignored_non_object";
|
|
753
|
+
}
|
|
754
|
+
const normalizedThreadId = normalizeIncomingSlackThreadId(args.threadId, message);
|
|
755
|
+
if ("threadId" in message) {
|
|
756
|
+
message.threadId = normalizedThreadId;
|
|
757
|
+
}
|
|
758
|
+
const typedMessage = message;
|
|
759
|
+
if (typedMessage.author?.isMe) {
|
|
760
|
+
return "ignored_self_message";
|
|
761
|
+
}
|
|
762
|
+
const messageId = nonEmptyString(typedMessage.id);
|
|
763
|
+
if (!messageId) {
|
|
764
|
+
return "ignored_missing_message_id";
|
|
765
|
+
}
|
|
766
|
+
const isSubscribed = await deps.getIsSubscribed(normalizedThreadId);
|
|
767
|
+
const isMention = Boolean(typedMessage.isMention || runtime.detectMention?.(adapter, message));
|
|
768
|
+
const kind = determineThreadMessageKind({
|
|
769
|
+
isSubscribed,
|
|
770
|
+
isMention
|
|
771
|
+
});
|
|
772
|
+
if (!kind) {
|
|
773
|
+
return "ignored_unsubscribed_non_mention";
|
|
774
|
+
}
|
|
775
|
+
const dedupKey = buildQueueIngressDedupKey(normalizedThreadId, messageId);
|
|
776
|
+
const alreadyDeduped = await deps.hasDedup(dedupKey);
|
|
777
|
+
if (alreadyDeduped) {
|
|
778
|
+
deps.logInfo(
|
|
779
|
+
"queue_ingress_dedup_hit",
|
|
780
|
+
{
|
|
781
|
+
slackThreadId: normalizedThreadId,
|
|
782
|
+
slackUserId: message.author.userId
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
"messaging.message.id": messageId,
|
|
786
|
+
"app.queue.message_kind": kind,
|
|
787
|
+
"app.queue.dedup_key": dedupKey,
|
|
788
|
+
"app.queue.dedup_outcome": "duplicate"
|
|
789
|
+
},
|
|
790
|
+
"Skipping duplicate incoming message before queue enqueue"
|
|
791
|
+
);
|
|
792
|
+
return "ignored_duplicate";
|
|
793
|
+
}
|
|
794
|
+
const thread = await runtime.createThread(adapter, normalizedThreadId, message, isSubscribed);
|
|
795
|
+
const serializedMessage = serializeMessageForQueue(message);
|
|
796
|
+
const serializedThread = serializeThreadForQueue(thread);
|
|
797
|
+
let payloadKind = kind;
|
|
798
|
+
if (kind === "subscribed_message" && !isMention) {
|
|
799
|
+
const decision = await deps.shouldReplyInSubscribedThread({
|
|
800
|
+
message,
|
|
801
|
+
normalizedThreadId,
|
|
802
|
+
thread
|
|
803
|
+
});
|
|
804
|
+
if (!decision.shouldReply) {
|
|
805
|
+
return "ignored_passive_no_reply";
|
|
806
|
+
}
|
|
807
|
+
payloadKind = "subscribed_reply";
|
|
808
|
+
}
|
|
809
|
+
const payload = {
|
|
810
|
+
dedupKey,
|
|
811
|
+
kind: payloadKind,
|
|
812
|
+
message: serializedMessage,
|
|
813
|
+
normalizedThreadId,
|
|
814
|
+
thread: serializedThread
|
|
815
|
+
};
|
|
816
|
+
await withContext(
|
|
817
|
+
{
|
|
818
|
+
slackThreadId: normalizedThreadId,
|
|
819
|
+
slackChannelId: thread.channelId,
|
|
820
|
+
slackUserId: message.author.userId
|
|
821
|
+
},
|
|
822
|
+
async () => {
|
|
823
|
+
let processingReactionAdded = false;
|
|
824
|
+
let queueMessageId;
|
|
825
|
+
try {
|
|
826
|
+
await deps.addProcessingReaction({
|
|
827
|
+
channelId: thread.channelId,
|
|
828
|
+
timestamp: messageId
|
|
829
|
+
});
|
|
830
|
+
processingReactionAdded = true;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
833
|
+
deps.logWarn(
|
|
834
|
+
"queue_ingress_reaction_add_failed",
|
|
835
|
+
{},
|
|
836
|
+
{
|
|
837
|
+
"messaging.message.id": messageId,
|
|
838
|
+
"app.queue.message_kind": payloadKind,
|
|
839
|
+
"error.message": errorMessage
|
|
840
|
+
},
|
|
841
|
+
"Failed to add ingress processing reaction"
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
await withSpan(
|
|
846
|
+
"queue.enqueue_message",
|
|
847
|
+
"queue.enqueue_message",
|
|
848
|
+
{
|
|
849
|
+
slackThreadId: normalizedThreadId,
|
|
850
|
+
slackChannelId: thread.channelId,
|
|
851
|
+
slackUserId: message.author.userId
|
|
852
|
+
},
|
|
853
|
+
async () => {
|
|
854
|
+
queueMessageId = await deps.enqueueThreadMessage(payload, dedupKey);
|
|
855
|
+
if (queueMessageId) {
|
|
856
|
+
setSpanAttributes({
|
|
857
|
+
"app.queue.message_id": queueMessageId
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
"messaging.message.id": messageId,
|
|
863
|
+
"app.queue.message_kind": payloadKind
|
|
864
|
+
}
|
|
865
|
+
);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (processingReactionAdded) {
|
|
868
|
+
try {
|
|
869
|
+
await deps.removeProcessingReaction({
|
|
870
|
+
channelId: thread.channelId,
|
|
871
|
+
timestamp: messageId
|
|
872
|
+
});
|
|
873
|
+
} catch (cleanupError) {
|
|
874
|
+
const cleanupErrorMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
875
|
+
deps.logWarn(
|
|
876
|
+
"queue_ingress_reaction_cleanup_failed",
|
|
877
|
+
{},
|
|
878
|
+
{
|
|
879
|
+
"messaging.message.id": messageId,
|
|
880
|
+
"app.queue.message_kind": payloadKind,
|
|
881
|
+
"error.message": cleanupErrorMessage
|
|
882
|
+
},
|
|
883
|
+
"Failed to remove ingress processing reaction after enqueue failure"
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
deps.logInfo(
|
|
890
|
+
"queue_ingress_enqueued",
|
|
891
|
+
{},
|
|
892
|
+
{
|
|
893
|
+
"messaging.message.id": messageId,
|
|
894
|
+
"app.queue.message_kind": payloadKind,
|
|
895
|
+
"app.queue.dedup_key": dedupKey,
|
|
896
|
+
"app.queue.dedup_outcome": "primary",
|
|
897
|
+
...queueMessageId ? { "app.queue.message_id": queueMessageId } : {}
|
|
898
|
+
},
|
|
899
|
+
"Routing incoming message to queue"
|
|
900
|
+
);
|
|
901
|
+
const marked = await deps.markDedup(dedupKey, QUEUE_INGRESS_DEDUP_TTL_MS);
|
|
902
|
+
if (!marked) {
|
|
903
|
+
deps.logInfo(
|
|
904
|
+
"queue_ingress_dedup_mark_failed",
|
|
905
|
+
{},
|
|
906
|
+
{
|
|
907
|
+
"messaging.message.id": messageId,
|
|
908
|
+
"app.queue.message_kind": payloadKind,
|
|
909
|
+
"app.queue.dedup_key": dedupKey
|
|
910
|
+
},
|
|
911
|
+
"Queue ingress dedup state write failed after enqueue"
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
);
|
|
916
|
+
return "routed";
|
|
917
|
+
}
|
|
918
|
+
function scheduleBackgroundWork(options, run) {
|
|
919
|
+
if (!options?.waitUntil) {
|
|
920
|
+
throw new Error("Chat background processing requires waitUntil");
|
|
921
|
+
}
|
|
922
|
+
options.waitUntil(run);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
function installChatBackgroundPatch() {
|
|
926
|
+
const target = Chat.prototype;
|
|
927
|
+
if (target[PATCH_FLAG]) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
target[PATCH_FLAG] = true;
|
|
931
|
+
const chatProto = Chat.prototype;
|
|
932
|
+
chatProto.processMessage = function processMessage(adapter, threadId, messageOrFactory, options) {
|
|
933
|
+
const run = async () => {
|
|
934
|
+
try {
|
|
935
|
+
const message = typeof messageOrFactory === "function" ? await messageOrFactory() : messageOrFactory;
|
|
936
|
+
const result = await routeIncomingMessageToQueue({
|
|
937
|
+
adapter,
|
|
938
|
+
threadId,
|
|
939
|
+
message,
|
|
940
|
+
runtime: {
|
|
941
|
+
createThread: this.createThread.bind(this),
|
|
942
|
+
detectMention: this.detectMention?.bind(this)
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
if (result === "ignored_missing_message_id") {
|
|
946
|
+
const normalizedThreadId = normalizeIncomingSlackThreadId(threadId, message);
|
|
947
|
+
this.logger?.error?.("Message processing error", {
|
|
948
|
+
threadId: normalizedThreadId,
|
|
949
|
+
reason: "missing_message_id"
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
} catch (err) {
|
|
953
|
+
this.logger?.error?.("Message processing error", { error: err, threadId });
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
scheduleBackgroundWork(options, run);
|
|
957
|
+
};
|
|
958
|
+
chatProto.processReaction = function processReaction(event, options) {
|
|
959
|
+
const run = async () => {
|
|
960
|
+
try {
|
|
961
|
+
await this.handleReactionEvent(event);
|
|
962
|
+
} catch (err) {
|
|
963
|
+
this.logger?.error?.("Reaction processing error", {
|
|
964
|
+
error: err,
|
|
965
|
+
emoji: event.emoji,
|
|
966
|
+
messageId: event.messageId
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
scheduleBackgroundWork(options, run);
|
|
971
|
+
};
|
|
972
|
+
chatProto.processAction = function processAction(event, options) {
|
|
973
|
+
const run = async () => {
|
|
974
|
+
try {
|
|
975
|
+
await this.handleActionEvent(event);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
this.logger?.error?.("Action processing error", {
|
|
978
|
+
error: err,
|
|
979
|
+
actionId: event.actionId,
|
|
980
|
+
messageId: event.messageId
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
scheduleBackgroundWork(options, run);
|
|
985
|
+
};
|
|
986
|
+
chatProto.processModalClose = function processModalClose(event, contextId, options) {
|
|
987
|
+
const run = async () => {
|
|
988
|
+
try {
|
|
989
|
+
const { relatedThread, relatedMessage, relatedChannel } = await this.retrieveModalContext(event.adapter.name, contextId);
|
|
990
|
+
const fullEvent = { ...event, relatedThread, relatedMessage, relatedChannel };
|
|
991
|
+
for (const { callbackIds, handler } of this.modalCloseHandlers) {
|
|
992
|
+
if (callbackIds.length === 0 || callbackIds.includes(event.callbackId)) {
|
|
993
|
+
await handler(fullEvent);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} catch (err) {
|
|
997
|
+
this.logger?.error?.("Modal close handler error", {
|
|
998
|
+
error: err,
|
|
999
|
+
callbackId: event.callbackId
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
scheduleBackgroundWork(options, run);
|
|
1004
|
+
};
|
|
1005
|
+
chatProto.processSlashCommand = function processSlashCommand(event, options) {
|
|
1006
|
+
const run = async () => {
|
|
1007
|
+
try {
|
|
1008
|
+
await this.handleSlashCommandEvent(event);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
this.logger?.error?.("Slash command processing error", {
|
|
1011
|
+
error: err,
|
|
1012
|
+
command: event.command,
|
|
1013
|
+
text: event.text
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
scheduleBackgroundWork(options, run);
|
|
1018
|
+
};
|
|
1019
|
+
chatProto.processAssistantThreadStarted = function processAssistantThreadStarted(event, options) {
|
|
1020
|
+
const run = async () => {
|
|
1021
|
+
try {
|
|
1022
|
+
for (const handler of this.assistantThreadStartedHandlers) {
|
|
1023
|
+
await handler(event);
|
|
1024
|
+
}
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
this.logger?.error?.("Assistant thread started handler error", {
|
|
1027
|
+
error: err,
|
|
1028
|
+
threadId: event.threadId
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
scheduleBackgroundWork(options, run);
|
|
1033
|
+
};
|
|
1034
|
+
chatProto.processAssistantContextChanged = function processAssistantContextChanged(event, options) {
|
|
1035
|
+
const run = async () => {
|
|
1036
|
+
try {
|
|
1037
|
+
for (const handler of this.assistantContextChangedHandlers) {
|
|
1038
|
+
await handler(event);
|
|
1039
|
+
}
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
this.logger?.error?.("Assistant context changed handler error", {
|
|
1042
|
+
error: err,
|
|
1043
|
+
threadId: event.threadId
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
scheduleBackgroundWork(options, run);
|
|
1048
|
+
};
|
|
1049
|
+
chatProto.processAppHomeOpened = function processAppHomeOpened(event, options) {
|
|
1050
|
+
const run = async () => {
|
|
1051
|
+
try {
|
|
1052
|
+
for (const handler of this.appHomeOpenedHandlers) {
|
|
1053
|
+
await handler(event);
|
|
1054
|
+
}
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
this.logger?.error?.("App home opened handler error", {
|
|
1057
|
+
error: err,
|
|
1058
|
+
userId: event.userId
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
scheduleBackgroundWork(options, run);
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
installChatBackgroundPatch();
|
|
1066
|
+
|
|
1067
|
+
// src/chat/app-runtime.ts
|
|
1068
|
+
function isExplicitMentionDecision(reason) {
|
|
1069
|
+
return reason === "explicit mention" || reason === "explicit_mention" || reason.startsWith("explicit_mention:");
|
|
1070
|
+
}
|
|
1071
|
+
function buildLogContext(deps, args) {
|
|
1072
|
+
return {
|
|
1073
|
+
slackThreadId: args.threadId,
|
|
1074
|
+
slackUserId: args.requesterId,
|
|
1075
|
+
slackUserName: args.requesterUserName,
|
|
1076
|
+
slackChannelId: args.channelId,
|
|
1077
|
+
runId: args.runId,
|
|
1078
|
+
assistantUserName: deps.assistantUserName,
|
|
1079
|
+
modelId: deps.modelId
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function createAppSlackRuntime(deps) {
|
|
1083
|
+
const logContext = (args) => buildLogContext(deps, args);
|
|
1084
|
+
return {
|
|
1085
|
+
async handleNewMention(thread, message, hooks) {
|
|
1086
|
+
try {
|
|
1087
|
+
const threadId = deps.getThreadId(thread, message);
|
|
1088
|
+
const channelId = deps.getChannelId(thread, message);
|
|
1089
|
+
const runId = deps.getRunId(thread, message);
|
|
1090
|
+
const context = logContext({
|
|
1091
|
+
threadId,
|
|
1092
|
+
channelId,
|
|
1093
|
+
requesterId: message.author.userId,
|
|
1094
|
+
requesterUserName: message.author.userName,
|
|
1095
|
+
runId
|
|
1096
|
+
});
|
|
1097
|
+
await deps.withSpan(
|
|
1098
|
+
"chat.turn",
|
|
1099
|
+
"chat.turn",
|
|
1100
|
+
context,
|
|
1101
|
+
async () => {
|
|
1102
|
+
await thread.subscribe();
|
|
1103
|
+
await deps.replyToThread(thread, message, {
|
|
1104
|
+
explicitMention: true,
|
|
1105
|
+
beforeFirstResponsePost: hooks?.beforeFirstResponsePost
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
);
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
const errorContext = logContext({
|
|
1111
|
+
threadId: deps.getThreadId(thread, message),
|
|
1112
|
+
requesterId: message.author.userId,
|
|
1113
|
+
requesterUserName: message.author.userName,
|
|
1114
|
+
channelId: deps.getChannelId(thread, message),
|
|
1115
|
+
runId: deps.getRunId(thread, message)
|
|
1116
|
+
});
|
|
1117
|
+
if (isRetryableTurnError(error)) {
|
|
1118
|
+
deps.logException(
|
|
1119
|
+
error,
|
|
1120
|
+
"mention_handler_retryable_failure",
|
|
1121
|
+
errorContext,
|
|
1122
|
+
{ "app.turn.retryable_reason": error.reason },
|
|
1123
|
+
"onNewMention failed with retryable error"
|
|
1124
|
+
);
|
|
1125
|
+
throw error;
|
|
1126
|
+
}
|
|
1127
|
+
deps.logException(
|
|
1128
|
+
error,
|
|
1129
|
+
"mention_handler_failed",
|
|
1130
|
+
errorContext,
|
|
1131
|
+
{},
|
|
1132
|
+
"onNewMention failed"
|
|
1133
|
+
);
|
|
1134
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1135
|
+
await hooks?.beforeFirstResponsePost?.();
|
|
1136
|
+
await thread.post(`Error: ${errorMessage}`);
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
async handleSubscribedMessage(thread, message, hooks) {
|
|
1140
|
+
try {
|
|
1141
|
+
const threadId = deps.getThreadId(thread, message);
|
|
1142
|
+
const channelId = deps.getChannelId(thread, message);
|
|
1143
|
+
const runId = deps.getRunId(thread, message);
|
|
1144
|
+
const rawUserText = message.text;
|
|
1145
|
+
const userText = deps.stripLeadingBotMention(rawUserText, {
|
|
1146
|
+
stripLeadingSlackMentionToken: Boolean(message.isMention)
|
|
1147
|
+
});
|
|
1148
|
+
const context = {
|
|
1149
|
+
threadId,
|
|
1150
|
+
requesterId: message.author.userId,
|
|
1151
|
+
channelId,
|
|
1152
|
+
runId
|
|
1153
|
+
};
|
|
1154
|
+
const preparedState = await deps.prepareTurnState({
|
|
1155
|
+
thread,
|
|
1156
|
+
message,
|
|
1157
|
+
userText,
|
|
1158
|
+
explicitMention: Boolean(message.isMention),
|
|
1159
|
+
context
|
|
1160
|
+
});
|
|
1161
|
+
await deps.persistPreparedState({
|
|
1162
|
+
thread,
|
|
1163
|
+
preparedState
|
|
1164
|
+
});
|
|
1165
|
+
const decision = hooks?.preApprovedReply ? {
|
|
1166
|
+
shouldReply: true,
|
|
1167
|
+
reason: "pre_approved_reply"
|
|
1168
|
+
} : await deps.shouldReplyInSubscribedThread({
|
|
1169
|
+
rawText: rawUserText,
|
|
1170
|
+
text: userText,
|
|
1171
|
+
conversationContext: deps.getPreparedConversationContext(preparedState),
|
|
1172
|
+
hasAttachments: message.attachments.length > 0,
|
|
1173
|
+
isExplicitMention: Boolean(message.isMention),
|
|
1174
|
+
context
|
|
1175
|
+
});
|
|
1176
|
+
if (!decision.shouldReply) {
|
|
1177
|
+
deps.logWarn(
|
|
1178
|
+
"subscribed_message_reply_skipped",
|
|
1179
|
+
logContext({
|
|
1180
|
+
threadId,
|
|
1181
|
+
requesterId: message.author.userId,
|
|
1182
|
+
requesterUserName: message.author.userName,
|
|
1183
|
+
channelId,
|
|
1184
|
+
runId
|
|
1185
|
+
}),
|
|
1186
|
+
{
|
|
1187
|
+
"app.decision.reason": decision.reason
|
|
1188
|
+
},
|
|
1189
|
+
"Skipping subscribed message reply"
|
|
1190
|
+
);
|
|
1191
|
+
await deps.onSubscribedMessageSkipped({
|
|
1192
|
+
thread,
|
|
1193
|
+
message,
|
|
1194
|
+
preparedState,
|
|
1195
|
+
decision,
|
|
1196
|
+
completedAtMs: deps.now()
|
|
1197
|
+
});
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
await deps.withSpan(
|
|
1201
|
+
"chat.turn",
|
|
1202
|
+
"chat.turn",
|
|
1203
|
+
logContext({
|
|
1204
|
+
threadId,
|
|
1205
|
+
requesterId: message.author.userId,
|
|
1206
|
+
requesterUserName: message.author.userName,
|
|
1207
|
+
channelId,
|
|
1208
|
+
runId
|
|
1209
|
+
}),
|
|
1210
|
+
async () => {
|
|
1211
|
+
await deps.replyToThread(thread, message, {
|
|
1212
|
+
explicitMention: isExplicitMentionDecision(decision.reason),
|
|
1213
|
+
preparedState,
|
|
1214
|
+
beforeFirstResponsePost: hooks?.beforeFirstResponsePost
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
const errorContext = logContext({
|
|
1220
|
+
threadId: deps.getThreadId(thread, message),
|
|
1221
|
+
requesterId: message.author.userId,
|
|
1222
|
+
requesterUserName: message.author.userName,
|
|
1223
|
+
channelId: deps.getChannelId(thread, message),
|
|
1224
|
+
runId: deps.getRunId(thread, message)
|
|
1225
|
+
});
|
|
1226
|
+
if (isRetryableTurnError(error)) {
|
|
1227
|
+
deps.logException(
|
|
1228
|
+
error,
|
|
1229
|
+
"subscribed_message_handler_retryable_failure",
|
|
1230
|
+
errorContext,
|
|
1231
|
+
{ "app.turn.retryable_reason": error.reason },
|
|
1232
|
+
"onSubscribedMessage failed with retryable error"
|
|
1233
|
+
);
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
deps.logException(
|
|
1237
|
+
error,
|
|
1238
|
+
"subscribed_message_handler_failed",
|
|
1239
|
+
errorContext,
|
|
1240
|
+
{},
|
|
1241
|
+
"onSubscribedMessage failed"
|
|
1242
|
+
);
|
|
1243
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1244
|
+
await hooks?.beforeFirstResponsePost?.();
|
|
1245
|
+
await thread.post(`Error: ${errorMessage}`);
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
async handleAssistantThreadStarted(event) {
|
|
1249
|
+
try {
|
|
1250
|
+
await deps.initializeAssistantThread({
|
|
1251
|
+
threadId: event.threadId,
|
|
1252
|
+
channelId: event.channelId,
|
|
1253
|
+
threadTs: event.threadTs,
|
|
1254
|
+
sourceChannelId: event.context?.channelId
|
|
1255
|
+
});
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
deps.logException(
|
|
1258
|
+
error,
|
|
1259
|
+
"assistant_thread_started_handler_failed",
|
|
1260
|
+
{
|
|
1261
|
+
slackThreadId: event.threadId,
|
|
1262
|
+
slackUserId: event.userId,
|
|
1263
|
+
slackChannelId: event.channelId,
|
|
1264
|
+
assistantUserName: deps.assistantUserName,
|
|
1265
|
+
modelId: deps.modelId
|
|
1266
|
+
},
|
|
1267
|
+
{},
|
|
1268
|
+
"onAssistantThreadStarted failed"
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
async handleAssistantContextChanged(event) {
|
|
1273
|
+
try {
|
|
1274
|
+
await deps.initializeAssistantThread({
|
|
1275
|
+
threadId: event.threadId,
|
|
1276
|
+
channelId: event.channelId,
|
|
1277
|
+
threadTs: event.threadTs,
|
|
1278
|
+
sourceChannelId: event.context?.channelId
|
|
1279
|
+
});
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
deps.logException(
|
|
1282
|
+
error,
|
|
1283
|
+
"assistant_context_changed_handler_failed",
|
|
1284
|
+
{
|
|
1285
|
+
slackThreadId: event.threadId,
|
|
1286
|
+
slackUserId: event.userId,
|
|
1287
|
+
slackChannelId: event.channelId,
|
|
1288
|
+
assistantUserName: deps.assistantUserName,
|
|
1289
|
+
modelId: deps.modelId
|
|
1290
|
+
},
|
|
1291
|
+
{},
|
|
1292
|
+
"onAssistantContextChanged failed"
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/chat/slash-command.ts
|
|
1300
|
+
function providerLabel(provider) {
|
|
1301
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
1302
|
+
}
|
|
1303
|
+
async function postEphemeral(event, text) {
|
|
1304
|
+
await event.channel.postEphemeral(event.user, text, { fallbackToDM: false });
|
|
1305
|
+
}
|
|
1306
|
+
async function handleLink(event, provider) {
|
|
1307
|
+
if (!isPluginProvider(provider)) {
|
|
1308
|
+
await postEphemeral(event, `Unknown provider: \`${provider}\``);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (!getOAuthProviderConfig(provider)) {
|
|
1312
|
+
await postEphemeral(
|
|
1313
|
+
event,
|
|
1314
|
+
`${providerLabel(provider)} uses app-level authentication and doesn't require account linking.`
|
|
1315
|
+
);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const raw = event.raw;
|
|
1319
|
+
const result = await startOAuthFlow(provider, {
|
|
1320
|
+
requesterId: event.user.userId,
|
|
1321
|
+
channelId: raw.channel_id
|
|
1322
|
+
});
|
|
1323
|
+
if (!result.ok) {
|
|
1324
|
+
await postEphemeral(event, `Failed to start linking: ${result.error}`);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (result.delivery === "fallback_dm") {
|
|
1328
|
+
await postEphemeral(event, `Check your DMs for a ${providerLabel(provider)} authorization link.`);
|
|
1329
|
+
} else if (result.delivery === false) {
|
|
1330
|
+
await postEphemeral(
|
|
1331
|
+
event,
|
|
1332
|
+
"I wasn't able to send you a private authorization link. Please try again in a direct message."
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async function handleUnlink(event, provider) {
|
|
1337
|
+
if (!isPluginProvider(provider)) {
|
|
1338
|
+
await postEphemeral(event, `Unknown provider: \`${provider}\``);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (!getOAuthProviderConfig(provider)) {
|
|
1342
|
+
await postEphemeral(
|
|
1343
|
+
event,
|
|
1344
|
+
`${providerLabel(provider)} uses app-level authentication and can't be unlinked.`
|
|
1345
|
+
);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const tokenStore = getUserTokenStore();
|
|
1349
|
+
await tokenStore.delete(event.user.userId, provider);
|
|
1350
|
+
logInfo(
|
|
1351
|
+
"slash_command_unlink",
|
|
1352
|
+
{ slackUserId: event.user.userId },
|
|
1353
|
+
{ "app.credential.provider": provider },
|
|
1354
|
+
`Unlinked ${providerLabel(provider)} account via /jr slash command`
|
|
1355
|
+
);
|
|
1356
|
+
await postEphemeral(event, `Your ${providerLabel(provider)} account has been unlinked.`);
|
|
1357
|
+
}
|
|
1358
|
+
async function handleSlashCommand(event) {
|
|
1359
|
+
const [subcommand, provider, ...rest] = event.text.trim().split(/\s+/);
|
|
1360
|
+
if (!subcommand || !["link", "unlink"].includes(subcommand)) {
|
|
1361
|
+
await postEphemeral(event, "Usage: `/jr link <provider>` or `/jr unlink <provider>`");
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (!provider || rest.length > 0) {
|
|
1365
|
+
await postEphemeral(event, `Usage: \`/jr ${subcommand} <provider>\``);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const normalized = provider.toLowerCase();
|
|
1369
|
+
if (subcommand === "link") {
|
|
1370
|
+
await handleLink(event, normalized);
|
|
1371
|
+
} else {
|
|
1372
|
+
await handleUnlink(event, normalized);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/chat/bootstrap/register-handlers.ts
|
|
1377
|
+
function registerBotHandlers(args) {
|
|
1378
|
+
const { bot: bot2, appSlackRuntime: appSlackRuntime2 } = args;
|
|
1379
|
+
bot2.onNewMention(appSlackRuntime2.handleNewMention);
|
|
1380
|
+
bot2.onSubscribedMessage(appSlackRuntime2.handleSubscribedMessage);
|
|
1381
|
+
bot2.onAssistantThreadStarted(
|
|
1382
|
+
(event) => appSlackRuntime2.handleAssistantThreadStarted(event)
|
|
1383
|
+
);
|
|
1384
|
+
bot2.onAssistantContextChanged(
|
|
1385
|
+
(event) => appSlackRuntime2.handleAssistantContextChanged(event)
|
|
1386
|
+
);
|
|
1387
|
+
bot2.onSlashCommand(
|
|
1388
|
+
"/jr",
|
|
1389
|
+
(event) => withSpan(
|
|
1390
|
+
"chat.slash_command",
|
|
1391
|
+
"chat.slash_command",
|
|
1392
|
+
{ slackUserId: event.user.userId },
|
|
1393
|
+
async () => {
|
|
1394
|
+
try {
|
|
1395
|
+
await handleSlashCommand(event);
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
logException(error, "slash_command_failed", { slackUserId: event.user.userId });
|
|
1398
|
+
throw error;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
)
|
|
1402
|
+
);
|
|
1403
|
+
bot2.onAppHomeOpened(
|
|
1404
|
+
(event) => withSpan(
|
|
1405
|
+
"chat.app_home_opened",
|
|
1406
|
+
"chat.app_home_opened",
|
|
1407
|
+
{ slackUserId: event.userId },
|
|
1408
|
+
async () => {
|
|
1409
|
+
try {
|
|
1410
|
+
await publishAppHomeView(getSlackClient(), event.userId, getUserTokenStore());
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
logException(error, "app_home_opened_failed", { slackUserId: event.userId });
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
)
|
|
1416
|
+
);
|
|
1417
|
+
bot2.onAction("app_home_disconnect", async (event) => {
|
|
1418
|
+
const provider = event.value;
|
|
1419
|
+
if (!provider) return;
|
|
1420
|
+
const userId = event.user.userId;
|
|
1421
|
+
await withSpan(
|
|
1422
|
+
"chat.app_home_disconnect",
|
|
1423
|
+
"chat.app_home_disconnect",
|
|
1424
|
+
{ slackUserId: userId },
|
|
1425
|
+
async () => {
|
|
1426
|
+
try {
|
|
1427
|
+
await getUserTokenStore().delete(userId, provider);
|
|
1428
|
+
await publishAppHomeView(getSlackClient(), userId, getUserTokenStore());
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
logException(error, "app_home_disconnect_failed", { slackUserId: userId }, {
|
|
1431
|
+
"app.credential.provider": provider
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
);
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// src/chat/runtime/assistant-lifecycle.ts
|
|
1440
|
+
import { ThreadImpl } from "chat";
|
|
1441
|
+
|
|
1442
|
+
// src/chat/slack-actions/types.ts
|
|
1443
|
+
function coerceThreadArtifactsState(value) {
|
|
1444
|
+
if (!value || typeof value !== "object") {
|
|
1445
|
+
return {};
|
|
1446
|
+
}
|
|
1447
|
+
const raw = value;
|
|
1448
|
+
const artifacts = raw.artifacts ?? {};
|
|
1449
|
+
const listColumnMap = artifacts.listColumnMap ?? {};
|
|
1450
|
+
const recentCanvases = [];
|
|
1451
|
+
if (Array.isArray(artifacts.recentCanvases)) {
|
|
1452
|
+
for (const entry of artifacts.recentCanvases) {
|
|
1453
|
+
if (!entry || typeof entry !== "object") {
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
const candidate = entry;
|
|
1457
|
+
if (typeof candidate.id !== "string" || candidate.id.trim().length === 0) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
recentCanvases.push({
|
|
1461
|
+
id: candidate.id,
|
|
1462
|
+
title: typeof candidate.title === "string" ? candidate.title : void 0,
|
|
1463
|
+
url: typeof candidate.url === "string" ? candidate.url : void 0,
|
|
1464
|
+
createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : void 0
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return {
|
|
1469
|
+
assistantContextChannelId: typeof artifacts.assistantContextChannelId === "string" ? artifacts.assistantContextChannelId : void 0,
|
|
1470
|
+
lastCanvasId: typeof artifacts.lastCanvasId === "string" ? artifacts.lastCanvasId : void 0,
|
|
1471
|
+
lastCanvasUrl: typeof artifacts.lastCanvasUrl === "string" ? artifacts.lastCanvasUrl : void 0,
|
|
1472
|
+
recentCanvases,
|
|
1473
|
+
lastListId: typeof artifacts.lastListId === "string" ? artifacts.lastListId : void 0,
|
|
1474
|
+
lastListUrl: typeof artifacts.lastListUrl === "string" ? artifacts.lastListUrl : void 0,
|
|
1475
|
+
listColumnMap: {
|
|
1476
|
+
titleColumnId: typeof listColumnMap.titleColumnId === "string" ? listColumnMap.titleColumnId : void 0,
|
|
1477
|
+
completedColumnId: typeof listColumnMap.completedColumnId === "string" ? listColumnMap.completedColumnId : void 0,
|
|
1478
|
+
assigneeColumnId: typeof listColumnMap.assigneeColumnId === "string" ? listColumnMap.assigneeColumnId : void 0,
|
|
1479
|
+
dueDateColumnId: typeof listColumnMap.dueDateColumnId === "string" ? listColumnMap.dueDateColumnId : void 0
|
|
1480
|
+
},
|
|
1481
|
+
updatedAt: typeof artifacts.updatedAt === "string" ? artifacts.updatedAt : void 0
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function buildArtifactStatePatch(patch) {
|
|
1485
|
+
return {
|
|
1486
|
+
artifacts: {
|
|
1487
|
+
...patch,
|
|
1488
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// src/chat/configuration/validation.ts
|
|
1494
|
+
var CONFIG_KEY_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
|
|
1495
|
+
var SECRET_KEY_RE = /(?:^|[_.-])(token|secret|password|passphrase|api[-_]?key|private[-_]?key|credential|auth)(?:$|[_.-])/i;
|
|
1496
|
+
var SECRET_VALUE_PATTERNS = [
|
|
1497
|
+
/\bBearer\s+[A-Za-z0-9._-]{16,}\b/i,
|
|
1498
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
1499
|
+
/\b(?:ghp|ghs|github_pat)_[A-Za-z0-9_]{20,}\b/,
|
|
1500
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/i,
|
|
1501
|
+
/\bsk-[A-Za-z0-9]{20,}\b/,
|
|
1502
|
+
/\bAIza[0-9A-Za-z\-_]{30,}\b/,
|
|
1503
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
1504
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
|
|
1505
|
+
];
|
|
1506
|
+
function validateConfigKey(key) {
|
|
1507
|
+
const trimmed = key.trim();
|
|
1508
|
+
if (!trimmed) {
|
|
1509
|
+
return "Configuration key must not be empty";
|
|
1510
|
+
}
|
|
1511
|
+
if (!CONFIG_KEY_RE.test(trimmed)) {
|
|
1512
|
+
return `Invalid configuration key "${key}"; expected dotted lowercase namespace (for example "github.repo")`;
|
|
1513
|
+
}
|
|
1514
|
+
if (SECRET_KEY_RE.test(trimmed)) {
|
|
1515
|
+
return `Configuration key "${key}" appears to be secret-related and is not allowed`;
|
|
1516
|
+
}
|
|
1517
|
+
return void 0;
|
|
1518
|
+
}
|
|
1519
|
+
function collectStringValues(value, output, depth = 0) {
|
|
1520
|
+
if (depth > 5) {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (typeof value === "string") {
|
|
1524
|
+
output.push(value);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (Array.isArray(value)) {
|
|
1528
|
+
for (const item of value) {
|
|
1529
|
+
collectStringValues(item, output, depth + 1);
|
|
1530
|
+
}
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (value && typeof value === "object") {
|
|
1534
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1535
|
+
output.push(key);
|
|
1536
|
+
collectStringValues(nested, output, depth + 1);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function validateConfigValue(value) {
|
|
1541
|
+
const stringValues = [];
|
|
1542
|
+
collectStringValues(value, stringValues);
|
|
1543
|
+
for (const text of stringValues) {
|
|
1544
|
+
for (const pattern of SECRET_VALUE_PATTERNS) {
|
|
1545
|
+
if (pattern.test(text)) {
|
|
1546
|
+
return "Configuration value appears to contain secret material and is not allowed";
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return void 0;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// src/chat/configuration/service.ts
|
|
1554
|
+
function isRecord2(value) {
|
|
1555
|
+
return Boolean(value) && typeof value === "object";
|
|
1556
|
+
}
|
|
1557
|
+
function toOptionalString4(value) {
|
|
1558
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
1559
|
+
}
|
|
1560
|
+
function defaultState() {
|
|
1561
|
+
return {
|
|
1562
|
+
schemaVersion: 1,
|
|
1563
|
+
entries: {}
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function sanitizeEntry(value) {
|
|
1567
|
+
if (!isRecord2(value)) {
|
|
1568
|
+
return void 0;
|
|
1569
|
+
}
|
|
1570
|
+
const key = toOptionalString4(value.key);
|
|
1571
|
+
if (!key) {
|
|
1572
|
+
return void 0;
|
|
1573
|
+
}
|
|
1574
|
+
if (validateConfigKey(key)) {
|
|
1575
|
+
return void 0;
|
|
1576
|
+
}
|
|
1577
|
+
const updatedAt = toOptionalString4(value.updatedAt);
|
|
1578
|
+
if (!updatedAt) {
|
|
1579
|
+
return void 0;
|
|
1580
|
+
}
|
|
1581
|
+
if (value.scope !== "channel" && value.scope !== "conversation") {
|
|
1582
|
+
return void 0;
|
|
1583
|
+
}
|
|
1584
|
+
const scope = "conversation";
|
|
1585
|
+
return {
|
|
1586
|
+
key,
|
|
1587
|
+
value: value.value,
|
|
1588
|
+
scope,
|
|
1589
|
+
updatedAt,
|
|
1590
|
+
updatedBy: toOptionalString4(value.updatedBy),
|
|
1591
|
+
source: toOptionalString4(value.source),
|
|
1592
|
+
expiresAt: toOptionalString4(value.expiresAt)
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
function coerceState(raw) {
|
|
1596
|
+
if (!isRecord2(raw)) {
|
|
1597
|
+
return defaultState();
|
|
1598
|
+
}
|
|
1599
|
+
const rawConfig = isRecord2(raw.configuration) ? raw.configuration : {};
|
|
1600
|
+
const rawEntries = isRecord2(rawConfig.entries) ? rawConfig.entries : {};
|
|
1601
|
+
const entries = {};
|
|
1602
|
+
for (const [key, value] of Object.entries(rawEntries)) {
|
|
1603
|
+
const entry = sanitizeEntry(value);
|
|
1604
|
+
if (!entry) {
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
entries[key] = entry;
|
|
1608
|
+
}
|
|
1609
|
+
return {
|
|
1610
|
+
schemaVersion: 1,
|
|
1611
|
+
entries
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function createChannelConfigurationService(storage) {
|
|
1615
|
+
const getState = async () => {
|
|
1616
|
+
const loaded = await storage.load();
|
|
1617
|
+
return coerceState(loaded);
|
|
1618
|
+
};
|
|
1619
|
+
const saveState = async (state) => {
|
|
1620
|
+
await storage.save({
|
|
1621
|
+
schemaVersion: 1,
|
|
1622
|
+
entries: state.entries
|
|
1623
|
+
});
|
|
1624
|
+
};
|
|
1625
|
+
const get = async (key) => {
|
|
1626
|
+
const normalizedKey = key.trim();
|
|
1627
|
+
const state = await getState();
|
|
1628
|
+
return state.entries[normalizedKey];
|
|
1629
|
+
};
|
|
1630
|
+
const set = async (input) => {
|
|
1631
|
+
const normalizedKey = input.key.trim();
|
|
1632
|
+
const keyError = validateConfigKey(normalizedKey);
|
|
1633
|
+
if (keyError) {
|
|
1634
|
+
throw new Error(keyError);
|
|
1635
|
+
}
|
|
1636
|
+
const valueError = validateConfigValue(input.value);
|
|
1637
|
+
if (valueError) {
|
|
1638
|
+
throw new Error(valueError);
|
|
1639
|
+
}
|
|
1640
|
+
const state = await getState();
|
|
1641
|
+
const nextEntry = {
|
|
1642
|
+
key: normalizedKey,
|
|
1643
|
+
value: input.value,
|
|
1644
|
+
scope: "conversation",
|
|
1645
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1646
|
+
updatedBy: toOptionalString4(input.updatedBy),
|
|
1647
|
+
source: toOptionalString4(input.source),
|
|
1648
|
+
expiresAt: toOptionalString4(input.expiresAt)
|
|
1649
|
+
};
|
|
1650
|
+
state.entries[normalizedKey] = nextEntry;
|
|
1651
|
+
await saveState(state);
|
|
1652
|
+
return nextEntry;
|
|
1653
|
+
};
|
|
1654
|
+
const unset = async (key) => {
|
|
1655
|
+
const normalizedKey = key.trim();
|
|
1656
|
+
const state = await getState();
|
|
1657
|
+
if (!state.entries[normalizedKey]) {
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
delete state.entries[normalizedKey];
|
|
1661
|
+
await saveState(state);
|
|
1662
|
+
return true;
|
|
1663
|
+
};
|
|
1664
|
+
const list = async (options = {}) => {
|
|
1665
|
+
const state = await getState();
|
|
1666
|
+
const prefix = options.prefix?.trim();
|
|
1667
|
+
return Object.values(state.entries).filter((entry) => prefix ? entry.key.startsWith(prefix) : true).sort((a, b) => a.key.localeCompare(b.key));
|
|
1668
|
+
};
|
|
1669
|
+
const resolve = async (key) => {
|
|
1670
|
+
const entry = await get(key);
|
|
1671
|
+
return entry?.value;
|
|
1672
|
+
};
|
|
1673
|
+
const resolveValues = async (options = {}) => {
|
|
1674
|
+
const keys = Array.isArray(options.keys) ? options.keys.map((entry) => entry.trim()).filter((entry) => entry.length > 0) : void 0;
|
|
1675
|
+
const entries = await list({
|
|
1676
|
+
...options.prefix ? { prefix: options.prefix } : {}
|
|
1677
|
+
});
|
|
1678
|
+
const filtered = keys ? entries.filter((entry) => keys.includes(entry.key)) : entries;
|
|
1679
|
+
const resolved = {};
|
|
1680
|
+
for (const entry of filtered) {
|
|
1681
|
+
resolved[entry.key] = entry.value;
|
|
1682
|
+
}
|
|
1683
|
+
return resolved;
|
|
1684
|
+
};
|
|
1685
|
+
return {
|
|
1686
|
+
get,
|
|
1687
|
+
set,
|
|
1688
|
+
unset,
|
|
1689
|
+
list,
|
|
1690
|
+
resolve,
|
|
1691
|
+
resolveValues
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/chat/runtime/thread-state.ts
|
|
1696
|
+
function mergeArtifactsState(artifacts, patch) {
|
|
1697
|
+
if (!patch) {
|
|
1698
|
+
return artifacts;
|
|
1699
|
+
}
|
|
1700
|
+
return {
|
|
1701
|
+
...artifacts,
|
|
1702
|
+
...patch,
|
|
1703
|
+
listColumnMap: {
|
|
1704
|
+
...artifacts.listColumnMap,
|
|
1705
|
+
...patch.listColumnMap
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
async function persistThreadState(thread, patch) {
|
|
1710
|
+
const payload = {};
|
|
1711
|
+
if (patch.artifacts) {
|
|
1712
|
+
Object.assign(payload, buildArtifactStatePatch(patch.artifacts));
|
|
1713
|
+
}
|
|
1714
|
+
if (patch.conversation) {
|
|
1715
|
+
Object.assign(payload, buildConversationStatePatch(patch.conversation));
|
|
1716
|
+
}
|
|
1717
|
+
if (patch.sandboxId) {
|
|
1718
|
+
payload.app_sandbox_id = patch.sandboxId;
|
|
1719
|
+
}
|
|
1720
|
+
if (Object.keys(payload).length === 0) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
await thread.setState(payload);
|
|
1724
|
+
}
|
|
1725
|
+
function getChannelConfigurationService(thread) {
|
|
1726
|
+
const channel = thread.channel;
|
|
1727
|
+
return createChannelConfigurationService({
|
|
1728
|
+
load: async () => channel.state,
|
|
1729
|
+
save: async (state) => {
|
|
1730
|
+
await channel.setState({
|
|
1731
|
+
configuration: state
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/chat/runtime/assistant-lifecycle.ts
|
|
1738
|
+
async function initializeAssistantThread(event) {
|
|
1739
|
+
const slack = event.getSlackAdapter();
|
|
1740
|
+
await slack.setAssistantTitle(event.channelId, event.threadTs, "Junior");
|
|
1741
|
+
await slack.setSuggestedPrompts(event.channelId, event.threadTs, [
|
|
1742
|
+
{ title: "Summarize thread", message: "Summarize the latest discussion in this thread." },
|
|
1743
|
+
{ title: "Draft a reply", message: "Draft a concise reply I can send." },
|
|
1744
|
+
{ title: "Generate image", message: "Generate an image based on this conversation." }
|
|
1745
|
+
]);
|
|
1746
|
+
if (!event.sourceChannelId) {
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
const thread = ThreadImpl.fromJSON({
|
|
1750
|
+
_type: "chat:Thread",
|
|
1751
|
+
adapterName: "slack",
|
|
1752
|
+
channelId: event.channelId,
|
|
1753
|
+
id: event.threadId,
|
|
1754
|
+
isDM: event.channelId.startsWith("D")
|
|
1755
|
+
});
|
|
1756
|
+
const currentArtifacts = coerceThreadArtifactsState(await thread.state);
|
|
1757
|
+
const nextArtifacts = mergeArtifactsState(currentArtifacts, {
|
|
1758
|
+
assistantContextChannelId: event.sourceChannelId
|
|
1759
|
+
});
|
|
1760
|
+
await persistThreadState(thread, {
|
|
1761
|
+
artifacts: nextArtifacts
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// src/chat/progress-reporter.ts
|
|
1766
|
+
var STATUS_UPDATE_DEBOUNCE_MS = 1e3;
|
|
1767
|
+
var STATUS_MIN_VISIBLE_MS = 1200;
|
|
1768
|
+
function createProgressReporter(args) {
|
|
1769
|
+
const now = args.now ?? (() => Date.now());
|
|
1770
|
+
const setTimer = args.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
1771
|
+
const clearTimer = args.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
1772
|
+
let active = false;
|
|
1773
|
+
let currentStatus = "";
|
|
1774
|
+
let lastStatusAt = 0;
|
|
1775
|
+
let pendingStatus = null;
|
|
1776
|
+
let pendingTimer = null;
|
|
1777
|
+
const postStatus = async (text) => {
|
|
1778
|
+
if (!args.channelId || !args.threadTs) {
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
currentStatus = text;
|
|
1782
|
+
lastStatusAt = now();
|
|
1783
|
+
try {
|
|
1784
|
+
await args.setAssistantStatus(args.channelId, args.threadTs, text, [text]);
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
const clearPending = () => {
|
|
1789
|
+
if (pendingTimer) {
|
|
1790
|
+
clearTimer(pendingTimer);
|
|
1791
|
+
pendingTimer = null;
|
|
1792
|
+
}
|
|
1793
|
+
pendingStatus = null;
|
|
1794
|
+
};
|
|
1795
|
+
const flushPending = async () => {
|
|
1796
|
+
if (!active || !pendingStatus) {
|
|
1797
|
+
clearPending();
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const next = pendingStatus;
|
|
1801
|
+
clearPending();
|
|
1802
|
+
if (next !== currentStatus) {
|
|
1803
|
+
await postStatus(next);
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
return {
|
|
1807
|
+
async start() {
|
|
1808
|
+
active = true;
|
|
1809
|
+
clearPending();
|
|
1810
|
+
void postStatus("Thinking...");
|
|
1811
|
+
},
|
|
1812
|
+
async stop() {
|
|
1813
|
+
active = false;
|
|
1814
|
+
clearPending();
|
|
1815
|
+
},
|
|
1816
|
+
async setStatus(text) {
|
|
1817
|
+
const truncated = truncateStatusText(text);
|
|
1818
|
+
if (!active || !truncated || truncated === currentStatus || truncated === pendingStatus) {
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
const elapsed = now() - lastStatusAt;
|
|
1822
|
+
const waitMs = Math.max(
|
|
1823
|
+
STATUS_UPDATE_DEBOUNCE_MS - elapsed,
|
|
1824
|
+
STATUS_MIN_VISIBLE_MS - elapsed,
|
|
1825
|
+
0
|
|
1826
|
+
);
|
|
1827
|
+
if (waitMs <= 0) {
|
|
1828
|
+
clearPending();
|
|
1829
|
+
void postStatus(truncated);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
pendingStatus = truncated;
|
|
1833
|
+
if (pendingTimer) {
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
pendingTimer = setTimer(() => {
|
|
1837
|
+
pendingTimer = null;
|
|
1838
|
+
void flushPending();
|
|
1839
|
+
}, Math.max(1, waitMs));
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// src/chat/runtime/streaming.ts
|
|
1845
|
+
function createTextStreamBridge() {
|
|
1846
|
+
const queue = [];
|
|
1847
|
+
let ended = false;
|
|
1848
|
+
let wakeConsumer = null;
|
|
1849
|
+
const iterable = {
|
|
1850
|
+
async *[Symbol.asyncIterator]() {
|
|
1851
|
+
while (!ended || queue.length > 0) {
|
|
1852
|
+
if (queue.length > 0) {
|
|
1853
|
+
yield queue.shift();
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
await new Promise((resolve) => {
|
|
1857
|
+
wakeConsumer = resolve;
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
return {
|
|
1863
|
+
iterable,
|
|
1864
|
+
push(delta) {
|
|
1865
|
+
if (!delta || ended) {
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
queue.push(delta);
|
|
1869
|
+
const wake = wakeConsumer;
|
|
1870
|
+
wakeConsumer = null;
|
|
1871
|
+
wake?.();
|
|
1872
|
+
},
|
|
1873
|
+
end() {
|
|
1874
|
+
if (ended) {
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
ended = true;
|
|
1878
|
+
const wake = wakeConsumer;
|
|
1879
|
+
wakeConsumer = null;
|
|
1880
|
+
wake?.();
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
function createNormalizingStream(inner, normalize) {
|
|
1885
|
+
return {
|
|
1886
|
+
async *[Symbol.asyncIterator]() {
|
|
1887
|
+
let accumulated = "";
|
|
1888
|
+
let emitted = 0;
|
|
1889
|
+
for await (const chunk of inner) {
|
|
1890
|
+
accumulated += chunk;
|
|
1891
|
+
const lastNewline = accumulated.lastIndexOf("\n");
|
|
1892
|
+
if (lastNewline === -1) {
|
|
1893
|
+
const delta2 = accumulated.slice(emitted);
|
|
1894
|
+
if (delta2) {
|
|
1895
|
+
yield delta2;
|
|
1896
|
+
emitted = accumulated.length;
|
|
1897
|
+
}
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
const stable = accumulated.slice(0, lastNewline + 1);
|
|
1901
|
+
const normalized = normalize(stable);
|
|
1902
|
+
const delta = normalized.slice(emitted);
|
|
1903
|
+
emitted = normalized.length;
|
|
1904
|
+
if (delta) yield delta;
|
|
1905
|
+
}
|
|
1906
|
+
if (accumulated) {
|
|
1907
|
+
const normalized = normalize(accumulated);
|
|
1908
|
+
const delta = normalized.slice(emitted);
|
|
1909
|
+
if (delta) yield delta;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/chat/services/conversation-memory.ts
|
|
1916
|
+
var CONTEXT_COMPACTION_TRIGGER_TOKENS = 9e3;
|
|
1917
|
+
var CONTEXT_COMPACTION_TARGET_TOKENS = 7e3;
|
|
1918
|
+
var CONTEXT_MIN_LIVE_MESSAGES = 12;
|
|
1919
|
+
var CONTEXT_COMPACTION_BATCH_SIZE = 24;
|
|
1920
|
+
var CONTEXT_MAX_COMPACTIONS = 16;
|
|
1921
|
+
var CONTEXT_MAX_MESSAGE_CHARS = 3200;
|
|
1922
|
+
var BACKFILL_MESSAGE_LIMIT = 80;
|
|
1923
|
+
function generateConversationId(prefix) {
|
|
1924
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1925
|
+
}
|
|
1926
|
+
function normalizeConversationText(text) {
|
|
1927
|
+
return text.trim().replace(/\s+/g, " ").slice(0, CONTEXT_MAX_MESSAGE_CHARS);
|
|
1928
|
+
}
|
|
1929
|
+
function estimateTokenCount(text) {
|
|
1930
|
+
return Math.ceil(text.length / 4);
|
|
1931
|
+
}
|
|
1932
|
+
function buildImageContextSuffix(message, conversation) {
|
|
1933
|
+
const byFileId = conversation?.vision.byFileId;
|
|
1934
|
+
const imageFileIds = message.meta?.imageFileIds ?? [];
|
|
1935
|
+
if (!byFileId || imageFileIds.length === 0) {
|
|
1936
|
+
return "";
|
|
1937
|
+
}
|
|
1938
|
+
const summaries = imageFileIds.map((fileId) => byFileId[fileId]?.summary?.trim()).filter((summary) => Boolean(summary));
|
|
1939
|
+
if (summaries.length === 0) {
|
|
1940
|
+
return "";
|
|
1941
|
+
}
|
|
1942
|
+
return ` [image context: ${summaries.join(" | ")}]`;
|
|
1943
|
+
}
|
|
1944
|
+
function renderConversationMessageLine(message, conversation) {
|
|
1945
|
+
const displayName = message.author?.fullName || message.author?.userName || (message.role === "assistant" ? botConfig.userName : message.role);
|
|
1946
|
+
const markers = [];
|
|
1947
|
+
if (message.meta?.replied === false) {
|
|
1948
|
+
markers.push(`assistant skipped: ${message.meta?.skippedReason ?? "no-reply route"}`);
|
|
1949
|
+
}
|
|
1950
|
+
if (message.meta?.explicitMention) {
|
|
1951
|
+
markers.push("explicit mention");
|
|
1952
|
+
}
|
|
1953
|
+
const markerSuffix = markers.length > 0 ? ` (${markers.join("; ")})` : "";
|
|
1954
|
+
const imageContext = buildImageContextSuffix(message, conversation);
|
|
1955
|
+
return `[${message.role}] ${displayName}: ${message.text}${imageContext}${markerSuffix}`;
|
|
1956
|
+
}
|
|
1957
|
+
function updateConversationStats(conversation) {
|
|
1958
|
+
const contextText = buildConversationContext(conversation);
|
|
1959
|
+
conversation.stats.estimatedContextTokens = estimateTokenCount(contextText ?? "");
|
|
1960
|
+
conversation.stats.totalMessageCount = conversation.messages.length;
|
|
1961
|
+
conversation.stats.updatedAtMs = Date.now();
|
|
1962
|
+
}
|
|
1963
|
+
function upsertConversationMessage(conversation, message) {
|
|
1964
|
+
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
|
|
1965
|
+
if (existingIndex >= 0) {
|
|
1966
|
+
conversation.messages[existingIndex] = {
|
|
1967
|
+
...conversation.messages[existingIndex],
|
|
1968
|
+
...message,
|
|
1969
|
+
meta: {
|
|
1970
|
+
...conversation.messages[existingIndex]?.meta,
|
|
1971
|
+
...message.meta
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
updateConversationStats(conversation);
|
|
1975
|
+
return message.id;
|
|
1976
|
+
}
|
|
1977
|
+
conversation.messages.push(message);
|
|
1978
|
+
updateConversationStats(conversation);
|
|
1979
|
+
return message.id;
|
|
1980
|
+
}
|
|
1981
|
+
function markConversationMessage(conversation, messageId, patch) {
|
|
1982
|
+
if (!messageId) return;
|
|
1983
|
+
const messageIndex = conversation.messages.findIndex((entry) => entry.id === messageId);
|
|
1984
|
+
if (messageIndex < 0) return;
|
|
1985
|
+
const current = conversation.messages[messageIndex];
|
|
1986
|
+
conversation.messages[messageIndex] = {
|
|
1987
|
+
...current,
|
|
1988
|
+
meta: {
|
|
1989
|
+
...current.meta ?? {},
|
|
1990
|
+
...patch
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
updateConversationStats(conversation);
|
|
1994
|
+
}
|
|
1995
|
+
function buildConversationContext(conversation, options = {}) {
|
|
1996
|
+
const messages = conversation.messages.filter((entry) => entry.id !== options.excludeMessageId);
|
|
1997
|
+
if (messages.length === 0 && conversation.compactions.length === 0) {
|
|
1998
|
+
return void 0;
|
|
1999
|
+
}
|
|
2000
|
+
const lines = [];
|
|
2001
|
+
if (conversation.compactions.length > 0) {
|
|
2002
|
+
lines.push("<thread-compactions>");
|
|
2003
|
+
for (const [index, compaction] of conversation.compactions.entries()) {
|
|
2004
|
+
lines.push(
|
|
2005
|
+
[
|
|
2006
|
+
`summary_${index + 1}:`,
|
|
2007
|
+
compaction.summary,
|
|
2008
|
+
`covered_messages: ${compaction.coveredMessageIds.length}`,
|
|
2009
|
+
`created_at: ${new Date(compaction.createdAtMs).toISOString()}`
|
|
2010
|
+
].join(" ")
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
lines.push("</thread-compactions>");
|
|
2014
|
+
lines.push("");
|
|
2015
|
+
}
|
|
2016
|
+
lines.push("<thread-transcript>");
|
|
2017
|
+
for (const message of messages) {
|
|
2018
|
+
lines.push(renderConversationMessageLine(message, conversation));
|
|
2019
|
+
}
|
|
2020
|
+
lines.push("</thread-transcript>");
|
|
2021
|
+
return lines.join("\n");
|
|
2022
|
+
}
|
|
2023
|
+
function pruneCompactions(compactions) {
|
|
2024
|
+
if (compactions.length <= CONTEXT_MAX_COMPACTIONS) {
|
|
2025
|
+
return compactions;
|
|
2026
|
+
}
|
|
2027
|
+
const overflowCount = compactions.length - CONTEXT_MAX_COMPACTIONS + 1;
|
|
2028
|
+
const merged = compactions.slice(0, overflowCount);
|
|
2029
|
+
const mergedSummary = merged.map((entry) => entry.summary).join("\n").slice(0, 3500);
|
|
2030
|
+
const mergedIds = merged.flatMap((entry) => entry.coveredMessageIds).slice(0, 500);
|
|
2031
|
+
const compacted = {
|
|
2032
|
+
id: generateConversationId("compaction"),
|
|
2033
|
+
createdAtMs: Date.now(),
|
|
2034
|
+
summary: mergedSummary,
|
|
2035
|
+
coveredMessageIds: mergedIds
|
|
2036
|
+
};
|
|
2037
|
+
return [compacted, ...compactions.slice(overflowCount)];
|
|
2038
|
+
}
|
|
2039
|
+
async function summarizeConversationChunk(messages, conversation, context) {
|
|
2040
|
+
const transcript = messages.map((message) => renderConversationMessageLine(message, conversation)).join("\n");
|
|
2041
|
+
try {
|
|
2042
|
+
const result = await getBotDeps().completeText({
|
|
2043
|
+
modelId: botConfig.fastModelId,
|
|
2044
|
+
temperature: 0,
|
|
2045
|
+
messages: [
|
|
2046
|
+
{
|
|
2047
|
+
role: "user",
|
|
2048
|
+
content: [
|
|
2049
|
+
"Summarize the following older Slack thread transcript segment for future assistant turns.",
|
|
2050
|
+
"Keep the summary factual and concise.",
|
|
2051
|
+
"Preserve decisions, commitments, constraints, locations, hiring criteria, and unresolved asks.",
|
|
2052
|
+
"Do not invent details.",
|
|
2053
|
+
"",
|
|
2054
|
+
transcript
|
|
2055
|
+
].join("\n"),
|
|
2056
|
+
timestamp: Date.now()
|
|
2057
|
+
}
|
|
2058
|
+
],
|
|
2059
|
+
metadata: {
|
|
2060
|
+
modelId: botConfig.fastModelId,
|
|
2061
|
+
threadId: context.threadId ?? "",
|
|
2062
|
+
channelId: context.channelId ?? "",
|
|
2063
|
+
requesterId: context.requesterId ?? "",
|
|
2064
|
+
runId: context.runId ?? ""
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
2067
|
+
const summary = result.text.trim();
|
|
2068
|
+
if (summary.length > 0) {
|
|
2069
|
+
return summary.slice(0, 3500);
|
|
2070
|
+
}
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
logWarn(
|
|
2073
|
+
"conversation_compaction_summary_failed",
|
|
2074
|
+
{
|
|
2075
|
+
slackThreadId: context.threadId,
|
|
2076
|
+
slackUserId: context.requesterId,
|
|
2077
|
+
slackChannelId: context.channelId,
|
|
2078
|
+
runId: context.runId,
|
|
2079
|
+
assistantUserName: botConfig.userName,
|
|
2080
|
+
modelId: botConfig.fastModelId
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
2084
|
+
"app.compaction_messages_covered": messages.length
|
|
2085
|
+
},
|
|
2086
|
+
"Compaction summarization failed; using fallback summary"
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
return transcript.slice(0, 2800);
|
|
2090
|
+
}
|
|
2091
|
+
async function generateThreadTitle(userText, assistantText) {
|
|
2092
|
+
const result = await getBotDeps().completeText({
|
|
2093
|
+
modelId: botConfig.fastModelId,
|
|
2094
|
+
temperature: 0,
|
|
2095
|
+
messages: [
|
|
2096
|
+
{
|
|
2097
|
+
role: "user",
|
|
2098
|
+
content: [
|
|
2099
|
+
"Generate a concise 5-8 word title for this conversation. Reply with ONLY the title, no quotes or punctuation.",
|
|
2100
|
+
"",
|
|
2101
|
+
`User: ${userText.slice(0, 500)}`,
|
|
2102
|
+
`Assistant: ${assistantText.slice(0, 500)}`
|
|
2103
|
+
].join("\n"),
|
|
2104
|
+
timestamp: Date.now()
|
|
2105
|
+
}
|
|
2106
|
+
]
|
|
2107
|
+
});
|
|
2108
|
+
return result.text.trim().slice(0, 60);
|
|
2109
|
+
}
|
|
2110
|
+
async function compactConversationIfNeeded(conversation, context) {
|
|
2111
|
+
updateConversationStats(conversation);
|
|
2112
|
+
let estimatedTokens = conversation.stats.estimatedContextTokens;
|
|
2113
|
+
setSpanAttributes({
|
|
2114
|
+
"app.context_tokens_estimated": estimatedTokens
|
|
2115
|
+
});
|
|
2116
|
+
while (estimatedTokens > CONTEXT_COMPACTION_TRIGGER_TOKENS && conversation.messages.length > CONTEXT_MIN_LIVE_MESSAGES) {
|
|
2117
|
+
const compactCount = Math.min(
|
|
2118
|
+
CONTEXT_COMPACTION_BATCH_SIZE,
|
|
2119
|
+
conversation.messages.length - CONTEXT_MIN_LIVE_MESSAGES
|
|
2120
|
+
);
|
|
2121
|
+
if (compactCount <= 0) {
|
|
2122
|
+
break;
|
|
2123
|
+
}
|
|
2124
|
+
const compactedChunk = conversation.messages.slice(0, compactCount);
|
|
2125
|
+
const summary = await summarizeConversationChunk(compactedChunk, conversation, context);
|
|
2126
|
+
conversation.compactions.push({
|
|
2127
|
+
id: generateConversationId("compaction"),
|
|
2128
|
+
createdAtMs: Date.now(),
|
|
2129
|
+
summary,
|
|
2130
|
+
coveredMessageIds: compactedChunk.map((entry) => entry.id)
|
|
2131
|
+
});
|
|
2132
|
+
conversation.compactions = pruneCompactions(conversation.compactions);
|
|
2133
|
+
conversation.messages = conversation.messages.slice(compactCount);
|
|
2134
|
+
conversation.stats.compactedMessageCount += compactCount;
|
|
2135
|
+
updateConversationStats(conversation);
|
|
2136
|
+
estimatedTokens = conversation.stats.estimatedContextTokens;
|
|
2137
|
+
setSpanAttributes({
|
|
2138
|
+
"app.compaction_messages_covered": compactCount,
|
|
2139
|
+
"app.context_tokens_estimated": estimatedTokens
|
|
2140
|
+
});
|
|
2141
|
+
if (estimatedTokens <= CONTEXT_COMPACTION_TARGET_TOKENS) {
|
|
2142
|
+
break;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
function createConversationMessageFromSdkMessage(entry) {
|
|
2147
|
+
const rawText = normalizeConversationText(entry.text);
|
|
2148
|
+
if (!rawText) {
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
return {
|
|
2152
|
+
id: entry.id,
|
|
2153
|
+
role: entry.author.isMe ? "assistant" : "user",
|
|
2154
|
+
text: rawText,
|
|
2155
|
+
createdAtMs: entry.metadata.dateSent.getTime(),
|
|
2156
|
+
author: {
|
|
2157
|
+
userId: entry.author.userId,
|
|
2158
|
+
userName: entry.author.userName,
|
|
2159
|
+
fullName: entry.author.fullName,
|
|
2160
|
+
isBot: typeof entry.author.isBot === "boolean" ? entry.author.isBot : void 0
|
|
2161
|
+
},
|
|
2162
|
+
meta: {
|
|
2163
|
+
slackTs: entry.id
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
async function seedConversationBackfill(thread, conversation, currentTurn) {
|
|
2168
|
+
if (conversation.backfill.completedAtMs) {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (conversation.messages.length > 0 || conversation.compactions.length > 0) {
|
|
2172
|
+
conversation.backfill = {
|
|
2173
|
+
completedAtMs: Date.now(),
|
|
2174
|
+
source: "recent_messages"
|
|
2175
|
+
};
|
|
2176
|
+
updateConversationStats(conversation);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const seeded = [];
|
|
2180
|
+
let source = "recent_messages";
|
|
2181
|
+
try {
|
|
2182
|
+
const fetchedNewestFirst = [];
|
|
2183
|
+
for await (const entry of thread.messages) {
|
|
2184
|
+
fetchedNewestFirst.push(entry);
|
|
2185
|
+
if (fetchedNewestFirst.length >= BACKFILL_MESSAGE_LIMIT) {
|
|
2186
|
+
break;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
fetchedNewestFirst.reverse();
|
|
2190
|
+
for (const entry of fetchedNewestFirst) {
|
|
2191
|
+
const message = createConversationMessageFromSdkMessage(entry);
|
|
2192
|
+
if (message) {
|
|
2193
|
+
seeded.push(message);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
if (seeded.length > 0) {
|
|
2197
|
+
source = "thread_fetch";
|
|
2198
|
+
}
|
|
2199
|
+
} catch {
|
|
2200
|
+
}
|
|
2201
|
+
if (seeded.length === 0) {
|
|
2202
|
+
try {
|
|
2203
|
+
await thread.refresh();
|
|
2204
|
+
} catch {
|
|
2205
|
+
}
|
|
2206
|
+
const fromRecent = thread.recentMessages.slice(-BACKFILL_MESSAGE_LIMIT);
|
|
2207
|
+
for (const entry of fromRecent) {
|
|
2208
|
+
const message = createConversationMessageFromSdkMessage(entry);
|
|
2209
|
+
if (message) {
|
|
2210
|
+
seeded.push(message);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
source = "recent_messages";
|
|
2214
|
+
}
|
|
2215
|
+
for (const message of seeded) {
|
|
2216
|
+
if (message.id !== currentTurn.messageId && message.createdAtMs > currentTurn.messageCreatedAtMs) {
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (message.id !== currentTurn.messageId && message.createdAtMs === currentTurn.messageCreatedAtMs && message.id > currentTurn.messageId) {
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
upsertConversationMessage(conversation, message);
|
|
2223
|
+
}
|
|
2224
|
+
conversation.backfill = {
|
|
2225
|
+
completedAtMs: Date.now(),
|
|
2226
|
+
source
|
|
2227
|
+
};
|
|
2228
|
+
updateConversationStats(conversation);
|
|
2229
|
+
}
|
|
2230
|
+
function isHumanConversationMessage(message) {
|
|
2231
|
+
return message.role === "user" && message.author?.isBot !== true;
|
|
2232
|
+
}
|
|
2233
|
+
function getConversationMessageSlackTs(message) {
|
|
2234
|
+
return message.meta?.slackTs ?? toOptionalString(message.id);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// src/chat/services/vision-context.ts
|
|
2238
|
+
var MAX_USER_ATTACHMENTS = 3;
|
|
2239
|
+
var MAX_USER_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
2240
|
+
var MAX_MESSAGE_IMAGE_ATTACHMENTS = 3;
|
|
2241
|
+
var MAX_VISION_SUMMARY_CHARS = 500;
|
|
2242
|
+
async function resolveUserAttachments(attachments, context) {
|
|
2243
|
+
if (!attachments || attachments.length === 0) {
|
|
2244
|
+
return [];
|
|
2245
|
+
}
|
|
2246
|
+
const results = [];
|
|
2247
|
+
for (const attachment of attachments) {
|
|
2248
|
+
if (results.length >= MAX_USER_ATTACHMENTS) break;
|
|
2249
|
+
if (attachment.type !== "image" && attachment.type !== "file") continue;
|
|
2250
|
+
const mediaType = attachment.mimeType ?? "application/octet-stream";
|
|
2251
|
+
try {
|
|
2252
|
+
let data = null;
|
|
2253
|
+
if (attachment.fetchData) {
|
|
2254
|
+
data = await attachment.fetchData();
|
|
2255
|
+
} else if (attachment.data instanceof Buffer) {
|
|
2256
|
+
data = attachment.data;
|
|
2257
|
+
}
|
|
2258
|
+
if (!data) continue;
|
|
2259
|
+
if (data.byteLength > MAX_USER_ATTACHMENT_BYTES) {
|
|
2260
|
+
logWarn(
|
|
2261
|
+
"attachment_skipped_size_limit",
|
|
2262
|
+
{
|
|
2263
|
+
slackThreadId: context.threadId,
|
|
2264
|
+
slackUserId: context.requesterId,
|
|
2265
|
+
slackChannelId: context.channelId,
|
|
2266
|
+
runId: context.runId,
|
|
2267
|
+
assistantUserName: botConfig.userName,
|
|
2268
|
+
modelId: botConfig.modelId
|
|
2269
|
+
},
|
|
2270
|
+
{
|
|
2271
|
+
"file.size": data.byteLength,
|
|
2272
|
+
"file.mime_type": mediaType
|
|
2273
|
+
},
|
|
2274
|
+
"Skipping user attachment that exceeds size limit"
|
|
2275
|
+
);
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
results.push({
|
|
2279
|
+
data,
|
|
2280
|
+
mediaType,
|
|
2281
|
+
filename: attachment.name
|
|
2282
|
+
});
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
logWarn(
|
|
2285
|
+
"attachment_resolution_failed",
|
|
2286
|
+
{
|
|
2287
|
+
slackThreadId: context.threadId,
|
|
2288
|
+
slackUserId: context.requesterId,
|
|
2289
|
+
slackChannelId: context.channelId,
|
|
2290
|
+
runId: context.runId,
|
|
2291
|
+
assistantUserName: botConfig.userName,
|
|
2292
|
+
modelId: botConfig.modelId
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
2296
|
+
"file.mime_type": mediaType
|
|
2297
|
+
},
|
|
2298
|
+
"Failed to resolve user attachment"
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
return results;
|
|
2303
|
+
}
|
|
2304
|
+
async function summarizeConversationImage(args) {
|
|
2305
|
+
try {
|
|
2306
|
+
const result = await getBotDeps().completeText({
|
|
2307
|
+
modelId: botConfig.modelId,
|
|
2308
|
+
temperature: 0,
|
|
2309
|
+
maxTokens: 220,
|
|
2310
|
+
messages: [
|
|
2311
|
+
{
|
|
2312
|
+
role: "user",
|
|
2313
|
+
content: [
|
|
2314
|
+
{
|
|
2315
|
+
type: "text",
|
|
2316
|
+
text: [
|
|
2317
|
+
"Extract concise, factual context from this image for future thread turns.",
|
|
2318
|
+
"Focus on visible text, names, titles, companies, and candidate-identifying details.",
|
|
2319
|
+
"Do not speculate.",
|
|
2320
|
+
"Return plain text only."
|
|
2321
|
+
].join(" ")
|
|
2322
|
+
},
|
|
2323
|
+
{
|
|
2324
|
+
type: "image",
|
|
2325
|
+
data: args.imageData.toString("base64"),
|
|
2326
|
+
mimeType: args.mimeType
|
|
2327
|
+
}
|
|
2328
|
+
],
|
|
2329
|
+
timestamp: Date.now()
|
|
2330
|
+
}
|
|
2331
|
+
],
|
|
2332
|
+
metadata: {
|
|
2333
|
+
modelId: botConfig.modelId,
|
|
2334
|
+
threadId: args.context.threadId ?? "",
|
|
2335
|
+
channelId: args.context.channelId ?? "",
|
|
2336
|
+
requesterId: args.context.requesterId ?? "",
|
|
2337
|
+
runId: args.context.runId ?? "",
|
|
2338
|
+
fileId: args.fileId
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
const summary = result.text.trim().replace(/\s+/g, " ");
|
|
2342
|
+
if (!summary) {
|
|
2343
|
+
return void 0;
|
|
2344
|
+
}
|
|
2345
|
+
return summary.slice(0, MAX_VISION_SUMMARY_CHARS);
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
logWarn(
|
|
2348
|
+
"conversation_image_vision_failed",
|
|
2349
|
+
{
|
|
2350
|
+
slackThreadId: args.context.threadId,
|
|
2351
|
+
slackUserId: args.context.requesterId,
|
|
2352
|
+
slackChannelId: args.context.channelId,
|
|
2353
|
+
runId: args.context.runId,
|
|
2354
|
+
assistantUserName: botConfig.userName,
|
|
2355
|
+
modelId: botConfig.modelId
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
2359
|
+
"file.id": args.fileId,
|
|
2360
|
+
"file.mime_type": args.mimeType
|
|
2361
|
+
},
|
|
2362
|
+
"Image analysis failed while hydrating conversation context"
|
|
2363
|
+
);
|
|
2364
|
+
return void 0;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
async function hydrateConversationVisionContext(conversation, context) {
|
|
2368
|
+
if (!context.channelId || !context.threadTs) {
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const messagesByTs = /* @__PURE__ */ new Map();
|
|
2372
|
+
for (const message of conversation.messages) {
|
|
2373
|
+
if (!isHumanConversationMessage(message)) continue;
|
|
2374
|
+
if (message.meta?.imagesHydrated) continue;
|
|
2375
|
+
const slackTs = getConversationMessageSlackTs(message);
|
|
2376
|
+
if (!slackTs) continue;
|
|
2377
|
+
messagesByTs.set(slackTs, message);
|
|
2378
|
+
}
|
|
2379
|
+
if (messagesByTs.size === 0) {
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
let replies;
|
|
2383
|
+
try {
|
|
2384
|
+
replies = await getBotDeps().listThreadReplies({
|
|
2385
|
+
channelId: context.channelId,
|
|
2386
|
+
threadTs: context.threadTs,
|
|
2387
|
+
limit: 1e3,
|
|
2388
|
+
maxPages: 10,
|
|
2389
|
+
targetMessageTs: [...messagesByTs.keys()]
|
|
2390
|
+
});
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
logWarn(
|
|
2393
|
+
"conversation_image_replies_fetch_failed",
|
|
2394
|
+
{
|
|
2395
|
+
slackThreadId: context.threadId,
|
|
2396
|
+
slackUserId: context.requesterId,
|
|
2397
|
+
slackChannelId: context.channelId,
|
|
2398
|
+
runId: context.runId,
|
|
2399
|
+
assistantUserName: botConfig.userName,
|
|
2400
|
+
modelId: botConfig.modelId
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
"error.message": error instanceof Error ? error.message : String(error)
|
|
2404
|
+
},
|
|
2405
|
+
"Failed to fetch thread replies for image context hydration"
|
|
2406
|
+
);
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
let cacheHits = 0;
|
|
2410
|
+
let cacheMisses = 0;
|
|
2411
|
+
let analyzed = 0;
|
|
2412
|
+
let mutated = false;
|
|
2413
|
+
const hydratedMessageIds = /* @__PURE__ */ new Set();
|
|
2414
|
+
for (const reply of replies) {
|
|
2415
|
+
const ts = toOptionalString(reply.ts);
|
|
2416
|
+
if (!ts || reply.bot_id || reply.subtype === "bot_message") {
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
const conversationMessage = messagesByTs.get(ts);
|
|
2420
|
+
if (!conversationMessage) {
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
hydratedMessageIds.add(conversationMessage.id);
|
|
2424
|
+
const imageFiles = (reply.files ?? []).filter((file) => {
|
|
2425
|
+
const mimeType = toOptionalString(file.mimetype);
|
|
2426
|
+
return Boolean(toOptionalString(file.id) && mimeType?.startsWith("image/"));
|
|
2427
|
+
}).slice(0, MAX_MESSAGE_IMAGE_ATTACHMENTS);
|
|
2428
|
+
if (imageFiles.length === 0) {
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
const imageFileIds = imageFiles.map((file) => toOptionalString(file.id)).filter((fileId) => Boolean(fileId));
|
|
2432
|
+
const existingMeta = conversationMessage.meta ?? {};
|
|
2433
|
+
conversationMessage.meta = {
|
|
2434
|
+
...existingMeta,
|
|
2435
|
+
slackTs: existingMeta.slackTs ?? ts,
|
|
2436
|
+
imageFileIds,
|
|
2437
|
+
imagesHydrated: true
|
|
2438
|
+
};
|
|
2439
|
+
mutated = true;
|
|
2440
|
+
for (const file of imageFiles) {
|
|
2441
|
+
const fileId = toOptionalString(file.id);
|
|
2442
|
+
if (!fileId) continue;
|
|
2443
|
+
if (conversation.vision.byFileId[fileId]) {
|
|
2444
|
+
cacheHits += 1;
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
cacheMisses += 1;
|
|
2448
|
+
const mimeType = toOptionalString(file.mimetype) ?? "application/octet-stream";
|
|
2449
|
+
const fileSize = typeof file.size === "number" && Number.isFinite(file.size) ? file.size : void 0;
|
|
2450
|
+
if (fileSize && fileSize > MAX_USER_ATTACHMENT_BYTES) {
|
|
2451
|
+
logWarn(
|
|
2452
|
+
"conversation_image_skipped_size_limit",
|
|
2453
|
+
{
|
|
2454
|
+
slackThreadId: context.threadId,
|
|
2455
|
+
slackUserId: context.requesterId,
|
|
2456
|
+
slackChannelId: context.channelId,
|
|
2457
|
+
runId: context.runId,
|
|
2458
|
+
assistantUserName: botConfig.userName,
|
|
2459
|
+
modelId: botConfig.modelId
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
"file.id": fileId,
|
|
2463
|
+
"file.size": fileSize,
|
|
2464
|
+
"file.mime_type": mimeType
|
|
2465
|
+
},
|
|
2466
|
+
"Skipping thread image that exceeds size limit"
|
|
2467
|
+
);
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
const downloadUrl = toOptionalString(file.url_private_download) ?? toOptionalString(file.url_private);
|
|
2471
|
+
if (!downloadUrl) {
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
let imageData;
|
|
2475
|
+
try {
|
|
2476
|
+
imageData = await getBotDeps().downloadPrivateSlackFile(downloadUrl);
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
logWarn(
|
|
2479
|
+
"conversation_image_download_failed",
|
|
2480
|
+
{
|
|
2481
|
+
slackThreadId: context.threadId,
|
|
2482
|
+
slackUserId: context.requesterId,
|
|
2483
|
+
slackChannelId: context.channelId,
|
|
2484
|
+
runId: context.runId,
|
|
2485
|
+
assistantUserName: botConfig.userName,
|
|
2486
|
+
modelId: botConfig.modelId
|
|
2487
|
+
},
|
|
2488
|
+
{
|
|
2489
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
2490
|
+
"file.id": fileId,
|
|
2491
|
+
"file.mime_type": mimeType
|
|
2492
|
+
},
|
|
2493
|
+
"Failed to download thread image for context hydration"
|
|
2494
|
+
);
|
|
2495
|
+
continue;
|
|
2496
|
+
}
|
|
2497
|
+
if (imageData.byteLength > MAX_USER_ATTACHMENT_BYTES) {
|
|
2498
|
+
logWarn(
|
|
2499
|
+
"conversation_image_skipped_size_limit",
|
|
2500
|
+
{
|
|
2501
|
+
slackThreadId: context.threadId,
|
|
2502
|
+
slackUserId: context.requesterId,
|
|
2503
|
+
slackChannelId: context.channelId,
|
|
2504
|
+
runId: context.runId,
|
|
2505
|
+
assistantUserName: botConfig.userName,
|
|
2506
|
+
modelId: botConfig.modelId
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
"file.id": fileId,
|
|
2510
|
+
"file.size": imageData.byteLength,
|
|
2511
|
+
"file.mime_type": mimeType
|
|
2512
|
+
},
|
|
2513
|
+
"Skipping downloaded thread image that exceeds size limit"
|
|
2514
|
+
);
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
const summary = await summarizeConversationImage({
|
|
2518
|
+
imageData,
|
|
2519
|
+
mimeType,
|
|
2520
|
+
fileId,
|
|
2521
|
+
context
|
|
2522
|
+
});
|
|
2523
|
+
if (!summary) {
|
|
2524
|
+
continue;
|
|
2525
|
+
}
|
|
2526
|
+
conversation.vision.byFileId[fileId] = {
|
|
2527
|
+
summary,
|
|
2528
|
+
analyzedAtMs: Date.now()
|
|
2529
|
+
};
|
|
2530
|
+
analyzed += 1;
|
|
2531
|
+
mutated = true;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
if (mutated) {
|
|
2535
|
+
updateConversationStats(conversation);
|
|
2536
|
+
}
|
|
2537
|
+
if (cacheHits > 0 || cacheMisses > 0 || analyzed > 0 || hydratedMessageIds.size > 0) {
|
|
2538
|
+
logInfo(
|
|
2539
|
+
"conversation_image_context_hydrated",
|
|
2540
|
+
{
|
|
2541
|
+
slackThreadId: context.threadId,
|
|
2542
|
+
slackUserId: context.requesterId,
|
|
2543
|
+
slackChannelId: context.channelId,
|
|
2544
|
+
runId: context.runId,
|
|
2545
|
+
assistantUserName: botConfig.userName,
|
|
2546
|
+
modelId: botConfig.modelId
|
|
2547
|
+
},
|
|
2548
|
+
{
|
|
2549
|
+
"app.conversation_image.cache_hits": cacheHits,
|
|
2550
|
+
"app.conversation_image.cache_misses": cacheMisses,
|
|
2551
|
+
"app.conversation_image.analyzed": analyzed,
|
|
2552
|
+
"app.conversation_image.messages_hydrated": hydratedMessageIds.size
|
|
2553
|
+
},
|
|
2554
|
+
"Hydrated conversation image context"
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
if (!conversation.vision.backfillCompletedAtMs) {
|
|
2558
|
+
conversation.vision.backfillCompletedAtMs = Date.now();
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// src/chat/turn/execute.ts
|
|
2563
|
+
function resolveReplyDelivery(args) {
|
|
2564
|
+
const replyHasFiles = Boolean(args.reply.files && args.reply.files.length > 0);
|
|
2565
|
+
const deliveryPlan = args.reply.deliveryPlan ?? {
|
|
2566
|
+
mode: args.reply.deliveryMode ?? "thread",
|
|
2567
|
+
ack: args.reply.ackStrategy ?? "none",
|
|
2568
|
+
postThreadText: (args.reply.deliveryMode ?? "thread") !== "channel_only",
|
|
2569
|
+
attachFiles: replyHasFiles ? args.hasStreamedThreadReply ? "followup" : "inline" : "none"
|
|
2570
|
+
};
|
|
2571
|
+
let attachFiles = deliveryPlan.attachFiles;
|
|
2572
|
+
if (attachFiles === "followup" && !args.hasStreamedThreadReply) {
|
|
2573
|
+
attachFiles = "inline";
|
|
2574
|
+
}
|
|
2575
|
+
return {
|
|
2576
|
+
shouldPostThreadReply: deliveryPlan.postThreadText,
|
|
2577
|
+
attachFiles
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// src/chat/turn/persist.ts
|
|
2582
|
+
function markTurnCompleted(args) {
|
|
2583
|
+
args.conversation.processing.activeTurnId = void 0;
|
|
2584
|
+
args.conversation.processing.lastCompletedAtMs = args.nowMs;
|
|
2585
|
+
args.updateConversationStats(args.conversation);
|
|
2586
|
+
}
|
|
2587
|
+
function markTurnFailed(args) {
|
|
2588
|
+
args.conversation.processing.activeTurnId = void 0;
|
|
2589
|
+
args.conversation.processing.lastCompletedAtMs = args.nowMs;
|
|
2590
|
+
args.markConversationMessage(args.conversation, args.userMessageId, {
|
|
2591
|
+
replied: false,
|
|
2592
|
+
skippedReason: "reply failed"
|
|
2593
|
+
});
|
|
2594
|
+
args.updateConversationStats(args.conversation);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// src/chat/turn/prepare.ts
|
|
2598
|
+
function startActiveTurn(args) {
|
|
2599
|
+
args.conversation.processing.activeTurnId = args.nextTurnId;
|
|
2600
|
+
args.updateConversationStats(args.conversation);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// src/chat/runtime/reply-executor.ts
|
|
2604
|
+
function buildDeterministicTurnId(messageId) {
|
|
2605
|
+
const sanitized = messageId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2606
|
+
return `turn_${sanitized}`;
|
|
2607
|
+
}
|
|
2608
|
+
function createReplyToThread(deps) {
|
|
2609
|
+
return async function replyToThread2(thread, message, options = {}) {
|
|
2610
|
+
if (message.author.isMe) {
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
const threadId = getThreadId(thread, message);
|
|
2614
|
+
const channelId = getChannelId(thread, message);
|
|
2615
|
+
const threadTs = getThreadTs(threadId);
|
|
2616
|
+
const messageTs = getMessageTs(message);
|
|
2617
|
+
const runId = getRunId(thread, message);
|
|
2618
|
+
const conversationId = threadId ?? runId;
|
|
2619
|
+
await withSpan(
|
|
2620
|
+
"chat.reply",
|
|
2621
|
+
"chat.reply",
|
|
2622
|
+
{
|
|
2623
|
+
conversationId,
|
|
2624
|
+
slackThreadId: threadId,
|
|
2625
|
+
slackUserId: message.author.userId,
|
|
2626
|
+
slackChannelId: channelId,
|
|
2627
|
+
runId,
|
|
2628
|
+
assistantUserName: botConfig.userName,
|
|
2629
|
+
modelId: botConfig.modelId
|
|
2630
|
+
},
|
|
2631
|
+
async () => {
|
|
2632
|
+
const userText = stripLeadingBotMention(message.text, {
|
|
2633
|
+
stripLeadingSlackMentionToken: options.explicitMention || Boolean(message.isMention)
|
|
2634
|
+
});
|
|
2635
|
+
const explicitChannelPostIntent = isExplicitChannelPostIntent(userText);
|
|
2636
|
+
const preparedState = options.preparedState ?? await deps.prepareTurnState({
|
|
2637
|
+
thread,
|
|
2638
|
+
message,
|
|
2639
|
+
userText,
|
|
2640
|
+
explicitMention: Boolean(options.explicitMention || message.isMention),
|
|
2641
|
+
context: {
|
|
2642
|
+
threadId,
|
|
2643
|
+
requesterId: message.author.userId,
|
|
2644
|
+
channelId,
|
|
2645
|
+
runId
|
|
2646
|
+
}
|
|
2647
|
+
});
|
|
2648
|
+
const turnId = buildDeterministicTurnId(message.id);
|
|
2649
|
+
startActiveTurn({
|
|
2650
|
+
conversation: preparedState.conversation,
|
|
2651
|
+
nextTurnId: turnId,
|
|
2652
|
+
updateConversationStats
|
|
2653
|
+
});
|
|
2654
|
+
const turnStartedAtMs = Date.now();
|
|
2655
|
+
const turnTraceContext = {
|
|
2656
|
+
conversationId,
|
|
2657
|
+
turnId,
|
|
2658
|
+
agentId: turnId,
|
|
2659
|
+
slackThreadId: threadId,
|
|
2660
|
+
slackUserId: message.author.userId,
|
|
2661
|
+
slackChannelId: channelId,
|
|
2662
|
+
runId,
|
|
2663
|
+
assistantUserName: botConfig.userName,
|
|
2664
|
+
modelId: botConfig.modelId
|
|
2665
|
+
};
|
|
2666
|
+
setTags({
|
|
2667
|
+
conversationId,
|
|
2668
|
+
turnId,
|
|
2669
|
+
agentId: turnId
|
|
2670
|
+
});
|
|
2671
|
+
if (shouldEmitDevAgentTrace()) {
|
|
2672
|
+
logInfo(
|
|
2673
|
+
"agent_turn_started",
|
|
2674
|
+
turnTraceContext,
|
|
2675
|
+
{
|
|
2676
|
+
"app.message.id": message.id,
|
|
2677
|
+
...messageTs ? { "messaging.message.id": messageTs } : {}
|
|
2678
|
+
},
|
|
2679
|
+
"Agent turn started"
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
await persistThreadState(thread, {
|
|
2683
|
+
conversation: preparedState.conversation
|
|
2684
|
+
});
|
|
2685
|
+
const fallbackIdentity = await getBotDeps().lookupSlackUser(message.author.userId);
|
|
2686
|
+
const resolvedUserName = message.author.userName ?? fallbackIdentity?.userName;
|
|
2687
|
+
if (resolvedUserName) {
|
|
2688
|
+
setTags({ slackUserName: resolvedUserName });
|
|
2689
|
+
}
|
|
2690
|
+
const userAttachments = await resolveUserAttachments(message.attachments, {
|
|
2691
|
+
threadId,
|
|
2692
|
+
requesterId: message.author.userId,
|
|
2693
|
+
channelId,
|
|
2694
|
+
runId
|
|
2695
|
+
});
|
|
2696
|
+
const progress = createProgressReporter({
|
|
2697
|
+
channelId,
|
|
2698
|
+
threadTs,
|
|
2699
|
+
setAssistantStatus: (channel, thread2, text, suggestions) => deps.getSlackAdapter().setAssistantStatus(channel, thread2, text, suggestions)
|
|
2700
|
+
});
|
|
2701
|
+
const textStream = createTextStreamBridge();
|
|
2702
|
+
let streamedReplyPromise;
|
|
2703
|
+
let beforeFirstResponsePostCalled = false;
|
|
2704
|
+
const beforeFirstResponsePost = async () => {
|
|
2705
|
+
if (beforeFirstResponsePostCalled) {
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
beforeFirstResponsePostCalled = true;
|
|
2709
|
+
await options.beforeFirstResponsePost?.();
|
|
2710
|
+
};
|
|
2711
|
+
const startStreamingReply = () => {
|
|
2712
|
+
if (!streamedReplyPromise) {
|
|
2713
|
+
const streamingReply = (async () => {
|
|
2714
|
+
await beforeFirstResponsePost();
|
|
2715
|
+
return await thread.post(
|
|
2716
|
+
createNormalizingStream(textStream.iterable, ensureBlockSpacing)
|
|
2717
|
+
);
|
|
2718
|
+
})();
|
|
2719
|
+
streamedReplyPromise = streamingReply;
|
|
2720
|
+
}
|
|
2721
|
+
};
|
|
2722
|
+
const postThreadReply = async (payload) => {
|
|
2723
|
+
await beforeFirstResponsePost();
|
|
2724
|
+
return await thread.post(payload);
|
|
2725
|
+
};
|
|
2726
|
+
await progress.start();
|
|
2727
|
+
let persistedAtLeastOnce = false;
|
|
2728
|
+
let shouldPersistFailureState = true;
|
|
2729
|
+
try {
|
|
2730
|
+
const toolChannelId = preparedState.artifacts.assistantContextChannelId ?? channelId;
|
|
2731
|
+
const reply = await getBotDeps().generateAssistantReply(userText, {
|
|
2732
|
+
assistant: {
|
|
2733
|
+
userName: botConfig.userName
|
|
2734
|
+
},
|
|
2735
|
+
requester: {
|
|
2736
|
+
userId: message.author.userId,
|
|
2737
|
+
userName: message.author.userName ?? fallbackIdentity?.userName,
|
|
2738
|
+
fullName: message.author.fullName ?? fallbackIdentity?.fullName
|
|
2739
|
+
},
|
|
2740
|
+
conversationContext: preparedState.routingContext ?? preparedState.conversationContext,
|
|
2741
|
+
artifactState: preparedState.artifacts,
|
|
2742
|
+
configuration: preparedState.configuration,
|
|
2743
|
+
channelConfiguration: preparedState.channelConfiguration,
|
|
2744
|
+
userAttachments,
|
|
2745
|
+
correlation: {
|
|
2746
|
+
conversationId,
|
|
2747
|
+
threadId,
|
|
2748
|
+
turnId,
|
|
2749
|
+
threadTs,
|
|
2750
|
+
messageTs,
|
|
2751
|
+
runId,
|
|
2752
|
+
channelId,
|
|
2753
|
+
requesterId: message.author.userId
|
|
2754
|
+
},
|
|
2755
|
+
toolChannelId,
|
|
2756
|
+
sandbox: {
|
|
2757
|
+
sandboxId: preparedState.sandboxId
|
|
2758
|
+
},
|
|
2759
|
+
onStatus: (status) => progress.setStatus(status),
|
|
2760
|
+
onTextDelta: (deltaText) => {
|
|
2761
|
+
if (explicitChannelPostIntent) {
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
startStreamingReply();
|
|
2765
|
+
textStream.push(deltaText);
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
textStream.end();
|
|
2769
|
+
const diagnosticsContext = {
|
|
2770
|
+
slackThreadId: threadId,
|
|
2771
|
+
slackUserId: message.author.userId,
|
|
2772
|
+
slackChannelId: channelId,
|
|
2773
|
+
runId,
|
|
2774
|
+
assistantUserName: botConfig.userName,
|
|
2775
|
+
modelId: botConfig.modelId
|
|
2776
|
+
};
|
|
2777
|
+
const diagnosticsAttributes = {
|
|
2778
|
+
"gen_ai.provider.name": GEN_AI_PROVIDER_NAME,
|
|
2779
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
2780
|
+
"app.ai.outcome": reply.diagnostics.outcome,
|
|
2781
|
+
"app.ai.assistant_messages": reply.diagnostics.assistantMessageCount,
|
|
2782
|
+
"app.ai.tool_results": reply.diagnostics.toolResultCount,
|
|
2783
|
+
"app.ai.tool_error_results": reply.diagnostics.toolErrorCount,
|
|
2784
|
+
"app.ai.tool_call_count": reply.diagnostics.toolCalls.length,
|
|
2785
|
+
"app.ai.used_primary_text": reply.diagnostics.usedPrimaryText,
|
|
2786
|
+
...reply.diagnostics.stopReason ? { "app.ai.stop_reason": reply.diagnostics.stopReason } : {},
|
|
2787
|
+
...reply.diagnostics.errorMessage ? { "error.message": reply.diagnostics.errorMessage } : {}
|
|
2788
|
+
};
|
|
2789
|
+
setSpanAttributes(diagnosticsAttributes);
|
|
2790
|
+
if (reply.diagnostics.outcome === "provider_error") {
|
|
2791
|
+
const providerError = reply.diagnostics.providerError ?? new Error(reply.diagnostics.errorMessage ?? "Provider error without explicit message");
|
|
2792
|
+
logException(
|
|
2793
|
+
providerError,
|
|
2794
|
+
"agent_turn_provider_error",
|
|
2795
|
+
diagnosticsContext,
|
|
2796
|
+
diagnosticsAttributes,
|
|
2797
|
+
"Agent turn failed with provider error"
|
|
2798
|
+
);
|
|
2799
|
+
} else if (reply.diagnostics.outcome !== "success") {
|
|
2800
|
+
logWarn(
|
|
2801
|
+
"agent_turn_diagnostics",
|
|
2802
|
+
diagnosticsContext,
|
|
2803
|
+
diagnosticsAttributes,
|
|
2804
|
+
"Agent turn completed with execution failure"
|
|
2805
|
+
);
|
|
2806
|
+
}
|
|
2807
|
+
markConversationMessage(preparedState.conversation, preparedState.userMessageId, {
|
|
2808
|
+
replied: true,
|
|
2809
|
+
skippedReason: void 0
|
|
2810
|
+
});
|
|
2811
|
+
upsertConversationMessage(preparedState.conversation, {
|
|
2812
|
+
id: generateConversationId("assistant"),
|
|
2813
|
+
role: "assistant",
|
|
2814
|
+
text: normalizeConversationText(reply.text) || "[empty response]",
|
|
2815
|
+
createdAtMs: Date.now(),
|
|
2816
|
+
author: {
|
|
2817
|
+
userName: botConfig.userName,
|
|
2818
|
+
isBot: true
|
|
2819
|
+
},
|
|
2820
|
+
meta: {
|
|
2821
|
+
replied: true
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
const artifactStatePatch = reply.artifactStatePatch ? { ...reply.artifactStatePatch } : {};
|
|
2825
|
+
const replyFiles = reply.files && reply.files.length > 0 ? reply.files : void 0;
|
|
2826
|
+
const { shouldPostThreadReply, attachFiles: resolvedAttachFiles } = resolveReplyDelivery({
|
|
2827
|
+
reply,
|
|
2828
|
+
hasStreamedThreadReply: Boolean(streamedReplyPromise)
|
|
2829
|
+
});
|
|
2830
|
+
if (shouldPostThreadReply) {
|
|
2831
|
+
if (!streamedReplyPromise) {
|
|
2832
|
+
await postThreadReply(
|
|
2833
|
+
buildSlackOutputMessage(reply.text, {
|
|
2834
|
+
files: resolvedAttachFiles === "inline" ? replyFiles : void 0
|
|
2835
|
+
})
|
|
2836
|
+
);
|
|
2837
|
+
} else {
|
|
2838
|
+
await streamedReplyPromise;
|
|
2839
|
+
if (reply.diagnostics.outcome !== "success" && reply.text.trim().length > 0) {
|
|
2840
|
+
await postThreadReply(buildSlackOutputMessage(reply.text));
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
const shouldPersistArtifacts = Object.keys(artifactStatePatch).length > 0;
|
|
2845
|
+
const nextArtifacts = shouldPersistArtifacts ? mergeArtifactsState(preparedState.artifacts, artifactStatePatch) : void 0;
|
|
2846
|
+
markTurnCompleted({
|
|
2847
|
+
conversation: preparedState.conversation,
|
|
2848
|
+
nowMs: Date.now(),
|
|
2849
|
+
updateConversationStats
|
|
2850
|
+
});
|
|
2851
|
+
await persistThreadState(thread, {
|
|
2852
|
+
artifacts: nextArtifacts,
|
|
2853
|
+
conversation: preparedState.conversation,
|
|
2854
|
+
sandboxId: reply.sandboxId
|
|
2855
|
+
});
|
|
2856
|
+
persistedAtLeastOnce = true;
|
|
2857
|
+
if (shouldEmitDevAgentTrace()) {
|
|
2858
|
+
logInfo(
|
|
2859
|
+
"agent_turn_completed",
|
|
2860
|
+
turnTraceContext,
|
|
2861
|
+
{
|
|
2862
|
+
"app.turn.duration_ms": Date.now() - turnStartedAtMs,
|
|
2863
|
+
"app.ai.outcome": reply.diagnostics.outcome,
|
|
2864
|
+
"app.ai.tool_call_count": reply.diagnostics.toolCalls.length,
|
|
2865
|
+
"app.ai.tool_error_results": reply.diagnostics.toolErrorCount
|
|
2866
|
+
},
|
|
2867
|
+
"Agent turn completed"
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
const isFirstAssistantReply = preparedState.conversation.stats.compactedMessageCount === 0 && preparedState.conversation.messages.filter((m) => m.role === "assistant").length === 1;
|
|
2871
|
+
if (isFirstAssistantReply && channelId && isDmChannel(channelId) && threadTs) {
|
|
2872
|
+
void generateThreadTitle(userText, reply.text).then((title) => deps.getSlackAdapter().setAssistantTitle(channelId, threadTs, title)).catch((error) => {
|
|
2873
|
+
const slackErrorCode = getSlackApiErrorCode(error);
|
|
2874
|
+
const assistantTitleErrorAttributes = {
|
|
2875
|
+
"app.slack.assistant_title.outcome": "permission_denied",
|
|
2876
|
+
...slackErrorCode ? { "app.slack.assistant_title.error_code": slackErrorCode } : {}
|
|
2877
|
+
};
|
|
2878
|
+
if (isSlackTitlePermissionError(error)) {
|
|
2879
|
+
setSpanAttributes(assistantTitleErrorAttributes);
|
|
2880
|
+
logError(
|
|
2881
|
+
"thread_title_generation_permission_denied",
|
|
2882
|
+
{
|
|
2883
|
+
slackThreadId: threadId,
|
|
2884
|
+
slackUserId: message.author.userId,
|
|
2885
|
+
slackChannelId: channelId,
|
|
2886
|
+
runId,
|
|
2887
|
+
assistantUserName: botConfig.userName,
|
|
2888
|
+
modelId: botConfig.fastModelId
|
|
2889
|
+
},
|
|
2890
|
+
assistantTitleErrorAttributes,
|
|
2891
|
+
"Skipping thread title update due to Slack permission error"
|
|
2892
|
+
);
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
logWarn(
|
|
2896
|
+
"thread_title_generation_failed",
|
|
2897
|
+
{
|
|
2898
|
+
slackThreadId: threadId,
|
|
2899
|
+
slackUserId: message.author.userId,
|
|
2900
|
+
slackChannelId: channelId,
|
|
2901
|
+
runId,
|
|
2902
|
+
assistantUserName: botConfig.userName,
|
|
2903
|
+
modelId: botConfig.fastModelId
|
|
2904
|
+
},
|
|
2905
|
+
{ "error.message": error instanceof Error ? error.message : String(error) },
|
|
2906
|
+
"Thread title generation failed"
|
|
2907
|
+
);
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
if (shouldPostThreadReply && resolvedAttachFiles === "followup" && replyFiles) {
|
|
2911
|
+
await postThreadReply({ files: replyFiles });
|
|
2912
|
+
}
|
|
2913
|
+
} catch (error) {
|
|
2914
|
+
shouldPersistFailureState = !isRetryableTurnError(error);
|
|
2915
|
+
throw error;
|
|
2916
|
+
} finally {
|
|
2917
|
+
textStream.end();
|
|
2918
|
+
if (!persistedAtLeastOnce && shouldPersistFailureState) {
|
|
2919
|
+
markTurnFailed({
|
|
2920
|
+
conversation: preparedState.conversation,
|
|
2921
|
+
nowMs: Date.now(),
|
|
2922
|
+
userMessageId: preparedState.userMessageId,
|
|
2923
|
+
markConversationMessage: (conversation, messageId, patch) => {
|
|
2924
|
+
markConversationMessage(conversation, messageId, patch);
|
|
2925
|
+
},
|
|
2926
|
+
updateConversationStats
|
|
2927
|
+
});
|
|
2928
|
+
await persistThreadState(thread, {
|
|
2929
|
+
conversation: preparedState.conversation
|
|
2930
|
+
});
|
|
2931
|
+
if (shouldEmitDevAgentTrace()) {
|
|
2932
|
+
logWarn(
|
|
2933
|
+
"agent_turn_failed",
|
|
2934
|
+
turnTraceContext,
|
|
2935
|
+
{
|
|
2936
|
+
"app.turn.duration_ms": Date.now() - turnStartedAtMs
|
|
2937
|
+
},
|
|
2938
|
+
"Agent turn failed"
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
await progress.stop();
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
);
|
|
2946
|
+
};
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
// src/chat/runtime/turn-preparation.ts
|
|
2950
|
+
async function prepareTurnState(args) {
|
|
2951
|
+
const existingState = await args.thread.state;
|
|
2952
|
+
const existingSandboxId = existingState ? toOptionalString(existingState.app_sandbox_id) : void 0;
|
|
2953
|
+
const artifacts = coerceThreadArtifactsState(existingState);
|
|
2954
|
+
const conversation = coerceThreadConversationState(existingState);
|
|
2955
|
+
const channelConfiguration = getChannelConfigurationService(args.thread);
|
|
2956
|
+
const configuration = await channelConfiguration.resolveValues();
|
|
2957
|
+
await seedConversationBackfill(args.thread, conversation, {
|
|
2958
|
+
messageId: args.message.id,
|
|
2959
|
+
messageCreatedAtMs: args.message.metadata.dateSent.getTime()
|
|
2960
|
+
});
|
|
2961
|
+
const messageHasPotentialImageAttachment = args.message.attachments.some((attachment) => {
|
|
2962
|
+
if (attachment.type === "image") {
|
|
2963
|
+
return true;
|
|
2964
|
+
}
|
|
2965
|
+
const mimeType = attachment.mimeType ?? "";
|
|
2966
|
+
return attachment.type === "file" && mimeType.startsWith("image/");
|
|
2967
|
+
});
|
|
2968
|
+
const normalizedUserText = normalizeConversationText(args.userText) || "[non-text message]";
|
|
2969
|
+
const incomingUserMessage = {
|
|
2970
|
+
id: args.message.id,
|
|
2971
|
+
role: "user",
|
|
2972
|
+
text: normalizedUserText,
|
|
2973
|
+
createdAtMs: args.message.metadata.dateSent.getTime(),
|
|
2974
|
+
author: {
|
|
2975
|
+
userId: args.message.author.userId,
|
|
2976
|
+
userName: args.message.author.userName,
|
|
2977
|
+
fullName: args.message.author.fullName,
|
|
2978
|
+
isBot: typeof args.message.author.isBot === "boolean" ? args.message.author.isBot : void 0
|
|
2979
|
+
},
|
|
2980
|
+
meta: {
|
|
2981
|
+
explicitMention: args.explicitMention,
|
|
2982
|
+
slackTs: args.message.id,
|
|
2983
|
+
imagesHydrated: !messageHasPotentialImageAttachment
|
|
2984
|
+
}
|
|
2985
|
+
};
|
|
2986
|
+
const userMessageId = upsertConversationMessage(conversation, incomingUserMessage);
|
|
2987
|
+
if (messageHasPotentialImageAttachment || !conversation.vision.backfillCompletedAtMs) {
|
|
2988
|
+
await hydrateConversationVisionContext(conversation, {
|
|
2989
|
+
threadId: args.context.threadId,
|
|
2990
|
+
channelId: args.context.channelId,
|
|
2991
|
+
requesterId: args.context.requesterId,
|
|
2992
|
+
runId: args.context.runId,
|
|
2993
|
+
threadTs: getThreadTs(args.context.threadId)
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
await compactConversationIfNeeded(conversation, {
|
|
2997
|
+
threadId: args.context.threadId,
|
|
2998
|
+
channelId: args.context.channelId,
|
|
2999
|
+
requesterId: args.context.requesterId,
|
|
3000
|
+
runId: args.context.runId
|
|
3001
|
+
});
|
|
3002
|
+
const conversationContext = buildConversationContext(conversation);
|
|
3003
|
+
const routingContext = buildConversationContext(conversation, {
|
|
3004
|
+
excludeMessageId: userMessageId
|
|
3005
|
+
});
|
|
3006
|
+
setSpanAttributes({
|
|
3007
|
+
"app.backfill_source": conversation.backfill.source ?? "none",
|
|
3008
|
+
"app.context_tokens_estimated": conversation.stats.estimatedContextTokens
|
|
3009
|
+
});
|
|
3010
|
+
return {
|
|
3011
|
+
artifacts,
|
|
3012
|
+
configuration,
|
|
3013
|
+
channelConfiguration,
|
|
3014
|
+
conversation,
|
|
3015
|
+
sandboxId: existingSandboxId,
|
|
3016
|
+
conversationContext,
|
|
3017
|
+
routingContext,
|
|
3018
|
+
userMessageId
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// src/chat/bot.ts
|
|
3023
|
+
var createdBot = new Chat2({
|
|
3024
|
+
userName: botConfig.userName,
|
|
3025
|
+
adapters: {
|
|
3026
|
+
slack: (() => {
|
|
3027
|
+
const signingSecret = getSlackSigningSecret();
|
|
3028
|
+
const botToken = getSlackBotToken();
|
|
3029
|
+
const clientId = getSlackClientId();
|
|
3030
|
+
const clientSecret = getSlackClientSecret();
|
|
3031
|
+
if (!signingSecret) {
|
|
3032
|
+
throw new Error("SLACK_SIGNING_SECRET is required");
|
|
3033
|
+
}
|
|
3034
|
+
return createSlackAdapter({
|
|
3035
|
+
signingSecret,
|
|
3036
|
+
...botToken ? { botToken } : {},
|
|
3037
|
+
...clientId ? { clientId } : {},
|
|
3038
|
+
...clientSecret ? { clientSecret } : {}
|
|
3039
|
+
});
|
|
3040
|
+
})()
|
|
3041
|
+
},
|
|
3042
|
+
state: getStateAdapter()
|
|
3043
|
+
});
|
|
3044
|
+
var registerSingleton = createdBot.registerSingleton;
|
|
3045
|
+
if (typeof registerSingleton === "function") {
|
|
3046
|
+
registerSingleton.call(createdBot);
|
|
3047
|
+
}
|
|
3048
|
+
var bot = createdBot;
|
|
3049
|
+
function getSlackAdapter() {
|
|
3050
|
+
return bot.getAdapter("slack");
|
|
3051
|
+
}
|
|
3052
|
+
var replyToThread = createReplyToThread({
|
|
3053
|
+
getSlackAdapter,
|
|
3054
|
+
prepareTurnState
|
|
3055
|
+
});
|
|
3056
|
+
var appSlackRuntime = createAppSlackRuntime({
|
|
3057
|
+
assistantUserName: botConfig.userName,
|
|
3058
|
+
modelId: botConfig.modelId,
|
|
3059
|
+
now: () => Date.now(),
|
|
3060
|
+
getThreadId,
|
|
3061
|
+
getChannelId,
|
|
3062
|
+
getRunId,
|
|
3063
|
+
stripLeadingBotMention,
|
|
3064
|
+
withSpan,
|
|
3065
|
+
logWarn,
|
|
3066
|
+
logException,
|
|
3067
|
+
prepareTurnState,
|
|
3068
|
+
persistPreparedState: async ({ thread, preparedState }) => {
|
|
3069
|
+
await persistThreadState(thread, {
|
|
3070
|
+
conversation: preparedState.conversation
|
|
3071
|
+
});
|
|
3072
|
+
},
|
|
3073
|
+
getPreparedConversationContext: (preparedState) => preparedState.routingContext ?? preparedState.conversationContext,
|
|
3074
|
+
shouldReplyInSubscribedThread,
|
|
3075
|
+
onSubscribedMessageSkipped: async ({ thread, preparedState, decision, completedAtMs }) => {
|
|
3076
|
+
markConversationMessage(preparedState.conversation, preparedState.userMessageId, {
|
|
3077
|
+
replied: false,
|
|
3078
|
+
skippedReason: decision.reason
|
|
3079
|
+
});
|
|
3080
|
+
preparedState.conversation.processing.activeTurnId = void 0;
|
|
3081
|
+
preparedState.conversation.processing.lastCompletedAtMs = completedAtMs;
|
|
3082
|
+
updateConversationStats(preparedState.conversation);
|
|
3083
|
+
await persistThreadState(thread, {
|
|
3084
|
+
conversation: preparedState.conversation
|
|
3085
|
+
});
|
|
3086
|
+
},
|
|
3087
|
+
replyToThread,
|
|
3088
|
+
initializeAssistantThread: async ({ threadId, channelId, threadTs, sourceChannelId }) => {
|
|
3089
|
+
await initializeAssistantThread({
|
|
3090
|
+
threadId,
|
|
3091
|
+
channelId,
|
|
3092
|
+
threadTs,
|
|
3093
|
+
sourceChannelId,
|
|
3094
|
+
getSlackAdapter
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
});
|
|
3098
|
+
registerBotHandlers({
|
|
3099
|
+
bot,
|
|
3100
|
+
appSlackRuntime
|
|
3101
|
+
});
|
|
3102
|
+
export {
|
|
3103
|
+
appSlackRuntime,
|
|
3104
|
+
bot,
|
|
3105
|
+
createNormalizingStream,
|
|
3106
|
+
resetBotDepsForTests,
|
|
3107
|
+
setBotDepsForTests
|
|
3108
|
+
};
|