@sentry/junior 0.5.0 → 0.7.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.
@@ -1,7 +1,12 @@
1
+ import {
2
+ parseSkillFile,
3
+ stripFrontmatter
4
+ } from "./chunk-KT5HARSN.js";
1
5
  import {
2
6
  CredentialUnavailableError,
3
7
  SANDBOX_SKILLS_ROOT,
4
8
  SANDBOX_WORKSPACE_ROOT,
9
+ aboutPathCandidates,
5
10
  botConfig,
6
11
  claimQueueIngressDedup,
7
12
  createPluginBroker,
@@ -26,7 +31,7 @@ import {
26
31
  skillRoots,
27
32
  soulPathCandidates,
28
33
  upsertAgentTurnSessionCheckpoint
29
- } from "./chunk-BKYYVLVN.js";
34
+ } from "./chunk-RKOO42TW.js";
30
35
  import {
31
36
  logError,
32
37
  logException,
@@ -270,7 +275,7 @@ function escapeXml(value) {
270
275
  var replyDecisionSchema = z.object({
271
276
  should_reply: z.boolean().describe("Whether Junior should respond to this thread message."),
272
277
  confidence: z.number().min(0).max(1).describe("Classifier confidence from 0 to 1."),
273
- reason: z.string().max(160).optional().describe("Short reason for the decision.")
278
+ reason: z.string().optional().describe("Short reason for the decision.")
274
279
  });
275
280
  var ROUTER_CONFIDENCE_THRESHOLD = 0.72;
276
281
  var ACK_REGEXES = [
@@ -281,6 +286,11 @@ var ACK_REGEXES = [
281
286
  ];
282
287
  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;
283
288
  var FOLLOW_UP_REF_RE = /\b(you|your|that|this|it|above|previous|earlier|last|just\s+said)\b/i;
289
+ var LEADING_SLACK_MENTION_RE = /^\s*<@([A-Z0-9]+)(?:\|([^>]+))?>[\s,:-]*/i;
290
+ var LEADING_NAMED_MENTION_RE = /^\s*@([a-z0-9._-]+)\b[\s,:-]*/i;
291
+ function escapeRegExp(value) {
292
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
293
+ }
284
294
  function tokenizeForOverlap(value) {
285
295
  return value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 4);
286
296
  }
@@ -329,6 +339,54 @@ function isLikelyAssistantDirectedFollowUp(text, conversationContext) {
329
339
  }
330
340
  return false;
331
341
  }
342
+ function containsAssistantInvocation(text, botUserName) {
343
+ const escapedUserName = escapeRegExp(botUserName);
344
+ const plainNameMentionRe = new RegExp(`(^|\\s)@${escapedUserName}\\b`, "i");
345
+ const labeledEntityMentionRe = new RegExp(
346
+ `<@[^>|]+\\|${escapedUserName}>`,
347
+ "i"
348
+ );
349
+ return plainNameMentionRe.test(text) || labeledEntityMentionRe.test(text);
350
+ }
351
+ function detectLeadingOtherPartyAddress(rawText, text, botUserName) {
352
+ if (containsAssistantInvocation(rawText, botUserName) || containsAssistantInvocation(text, botUserName)) {
353
+ return void 0;
354
+ }
355
+ const leadingSlackMention = rawText.match(LEADING_SLACK_MENTION_RE);
356
+ if (leadingSlackMention) {
357
+ const label = leadingSlackMention[2]?.trim();
358
+ return label ? `slack_mention:${label}` : "slack_mention";
359
+ }
360
+ const leadingNamedMention = text.match(LEADING_NAMED_MENTION_RE);
361
+ if (!leadingNamedMention) {
362
+ return void 0;
363
+ }
364
+ const directedName = leadingNamedMention[1]?.trim();
365
+ if (!directedName || directedName.toLowerCase() === botUserName.toLowerCase()) {
366
+ return void 0;
367
+ }
368
+ return `named_mention:${directedName}`;
369
+ }
370
+ function getSubscribedReplyPreflightDecision(args) {
371
+ const text = args.text.trim();
372
+ const rawText = args.rawText.trim();
373
+ if (args.isExplicitMention) {
374
+ return { shouldReply: true, reason: "explicit_mention" /* ExplicitMention */ };
375
+ }
376
+ const leadingOtherPartyAddress = detectLeadingOtherPartyAddress(
377
+ rawText,
378
+ text,
379
+ args.botUserName
380
+ );
381
+ if (!leadingOtherPartyAddress) {
382
+ return void 0;
383
+ }
384
+ return {
385
+ shouldReply: false,
386
+ reason: "directed_to_other_party" /* DirectedToOtherParty */,
387
+ reasonDetail: leadingOtherPartyAddress
388
+ };
389
+ }
332
390
  function buildRouterSystemPrompt(botUserName, conversationContext) {
333
391
  return [
334
392
  "You are a message router for a Slack assistant named Junior in a subscribed Slack thread.",
@@ -352,8 +410,14 @@ function buildRouterSystemPrompt(botUserName, conversationContext) {
352
410
  async function decideSubscribedThreadReply(args) {
353
411
  const text = args.input.text.trim();
354
412
  const rawText = args.input.rawText.trim();
355
- if (args.input.isExplicitMention) {
356
- return { shouldReply: true, reason: "explicit_mention" /* ExplicitMention */ };
413
+ const preflightDecision = getSubscribedReplyPreflightDecision({
414
+ botUserName: args.botUserName,
415
+ rawText,
416
+ text,
417
+ isExplicitMention: args.input.isExplicitMention
418
+ });
419
+ if (preflightDecision) {
420
+ return preflightDecision;
357
421
  }
358
422
  if (!text && !args.input.hasAttachments) {
359
423
  return { shouldReply: false, reason: "empty_message" /* EmptyMessage */ };
@@ -365,7 +429,10 @@ async function decideSubscribedThreadReply(args) {
365
429
  return { shouldReply: false, reason: "acknowledgment" /* Acknowledgment */ };
366
430
  }
367
431
  if (isLikelyAssistantDirectedFollowUp(text, args.input.conversationContext)) {
368
- return { shouldReply: true, reason: "follow_up_question" /* FollowUpQuestion */ };
432
+ return {
433
+ shouldReply: true,
434
+ reason: "follow_up_question" /* FollowUpQuestion */
435
+ };
369
436
  }
370
437
  try {
371
438
  const result = await args.completeObject({
@@ -373,7 +440,10 @@ async function decideSubscribedThreadReply(args) {
373
440
  schema: replyDecisionSchema,
374
441
  maxTokens: 120,
375
442
  temperature: 0,
376
- system: buildRouterSystemPrompt(args.botUserName, args.input.conversationContext),
443
+ system: buildRouterSystemPrompt(
444
+ args.botUserName,
445
+ args.input.conversationContext
446
+ ),
377
447
  prompt: rawText,
378
448
  metadata: {
379
449
  modelId: args.modelId,
@@ -694,20 +764,20 @@ var slackOutputPolicy = {
694
764
 
695
765
  // src/chat/prompt.ts
696
766
  var DEFAULT_SOUL = "You are Junior, a practical and concise assistant.";
697
- function loadSoul() {
767
+ function loadOptionalMarkdownFile(candidates, fileName) {
698
768
  const attempted = [];
699
- for (const resolved of soulPathCandidates()) {
769
+ for (const resolved of candidates) {
700
770
  attempted.push(resolved);
701
771
  try {
702
772
  const raw = fs.readFileSync(resolved, "utf8").trim();
703
773
  if (raw.length > 0) {
704
774
  logInfo(
705
- "soul_loaded",
775
+ `${fileName.toLowerCase()}_loaded`,
706
776
  {},
707
777
  {
708
778
  "file.path": resolved
709
779
  },
710
- "Loaded SOUL.md"
780
+ `Loaded ${fileName}`
711
781
  );
712
782
  return raw;
713
783
  }
@@ -715,16 +785,26 @@ function loadSoul() {
715
785
  continue;
716
786
  }
717
787
  }
788
+ return null;
789
+ }
790
+ function loadSoul() {
791
+ const soul = loadOptionalMarkdownFile(soulPathCandidates(), "SOUL.md");
792
+ if (soul) {
793
+ return soul;
794
+ }
718
795
  logWarn(
719
796
  "soul_load_fallback",
720
797
  {},
721
798
  {
722
- "file.candidates": attempted
799
+ "file.candidates": soulPathCandidates()
723
800
  },
724
801
  "SOUL.md not found; using built-in default personality"
725
802
  );
726
803
  return DEFAULT_SOUL;
727
804
  }
805
+ function loadAbout() {
806
+ return loadOptionalMarkdownFile(aboutPathCandidates(), "ABOUT.md");
807
+ }
728
808
  var JUNIOR_PERSONALITY = (() => {
729
809
  try {
730
810
  return loadSoul();
@@ -740,6 +820,21 @@ var JUNIOR_PERSONALITY = (() => {
740
820
  return DEFAULT_SOUL;
741
821
  }
742
822
  })();
823
+ var JUNIOR_ABOUT = (() => {
824
+ try {
825
+ return loadAbout();
826
+ } catch (error) {
827
+ logWarn(
828
+ "about_load_failed",
829
+ {},
830
+ {
831
+ "error.message": error instanceof Error ? error.message : String(error)
832
+ },
833
+ "Failed to load ABOUT.md; omitting about prompt context"
834
+ );
835
+ return null;
836
+ }
837
+ })();
743
838
  function workspaceSkillDir(skillName) {
744
839
  return sandboxSkillDir(skillName);
745
840
  }
@@ -912,6 +1007,16 @@ function buildSystemPrompt(params) {
912
1007
  JUNIOR_PERSONALITY.trim()
913
1008
  ].join("\n")
914
1009
  ),
1010
+ ...JUNIOR_ABOUT ? [
1011
+ renderTag(
1012
+ "about",
1013
+ [
1014
+ "Use this as the assistant's product/domain description when relevant.",
1015
+ "",
1016
+ JUNIOR_ABOUT.trim()
1017
+ ].join("\n")
1018
+ )
1019
+ ] : [],
915
1020
  renderTag(
916
1021
  "identity-context",
917
1022
  [
@@ -965,11 +1070,12 @@ function buildSystemPrompt(params) {
965
1070
  "- For factual or external questions, run tools/skills first, then answer from evidence.",
966
1071
  "- Use tool descriptions as the source of truth for when each tool should or should not be called.",
967
1072
  "- Use `bash` to inspect skill files from `skill_dir` and run shell commands inside the sandbox workspace.",
968
- "- Use `attachFile` to attach files from the sandbox (for example screenshots, PDFs, logs) to the Slack reply.",
1073
+ "- Use `attachFile` for files that actually exist in the sandbox (for example screenshots, PDFs, logs), or for `attachment_path` values returned by `imageGenerate`.",
969
1074
  "- If the user asks to see/share/show a screenshot or file, attach the file with `attachFile` instead of only reporting its path.",
970
1075
  "- Never claim a screenshot/file is attached unless `attachFile` succeeded in this turn.",
971
1076
  "- If `attachFile` fails, explain the failure and do not say the file was shared.",
972
1077
  "- Use `imageGenerate` when the user asks for image creation.",
1078
+ "- `imageGenerate` returns generated image metadata, including `attachment_path` values you can pass to `attachFile` when the user should receive the image.",
973
1079
  "- Use `slackCanvasCreate` for long-form docs/specs and `slackCanvasUpdate` for doc follow-ups.",
974
1080
  "- `slackCanvasUpdate` targets the active artifact-context canvas automatically; do not ask the user for `canvas_id`.",
975
1081
  "- When you create or update a Slack artifact in this turn (for example a canvas, list, posted message, or attached file), mention it explicitly in the final reply and include its link when the tool returned one.",
@@ -2358,127 +2464,6 @@ import path2 from "path";
2358
2464
  // src/chat/skills.ts
2359
2465
  import fs2 from "fs/promises";
2360
2466
  import path from "path";
2361
-
2362
- // src/chat/skill-frontmatter.ts
2363
- import { parse as parseYaml } from "yaml";
2364
- var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
2365
- var SKILL_NAME_RE = /^[a-z0-9-]+$/;
2366
- var CAPABILITY_TOKEN_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
2367
- var MAX_NAME_LENGTH = 64;
2368
- var MAX_DESCRIPTION_LENGTH = 1024;
2369
- var MAX_COMPATIBILITY_LENGTH = 500;
2370
- function hasAngleBrackets(value) {
2371
- return value.includes("<") || value.includes(">");
2372
- }
2373
- function validateSkillName(name) {
2374
- if (!name) return "name must not be empty";
2375
- if (name.length > MAX_NAME_LENGTH) return `name must be <= ${MAX_NAME_LENGTH} characters`;
2376
- if (!SKILL_NAME_RE.test(name)) return "name must contain only lowercase letters, digits, and hyphens";
2377
- if (name.startsWith("-") || name.endsWith("-")) return "name must not start or end with a hyphen";
2378
- if (name.includes("--")) return "name must not contain consecutive hyphens";
2379
- return null;
2380
- }
2381
- function stripFrontmatter(raw) {
2382
- return raw.replace(FRONTMATTER_RE, "").trim();
2383
- }
2384
- function parseAndValidateSkillFrontmatter(raw, expectedName) {
2385
- const match = FRONTMATTER_RE.exec(raw);
2386
- if (!match) {
2387
- return { ok: false, error: "Missing YAML frontmatter at start of file" };
2388
- }
2389
- let parsed;
2390
- try {
2391
- parsed = parseYaml(match[1]);
2392
- } catch (error) {
2393
- return {
2394
- ok: false,
2395
- error: `Invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`
2396
- };
2397
- }
2398
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2399
- return { ok: false, error: "Frontmatter must be a YAML object" };
2400
- }
2401
- const frontmatter = parsed;
2402
- const name = frontmatter.name;
2403
- const description = frontmatter.description;
2404
- if (typeof name !== "string") {
2405
- return { ok: false, error: 'Frontmatter field "name" must be a string' };
2406
- }
2407
- const nameError = validateSkillName(name);
2408
- if (nameError) {
2409
- return { ok: false, error: nameError };
2410
- }
2411
- if (expectedName && name !== expectedName) {
2412
- return { ok: false, error: `name "${name}" must match directory "${expectedName}"` };
2413
- }
2414
- if (typeof description !== "string") {
2415
- return { ok: false, error: 'Frontmatter field "description" must be a string' };
2416
- }
2417
- if (!description.trim()) {
2418
- return { ok: false, error: "description must not be empty" };
2419
- }
2420
- if (description.length > MAX_DESCRIPTION_LENGTH) {
2421
- return { ok: false, error: `description must be <= ${MAX_DESCRIPTION_LENGTH} characters` };
2422
- }
2423
- if (hasAngleBrackets(description)) {
2424
- return { ok: false, error: 'description must not contain "<" or ">"' };
2425
- }
2426
- if ("metadata" in frontmatter && (typeof frontmatter.metadata !== "object" || !frontmatter.metadata || Array.isArray(frontmatter.metadata))) {
2427
- return { ok: false, error: 'Frontmatter field "metadata" must be an object when present' };
2428
- }
2429
- if ("compatibility" in frontmatter) {
2430
- if (typeof frontmatter.compatibility !== "string") {
2431
- return { ok: false, error: 'Frontmatter field "compatibility" must be a string when present' };
2432
- }
2433
- if (frontmatter.compatibility.length > MAX_COMPATIBILITY_LENGTH) {
2434
- return { ok: false, error: `compatibility must be <= ${MAX_COMPATIBILITY_LENGTH} characters` };
2435
- }
2436
- }
2437
- if ("license" in frontmatter && typeof frontmatter.license !== "string") {
2438
- return { ok: false, error: 'Frontmatter field "license" must be a string when present' };
2439
- }
2440
- if ("allowed-tools" in frontmatter && typeof frontmatter["allowed-tools"] !== "string") {
2441
- return { ok: false, error: 'Frontmatter field "allowed-tools" must be a string when present' };
2442
- }
2443
- if ("requires-capabilities" in frontmatter) {
2444
- if (typeof frontmatter["requires-capabilities"] !== "string") {
2445
- return { ok: false, error: 'Frontmatter field "requires-capabilities" must be a string when present' };
2446
- }
2447
- const tokens = frontmatter["requires-capabilities"].split(/\s+/).map((token) => token.trim()).filter((token) => token.length > 0);
2448
- for (const token of tokens) {
2449
- if (!CAPABILITY_TOKEN_RE.test(token)) {
2450
- return {
2451
- ok: false,
2452
- error: `requires-capabilities token "${token}" is invalid; expected dotted lowercase tokens (for example "github.issues.write")`
2453
- };
2454
- }
2455
- }
2456
- }
2457
- if ("uses-config" in frontmatter) {
2458
- if (typeof frontmatter["uses-config"] !== "string") {
2459
- return { ok: false, error: 'Frontmatter field "uses-config" must be a string when present' };
2460
- }
2461
- const tokens = frontmatter["uses-config"].split(/\s+/).map((token) => token.trim()).filter((token) => token.length > 0);
2462
- for (const token of tokens) {
2463
- if (!CAPABILITY_TOKEN_RE.test(token)) {
2464
- return {
2465
- ok: false,
2466
- error: `uses-config token "${token}" is invalid; expected dotted lowercase tokens (for example "github.repo")`
2467
- };
2468
- }
2469
- }
2470
- }
2471
- return {
2472
- ok: true,
2473
- frontmatter: {
2474
- ...frontmatter,
2475
- name,
2476
- description
2477
- }
2478
- };
2479
- }
2480
-
2481
- // src/chat/skills.ts
2482
2467
  var SKILL_CACHE_TTL_MS = 5e3;
2483
2468
  var skillCache = null;
2484
2469
  function resolveSkillRoots(options) {
@@ -2488,7 +2473,12 @@ function resolveSkillRoots(options) {
2488
2473
  const pluginRoots = getPluginSkillRoots();
2489
2474
  const seen = /* @__PURE__ */ new Set();
2490
2475
  const resolved = [];
2491
- for (const root of [...additionalRoots, ...envRoots, ...defaults, ...pluginRoots]) {
2476
+ for (const root of [
2477
+ ...additionalRoots,
2478
+ ...envRoots,
2479
+ ...defaults,
2480
+ ...pluginRoots
2481
+ ]) {
2492
2482
  const normalized = path.resolve(root);
2493
2483
  if (seen.has(normalized)) {
2494
2484
  continue;
@@ -2498,22 +2488,6 @@ function resolveSkillRoots(options) {
2498
2488
  }
2499
2489
  return resolved;
2500
2490
  }
2501
- function parseAllowedTools(value) {
2502
- return parseTokenList(value);
2503
- }
2504
- function parseRequiresCapabilities(value) {
2505
- return parseTokenList(value);
2506
- }
2507
- function parseUsesConfig(value) {
2508
- return parseTokenList(value);
2509
- }
2510
- function parseTokenList(value) {
2511
- if (typeof value !== "string") {
2512
- return void 0;
2513
- }
2514
- const parsed = value.split(/\s+/).map((token) => token.trim()).filter((token) => token.length > 0);
2515
- return parsed.length > 0 ? parsed : void 0;
2516
- }
2517
2491
  function validateSkillMetadata(input) {
2518
2492
  const unknownCapabilities = (input.requiresCapabilities ?? []).filter(
2519
2493
  (capability) => !isKnownCapability(capability)
@@ -2521,7 +2495,9 @@ function validateSkillMetadata(input) {
2521
2495
  if (unknownCapabilities.length > 0) {
2522
2496
  return `Unknown requires-capabilities values: ${unknownCapabilities.join(", ")}`;
2523
2497
  }
2524
- const unknownConfigKeys = (input.usesConfig ?? []).filter((configKey) => !isKnownConfigKey(configKey));
2498
+ const unknownConfigKeys = (input.usesConfig ?? []).filter(
2499
+ (configKey) => !isKnownConfigKey(configKey)
2500
+ );
2525
2501
  if (unknownConfigKeys.length > 0) {
2526
2502
  return `Unknown uses-config values: ${unknownConfigKeys.join(", ")}`;
2527
2503
  }
@@ -2531,24 +2507,40 @@ async function readSkillDirectory(skillDir) {
2531
2507
  const skillFile = path.join(skillDir, "SKILL.md");
2532
2508
  try {
2533
2509
  const raw = await fs2.readFile(skillFile, "utf8");
2534
- const parsed = parseAndValidateSkillFrontmatter(raw, path.basename(skillDir));
2510
+ const parsed = parseSkillFile(raw, path.basename(skillDir));
2535
2511
  if (!parsed.ok) {
2536
- logWarn("skill_frontmatter_invalid", {}, {
2537
- "file.path": skillDir,
2538
- "error.message": parsed.error
2539
- }, "Invalid skill frontmatter");
2512
+ logWarn(
2513
+ "skill_frontmatter_invalid",
2514
+ {},
2515
+ {
2516
+ "file.path": skillDir,
2517
+ "error.message": parsed.error
2518
+ },
2519
+ "Invalid skill frontmatter"
2520
+ );
2540
2521
  return null;
2541
2522
  }
2542
- const { name, description } = parsed.frontmatter;
2543
- const allowedTools = parseAllowedTools(parsed.frontmatter["allowed-tools"]);
2544
- const requiresCapabilities = parseRequiresCapabilities(parsed.frontmatter["requires-capabilities"]);
2545
- const usesConfig = parseUsesConfig(parsed.frontmatter["uses-config"]);
2546
- const metadataError = validateSkillMetadata({ requiresCapabilities, usesConfig });
2523
+ const {
2524
+ name,
2525
+ description,
2526
+ allowedTools,
2527
+ requiresCapabilities,
2528
+ usesConfig
2529
+ } = parsed.skill;
2530
+ const metadataError = validateSkillMetadata({
2531
+ requiresCapabilities,
2532
+ usesConfig
2533
+ });
2547
2534
  if (metadataError) {
2548
- logWarn("skill_frontmatter_invalid", {}, {
2549
- "file.path": skillDir,
2550
- "error.message": metadataError
2551
- }, "Invalid skill frontmatter");
2535
+ logWarn(
2536
+ "skill_frontmatter_invalid",
2537
+ {},
2538
+ {
2539
+ "file.path": skillDir,
2540
+ "error.message": metadataError
2541
+ },
2542
+ "Invalid skill frontmatter"
2543
+ );
2552
2544
  return null;
2553
2545
  }
2554
2546
  return {
@@ -2560,10 +2552,15 @@ async function readSkillDirectory(skillDir) {
2560
2552
  usesConfig
2561
2553
  };
2562
2554
  } catch (error) {
2563
- logWarn("skill_directory_read_failed", {}, {
2564
- "file.path": skillDir,
2565
- "error.message": error instanceof Error ? error.message : String(error)
2566
- }, "Failed to read skill directory");
2555
+ logWarn(
2556
+ "skill_directory_read_failed",
2557
+ {},
2558
+ {
2559
+ "file.path": skillDir,
2560
+ "error.message": error instanceof Error ? error.message : String(error)
2561
+ },
2562
+ "Failed to read skill directory"
2563
+ );
2567
2564
  return null;
2568
2565
  }
2569
2566
  }
@@ -2578,7 +2575,9 @@ async function discoverSkills(options) {
2578
2575
  for (const root of roots) {
2579
2576
  try {
2580
2577
  const entries = await fs2.readdir(root, { withFileTypes: true });
2581
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
2578
+ for (const entry of entries.sort(
2579
+ (a, b) => a.name.localeCompare(b.name)
2580
+ )) {
2582
2581
  if (!entry.isDirectory()) {
2583
2582
  continue;
2584
2583
  }
@@ -2589,10 +2588,15 @@ async function discoverSkills(options) {
2589
2588
  }
2590
2589
  }
2591
2590
  } catch (error) {
2592
- logWarn("skill_root_read_failed", {}, {
2593
- "file.directory": root,
2594
- "error.message": error instanceof Error ? error.message : String(error)
2595
- }, "Failed to read skill root");
2591
+ logWarn(
2592
+ "skill_root_read_failed",
2593
+ {},
2594
+ {
2595
+ "file.directory": root,
2596
+ "error.message": error instanceof Error ? error.message : String(error)
2597
+ },
2598
+ "Failed to read skill root"
2599
+ );
2596
2600
  }
2597
2601
  }
2598
2602
  const sorted = discovered.sort((a, b) => a.name.localeCompare(b.name));
@@ -2605,7 +2609,9 @@ async function discoverSkills(options) {
2605
2609
  }
2606
2610
  function parseSkillInvocation(messageText, availableSkills) {
2607
2611
  const trimmed = messageText.trim();
2608
- const match = /(?:^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+([\s\S]*))?/i.exec(trimmed);
2612
+ const match = /(?:^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+([\s\S]*))?/i.exec(
2613
+ trimmed
2614
+ );
2609
2615
  if (!match) {
2610
2616
  return null;
2611
2617
  }
@@ -2630,9 +2636,13 @@ async function loadSkillsByName(skillNames, available) {
2630
2636
  }
2631
2637
  const skillFile = path.join(meta.skillPath, "SKILL.md");
2632
2638
  const raw = await fs2.readFile(skillFile, "utf8");
2639
+ const parsed = parseSkillFile(raw, meta.name);
2640
+ if (!parsed.ok) {
2641
+ throw new Error(`Invalid skill file in ${skillFile}: ${parsed.error}`);
2642
+ }
2633
2643
  skills.push({
2634
2644
  ...meta,
2635
- body: stripFrontmatter(raw)
2645
+ body: parsed.skill.body
2636
2646
  });
2637
2647
  }
2638
2648
  return skills;
@@ -2906,7 +2916,7 @@ async function detectMimeType(sandbox, targetPath) {
2906
2916
  }
2907
2917
  function createAttachFileTool(sandbox, hooks = {}) {
2908
2918
  return tool({
2909
- description: "Attach a file from the sandbox to the Slack reply. Use this immediately after creating screenshots/reports when the user asks to see/share the actual file, not just its path.",
2919
+ description: "Attach a file to the Slack reply. Use this for files that exist in the sandbox, such as screenshots, PDFs, or logs, or for generated image `attachment_path` values returned earlier in the turn.",
2910
2920
  inputSchema: Type2.Object(
2911
2921
  {
2912
2922
  path: Type2.String({
@@ -2932,6 +2942,20 @@ function createAttachFileTool(sandbox, hooks = {}) {
2932
2942
  const targetPath = normalizeSandboxPath(requestedPath);
2933
2943
  const fileBuffer = await sandbox.readFileToBuffer({ path: targetPath });
2934
2944
  if (!fileBuffer) {
2945
+ const generatedFile = hooks.getGeneratedFile?.(
2946
+ path3.posix.basename(targetPath)
2947
+ );
2948
+ if (generatedFile) {
2949
+ hooks.onGeneratedFiles?.([generatedFile]);
2950
+ return {
2951
+ ok: true,
2952
+ attached: true,
2953
+ path: targetPath,
2954
+ filename: generatedFile.filename,
2955
+ mime_type: generatedFile.mimeType ?? inferMimeType(generatedFile.filename),
2956
+ bytes: Buffer.isBuffer(generatedFile.data) ? generatedFile.data.byteLength : generatedFile.data instanceof ArrayBuffer ? generatedFile.data.byteLength : generatedFile.data.size
2957
+ };
2958
+ }
2935
2959
  throw new Error(`failed to read file: ${targetPath}`);
2936
2960
  }
2937
2961
  if (fileBuffer.byteLength === 0) {
@@ -3215,12 +3239,22 @@ async function enrichImagePrompt(rawPrompt) {
3215
3239
  maxTokens: 1024
3216
3240
  });
3217
3241
  if (text && text.trim().length > 0) {
3218
- logInfo("image_prompt_enriched", {}, { "app.image.enriched_prompt_length": text.trim().length }, "Image prompt enriched with persona");
3242
+ logInfo(
3243
+ "image_prompt_enriched",
3244
+ {},
3245
+ { "app.image.enriched_prompt_length": text.trim().length },
3246
+ "Image prompt enriched with persona"
3247
+ );
3219
3248
  return text.trim();
3220
3249
  }
3221
3250
  return rawPrompt;
3222
3251
  } catch (error) {
3223
- logWarn("image_prompt_enrichment_failed", {}, { "error.message": String(error) }, "Image prompt enrichment failed, using raw prompt");
3252
+ logWarn(
3253
+ "image_prompt_enrichment_failed",
3254
+ {},
3255
+ { "error.message": String(error) },
3256
+ "Image prompt enrichment failed, using raw prompt"
3257
+ );
3224
3258
  return rawPrompt;
3225
3259
  }
3226
3260
  }
@@ -3245,7 +3279,7 @@ function parseImageGenerationError(status, body, model) {
3245
3279
  return `image generation failed: ${status} ${body}`;
3246
3280
  }
3247
3281
  }
3248
- function createImageGenerateTool(hooks) {
3282
+ function createImageGenerateTool(hooks, deps = {}) {
3249
3283
  return tool({
3250
3284
  description: "Generate images from a prompt. Use when the user wants to visually show or represent something \u2014 feelings, concepts, art, humor, or any visual idea. Also use for explicit image creation requests.",
3251
3285
  inputSchema: Type3.Object({
@@ -3256,27 +3290,35 @@ function createImageGenerateTool(hooks) {
3256
3290
  })
3257
3291
  }),
3258
3292
  execute: async ({ prompt }) => {
3293
+ const fetchImpl = deps.fetch ?? fetch;
3259
3294
  const apiKey = process.env.AI_GATEWAY_API_KEY ?? process.env.VERCEL_OIDC_TOKEN;
3260
3295
  if (!apiKey) {
3261
- throw new Error("Missing AI gateway credentials (AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN)");
3296
+ throw new Error(
3297
+ "Missing AI gateway credentials (AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN)"
3298
+ );
3262
3299
  }
3263
3300
  const model = process.env.AI_IMAGE_MODEL ?? DEFAULT_IMAGE_MODEL;
3264
3301
  const enrichedPrompt = await enrichImagePrompt(prompt);
3265
- const response = await fetch("https://ai-gateway.vercel.sh/v1/chat/completions", {
3266
- method: "POST",
3267
- headers: {
3268
- "content-type": "application/json",
3269
- authorization: `Bearer ${apiKey}`
3270
- },
3271
- body: JSON.stringify({
3272
- model,
3273
- messages: [{ role: "user", content: enrichedPrompt }],
3274
- modalities: ["image"]
3275
- })
3276
- });
3302
+ const response = await fetchImpl(
3303
+ "https://ai-gateway.vercel.sh/v1/chat/completions",
3304
+ {
3305
+ method: "POST",
3306
+ headers: {
3307
+ "content-type": "application/json",
3308
+ authorization: `Bearer ${apiKey}`
3309
+ },
3310
+ body: JSON.stringify({
3311
+ model,
3312
+ messages: [{ role: "user", content: enrichedPrompt }],
3313
+ modalities: ["image"]
3314
+ })
3315
+ }
3316
+ );
3277
3317
  if (!response.ok) {
3278
3318
  const text = await response.text();
3279
- throw new Error(parseImageGenerationError(response.status, text, model));
3319
+ throw new Error(
3320
+ parseImageGenerationError(response.status, text, model)
3321
+ );
3280
3322
  }
3281
3323
  const payload = await response.json();
3282
3324
  const uploads = [];
@@ -3291,7 +3333,7 @@ function createImageGenerateTool(hooks) {
3291
3333
  mimeType = match[1] ?? mimeType;
3292
3334
  bytes = Buffer.from(match[2] ?? "", "base64");
3293
3335
  } else if (typeof url === "string" && url.length > 0) {
3294
- const fetched = await fetch(url);
3336
+ const fetched = await fetchImpl(url);
3295
3337
  if (!fetched.ok) continue;
3296
3338
  mimeType = fetched.headers.get("content-type") ?? mimeType;
3297
3339
  bytes = Buffer.from(await fetched.arrayBuffer());
@@ -3305,7 +3347,7 @@ function createImageGenerateTool(hooks) {
3305
3347
  });
3306
3348
  }
3307
3349
  if (uploads.length > 0) {
3308
- hooks.onGeneratedFiles?.(uploads);
3350
+ hooks.onGeneratedArtifactFiles?.(uploads);
3309
3351
  }
3310
3352
  return {
3311
3353
  ok: true,
@@ -3315,10 +3357,11 @@ function createImageGenerateTool(hooks) {
3315
3357
  image_count: uploads.length,
3316
3358
  images: uploads.map((upload) => ({
3317
3359
  filename: upload.filename,
3360
+ attachment_path: upload.filename,
3318
3361
  media_type: upload.mimeType,
3319
3362
  bytes: upload.data.byteLength
3320
3363
  })),
3321
- delivery: "Images will be attached to the Slack response as files."
3364
+ delivery: "Generated images are available to attach with attachFile using the returned attachment_path."
3322
3365
  };
3323
3366
  }
3324
3367
  });
@@ -3337,19 +3380,11 @@ function toLoadedSkill(result) {
3337
3380
  body: result.instructions
3338
3381
  };
3339
3382
  }
3340
- function stripFrontmatter2(raw) {
3341
- if (!raw.startsWith("---")) {
3342
- return raw;
3343
- }
3344
- const match = /^---\n[\s\S]*?\n---\n?/.exec(raw);
3345
- if (!match) {
3346
- return raw;
3347
- }
3348
- return raw.slice(match[0].length);
3349
- }
3350
3383
  async function loadSkillFromSandbox(sandbox, availableSkills, skillName) {
3351
3384
  const requested = skillName.trim().toLowerCase();
3352
- const skill = availableSkills.find((entry) => entry.name.toLowerCase() === requested);
3385
+ const skill = availableSkills.find(
3386
+ (entry) => entry.name.toLowerCase() === requested
3387
+ );
3353
3388
  if (!skill) {
3354
3389
  return {
3355
3390
  ok: false,
@@ -3369,7 +3404,7 @@ async function loadSkillFromSandbox(sandbox, availableSkills, skillName) {
3369
3404
  description: skill.description,
3370
3405
  skill_dir: skillDir,
3371
3406
  location: skillFilePath,
3372
- instructions: stripFrontmatter2(file.toString("utf8"))
3407
+ instructions: stripFrontmatter(file.toString("utf8"))
3373
3408
  };
3374
3409
  }
3375
3410
  function createLoadSkillTool(sandbox, availableSkills, options) {
@@ -3382,7 +3417,11 @@ function createLoadSkillTool(sandbox, availableSkills, options) {
3382
3417
  })
3383
3418
  }),
3384
3419
  execute: async ({ skill_name }) => {
3385
- const result = await loadSkillFromSandbox(sandbox, availableSkills, skill_name);
3420
+ const result = await loadSkillFromSandbox(
3421
+ sandbox,
3422
+ availableSkills,
3423
+ skill_name
3424
+ );
3386
3425
  const loadedSkill = toLoadedSkill(result);
3387
3426
  if (loadedSkill) {
3388
3427
  await options?.onSkillLoaded?.(loadedSkill);
@@ -5034,7 +5073,7 @@ function createTools(availableSkills, hooks = {}, context) {
5034
5073
  webFetch: wrapToolExecution("webFetch", createWebFetchTool(hooks), hooks),
5035
5074
  imageGenerate: wrapToolExecution(
5036
5075
  "imageGenerate",
5037
- createImageGenerateTool(hooks),
5076
+ createImageGenerateTool(hooks, hooks.toolOverrides?.imageGenerate),
5038
5077
  hooks
5039
5078
  ),
5040
5079
  slackCanvasUpdate: wrapToolExecution(
@@ -5540,14 +5579,14 @@ function createSandboxExecutor(options) {
5540
5579
  } : {}
5541
5580
  });
5542
5581
  if (!snapshot.snapshotId) {
5543
- await emitSandboxStatus("Starting sandbox...");
5582
+ await emitSandboxStatus("Booting up...");
5544
5583
  return await Sandbox.create({
5545
5584
  timeout: timeoutMs,
5546
5585
  runtime
5547
5586
  });
5548
5587
  }
5549
5588
  try {
5550
- await emitSandboxStatus("Starting sandbox from snapshot...");
5589
+ await emitSandboxStatus("Booting up...");
5551
5590
  return await Sandbox.create({
5552
5591
  timeout: timeoutMs,
5553
5592
  source: {
@@ -5573,7 +5612,7 @@ function createSandboxExecutor(options) {
5573
5612
  throw error;
5574
5613
  }
5575
5614
  await emitSandboxStatus(
5576
- "Retrying sandbox startup with a fresh snapshot..."
5615
+ "Booting up..."
5577
5616
  );
5578
5617
  return await Sandbox.create({
5579
5618
  timeout: timeoutMs,
@@ -6185,9 +6224,9 @@ function formatToolStatus(toolName) {
6185
6224
  const known = {
6186
6225
  loadSkill: "Loading skill instructions",
6187
6226
  systemTime: "Reading current system time",
6188
- bash: "Running shell command in sandbox",
6189
- readFile: "Reading file in sandbox",
6190
- writeFile: "Writing file in sandbox",
6227
+ bash: "Working in the shell",
6228
+ readFile: "Reading a file",
6229
+ writeFile: "Updating a file",
6191
6230
  webSearch: "Searching public sources",
6192
6231
  webFetch: "Reading source pages",
6193
6232
  slackChannelPostMessage: "Posting message to channel",
@@ -6208,16 +6247,23 @@ function formatToolStatus(toolName) {
6208
6247
  }
6209
6248
  function formatToolStatusWithInput(toolName, input) {
6210
6249
  const obj = input && typeof input === "object" ? input : void 0;
6250
+ const command = obj ? compactStatusText(obj.command, 70) : void 0;
6211
6251
  const path6 = obj ? compactStatusPath(obj.path) : void 0;
6212
6252
  const filename = obj ? compactStatusFilename(obj.path) : void 0;
6213
6253
  const query = obj ? compactStatusText(obj.query, 70) : void 0;
6214
6254
  const domain = obj ? extractStatusUrlDomain(obj.url) : void 0;
6215
6255
  const skillName = obj ? compactStatusText(obj.skill_name ?? obj.skillName, 40) : void 0;
6256
+ if (command && toolName === "bash") {
6257
+ return `Running ${command}`;
6258
+ }
6216
6259
  if (filename && toolName === "readFile") {
6217
6260
  return `Reading file ${filename}`;
6218
6261
  }
6262
+ if (filename && toolName === "writeFile") {
6263
+ return `Updating file ${filename}`;
6264
+ }
6219
6265
  if (path6 && toolName === "writeFile") {
6220
- return `Writing file ${path6}`;
6266
+ return `Updating file ${path6}`;
6221
6267
  }
6222
6268
  if (skillName && toolName === "loadSkill") {
6223
6269
  return `Loading skill ${skillName}`;
@@ -6234,7 +6280,7 @@ function formatToolResultStatus(toolName) {
6234
6280
  const known = {
6235
6281
  loadSkill: "Integrating loaded skill guidance",
6236
6282
  systemTime: "Applying current time context",
6237
- bash: "Analyzing command output",
6283
+ bash: "Reviewing command results",
6238
6284
  readFile: "Analyzing file contents",
6239
6285
  writeFile: "Saving file update",
6240
6286
  webSearch: "Reviewing search results",
@@ -6257,16 +6303,23 @@ function formatToolResultStatus(toolName) {
6257
6303
  }
6258
6304
  function formatToolResultStatusWithInput(toolName, input) {
6259
6305
  const obj = input && typeof input === "object" ? input : void 0;
6306
+ const command = obj ? compactStatusText(obj.command, 70) : void 0;
6260
6307
  const path6 = obj ? compactStatusPath(obj.path) : void 0;
6261
6308
  const filename = obj ? compactStatusFilename(obj.path) : void 0;
6262
6309
  const query = obj ? compactStatusText(obj.query, 70) : void 0;
6263
6310
  const domain = obj ? extractStatusUrlDomain(obj.url) : void 0;
6264
6311
  const skillName = obj ? compactStatusText(obj.skill_name ?? obj.skillName, 40) : void 0;
6312
+ if (command && toolName === "bash") {
6313
+ return `Reviewed results from ${command}`;
6314
+ }
6265
6315
  if (filename && toolName === "readFile") {
6266
6316
  return `Reviewed file ${filename}`;
6267
6317
  }
6318
+ if (filename && toolName === "writeFile") {
6319
+ return `Updated file ${filename}`;
6320
+ }
6268
6321
  if (path6 && toolName === "writeFile") {
6269
- return `Saved file ${path6}`;
6322
+ return `Updated file ${path6}`;
6270
6323
  }
6271
6324
  if (skillName && toolName === "loadSkill") {
6272
6325
  return `Loaded skill ${skillName}`;
@@ -6766,6 +6819,7 @@ async function generateAssistantReply(messageText, context = {}) {
6766
6819
  };
6767
6820
  }
6768
6821
  const generatedFiles = [];
6822
+ const replyFiles = [];
6769
6823
  const artifactStatePatch = {};
6770
6824
  const toolCalls = [];
6771
6825
  setTags({
@@ -6782,9 +6836,13 @@ async function generateAssistantReply(messageText, context = {}) {
6782
6836
  const tools = createTools(
6783
6837
  availableSkills,
6784
6838
  {
6785
- onGeneratedFiles: (files) => {
6839
+ getGeneratedFile: (filename) => generatedFiles.find((file) => file.filename === filename),
6840
+ onGeneratedArtifactFiles: (files) => {
6786
6841
  generatedFiles.push(...files);
6787
6842
  },
6843
+ onGeneratedFiles: (files) => {
6844
+ replyFiles.push(...files);
6845
+ },
6788
6846
  onArtifactStatePatch: (patch) => {
6789
6847
  Object.assign(artifactStatePatch, patch);
6790
6848
  },
@@ -6798,6 +6856,7 @@ async function generateAssistantReply(messageText, context = {}) {
6798
6856
  `${formatToolResultStatusWithInput(toolName, input)}...`
6799
6857
  );
6800
6858
  },
6859
+ toolOverrides: context.toolOverrides,
6801
6860
  onSkillLoaded: async (loadedSkill) => {
6802
6861
  const resolvedSkill = await skillSandbox.loadSkill(loadedSkill.name);
6803
6862
  const effective = resolvedSkill ?? loadedSkill;
@@ -6890,9 +6949,7 @@ async function generateAssistantReply(messageText, context = {}) {
6890
6949
  {
6891
6950
  onToolCall: (toolName) => {
6892
6951
  toolCalls.push(toolName);
6893
- },
6894
- onGeneratedFiles: (files) => generatedFiles.push(...files),
6895
- onArtifactStatePatch: (patch) => Object.assign(artifactStatePatch, patch)
6952
+ }
6896
6953
  }
6897
6954
  )
6898
6955
  }
@@ -7044,7 +7101,7 @@ async function generateAssistantReply(messageText, context = {}) {
7044
7101
  explicitChannelPostIntent,
7045
7102
  channelPostPerformed,
7046
7103
  reactionPerformed,
7047
- hasFiles: generatedFiles.length > 0,
7104
+ hasFiles: replyFiles.length > 0,
7048
7105
  streamingThreadReply: Boolean(context.onTextDelta)
7049
7106
  });
7050
7107
  const deliveryMode = deliveryPlan.mode;
@@ -7075,7 +7132,7 @@ async function generateAssistantReply(messageText, context = {}) {
7075
7132
  const outcome = primaryText ? stopReason === "error" ? "provider_error" : "success" : "execution_failure";
7076
7133
  const candidateText = primaryText || buildExecutionFailureMessage(toolErrorCount);
7077
7134
  const escapedOrRawPayload = isExecutionEscapeResponse(candidateText) || isRawToolPayloadResponse(candidateText);
7078
- const resolvedText = escapedOrRawPayload ? buildExecutionFailureMessage(toolErrorCount) : enforceAttachmentClaimTruth(candidateText, generatedFiles.length > 0);
7135
+ const resolvedText = escapedOrRawPayload ? buildExecutionFailureMessage(toolErrorCount) : enforceAttachmentClaimTruth(candidateText, replyFiles.length > 0);
7079
7136
  const resolvedOutcome = escapedOrRawPayload ? "execution_failure" : outcome;
7080
7137
  if (shouldTrace) {
7081
7138
  logInfo(
@@ -7095,7 +7152,7 @@ async function generateAssistantReply(messageText, context = {}) {
7095
7152
  if (escapedOrRawPayload) {
7096
7153
  return {
7097
7154
  text: resolvedText,
7098
- files: generatedFiles.length > 0 ? generatedFiles : void 0,
7155
+ files: replyFiles.length > 0 ? replyFiles : void 0,
7099
7156
  artifactStatePatch: Object.keys(artifactStatePatch).length > 0 ? artifactStatePatch : void 0,
7100
7157
  deliveryPlan,
7101
7158
  deliveryMode,
@@ -7118,7 +7175,7 @@ async function generateAssistantReply(messageText, context = {}) {
7118
7175
  }
7119
7176
  return {
7120
7177
  text: resolvedText,
7121
- files: generatedFiles.length > 0 ? generatedFiles : void 0,
7178
+ files: replyFiles.length > 0 ? replyFiles : void 0,
7122
7179
  artifactStatePatch: Object.keys(artifactStatePatch).length > 0 ? artifactStatePatch : void 0,
7123
7180
  deliveryPlan,
7124
7181
  deliveryMode,
@@ -7408,7 +7465,7 @@ function resolveSlackChannelIdFromMessage(message) {
7408
7465
  }
7409
7466
 
7410
7467
  // src/chat/runtime/thread-context.ts
7411
- function escapeRegExp(value) {
7468
+ function escapeRegExp2(value) {
7412
7469
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7413
7470
  }
7414
7471
  function stripLeadingBotMention(text, options = {}) {
@@ -7417,10 +7474,10 @@ function stripLeadingBotMention(text, options = {}) {
7417
7474
  if (options.stripLeadingSlackMentionToken) {
7418
7475
  next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim();
7419
7476
  }
7420
- const mentionByNameRe = new RegExp(`^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`, "i");
7477
+ const mentionByNameRe = new RegExp(`^\\s*@${escapeRegExp2(botConfig.userName)}\\b[\\s,:-]*`, "i");
7421
7478
  next = next.replace(mentionByNameRe, "").trim();
7422
7479
  const mentionByLabeledEntityRe = new RegExp(
7423
- `^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`,
7480
+ `^\\s*<@[^>|]+\\|${escapeRegExp2(botConfig.userName)}>[\\s,:-]*`,
7424
7481
  "i"
7425
7482
  );
7426
7483
  next = next.replace(mentionByLabeledEntityRe, "").trim();
@@ -7595,6 +7652,27 @@ function determineThreadMessageKind(args) {
7595
7652
  }
7596
7653
  return void 0;
7597
7654
  }
7655
+ function getMessageLogContext(args) {
7656
+ return {
7657
+ slackThreadId: args.normalizedThreadId,
7658
+ slackChannelId: nonEmptyString(args.message.raw?.channel),
7659
+ slackUserId: args.message.author?.userId
7660
+ };
7661
+ }
7662
+ function logIgnoredIngressResult(args) {
7663
+ args.deps.logInfo(
7664
+ args.eventName,
7665
+ args.logContext,
7666
+ {
7667
+ ...args.messageId ? { "messaging.message.id": args.messageId } : {},
7668
+ ...args.kind ? { "app.queue.message_kind": args.kind } : {},
7669
+ ...args.dedupKey ? { "app.queue.dedup_key": args.dedupKey } : {},
7670
+ ...args.decisionReason ? { "app.decision.reason": args.decisionReason } : {},
7671
+ "app.queue.route_result": args.routeResult
7672
+ },
7673
+ args.body
7674
+ );
7675
+ }
7598
7676
  var defaultQueueRoutingDeps = {
7599
7677
  hasDedup: (key) => hasQueueIngressDedup(key),
7600
7678
  markDedup: (key, ttlMs) => claimQueueIngressDedup(key, ttlMs),
@@ -7604,7 +7682,11 @@ var defaultQueueRoutingDeps = {
7604
7682
  enqueueThreadMessage: async (payload, dedupKey) => await enqueueThreadMessage(payload, {
7605
7683
  idempotencyKey: dedupKey
7606
7684
  }),
7607
- shouldReplyInSubscribedThread: async ({ message, normalizedThreadId, thread }) => {
7685
+ shouldReplyInSubscribedThread: async ({
7686
+ message,
7687
+ normalizedThreadId,
7688
+ thread
7689
+ }) => {
7608
7690
  const rawText = message.text;
7609
7691
  const text = stripLeadingBotMention(rawText, {
7610
7692
  stripLeadingSlackMentionToken: Boolean(message.isMention)
@@ -7649,25 +7731,56 @@ async function routeIncomingMessageToQueue(args) {
7649
7731
  if (!message || typeof message !== "object") {
7650
7732
  return "ignored_non_object";
7651
7733
  }
7652
- const normalizedThreadId = normalizeIncomingSlackThreadId(args.threadId, message);
7734
+ const normalizedThreadId = normalizeIncomingSlackThreadId(
7735
+ args.threadId,
7736
+ message
7737
+ );
7738
+ const baseLogContext = getMessageLogContext({
7739
+ message,
7740
+ normalizedThreadId
7741
+ });
7653
7742
  if ("threadId" in message) {
7654
7743
  message.threadId = normalizedThreadId;
7655
7744
  }
7656
7745
  const typedMessage = message;
7657
7746
  if (typedMessage.author?.isMe) {
7747
+ logIgnoredIngressResult({
7748
+ deps,
7749
+ eventName: "queue_ingress_ignored_self_message",
7750
+ logContext: baseLogContext,
7751
+ messageId: nonEmptyString(typedMessage.id),
7752
+ routeResult: "ignored_self_message",
7753
+ body: "Ignoring self-authored message before queue routing"
7754
+ });
7658
7755
  return "ignored_self_message";
7659
7756
  }
7660
7757
  const messageId = nonEmptyString(typedMessage.id);
7661
7758
  if (!messageId) {
7759
+ logIgnoredIngressResult({
7760
+ deps,
7761
+ eventName: "queue_ingress_ignored_missing_message_id",
7762
+ logContext: baseLogContext,
7763
+ routeResult: "ignored_missing_message_id",
7764
+ body: "Ignoring message without an id before queue routing"
7765
+ });
7662
7766
  return "ignored_missing_message_id";
7663
7767
  }
7664
7768
  const isSubscribed = await deps.getIsSubscribed(normalizedThreadId);
7665
- const isMention = Boolean(typedMessage.isMention || runtime.detectMention?.(adapter, message));
7769
+ const mentionSource = typedMessage.isMention ? "sdk_flag" : runtime.detectMention?.(adapter, message) ? "fallback_detector" : void 0;
7770
+ const isMention = mentionSource !== void 0;
7666
7771
  const kind = determineThreadMessageKind({
7667
7772
  isSubscribed,
7668
7773
  isMention
7669
7774
  });
7670
7775
  if (!kind) {
7776
+ logIgnoredIngressResult({
7777
+ deps,
7778
+ eventName: "queue_ingress_ignored_unsubscribed_non_mention",
7779
+ logContext: baseLogContext,
7780
+ messageId,
7781
+ routeResult: "ignored_unsubscribed_non_mention",
7782
+ body: "Ignoring unsubscribed non-mention message before queue routing"
7783
+ });
7671
7784
  return "ignored_unsubscribed_non_mention";
7672
7785
  }
7673
7786
  const dedupKey = buildQueueIngressDedupKey(normalizedThreadId, messageId);
@@ -7675,21 +7788,25 @@ async function routeIncomingMessageToQueue(args) {
7675
7788
  if (alreadyDeduped) {
7676
7789
  deps.logInfo(
7677
7790
  "queue_ingress_dedup_hit",
7678
- {
7679
- slackThreadId: normalizedThreadId,
7680
- slackUserId: message.author.userId
7681
- },
7791
+ baseLogContext,
7682
7792
  {
7683
7793
  "messaging.message.id": messageId,
7684
7794
  "app.queue.message_kind": kind,
7685
7795
  "app.queue.dedup_key": dedupKey,
7686
- "app.queue.dedup_outcome": "duplicate"
7796
+ "app.queue.dedup_outcome": "duplicate",
7797
+ ...mentionSource ? { "app.slack.mention_source": mentionSource } : {},
7798
+ "app.queue.route_result": "ignored_duplicate"
7687
7799
  },
7688
7800
  "Skipping duplicate incoming message before queue enqueue"
7689
7801
  );
7690
7802
  return "ignored_duplicate";
7691
7803
  }
7692
- const thread = await runtime.createThread(adapter, normalizedThreadId, message, isSubscribed);
7804
+ const thread = await runtime.createThread(
7805
+ adapter,
7806
+ normalizedThreadId,
7807
+ message,
7808
+ isSubscribed
7809
+ );
7693
7810
  const serializedMessage = serializeMessageForQueue(message);
7694
7811
  const serializedThread = serializeThreadForQueue(thread);
7695
7812
  let payloadKind = kind;
@@ -7700,6 +7817,17 @@ async function routeIncomingMessageToQueue(args) {
7700
7817
  thread
7701
7818
  });
7702
7819
  if (!decision.shouldReply) {
7820
+ logIgnoredIngressResult({
7821
+ deps,
7822
+ eventName: "queue_ingress_ignored_passive_no_reply",
7823
+ logContext: baseLogContext,
7824
+ messageId,
7825
+ dedupKey,
7826
+ kind,
7827
+ routeResult: "ignored_passive_no_reply",
7828
+ decisionReason: decision.reason,
7829
+ body: "Skipping passive subscribed-thread reply before queue enqueue"
7830
+ });
7703
7831
  return "ignored_passive_no_reply";
7704
7832
  }
7705
7833
  payloadKind = "subscribed_reply";
@@ -7734,6 +7862,7 @@ async function routeIncomingMessageToQueue(args) {
7734
7862
  {
7735
7863
  "messaging.message.id": messageId,
7736
7864
  "app.queue.message_kind": payloadKind,
7865
+ ...mentionSource ? { "app.slack.mention_source": mentionSource } : {},
7737
7866
  "error.message": errorMessage
7738
7867
  },
7739
7868
  "Failed to add ingress processing reaction"
@@ -7790,8 +7919,10 @@ async function routeIncomingMessageToQueue(args) {
7790
7919
  {
7791
7920
  "messaging.message.id": messageId,
7792
7921
  "app.queue.message_kind": payloadKind,
7922
+ ...mentionSource ? { "app.slack.mention_source": mentionSource } : {},
7793
7923
  "app.queue.dedup_key": dedupKey,
7794
7924
  "app.queue.dedup_outcome": "primary",
7925
+ "app.queue.route_result": "routed",
7795
7926
  ...queueMessageId ? { "app.queue.message_id": queueMessageId } : {}
7796
7927
  },
7797
7928
  "Routing incoming message to queue"
@@ -7841,14 +7972,20 @@ function installChatBackgroundPatch() {
7841
7972
  }
7842
7973
  });
7843
7974
  if (result === "ignored_missing_message_id") {
7844
- const normalizedThreadId = normalizeIncomingSlackThreadId(threadId, message);
7975
+ const normalizedThreadId = normalizeIncomingSlackThreadId(
7976
+ threadId,
7977
+ message
7978
+ );
7845
7979
  this.logger?.error?.("Message processing error", {
7846
7980
  threadId: normalizedThreadId,
7847
7981
  reason: "missing_message_id"
7848
7982
  });
7849
7983
  }
7850
7984
  } catch (err) {
7851
- this.logger?.error?.("Message processing error", { error: err, threadId });
7985
+ this.logger?.error?.("Message processing error", {
7986
+ error: err,
7987
+ threadId
7988
+ });
7852
7989
  }
7853
7990
  };
7854
7991
  scheduleBackgroundWork(options, run);
@@ -7885,7 +8022,12 @@ function installChatBackgroundPatch() {
7885
8022
  const run = async () => {
7886
8023
  try {
7887
8024
  const { relatedThread, relatedMessage, relatedChannel } = await this.retrieveModalContext(event.adapter.name, contextId);
7888
- const fullEvent = { ...event, relatedThread, relatedMessage, relatedChannel };
8025
+ const fullEvent = {
8026
+ ...event,
8027
+ relatedThread,
8028
+ relatedMessage,
8029
+ relatedChannel
8030
+ };
7889
8031
  for (const { callbackIds, handler } of this.modalCloseHandlers) {
7890
8032
  if (callbackIds.length === 0 || callbackIds.includes(event.callbackId)) {
7891
8033
  await handler(fullEvent);
@@ -8019,18 +8161,13 @@ function createAppSlackRuntime(deps) {
8019
8161
  requesterUserName: message.author.userName,
8020
8162
  runId
8021
8163
  });
8022
- await deps.withSpan(
8023
- "chat.turn",
8024
- "chat.turn",
8025
- context,
8026
- async () => {
8027
- await thread.subscribe();
8028
- await deps.replyToThread(thread, message, {
8029
- explicitMention: true,
8030
- beforeFirstResponsePost: hooks?.beforeFirstResponsePost
8031
- });
8032
- }
8033
- );
8164
+ await deps.withSpan("chat.turn", "chat.turn", context, async () => {
8165
+ await thread.subscribe();
8166
+ await deps.replyToThread(thread, message, {
8167
+ explicitMention: true,
8168
+ beforeFirstResponsePost: hooks?.beforeFirstResponsePost
8169
+ });
8170
+ });
8034
8171
  } catch (error) {
8035
8172
  const errorContext = logContext({
8036
8173
  threadId: deps.getThreadId(thread, message),
@@ -8083,6 +8220,45 @@ function createAppSlackRuntime(deps) {
8083
8220
  channelId,
8084
8221
  runId
8085
8222
  };
8223
+ const preflightDecision = hooks?.preApprovedReply ? void 0 : getSubscribedReplyPreflightDecision({
8224
+ botUserName: deps.assistantUserName,
8225
+ rawText: rawUserText,
8226
+ text: userText,
8227
+ isExplicitMention: Boolean(message.isMention)
8228
+ });
8229
+ if (preflightDecision && !preflightDecision.shouldReply) {
8230
+ const completedAtMs = deps.now();
8231
+ const reason = preflightDecision.reasonDetail ? `${preflightDecision.reason}:${preflightDecision.reasonDetail}` : preflightDecision.reason;
8232
+ deps.logWarn(
8233
+ "subscribed_message_reply_skipped",
8234
+ logContext({
8235
+ threadId,
8236
+ requesterId: message.author.userId,
8237
+ requesterUserName: message.author.userName,
8238
+ channelId,
8239
+ runId
8240
+ }),
8241
+ {
8242
+ "app.decision.reason": reason
8243
+ },
8244
+ "Skipping subscribed message reply"
8245
+ );
8246
+ await deps.onSubscribedMessageSkipped({
8247
+ thread,
8248
+ message,
8249
+ decision: { shouldReply: false, reason },
8250
+ completedAtMs,
8251
+ preparedState: void 0
8252
+ });
8253
+ await deps.recordSkippedSubscribedMessage({
8254
+ thread,
8255
+ message,
8256
+ decision: { shouldReply: false, reason },
8257
+ completedAtMs,
8258
+ userText
8259
+ });
8260
+ return;
8261
+ }
8086
8262
  const preparedState = await deps.prepareTurnState({
8087
8263
  thread,
8088
8264
  message,
@@ -9647,7 +9823,7 @@ function resolveReplyDelivery(args) {
9647
9823
  postThreadText: (args.reply.deliveryMode ?? "thread") !== "channel_only",
9648
9824
  attachFiles: replyHasFiles ? args.hasStreamedThreadReply ? "followup" : "inline" : "none"
9649
9825
  };
9650
- let attachFiles = deliveryPlan.attachFiles;
9826
+ let attachFiles = replyHasFiles ? deliveryPlan.attachFiles : "none";
9651
9827
  if (attachFiles === "followup" && !args.hasStreamedThreadReply) {
9652
9828
  attachFiles = "inline";
9653
9829
  }
@@ -10230,11 +10406,58 @@ var appSlackRuntime = createAppSlackRuntime({
10230
10406
  },
10231
10407
  getPreparedConversationContext: (preparedState) => preparedState.routingContext ?? preparedState.conversationContext,
10232
10408
  shouldReplyInSubscribedThread,
10233
- onSubscribedMessageSkipped: async ({ thread, preparedState, decision, completedAtMs }) => {
10234
- markConversationMessage(preparedState.conversation, preparedState.userMessageId, {
10235
- replied: false,
10236
- skippedReason: decision.reason
10409
+ recordSkippedSubscribedMessage: async ({
10410
+ thread,
10411
+ message,
10412
+ decision,
10413
+ completedAtMs,
10414
+ userText
10415
+ }) => {
10416
+ const conversation = coerceThreadConversationState(await thread.state);
10417
+ const normalizedUserText = normalizeConversationText(userText) || "[non-text message]";
10418
+ upsertConversationMessage(conversation, {
10419
+ id: message.id,
10420
+ role: "user",
10421
+ text: normalizedUserText,
10422
+ createdAtMs: message.metadata.dateSent.getTime(),
10423
+ author: {
10424
+ userId: message.author.userId,
10425
+ userName: message.author.userName,
10426
+ fullName: message.author.fullName,
10427
+ isBot: typeof message.author.isBot === "boolean" ? message.author.isBot : void 0
10428
+ },
10429
+ meta: {
10430
+ explicitMention: Boolean(message.isMention),
10431
+ slackTs: message.id,
10432
+ replied: false,
10433
+ skippedReason: decision.reason,
10434
+ imagesHydrated: true
10435
+ }
10436
+ });
10437
+ conversation.processing.activeTurnId = void 0;
10438
+ conversation.processing.lastCompletedAtMs = completedAtMs;
10439
+ updateConversationStats(conversation);
10440
+ await persistThreadState(thread, {
10441
+ conversation
10237
10442
  });
10443
+ },
10444
+ onSubscribedMessageSkipped: async ({
10445
+ thread,
10446
+ preparedState,
10447
+ decision,
10448
+ completedAtMs
10449
+ }) => {
10450
+ if (!preparedState) {
10451
+ return;
10452
+ }
10453
+ markConversationMessage(
10454
+ preparedState.conversation,
10455
+ preparedState.userMessageId,
10456
+ {
10457
+ replied: false,
10458
+ skippedReason: decision.reason
10459
+ }
10460
+ );
10238
10461
  preparedState.conversation.processing.activeTurnId = void 0;
10239
10462
  preparedState.conversation.processing.lastCompletedAtMs = completedAtMs;
10240
10463
  updateConversationStats(preparedState.conversation);
@@ -10243,7 +10466,12 @@ var appSlackRuntime = createAppSlackRuntime({
10243
10466
  });
10244
10467
  },
10245
10468
  replyToThread,
10246
- initializeAssistantThread: async ({ threadId, channelId, threadTs, sourceChannelId }) => {
10469
+ initializeAssistantThread: async ({
10470
+ threadId,
10471
+ channelId,
10472
+ threadTs,
10473
+ sourceChannelId
10474
+ }) => {
10247
10475
  await initializeAssistantThread({
10248
10476
  threadId,
10249
10477
  channelId,