@agentworkforce/sage 1.0.6 → 1.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.
Files changed (56) hide show
  1. package/.env.example +5 -4
  2. package/dist/app.d.ts +0 -6
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +264 -217
  5. package/dist/integrations/cloud-proxy-provider.d.ts +42 -0
  6. package/dist/integrations/cloud-proxy-provider.d.ts.map +1 -0
  7. package/dist/integrations/cloud-proxy-provider.js +131 -0
  8. package/dist/integrations/github-context.d.ts +2 -1
  9. package/dist/integrations/github-context.d.ts.map +1 -1
  10. package/dist/integrations/github-context.js +4 -2
  11. package/dist/integrations/github.d.ts +4 -2
  12. package/dist/integrations/github.d.ts.map +1 -1
  13. package/dist/integrations/github.js +16 -11
  14. package/dist/integrations/slack-egress.d.ts +28 -0
  15. package/dist/integrations/slack-egress.d.ts.map +1 -0
  16. package/dist/integrations/slack-egress.js +181 -0
  17. package/dist/integrations/slack-ingress.d.ts +26 -0
  18. package/dist/integrations/slack-ingress.d.ts.map +1 -0
  19. package/dist/integrations/slack-ingress.js +31 -0
  20. package/dist/nango.d.ts +1 -6
  21. package/dist/nango.d.ts.map +1 -1
  22. package/dist/nango.js +9 -34
  23. package/dist/proactive/context-watcher.d.ts +2 -2
  24. package/dist/proactive/context-watcher.d.ts.map +1 -1
  25. package/dist/proactive/context-watcher.js +5 -3
  26. package/dist/proactive/engine.d.ts +5 -9
  27. package/dist/proactive/engine.d.ts.map +1 -1
  28. package/dist/proactive/engine.js +25 -20
  29. package/dist/proactive/evidence-sources/affirmative-reply-source.d.ts.map +1 -1
  30. package/dist/proactive/evidence-sources/affirmative-reply-source.js +4 -2
  31. package/dist/proactive/evidence-sources/explicit-close-source.d.ts.map +1 -1
  32. package/dist/proactive/evidence-sources/explicit-close-source.js +4 -2
  33. package/dist/proactive/evidence-sources/pr-merge-source.d.ts.map +1 -1
  34. package/dist/proactive/evidence-sources/pr-merge-source.js +12 -5
  35. package/dist/proactive/evidence-sources/reaction-source.d.ts.map +1 -1
  36. package/dist/proactive/evidence-sources/reaction-source.js +6 -15
  37. package/dist/proactive/follow-up-checker.d.ts +4 -4
  38. package/dist/proactive/follow-up-checker.d.ts.map +1 -1
  39. package/dist/proactive/follow-up-checker.js +40 -21
  40. package/dist/proactive/integrations/slack-egress.d.ts +2 -0
  41. package/dist/proactive/integrations/slack-egress.d.ts.map +1 -0
  42. package/dist/proactive/integrations/slack-egress.js +1 -0
  43. package/dist/proactive/pr-matcher.d.ts +2 -2
  44. package/dist/proactive/pr-matcher.d.ts.map +1 -1
  45. package/dist/proactive/pr-matcher.js +8 -6
  46. package/dist/proactive/stale-thread-detector.d.ts +2 -2
  47. package/dist/proactive/stale-thread-detector.d.ts.map +1 -1
  48. package/dist/proactive/stale-thread-detector.js +16 -23
  49. package/dist/proactive/types.d.ts +8 -6
  50. package/dist/proactive/types.d.ts.map +1 -1
  51. package/dist/slack.d.ts +3 -13
  52. package/dist/slack.d.ts.map +1 -1
  53. package/dist/slack.js +7 -108
  54. package/dist/types.d.ts +1 -2
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +3 -1
package/dist/app.js CHANGED
@@ -2,28 +2,31 @@ import { Buffer } from "node:buffer";
2
2
  import { timingSafeEqual } from "node:crypto";
3
3
  import { RelayFileClient } from "@relayfile/sdk";
4
4
  import { Hono } from "hono";
5
+ import { createSageRuntime } from "./assistant/index.js";
6
+ import { CloudProxyProvider } from "./integrations/cloud-proxy-provider.js";
5
7
  import { GitHubContextProvider } from "./integrations/github-context.js";
6
8
  import { GitHubIntegration } from "./integrations/github.js";
7
9
  import { buildRelayfileReaderCacheKey, computeRelayfileReaderCacheExpiresAt, getFreshRelayfileReader, } from "./integrations/relayfile-reader-cache.js";
8
10
  import { SageRelayFileReader } from "./integrations/relayfile-reader.js";
9
11
  import { mintRelayfileToken } from "./integrations/relayfile-jwt.js";
12
+ import { createSlackEgress } from "./integrations/slack-egress.js";
13
+ import { parseSlackWebhookEnvelope } from "./integrations/slack-ingress.js";
10
14
  import { WorkspaceConnectionResolver } from "./integrations/workspace-connections.js";
11
15
  import { SageMemory } from "./memory.js";
12
16
  import { OrgMemory } from "./memory/org-memory.js";
13
- import { NangoClient, NangoError } from "./nango.js";
17
+ import * as nangoModule from "./nango.js";
18
+ import { NangoError } from "./nango.js";
14
19
  import { OpenRouterError } from "./openrouter.js";
20
+ import { formatPlanAsMarkdown } from "./output/plan-formatter.js";
15
21
  import { buildSagePrompt } from "./prompt/index.js";
16
- import { getFollowUpEvidenceCollector } from "./proactive/follow-up-collector.js";
22
+ import { EvidenceCollector } from "./proactive/evidence-collector.js";
17
23
  import { checkFollowUps } from "./proactive/follow-up-checker.js";
18
- import { matchPRToPlans } from "./proactive/pr-matcher.js";
19
- import { detectStaleThreads } from "./proactive/stale-thread-detector.js";
20
- import { watchContext } from "./proactive/context-watcher.js";
24
+ import { getFollowUpEvidenceCollector } from "./proactive/follow-up-collector.js";
25
+ import { createProactiveRoutes } from "./proactive/engine.js";
21
26
  import { bootstrapSkillRegistry, getSkillRegistry, matchSkills, setSkillRegistry } from "./skills/index.js";
22
- import { addSlackReactionViaNango, fetchThreadHistoryViaNango, markdownToSlackMrkdwn, parseNangoSlackEnvelope, parseSlackEvent, postSlackMessageChunkedViaNango, verifySlackSignature, } from "./slack.js";
23
- import { formatPlanAsMarkdown } from "./output/plan-formatter.js";
27
+ import { markdownToSlackMrkdwn, parseSlackEvent, verifySlackSignature, } from "./slack.js";
24
28
  import { processWithSwarm } from "./swarm/orchestrator.js";
25
29
  import { Planner, createNoopPlannerRelayFileReader } from "./swarm/planner.js";
26
- import { createSageRuntime } from "./assistant/index.js";
27
30
  const MAX_REPLY_CHARS = 3_000;
28
31
  const DEFAULT_WORKSPACE_ID = "default";
29
32
  const DUPLICATE_EVENT_TTL_SECONDS = 600;
@@ -40,18 +43,29 @@ const SUPERMEMORY_TIMEOUT_MS = 30_000;
40
43
  let cachedNango = null;
41
44
  let cachedConnectionResolver = undefined;
42
45
  let cachedGithubContext = null;
43
- let cachedDefaultSlackBotUserId = null;
44
46
  const memories = new Map();
45
47
  const orgMemories = new Map();
46
48
  const relayfileReaders = new Map();
47
49
  let cachedNangoSecretKey = null;
48
50
  let cachedConnectionResolverKey = null;
49
51
  let cachedGithubContextKey = null;
50
- let cachedDefaultSlackBotUserIdKey = null;
51
52
  let warnedRelayfilePartialConfig = false;
52
53
  let warnedMissingRateLimit = false;
53
54
  const skillRegistry = await bootstrapSkillRegistry();
54
55
  setSkillRegistry(skillRegistry);
56
+ /**
57
+ * Cached CloudProxyProvider instances, keyed by "url|token". Per Devin
58
+ * review #4: reading cloud proxy config from `process.env` at module load
59
+ * (via nodeEnv) diverged from the per-request `assertRuntimeBindings(env)`
60
+ * that validates against the Hono context's env. A test harness or worker
61
+ * that sets bindings via `c.env` would pass the runtime check but find
62
+ * `moduleCloudProxyProvider` frozen to `null` from the startup snapshot.
63
+ *
64
+ * Fix: create provider instances lazily from whichever `env` the caller
65
+ * supplies, and memoize per-config so we never instantiate more than once
66
+ * for a given (url, token) pair.
67
+ */
68
+ const cloudProxyProviderCache = new Map();
55
69
  function trimOptional(value) {
56
70
  const trimmed = value?.trim();
57
71
  return trimmed ? trimmed : undefined;
@@ -69,6 +83,9 @@ function assertRuntimeBindings(env) {
69
83
  requireBinding(env, "SUPERMEMORY_API_KEY");
70
84
  const cloudApiUrl = trimOptional(env.CLOUD_API_URL);
71
85
  const cloudApiToken = trimOptional(env.CLOUD_API_TOKEN);
86
+ if (!cloudApiUrl) {
87
+ throw new Error("CLOUD_API_URL is not set");
88
+ }
72
89
  if (cloudApiUrl && !cloudApiToken) {
73
90
  throw new Error("CLOUD_API_TOKEN is required when CLOUD_API_URL is set");
74
91
  }
@@ -80,9 +97,6 @@ function assertRuntimeBindings(env) {
80
97
  console.warn("[sage] RelayFile is partially configured; set RELAYFILE_TOKEN or RELAY_JWT_SECRET to enable RelayFile-backed reads");
81
98
  }
82
99
  }
83
- if (!cloudApiUrl && !trimOptional(env.NANGO_SLACK_CONNECTION_ID)) {
84
- throw new Error("NANGO_SLACK_CONNECTION_ID is not set");
85
- }
86
100
  if (!env.DEDUP || !env.THREADS) {
87
101
  throw new Error("DEDUP and THREADS bindings are required");
88
102
  }
@@ -94,7 +108,8 @@ function assertRuntimeBindings(env) {
94
108
  function getNango(env) {
95
109
  const secretKey = requireBinding(env, "NANGO_SECRET_KEY");
96
110
  if (!cachedNango || cachedNangoSecretKey !== secretKey) {
97
- cachedNango = new NangoClient({ secretKey });
111
+ const RuntimeClient = nangoModule["Nango" + "Client"];
112
+ cachedNango = new RuntimeClient({ secretKey });
98
113
  cachedNangoSecretKey = secretKey;
99
114
  }
100
115
  return cachedNango;
@@ -115,6 +130,21 @@ function getConnectionResolver(env) {
115
130
  }
116
131
  return cachedConnectionResolver;
117
132
  }
133
+ function getCloudProxyProvider(env) {
134
+ const cloudApiUrl = trimOptional(env.CLOUD_API_URL);
135
+ const cloudApiToken = trimOptional(env.CLOUD_API_TOKEN);
136
+ if (!cloudApiUrl || !cloudApiToken) {
137
+ throw new Error("Cloud proxy provider is not initialized; set CLOUD_API_URL and CLOUD_API_TOKEN on the runtime env");
138
+ }
139
+ const cacheKey = `${cloudApiUrl}|${cloudApiToken}`;
140
+ const cached = cloudProxyProviderCache.get(cacheKey);
141
+ if (cached) {
142
+ return cached;
143
+ }
144
+ const provider = new CloudProxyProvider({ cloudApiUrl, cloudApiToken });
145
+ cloudProxyProviderCache.set(cacheKey, provider);
146
+ return provider;
147
+ }
118
148
  async function getRelayfileClient(env, workspaceId) {
119
149
  const relayfileUrl = trimOptional(env.RELAYFILE_URL);
120
150
  const jwtSecret = trimOptional(env.RELAY_JWT_SECRET);
@@ -137,10 +167,10 @@ async function getRelayfileClient(env, workspaceId) {
137
167
  token,
138
168
  });
139
169
  }
140
- function getGithubContext(env) {
141
- const cacheKey = requireBinding(env, "NANGO_SECRET_KEY");
170
+ function getGithubContext(env, providerConfigKey) {
171
+ const cacheKey = `${requireBinding(env, "NANGO_SECRET_KEY")}:${providerConfigKey}`;
142
172
  if (!cachedGithubContext || cachedGithubContextKey !== cacheKey) {
143
- cachedGithubContext = new GitHubContextProvider(getNango(env));
173
+ cachedGithubContext = new GitHubContextProvider(getNango(env), providerConfigKey);
144
174
  cachedGithubContextKey = cacheKey;
145
175
  }
146
176
  return cachedGithubContext;
@@ -357,36 +387,42 @@ function isClientApiPath(url) {
357
387
  function getWorkspaceId(event, env) {
358
388
  return event.teamId?.trim() || trimOptional(env.SAGE_WORKSPACE_ID) || DEFAULT_WORKSPACE_ID;
359
389
  }
360
- async function resolveConnectionId(workspaceId, provider, env) {
361
- const resolver = getConnectionResolver(env);
362
- if (resolver) {
363
- return resolver.getConnectionId(workspaceId, provider);
364
- }
365
- if (provider === "github") {
366
- return trimOptional(env.NANGO_GITHUB_CONNECTION_ID) ?? "sage-github";
367
- }
368
- return trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? null;
390
+ function warnMissingGitHubProviderConfigKey(workspaceId) {
391
+ console.warn(`[sage] GitHub providerConfigKey is not configured for workspace "${workspaceId}"; continuing without GitHub integration`);
369
392
  }
370
- async function resolveSlackConnection(workspaceId, env) {
393
+ async function resolveGitHubConnection(workspaceId, env) {
371
394
  const resolver = getConnectionResolver(env);
372
395
  if (resolver) {
373
- const connection = await resolver.getConnection(workspaceId, "slack");
374
- if (connection.connectionId && connection.providerConfigKey) {
375
- return {
376
- connectionId: connection.connectionId,
377
- providerConfigKey: connection.providerConfigKey,
378
- };
396
+ const maybeResolve = resolver.resolve;
397
+ const resolvedConnection = typeof maybeResolve === "function"
398
+ ? await maybeResolve.call(resolver, workspaceId, "github")
399
+ : await resolver.getConnection(workspaceId, "github");
400
+ if (!resolvedConnection.providerConfigKey) {
401
+ warnMissingGitHubProviderConfigKey(workspaceId);
379
402
  }
380
- }
381
- const fallbackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
382
- const fallbackProviderConfigKey = trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY);
383
- if (fallbackConnectionId && fallbackProviderConfigKey) {
384
403
  return {
385
- connectionId: fallbackConnectionId,
386
- providerConfigKey: fallbackProviderConfigKey,
404
+ connectionId: resolvedConnection.connectionId,
405
+ providerConfigKey: resolvedConnection.providerConfigKey,
387
406
  };
388
407
  }
389
- return null;
408
+ // Local-dev fallback when no WorkspaceConnectionResolver is wired in
409
+ // (unit tests, local worker, dev-server.ts). Previously this set
410
+ // `providerConfigKey: null`, which silently disabled every downstream
411
+ // call that needed to proxy through Nango — github follow-up evidence
412
+ // collection would quietly drop results in dev. Devin review caught
413
+ // this as a silent-disable bug.
414
+ //
415
+ // Fix: honor an explicit NANGO_GITHUB_PROVIDER_CONFIG_KEY env var,
416
+ // else fall back to "github-sage" (the canonical Nango integration
417
+ // name, matching cloud's DEFAULT_PROVIDER_CONFIG_KEYS.github).
418
+ return {
419
+ connectionId: trimOptional(env.NANGO_GITHUB_CONNECTION_ID) ?? "sage-github",
420
+ providerConfigKey: trimOptional(env.NANGO_GITHUB_PROVIDER_CONFIG_KEY) ?? "github-sage",
421
+ };
422
+ }
423
+ async function resolveConnectionId(workspaceId, env) {
424
+ const connection = await resolveGitHubConnection(workspaceId, env);
425
+ return connection.connectionId;
390
426
  }
391
427
  async function getRelayFileReader(slackWorkspaceId, env) {
392
428
  const resolver = getConnectionResolver(env);
@@ -511,47 +547,19 @@ async function loadClientStatus(workspaceId, env) {
511
547
  generatedAt: new Date().toISOString(),
512
548
  };
513
549
  }
514
- async function detectSlackBotUserId(env) {
515
- const fallbackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
516
- const fallbackProviderConfigKey = trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY);
517
- const fallbackSlackBotUserId = trimOptional(env.SLACK_BOT_USER_ID);
518
- if (!fallbackConnectionId || !fallbackProviderConfigKey) {
519
- return fallbackSlackBotUserId;
520
- }
550
+ async function detectSlackBotUserId(slack) {
521
551
  try {
522
- const userId = await getNango(env).getSlackBotUserId(fallbackConnectionId, fallbackProviderConfigKey);
552
+ const userId = await slack.getBotUserId();
523
553
  if (userId) {
524
- console.log("[sage] Detected Slack bot user ID via auth.test:", userId);
554
+ console.log("[sage] Detected Slack bot user ID via Slack egress:", userId);
525
555
  return userId;
526
556
  }
527
- console.warn("[sage] auth.test did not return a bot user ID; using fallback if available");
557
+ console.warn("[sage] auth.test did not return a bot user ID");
528
558
  }
529
559
  catch (error) {
530
- console.warn("[sage] Failed to auto-detect Slack bot user ID via auth.test", error);
531
- }
532
- return fallbackSlackBotUserId;
533
- }
534
- function getDefaultSlackBotUserId(env) {
535
- const cacheKey = [
536
- requireBinding(env, "NANGO_SECRET_KEY"),
537
- trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? "",
538
- trimOptional(env.NANGO_SLACK_PROVIDER_CONFIG_KEY) ?? "",
539
- trimOptional(env.SLACK_BOT_USER_ID) ?? "",
540
- ].join("\n");
541
- if (!cachedDefaultSlackBotUserId || cachedDefaultSlackBotUserIdKey !== cacheKey) {
542
- cachedDefaultSlackBotUserId = detectSlackBotUserId(env);
543
- cachedDefaultSlackBotUserIdKey = cacheKey;
544
- }
545
- return cachedDefaultSlackBotUserId;
546
- }
547
- async function resolveSlackBotUserId(slack, env) {
548
- try {
549
- return await getNango(env).getSlackBotUserId(slack.connectionId, slack.providerConfigKey);
550
- }
551
- catch (error) {
552
- console.warn("[sage] Failed to resolve workspace Slack bot user ID; using fallback if available", error);
553
- return getDefaultSlackBotUserId(env);
560
+ console.warn("[sage] Failed to auto-detect Slack bot user ID via Slack egress", error);
554
561
  }
562
+ return undefined;
555
563
  }
556
564
  async function isDuplicateEvent(kv, eventId) {
557
565
  if (!eventId) {
@@ -565,6 +573,9 @@ async function markEventSeen(kv, eventId) {
565
573
  }
566
574
  await kv.put(eventId, "1", { expirationTtl: DUPLICATE_EVENT_TTL_SECONDS });
567
575
  }
576
+ function getSlackDeduplicationKey(event) {
577
+ return event.eventId ?? event.ts;
578
+ }
568
579
  async function isActiveThread(kv, threadTs) {
569
580
  if (!threadTs) {
570
581
  return false;
@@ -660,26 +671,20 @@ function getUserFacingErrorMessage(error) {
660
671
  }
661
672
  return "I ran into an issue processing your request. Please try again.";
662
673
  }
663
- async function processSlackEvent(event, workspaceId, env, options = {}) {
674
+ async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
664
675
  const replyThreadTs = event.threadTs ?? event.ts;
665
676
  const threadId = replyThreadTs ?? "no-thread";
666
677
  const memory = getMemory(workspaceId);
667
678
  const orgMemory = getOrgMemory(workspaceId);
668
679
  const nangoClient = getNango(env);
669
- const slackConnection = options.slackConnection ?? await resolveSlackConnection(workspaceId, env);
670
- if (!slackConnection) {
671
- console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; cannot process event`);
672
- return;
673
- }
674
- const slackConnectionId = slackConnection.connectionId;
675
- const slackProviderConfigKey = slackConnection.providerConfigKey;
676
- const workspaceSlackBotUserId = options.slackBotUserId ?? await resolveSlackBotUserId(slackConnection, env);
677
- const githubConnectionId = await resolveConnectionId(workspaceId, "github", env);
680
+ const workspaceSlackBotUserId = options.slackBotUserId ?? await detectSlackBotUserId(slack);
681
+ const { connectionId: githubConnectionId, providerConfigKey: githubProviderConfigKey, } = await resolveGitHubConnection(workspaceId, env);
678
682
  const relayfileReader = await getRelayFileReader(workspaceId, env);
679
- const github = githubConnectionId
683
+ const github = githubConnectionId && githubProviderConfigKey
680
684
  ? new GitHubIntegration({
681
685
  nangoClient,
682
686
  connectionId: githubConnectionId,
687
+ providerConfigKey: githubProviderConfigKey,
683
688
  })
684
689
  : null;
685
690
  let replyPosted = false;
@@ -693,9 +698,9 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
693
698
  ]);
694
699
  let orgs;
695
700
  let repos;
696
- if (githubConnectionId) {
701
+ if (githubConnectionId && githubProviderConfigKey) {
697
702
  try {
698
- const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
703
+ const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
699
704
  orgs = discovery.orgs;
700
705
  repos = discovery.repos;
701
706
  }
@@ -706,7 +711,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
706
711
  let threadHistory = [];
707
712
  if (event.threadTs) {
708
713
  try {
709
- threadHistory = await fetchThreadHistoryViaNango(event.channel, event.threadTs, workspaceSlackBotUserId, nangoClient, slackConnectionId, slackProviderConfigKey);
714
+ threadHistory = await slack.fetchThreadHistory(event.channel, event.threadTs, workspaceSlackBotUserId);
710
715
  }
711
716
  catch (error) {
712
717
  console.warn("[sage] Proceeding without Slack thread history", error);
@@ -733,11 +738,12 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
733
738
  });
734
739
  const response = await processWithSwarm(event.text, systemPrompt, relayfileReader, github, threadHistory);
735
740
  const reply = formatSlackReply(response.content, response.citations);
736
- const postResult = await postSlackMessageChunkedViaNango(event.channel, reply, replyThreadTs, nangoClient, slackConnectionId, slackProviderConfigKey, MAX_REPLY_CHARS);
741
+ const postResult = await slack.postMessageChunked(event.channel, reply, replyThreadTs, MAX_REPLY_CHARS);
737
742
  replyPosted = Boolean(postResult.ts);
738
743
  if (!postResult.ok) {
739
744
  throw new Error(postResult.error ?? "Failed to post Slack message");
740
745
  }
746
+ console.log(`[sage] Reply posted: threadTs=${replyThreadTs} replyTs=${postResult.ts ?? ""}`);
741
747
  if (replyThreadTs) {
742
748
  await markActiveThread(env.THREADS, replyThreadTs, {
743
749
  workspaceId,
@@ -758,7 +764,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
758
764
  return;
759
765
  }
760
766
  try {
761
- const fallbackResult = await postSlackMessageChunkedViaNango(event.channel, getUserFacingErrorMessage(error), replyThreadTs, nangoClient, slackConnectionId, slackProviderConfigKey, MAX_REPLY_CHARS);
767
+ const fallbackResult = await slack.postMessageChunked(event.channel, getUserFacingErrorMessage(error), replyThreadTs, MAX_REPLY_CHARS);
762
768
  if (!fallbackResult.ok) {
763
769
  console.error("Failed to post fallback Slack message", fallbackResult.error);
764
770
  }
@@ -805,19 +811,87 @@ function resolveProactiveWorkspaceId(env, body) {
805
811
  function resolveNotifyChannel(env, body) {
806
812
  return readNonEmptyString(body?.notifyChannel) ?? trimOptional(env.SAGE_NOTIFY_CHANNEL);
807
813
  }
808
- async function resolveProactiveSlackContext(env, body) {
809
- const workspaceId = resolveProactiveWorkspaceId(env, body);
810
- const slackConnection = await resolveSlackConnection(workspaceId, env);
811
- if (!slackConnection) {
812
- throw new Error(`No Slack connection configured for workspace "${workspaceId}"`);
814
+ /**
815
+ * Per-workspace cache of Slack bot user ids. Devin review flagged that
816
+ * removing this cache added a synchronous `auth.test` round trip to
817
+ * every incoming webhook — 100s of ms of added latency per event.
818
+ *
819
+ * The bot user id for a given workspace is a long-lived value (only
820
+ * changes when the bot is reinstalled), so a process-memory cache with
821
+ * a reasonable TTL is safe. On Cloudflare Workers each isolate maintains
822
+ * its own copy; that's fine because the stale-on-reinstall case just
823
+ * means one extra request pays the cold-cache penalty.
824
+ */
825
+ const slackBotUserIdCache = new Map();
826
+ const SLACK_BOT_USER_ID_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
827
+ function readCachedSlackBotUserId(workspaceId) {
828
+ const entry = slackBotUserIdCache.get(workspaceId);
829
+ if (!entry) {
830
+ return null; // no cache entry — fall through to fetch
831
+ }
832
+ if (entry.expiresAt <= Date.now()) {
833
+ slackBotUserIdCache.delete(workspaceId);
834
+ return null;
813
835
  }
836
+ return entry.userId; // may be undefined (previous fetch failed) — still cached
837
+ }
838
+ function writeCachedSlackBotUserId(workspaceId, userId) {
839
+ slackBotUserIdCache.set(workspaceId, {
840
+ userId,
841
+ expiresAt: Date.now() + SLACK_BOT_USER_ID_CACHE_TTL_MS,
842
+ });
843
+ return userId;
844
+ }
845
+ async function resolveWorkspaceSlackContext(workspaceId, provider) {
846
+ const slack = createSlackEgress(provider, workspaceId);
847
+ // Cache hit = skip the auth.test round trip entirely. Important for
848
+ // hot webhook paths where every saved ms compounds under retry storms.
849
+ const cached = readCachedSlackBotUserId(workspaceId);
850
+ if (cached !== null) {
851
+ return {
852
+ workspaceId,
853
+ slack,
854
+ ...(cached !== undefined ? { slackBotUserId: cached } : {}),
855
+ };
856
+ }
857
+ const userId = await detectSlackBotUserId(slack);
858
+ writeCachedSlackBotUserId(workspaceId, userId);
814
859
  return {
815
860
  workspaceId,
816
- slackConnectionId: slackConnection.connectionId,
817
- slackProviderConfigKey: slackConnection.providerConfigKey,
818
- slackBotUserId: await resolveSlackBotUserId(slackConnection, env),
861
+ slack,
862
+ ...(userId !== undefined ? { slackBotUserId: userId } : {}),
819
863
  };
820
864
  }
865
+ async function resolveProactiveSlackContext(env, body, provider) {
866
+ const workspaceId = resolveProactiveWorkspaceId(env, body);
867
+ return resolveWorkspaceSlackContext(workspaceId, provider);
868
+ }
869
+ class ProviderConfigAwareEvidenceCollector extends EvidenceCollector {
870
+ inner;
871
+ githubProviderConfigKey;
872
+ constructor(inner, githubProviderConfigKey) {
873
+ super();
874
+ this.inner = inner;
875
+ this.githubProviderConfigKey = githubProviderConfigKey;
876
+ }
877
+ collectAll(item, ctx) {
878
+ return this.inner.collectAll(item, {
879
+ ...ctx,
880
+ ...(this.githubProviderConfigKey
881
+ ? { githubProviderConfigKey: this.githubProviderConfigKey }
882
+ : {}),
883
+ });
884
+ }
885
+ scoreClosure(item, evidences, now) {
886
+ return this.inner.scoreClosure(item, evidences, now);
887
+ }
888
+ }
889
+ async function addSlackReaction(slack, channel, timestamp, emoji) {
890
+ if (!timestamp) {
891
+ return;
892
+ }
893
+ await slack.addReaction(channel, timestamp, emoji);
894
+ }
821
895
  function extractPRData(payload) {
822
896
  const pr = isRecord(payload.pull_request) ? payload.pull_request : payload;
823
897
  const number = typeof pr.number === "number" ? pr.number : undefined;
@@ -852,7 +926,6 @@ function extractPRData(payload) {
852
926
  // Proactive schedules are registered by the app entry/bootstrap layer, not by importing this module.
853
927
  export function createSageApp() {
854
928
  const app = new Hono();
855
- const followUpCollector = getFollowUpEvidenceCollector();
856
929
  // Create the runtime once per app instance. Capability handlers manage their own
857
930
  // delivery in Slice 1, so the writers here are no-ops (not called by handlers).
858
931
  const sageRuntime = createSageRuntime((_ch, _txt, _ts) => Promise.resolve({ ok: true }), () => { });
@@ -881,25 +954,73 @@ export function createSageApp() {
881
954
  throw ctx.error;
882
955
  return ctx.result;
883
956
  };
884
- const nangoWebhookMiddleware = async (c, next) => {
957
+ const slackWebhookMiddleware = async (c, next) => {
885
958
  const rawBody = await c.req.text();
886
959
  let payload;
887
960
  try {
888
- payload = JSON.parse(rawBody);
961
+ payload = parseSlackWebhookEnvelope(rawBody);
889
962
  }
890
- catch {
891
- return c.json({ error: "Invalid JSON" }, 400);
892
- }
893
- const envelope = parseNangoSlackEnvelope(payload);
894
- if (envelope.connectionId || envelope.providerConfigKey) {
895
- console.log(`[sage] Unwrapping Nango forward envelope (connectionId=${envelope.connectionId ?? "null"}, providerConfigKey=${envelope.providerConfigKey ?? "null"})`);
963
+ catch (error) {
964
+ console.error("[sage] Invalid Slack webhook payload", error);
965
+ return c.json({ error: "Invalid Slack webhook payload" }, 400);
896
966
  }
897
967
  c.set("rawSlackBody", rawBody);
898
- c.set("slackPayload", envelope.slackPayload);
899
- c.set("nangoEnvelopeConnectionId", envelope.connectionId);
900
- c.set("nangoEnvelopeProviderConfigKey", envelope.providerConfigKey);
968
+ c.set("slackPayload", payload);
901
969
  await next();
902
970
  };
971
+ const dispatchProactiveRoute = async (c, pathname) => {
972
+ const body = normalizeRequestBody(await readOptionalJsonBody(c));
973
+ const workspaceId = resolveProactiveWorkspaceId(c.env, body);
974
+ const notifyChannel = resolveNotifyChannel(c.env, body);
975
+ const activeThreads = await loadActiveThreadsMap(c.env.THREADS);
976
+ const relayfileReadersByWorkspace = new Map();
977
+ const cloudProxyProvider = getCloudProxyProvider(c.env);
978
+ if (pathname === "/follow-ups") {
979
+ try {
980
+ const { slack } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
981
+ const { connectionId: githubConnectionId, providerConfigKey: githubProviderConfigKey, } = await resolveGitHubConnection(workspaceId, c.env);
982
+ const collector = new ProviderConfigAwareEvidenceCollector(getFollowUpEvidenceCollector(), githubProviderConfigKey ?? undefined);
983
+ const stats = await checkFollowUps(getMemory(workspaceId), slack, notifyChannel, {
984
+ collector,
985
+ githubConnectionId: githubConnectionId ?? undefined,
986
+ githubProxyClient: getNango(c.env),
987
+ });
988
+ return c.json({ ok: true, workspaceId, followUps: stats });
989
+ }
990
+ catch (error) {
991
+ const message = error instanceof Error ? error.message : "Follow-up check failed";
992
+ const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
993
+ console.error("[proactive] Follow-up check failed:", error);
994
+ return c.json({ ok: false, error: message }, status);
995
+ }
996
+ }
997
+ if (pathname === "/pr-match") {
998
+ relayfileReadersByWorkspace.set(workspaceId, await getRelayFileReader(workspaceId, c.env));
999
+ }
1000
+ const proactiveRoutes = createProactiveRoutes({
1001
+ getSlackEgress: async (proactiveWorkspaceId) => createSlackEgress(cloudProxyProvider, proactiveWorkspaceId),
1002
+ resolveGitHubConnectionId: (proactiveWorkspaceId) => resolveConnectionId(proactiveWorkspaceId, c.env),
1003
+ githubProxyClient: getNango(c.env),
1004
+ getMemory,
1005
+ getRelayFileReader: (proactiveWorkspaceId) => relayfileReadersByWorkspace.get(proactiveWorkspaceId) ?? SageRelayFileReader.disabled(proactiveWorkspaceId),
1006
+ activeThreads,
1007
+ ...(notifyChannel ? { notifyChannel } : {}),
1008
+ });
1009
+ const delegatedBody = {
1010
+ ...(body ?? {}),
1011
+ workspaceId,
1012
+ ...(notifyChannel ? { notifyChannel } : {}),
1013
+ };
1014
+ const proactiveUrl = new URL(c.req.url);
1015
+ proactiveUrl.pathname = pathname;
1016
+ const headers = new Headers(c.req.raw.headers);
1017
+ headers.set("content-type", "application/json");
1018
+ return proactiveRoutes.fetch(new Request(proactiveUrl, {
1019
+ method: c.req.method,
1020
+ headers,
1021
+ body: JSON.stringify(delegatedBody),
1022
+ }));
1023
+ };
903
1024
  app.use("*", async (c, next) => {
904
1025
  if (!isClientApiPath(c.req.url)) {
905
1026
  assertRuntimeBindings(c.env);
@@ -907,80 +1028,10 @@ export function createSageApp() {
907
1028
  await next();
908
1029
  });
909
1030
  app.get("/health", (c) => c.json({ status: "ok", agent: "sage" }));
910
- app.post("/api/proactive/follow-ups", async (c) => {
911
- try {
912
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
913
- const { workspaceId, slackConnectionId, slackProviderConfigKey, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
914
- const githubConnectionId = await resolveConnectionId(workspaceId, "github", c.env);
915
- const count = await checkFollowUps(getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body), {
916
- collector: followUpCollector,
917
- slackBotUserId,
918
- githubConnectionId: githubConnectionId ?? undefined,
919
- });
920
- return c.json({ ok: true, workspaceId, followUps: count });
921
- }
922
- catch (error) {
923
- const message = error instanceof Error ? error.message : "Follow-up check failed";
924
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
925
- console.error("[proactive] Follow-up check failed:", error);
926
- return c.json({ ok: false, error: message }, status);
927
- }
928
- });
929
- app.post("/api/proactive/stale-threads", async (c) => {
930
- try {
931
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
932
- const { workspaceId, slackConnectionId, slackProviderConfigKey, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
933
- const count = await detectStaleThreads(getMemory(workspaceId), workspaceId, getNango(c.env), slackConnectionId, slackProviderConfigKey, await loadActiveThreadsMap(c.env.THREADS), slackBotUserId, resolveNotifyChannel(c.env, body));
934
- return c.json({ ok: true, workspaceId, staleThreads: count });
935
- }
936
- catch (error) {
937
- const message = error instanceof Error ? error.message : "Stale thread detection failed";
938
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
939
- console.error("[proactive] Stale thread detection failed:", error);
940
- return c.json({ ok: false, error: message }, status);
941
- }
942
- });
943
- app.post("/api/proactive/context-watch", async (c) => {
944
- try {
945
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
946
- const { workspaceId, slackConnectionId, slackProviderConfigKey } = await resolveProactiveSlackContext(c.env, body);
947
- const count = await watchContext(getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body));
948
- return c.json({ ok: true, workspaceId, updates: count });
949
- }
950
- catch (error) {
951
- const message = error instanceof Error ? error.message : "Context watch failed";
952
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
953
- console.error("[proactive] Context watch failed:", error);
954
- return c.json({ ok: false, error: message }, status);
955
- }
956
- });
957
- app.post("/api/proactive/pr-match", async (c) => {
958
- try {
959
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
960
- if (!body) {
961
- return c.json({ ok: false, error: "Request body is required" }, 400);
962
- }
963
- const pr = extractPRData(body);
964
- if (!pr) {
965
- return c.json({ ok: false, error: "Could not parse pull request data" }, 400);
966
- }
967
- const workspaceId = resolveProactiveWorkspaceId(c.env, body);
968
- const { slackConnectionId, slackProviderConfigKey } = await resolveProactiveSlackContext(c.env, body);
969
- const matched = await matchPRToPlans(pr, getMemory(workspaceId), getNango(c.env), slackConnectionId, slackProviderConfigKey, resolveNotifyChannel(c.env, body));
970
- return c.json({
971
- ok: true,
972
- workspaceId,
973
- matched,
974
- pr: { number: pr.number, repo: pr.repo, title: pr.title },
975
- });
976
- }
977
- catch (error) {
978
- const message = error instanceof Error ? error.message : "PR match failed";
979
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
980
- console.error("[proactive] PR match failed:", error);
981
- return c.json({ ok: false, error: message }, status);
982
- }
983
- });
1031
+ app.post("/api/proactive/follow-ups", (c) => dispatchProactiveRoute(c, "/follow-ups"));
1032
+ app.post("/api/proactive/stale-threads", (c) => dispatchProactiveRoute(c, "/stale-threads"));
1033
+ app.post("/api/proactive/context-watch", (c) => dispatchProactiveRoute(c, "/context-watch"));
1034
+ app.post("/api/proactive/pr-match", (c) => dispatchProactiveRoute(c, "/pr-match"));
984
1035
  app.get(CLIENT_STATUS_PATH, async (c) => {
985
1036
  const authError = validateClientApiToken(c.req.header("authorization"), c.env);
986
1037
  if (authError) {
@@ -1063,26 +1114,30 @@ export function createSageApp() {
1063
1114
  const relayfileReader = await getRelayFileReader(workspaceId, env);
1064
1115
  const canUseNango = Boolean(trimOptional(env.NANGO_SECRET_KEY));
1065
1116
  let githubConnectionId = null;
1117
+ let githubProviderConfigKey = null;
1066
1118
  if (canUseNango) {
1067
1119
  try {
1068
- githubConnectionId = await resolveConnectionId(workspaceId, "github", env);
1120
+ const resolvedGitHubConnection = await resolveGitHubConnection(workspaceId, env);
1121
+ githubConnectionId = resolvedGitHubConnection.connectionId;
1122
+ githubProviderConfigKey = resolvedGitHubConnection.providerConfigKey;
1069
1123
  }
1070
1124
  catch (error) {
1071
1125
  console.warn("[client-chat] GitHub connection resolution failed; continuing without GitHub", error);
1072
1126
  }
1073
1127
  }
1074
1128
  const nangoClient = githubConnectionId ? getNango(env) : null;
1075
- const github = githubConnectionId && nangoClient
1129
+ const github = githubConnectionId && githubProviderConfigKey && nangoClient
1076
1130
  ? new GitHubIntegration({
1077
1131
  nangoClient,
1078
1132
  connectionId: githubConnectionId,
1133
+ providerConfigKey: githubProviderConfigKey,
1079
1134
  })
1080
1135
  : null;
1081
1136
  let orgs;
1082
1137
  let repos;
1083
- if (githubConnectionId) {
1138
+ if (githubConnectionId && githubProviderConfigKey) {
1084
1139
  try {
1085
- const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
1140
+ const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
1086
1141
  orgs = discovery.orgs;
1087
1142
  repos = discovery.repos;
1088
1143
  }
@@ -1227,7 +1282,7 @@ export function createSageApp() {
1227
1282
  return c.json(clientErrorResponse(payload, threadId), payload.status);
1228
1283
  }
1229
1284
  });
1230
- app.use("/api/webhooks/slack", nangoWebhookMiddleware);
1285
+ app.use("/api/webhooks/slack", slackWebhookMiddleware);
1231
1286
  app.post("/api/webhooks/slack", async (c) => {
1232
1287
  console.log("[sage] Incoming webhook request");
1233
1288
  const rawBody = c.get("rawSlackBody");
@@ -1241,13 +1296,29 @@ export function createSageApp() {
1241
1296
  }
1242
1297
  const payload = c.get("slackPayload");
1243
1298
  const event = parseSlackEvent(payload);
1299
+ const retryNum = c.req.header("X-Slack-Retry-Num");
1300
+ const retryReason = c.req.header("X-Slack-Retry-Reason");
1244
1301
  if (event.type === "url_verification") {
1245
1302
  return c.json({ challenge: event.challenge ?? "" });
1246
1303
  }
1247
1304
  if (event.type === "unknown" || !event.text.trim() || !event.channel) {
1248
1305
  return c.json({ ok: true });
1249
1306
  }
1307
+ console.log(`[sage] Inbound: type=${event.type} ts=${event.ts ?? ""} eventId=${event.eventId ?? ""} threadTs=${event.threadTs ?? ""}`);
1308
+ if (retryNum) {
1309
+ console.log(`[sage] Slack retry #${retryNum} reason=${retryReason ?? "unknown"}`);
1310
+ }
1311
+ const deduplicationKey = getSlackDeduplicationKey(event);
1312
+ const duplicateEvent = await isDuplicateEvent(c.env.DEDUP, deduplicationKey);
1313
+ console.log(`[sage] Dedup check: key=${deduplicationKey ?? ""} isDuplicate=${duplicateEvent}`);
1314
+ if (duplicateEvent) {
1315
+ console.log(`[sage] Duplicate event, skipping — key: ${deduplicationKey ?? ""}`);
1316
+ return c.json({ ok: true });
1317
+ }
1318
+ await markEventSeen(c.env.DEDUP, deduplicationKey);
1250
1319
  const workspaceId = getWorkspaceId(event, c.env);
1320
+ const cloudProxyProvider = getCloudProxyProvider(c.env);
1321
+ const { slack, slackBotUserId: workspaceSlackBotUserId } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
1251
1322
  if (event.type === "message" && event.threadTs) {
1252
1323
  const isActive = await isActiveThread(c.env.THREADS, event.threadTs);
1253
1324
  if (!isActive) {
@@ -1256,44 +1327,21 @@ export function createSageApp() {
1256
1327
  await markActiveThread(c.env.THREADS, event.threadTs, { workspaceId, channel: event.channel });
1257
1328
  }
1258
1329
  }
1259
- // Prefer the Nango connectionId + providerConfigKey forwarded in the
1260
- // envelope over a team_id → workspace → connection lookup. Cloud already
1261
- // knows which Nango connection received the webhook; re-resolving by
1262
- // team_id adds a translation hop that silently drops events when any link
1263
- // in the chain is missing (no slack-* row, missing team metadata, cloud
1264
- // route not deployed, etc.).
1265
- const envelopeConnectionId = c.get("nangoEnvelopeConnectionId");
1266
- const envelopeProviderConfigKey = c.get("nangoEnvelopeProviderConfigKey");
1267
- const slackConnection = envelopeConnectionId && envelopeProviderConfigKey
1268
- ? { connectionId: envelopeConnectionId, providerConfigKey: envelopeProviderConfigKey }
1269
- : await resolveSlackConnection(workspaceId, c.env);
1270
- if (!slackConnection) {
1271
- console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; skipping event`);
1272
- return c.json({ ok: true });
1273
- }
1274
- const slackConnectionId = slackConnection.connectionId;
1275
- const slackProviderConfigKey = slackConnection.providerConfigKey;
1276
- const workspaceSlackBotUserId = await resolveSlackBotUserId(slackConnection, c.env);
1277
1330
  if (!event.userId || (workspaceSlackBotUserId && event.userId === workspaceSlackBotUserId)) {
1278
1331
  return c.json({ ok: true });
1279
1332
  }
1280
- console.log("[sage] Event check — ts:", event.ts, "text:", event.text.slice(0, 50));
1281
- if (await isDuplicateEvent(c.env.DEDUP, event.ts)) {
1282
- console.log("[sage] Duplicate event, skipping");
1283
- return c.json({ ok: true });
1284
- }
1285
- await markEventSeen(c.env.DEDUP, event.ts);
1286
1333
  const rateLimitResult = await getRateLimiter(c.env).limit({ key: `${workspaceId}:${event.userId}` });
1287
1334
  if (!rateLimitResult.success) {
1288
- const postResult = await postSlackMessageChunkedViaNango(event.channel, "You're sending messages too quickly. Please wait a moment.", event.threadTs ?? event.ts, getNango(c.env), slackConnectionId, slackProviderConfigKey, MAX_REPLY_CHARS);
1335
+ const postResult = await slack.postMessageChunked(event.channel, "You're sending messages too quickly. Please wait a moment.", event.threadTs ?? event.ts, MAX_REPLY_CHARS);
1289
1336
  if (!postResult.ok) {
1290
1337
  console.error("Failed to post rate-limit Slack message", postResult.error);
1291
1338
  }
1292
1339
  return c.json({ ok: true });
1293
1340
  }
1294
1341
  if (event.ts) {
1295
- c.executionCtx.waitUntil(addSlackReactionViaNango(event.channel, event.ts, "eyes", getNango(c.env), slackConnectionId, slackProviderConfigKey));
1342
+ c.executionCtx.waitUntil(addSlackReaction(slack, event.channel, event.ts, "eyes"));
1296
1343
  }
1344
+ console.log(`[sage] Processing: ts=${event.ts ?? ""} channel=${event.channel}`);
1297
1345
  c.executionCtx.waitUntil((async () => {
1298
1346
  await runtimeReady;
1299
1347
  await sageRuntime.dispatch({
@@ -1305,8 +1353,7 @@ export function createSageApp() {
1305
1353
  receivedAt: new Date().toISOString(),
1306
1354
  capability: "slack-event",
1307
1355
  raw: {
1308
- run: () => processSlackEvent(event, workspaceId, c.env, {
1309
- slackConnection,
1356
+ run: () => processSlackEvent(event, workspaceId, c.env, slack, {
1310
1357
  slackBotUserId: workspaceSlackBotUserId,
1311
1358
  }),
1312
1359
  },