@agentworkforce/sage 1.0.5 → 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 -2
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +268 -190
  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 -7
  21. package/dist/nango.d.ts.map +1 -1
  22. package/dist/nango.js +9 -30
  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 -5
  27. package/dist/proactive/engine.d.ts.map +1 -1
  28. package/dist/proactive/engine.js +25 -19
  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 -16
  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 -20
  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 -5
  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 -1
  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,15 +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) {
390
+ function warnMissingGitHubProviderConfigKey(workspaceId) {
391
+ console.warn(`[sage] GitHub providerConfigKey is not configured for workspace "${workspaceId}"; continuing without GitHub integration`);
392
+ }
393
+ async function resolveGitHubConnection(workspaceId, env) {
361
394
  const resolver = getConnectionResolver(env);
362
395
  if (resolver) {
363
- return resolver.getConnectionId(workspaceId, provider);
364
- }
365
- if (provider === "github") {
366
- return trimOptional(env.NANGO_GITHUB_CONNECTION_ID) ?? "sage-github";
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);
402
+ }
403
+ return {
404
+ connectionId: resolvedConnection.connectionId,
405
+ providerConfigKey: resolvedConnection.providerConfigKey,
406
+ };
367
407
  }
368
- return trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? 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;
369
426
  }
370
427
  async function getRelayFileReader(slackWorkspaceId, env) {
371
428
  const resolver = getConnectionResolver(env);
@@ -490,45 +547,19 @@ async function loadClientStatus(workspaceId, env) {
490
547
  generatedAt: new Date().toISOString(),
491
548
  };
492
549
  }
493
- async function detectSlackBotUserId(env) {
494
- const staticSlackConnectionId = trimOptional(env.NANGO_SLACK_CONNECTION_ID);
495
- const fallbackSlackBotUserId = trimOptional(env.SLACK_BOT_USER_ID);
496
- if (!staticSlackConnectionId) {
497
- return fallbackSlackBotUserId;
498
- }
550
+ async function detectSlackBotUserId(slack) {
499
551
  try {
500
- const userId = await getNango(env).getSlackBotUserId(staticSlackConnectionId);
552
+ const userId = await slack.getBotUserId();
501
553
  if (userId) {
502
- 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);
503
555
  return userId;
504
556
  }
505
- console.warn("[sage] auth.test did not return a bot user ID; using fallback if available");
506
- }
507
- catch (error) {
508
- console.warn("[sage] Failed to auto-detect Slack bot user ID via auth.test", error);
509
- }
510
- return fallbackSlackBotUserId;
511
- }
512
- function getDefaultSlackBotUserId(env) {
513
- const cacheKey = [
514
- requireBinding(env, "NANGO_SECRET_KEY"),
515
- trimOptional(env.NANGO_SLACK_CONNECTION_ID) ?? "",
516
- trimOptional(env.SLACK_BOT_USER_ID) ?? "",
517
- ].join("\n");
518
- if (!cachedDefaultSlackBotUserId || cachedDefaultSlackBotUserIdKey !== cacheKey) {
519
- cachedDefaultSlackBotUserId = detectSlackBotUserId(env);
520
- cachedDefaultSlackBotUserIdKey = cacheKey;
521
- }
522
- return cachedDefaultSlackBotUserId;
523
- }
524
- async function resolveSlackBotUserId(slackConnectionId, env) {
525
- try {
526
- return await getNango(env).getSlackBotUserId(slackConnectionId);
557
+ console.warn("[sage] auth.test did not return a bot user ID");
527
558
  }
528
559
  catch (error) {
529
- console.warn("[sage] Failed to resolve workspace Slack bot user ID; using fallback if available", error);
530
- return getDefaultSlackBotUserId(env);
560
+ console.warn("[sage] Failed to auto-detect Slack bot user ID via Slack egress", error);
531
561
  }
562
+ return undefined;
532
563
  }
533
564
  async function isDuplicateEvent(kv, eventId) {
534
565
  if (!eventId) {
@@ -542,6 +573,9 @@ async function markEventSeen(kv, eventId) {
542
573
  }
543
574
  await kv.put(eventId, "1", { expirationTtl: DUPLICATE_EVENT_TTL_SECONDS });
544
575
  }
576
+ function getSlackDeduplicationKey(event) {
577
+ return event.eventId ?? event.ts;
578
+ }
545
579
  async function isActiveThread(kv, threadTs) {
546
580
  if (!threadTs) {
547
581
  return false;
@@ -637,24 +671,20 @@ function getUserFacingErrorMessage(error) {
637
671
  }
638
672
  return "I ran into an issue processing your request. Please try again.";
639
673
  }
640
- async function processSlackEvent(event, workspaceId, env, options = {}) {
674
+ async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
641
675
  const replyThreadTs = event.threadTs ?? event.ts;
642
676
  const threadId = replyThreadTs ?? "no-thread";
643
677
  const memory = getMemory(workspaceId);
644
678
  const orgMemory = getOrgMemory(workspaceId);
645
679
  const nangoClient = getNango(env);
646
- const slackConnectionId = options.slackConnectionId ?? await resolveConnectionId(workspaceId, "slack", env);
647
- if (!slackConnectionId) {
648
- console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; cannot process event`);
649
- return;
650
- }
651
- const workspaceSlackBotUserId = options.slackBotUserId ?? await resolveSlackBotUserId(slackConnectionId, env);
652
- 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);
653
682
  const relayfileReader = await getRelayFileReader(workspaceId, env);
654
- const github = githubConnectionId
683
+ const github = githubConnectionId && githubProviderConfigKey
655
684
  ? new GitHubIntegration({
656
685
  nangoClient,
657
686
  connectionId: githubConnectionId,
687
+ providerConfigKey: githubProviderConfigKey,
658
688
  })
659
689
  : null;
660
690
  let replyPosted = false;
@@ -668,9 +698,9 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
668
698
  ]);
669
699
  let orgs;
670
700
  let repos;
671
- if (githubConnectionId) {
701
+ if (githubConnectionId && githubProviderConfigKey) {
672
702
  try {
673
- const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
703
+ const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
674
704
  orgs = discovery.orgs;
675
705
  repos = discovery.repos;
676
706
  }
@@ -681,7 +711,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
681
711
  let threadHistory = [];
682
712
  if (event.threadTs) {
683
713
  try {
684
- threadHistory = await fetchThreadHistoryViaNango(event.channel, event.threadTs, workspaceSlackBotUserId, nangoClient, slackConnectionId);
714
+ threadHistory = await slack.fetchThreadHistory(event.channel, event.threadTs, workspaceSlackBotUserId);
685
715
  }
686
716
  catch (error) {
687
717
  console.warn("[sage] Proceeding without Slack thread history", error);
@@ -708,11 +738,12 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
708
738
  });
709
739
  const response = await processWithSwarm(event.text, systemPrompt, relayfileReader, github, threadHistory);
710
740
  const reply = formatSlackReply(response.content, response.citations);
711
- const postResult = await postSlackMessageChunkedViaNango(event.channel, reply, replyThreadTs, nangoClient, slackConnectionId, MAX_REPLY_CHARS);
741
+ const postResult = await slack.postMessageChunked(event.channel, reply, replyThreadTs, MAX_REPLY_CHARS);
712
742
  replyPosted = Boolean(postResult.ts);
713
743
  if (!postResult.ok) {
714
744
  throw new Error(postResult.error ?? "Failed to post Slack message");
715
745
  }
746
+ console.log(`[sage] Reply posted: threadTs=${replyThreadTs} replyTs=${postResult.ts ?? ""}`);
716
747
  if (replyThreadTs) {
717
748
  await markActiveThread(env.THREADS, replyThreadTs, {
718
749
  workspaceId,
@@ -733,7 +764,7 @@ async function processSlackEvent(event, workspaceId, env, options = {}) {
733
764
  return;
734
765
  }
735
766
  try {
736
- const fallbackResult = await postSlackMessageChunkedViaNango(event.channel, getUserFacingErrorMessage(error), replyThreadTs, nangoClient, slackConnectionId, MAX_REPLY_CHARS);
767
+ const fallbackResult = await slack.postMessageChunked(event.channel, getUserFacingErrorMessage(error), replyThreadTs, MAX_REPLY_CHARS);
737
768
  if (!fallbackResult.ok) {
738
769
  console.error("Failed to post fallback Slack message", fallbackResult.error);
739
770
  }
@@ -780,18 +811,87 @@ function resolveProactiveWorkspaceId(env, body) {
780
811
  function resolveNotifyChannel(env, body) {
781
812
  return readNonEmptyString(body?.notifyChannel) ?? trimOptional(env.SAGE_NOTIFY_CHANNEL);
782
813
  }
783
- async function resolveProactiveSlackContext(env, body) {
784
- const workspaceId = resolveProactiveWorkspaceId(env, body);
785
- const slackConnectionId = await resolveConnectionId(workspaceId, "slack", env);
786
- if (!slackConnectionId) {
787
- 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;
788
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);
789
859
  return {
790
860
  workspaceId,
791
- slackConnectionId,
792
- slackBotUserId: await resolveSlackBotUserId(slackConnectionId, env),
861
+ slack,
862
+ ...(userId !== undefined ? { slackBotUserId: userId } : {}),
793
863
  };
794
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
+ }
795
895
  function extractPRData(payload) {
796
896
  const pr = isRecord(payload.pull_request) ? payload.pull_request : payload;
797
897
  const number = typeof pr.number === "number" ? pr.number : undefined;
@@ -826,7 +926,6 @@ function extractPRData(payload) {
826
926
  // Proactive schedules are registered by the app entry/bootstrap layer, not by importing this module.
827
927
  export function createSageApp() {
828
928
  const app = new Hono();
829
- const followUpCollector = getFollowUpEvidenceCollector();
830
929
  // Create the runtime once per app instance. Capability handlers manage their own
831
930
  // delivery in Slice 1, so the writers here are no-ops (not called by handlers).
832
931
  const sageRuntime = createSageRuntime((_ch, _txt, _ts) => Promise.resolve({ ok: true }), () => { });
@@ -855,25 +954,73 @@ export function createSageApp() {
855
954
  throw ctx.error;
856
955
  return ctx.result;
857
956
  };
858
- const nangoWebhookMiddleware = async (c, next) => {
957
+ const slackWebhookMiddleware = async (c, next) => {
859
958
  const rawBody = await c.req.text();
860
959
  let payload;
861
960
  try {
862
- payload = JSON.parse(rawBody);
863
- }
864
- catch {
865
- return c.json({ error: "Invalid JSON" }, 400);
961
+ payload = parseSlackWebhookEnvelope(rawBody);
866
962
  }
867
- const envelope = parseNangoSlackEnvelope(payload);
868
- if (envelope.connectionId || envelope.providerConfigKey) {
869
- 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);
870
966
  }
871
967
  c.set("rawSlackBody", rawBody);
872
- c.set("slackPayload", envelope.slackPayload);
873
- c.set("nangoEnvelopeConnectionId", envelope.connectionId);
874
- c.set("nangoEnvelopeProviderConfigKey", envelope.providerConfigKey);
968
+ c.set("slackPayload", payload);
875
969
  await next();
876
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
+ };
877
1024
  app.use("*", async (c, next) => {
878
1025
  if (!isClientApiPath(c.req.url)) {
879
1026
  assertRuntimeBindings(c.env);
@@ -881,80 +1028,10 @@ export function createSageApp() {
881
1028
  await next();
882
1029
  });
883
1030
  app.get("/health", (c) => c.json({ status: "ok", agent: "sage" }));
884
- app.post("/api/proactive/follow-ups", async (c) => {
885
- try {
886
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
887
- const { workspaceId, slackConnectionId, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
888
- const githubConnectionId = await resolveConnectionId(workspaceId, "github", c.env);
889
- const count = await checkFollowUps(getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body), {
890
- collector: followUpCollector,
891
- slackBotUserId,
892
- githubConnectionId: githubConnectionId ?? undefined,
893
- });
894
- return c.json({ ok: true, workspaceId, followUps: count });
895
- }
896
- catch (error) {
897
- const message = error instanceof Error ? error.message : "Follow-up check failed";
898
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
899
- console.error("[proactive] Follow-up check failed:", error);
900
- return c.json({ ok: false, error: message }, status);
901
- }
902
- });
903
- app.post("/api/proactive/stale-threads", async (c) => {
904
- try {
905
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
906
- const { workspaceId, slackConnectionId, slackBotUserId } = await resolveProactiveSlackContext(c.env, body);
907
- const count = await detectStaleThreads(getMemory(workspaceId), workspaceId, getNango(c.env), slackConnectionId, await loadActiveThreadsMap(c.env.THREADS), slackBotUserId, resolveNotifyChannel(c.env, body));
908
- return c.json({ ok: true, workspaceId, staleThreads: count });
909
- }
910
- catch (error) {
911
- const message = error instanceof Error ? error.message : "Stale thread detection failed";
912
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
913
- console.error("[proactive] Stale thread detection failed:", error);
914
- return c.json({ ok: false, error: message }, status);
915
- }
916
- });
917
- app.post("/api/proactive/context-watch", async (c) => {
918
- try {
919
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
920
- const { workspaceId, slackConnectionId } = await resolveProactiveSlackContext(c.env, body);
921
- const count = await watchContext(getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body));
922
- return c.json({ ok: true, workspaceId, updates: count });
923
- }
924
- catch (error) {
925
- const message = error instanceof Error ? error.message : "Context watch failed";
926
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
927
- console.error("[proactive] Context watch failed:", error);
928
- return c.json({ ok: false, error: message }, status);
929
- }
930
- });
931
- app.post("/api/proactive/pr-match", async (c) => {
932
- try {
933
- const body = normalizeRequestBody(await readOptionalJsonBody(c));
934
- if (!body) {
935
- return c.json({ ok: false, error: "Request body is required" }, 400);
936
- }
937
- const pr = extractPRData(body);
938
- if (!pr) {
939
- return c.json({ ok: false, error: "Could not parse pull request data" }, 400);
940
- }
941
- const workspaceId = resolveProactiveWorkspaceId(c.env, body);
942
- const { slackConnectionId } = await resolveProactiveSlackContext(c.env, body);
943
- const matched = await matchPRToPlans(pr, getMemory(workspaceId), getNango(c.env), slackConnectionId, resolveNotifyChannel(c.env, body));
944
- return c.json({
945
- ok: true,
946
- workspaceId,
947
- matched,
948
- pr: { number: pr.number, repo: pr.repo, title: pr.title },
949
- });
950
- }
951
- catch (error) {
952
- const message = error instanceof Error ? error.message : "PR match failed";
953
- const status = message.startsWith("Invalid JSON") || message.includes("JSON object") ? 400 : 500;
954
- console.error("[proactive] PR match failed:", error);
955
- return c.json({ ok: false, error: message }, status);
956
- }
957
- });
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"));
958
1035
  app.get(CLIENT_STATUS_PATH, async (c) => {
959
1036
  const authError = validateClientApiToken(c.req.header("authorization"), c.env);
960
1037
  if (authError) {
@@ -1037,26 +1114,30 @@ export function createSageApp() {
1037
1114
  const relayfileReader = await getRelayFileReader(workspaceId, env);
1038
1115
  const canUseNango = Boolean(trimOptional(env.NANGO_SECRET_KEY));
1039
1116
  let githubConnectionId = null;
1117
+ let githubProviderConfigKey = null;
1040
1118
  if (canUseNango) {
1041
1119
  try {
1042
- githubConnectionId = await resolveConnectionId(workspaceId, "github", env);
1120
+ const resolvedGitHubConnection = await resolveGitHubConnection(workspaceId, env);
1121
+ githubConnectionId = resolvedGitHubConnection.connectionId;
1122
+ githubProviderConfigKey = resolvedGitHubConnection.providerConfigKey;
1043
1123
  }
1044
1124
  catch (error) {
1045
1125
  console.warn("[client-chat] GitHub connection resolution failed; continuing without GitHub", error);
1046
1126
  }
1047
1127
  }
1048
1128
  const nangoClient = githubConnectionId ? getNango(env) : null;
1049
- const github = githubConnectionId && nangoClient
1129
+ const github = githubConnectionId && githubProviderConfigKey && nangoClient
1050
1130
  ? new GitHubIntegration({
1051
1131
  nangoClient,
1052
1132
  connectionId: githubConnectionId,
1133
+ providerConfigKey: githubProviderConfigKey,
1053
1134
  })
1054
1135
  : null;
1055
1136
  let orgs;
1056
1137
  let repos;
1057
- if (githubConnectionId) {
1138
+ if (githubConnectionId && githubProviderConfigKey) {
1058
1139
  try {
1059
- const discovery = await getGithubContext(env).getOrgsAndRepos(githubConnectionId, relayfileReader);
1140
+ const discovery = await getGithubContext(env, githubProviderConfigKey).getOrgsAndRepos(githubConnectionId, relayfileReader);
1060
1141
  orgs = discovery.orgs;
1061
1142
  repos = discovery.repos;
1062
1143
  }
@@ -1201,7 +1282,7 @@ export function createSageApp() {
1201
1282
  return c.json(clientErrorResponse(payload, threadId), payload.status);
1202
1283
  }
1203
1284
  });
1204
- app.use("/api/webhooks/slack", nangoWebhookMiddleware);
1285
+ app.use("/api/webhooks/slack", slackWebhookMiddleware);
1205
1286
  app.post("/api/webhooks/slack", async (c) => {
1206
1287
  console.log("[sage] Incoming webhook request");
1207
1288
  const rawBody = c.get("rawSlackBody");
@@ -1215,13 +1296,29 @@ export function createSageApp() {
1215
1296
  }
1216
1297
  const payload = c.get("slackPayload");
1217
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");
1218
1301
  if (event.type === "url_verification") {
1219
1302
  return c.json({ challenge: event.challenge ?? "" });
1220
1303
  }
1221
1304
  if (event.type === "unknown" || !event.text.trim() || !event.channel) {
1222
1305
  return c.json({ ok: true });
1223
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);
1224
1319
  const workspaceId = getWorkspaceId(event, c.env);
1320
+ const cloudProxyProvider = getCloudProxyProvider(c.env);
1321
+ const { slack, slackBotUserId: workspaceSlackBotUserId } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
1225
1322
  if (event.type === "message" && event.threadTs) {
1226
1323
  const isActive = await isActiveThread(c.env.THREADS, event.threadTs);
1227
1324
  if (!isActive) {
@@ -1230,39 +1327,21 @@ export function createSageApp() {
1230
1327
  await markActiveThread(c.env.THREADS, event.threadTs, { workspaceId, channel: event.channel });
1231
1328
  }
1232
1329
  }
1233
- // Prefer the Nango connectionId forwarded in the envelope over a
1234
- // team_id → workspace → connection lookup. Cloud already knows which
1235
- // Nango connection received the webhook; re-resolving by team_id adds a
1236
- // translation hop that silently drops events when any link in the chain
1237
- // is missing (no slack-* row, missing team metadata, cloud route not
1238
- // deployed, etc.).
1239
- const envelopeConnectionId = c.get("nangoEnvelopeConnectionId");
1240
- const slackConnectionId = envelopeConnectionId ?? (await resolveConnectionId(workspaceId, "slack", c.env));
1241
- if (!slackConnectionId) {
1242
- console.warn(`[sage] No Slack connection configured for workspace "${workspaceId}"; skipping event`);
1243
- return c.json({ ok: true });
1244
- }
1245
- const workspaceSlackBotUserId = await resolveSlackBotUserId(slackConnectionId, c.env);
1246
1330
  if (!event.userId || (workspaceSlackBotUserId && event.userId === workspaceSlackBotUserId)) {
1247
1331
  return c.json({ ok: true });
1248
1332
  }
1249
- console.log("[sage] Event check — ts:", event.ts, "text:", event.text.slice(0, 50));
1250
- if (await isDuplicateEvent(c.env.DEDUP, event.ts)) {
1251
- console.log("[sage] Duplicate event, skipping");
1252
- return c.json({ ok: true });
1253
- }
1254
- await markEventSeen(c.env.DEDUP, event.ts);
1255
1333
  const rateLimitResult = await getRateLimiter(c.env).limit({ key: `${workspaceId}:${event.userId}` });
1256
1334
  if (!rateLimitResult.success) {
1257
- const postResult = await postSlackMessageChunkedViaNango(event.channel, "You're sending messages too quickly. Please wait a moment.", event.threadTs ?? event.ts, getNango(c.env), slackConnectionId, 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);
1258
1336
  if (!postResult.ok) {
1259
1337
  console.error("Failed to post rate-limit Slack message", postResult.error);
1260
1338
  }
1261
1339
  return c.json({ ok: true });
1262
1340
  }
1263
1341
  if (event.ts) {
1264
- c.executionCtx.waitUntil(addSlackReactionViaNango(event.channel, event.ts, "eyes", getNango(c.env), slackConnectionId));
1342
+ c.executionCtx.waitUntil(addSlackReaction(slack, event.channel, event.ts, "eyes"));
1265
1343
  }
1344
+ console.log(`[sage] Processing: ts=${event.ts ?? ""} channel=${event.channel}`);
1266
1345
  c.executionCtx.waitUntil((async () => {
1267
1346
  await runtimeReady;
1268
1347
  await sageRuntime.dispatch({
@@ -1274,8 +1353,7 @@ export function createSageApp() {
1274
1353
  receivedAt: new Date().toISOString(),
1275
1354
  capability: "slack-event",
1276
1355
  raw: {
1277
- run: () => processSlackEvent(event, workspaceId, c.env, {
1278
- slackConnectionId,
1356
+ run: () => processSlackEvent(event, workspaceId, c.env, slack, {
1279
1357
  slackBotUserId: workspaceSlackBotUserId,
1280
1358
  }),
1281
1359
  },