@agent-native/core 0.111.3 → 0.112.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 (109) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +3 -1
  4. package/corpus/core/src/cli/code-agent-executor.ts +17 -5
  5. package/corpus/core/src/cli/code-agent-runs.ts +43 -2
  6. package/corpus/core/src/cli/migration-codemod.ts +59 -9
  7. package/corpus/core/src/cli/multi-frontier-runs.ts +841 -0
  8. package/corpus/core/src/coding-tools/index.ts +27 -8
  9. package/corpus/core/src/embeddings/index.ts +233 -0
  10. package/corpus/core/src/index.ts +2 -0
  11. package/corpus/core/src/search/index.ts +413 -0
  12. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +48 -5
  13. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +46 -14
  14. package/corpus/templates/brain/README.md +34 -3
  15. package/corpus/templates/brain/actions/approve-proposal.ts +2 -0
  16. package/corpus/templates/brain/actions/claim-distillation.ts +24 -27
  17. package/corpus/templates/brain/actions/create-source.ts +1 -1
  18. package/corpus/templates/brain/actions/get-knowledge.ts +6 -2
  19. package/corpus/templates/brain/actions/get-pilot-report.ts +44 -8
  20. package/corpus/templates/brain/actions/import-capture.ts +21 -9
  21. package/corpus/templates/brain/actions/import-transcript.ts +42 -14
  22. package/corpus/templates/brain/actions/list-captures.ts +10 -1
  23. package/corpus/templates/brain/actions/list-distillation-queue.ts +39 -14
  24. package/corpus/templates/brain/actions/list-projects.ts +51 -0
  25. package/corpus/templates/brain/actions/list-proposals.ts +80 -4
  26. package/corpus/templates/brain/actions/list-sources.ts +17 -3
  27. package/corpus/templates/brain/actions/manage-project.ts +118 -0
  28. package/corpus/templates/brain/actions/mark-capture-distilled.ts +68 -20
  29. package/corpus/templates/brain/actions/navigate.ts +1 -0
  30. package/corpus/templates/brain/actions/reject-proposal.ts +6 -1
  31. package/corpus/templates/brain/actions/resanitize-captures.ts +159 -33
  32. package/corpus/templates/brain/actions/review-proposal.ts +2 -0
  33. package/corpus/templates/brain/actions/search-everything.ts +10 -1
  34. package/corpus/templates/brain/actions/search-knowledge.ts +1 -1
  35. package/corpus/templates/brain/actions/set-settings.ts +36 -1
  36. package/corpus/templates/brain/actions/update-proposal.ts +2 -0
  37. package/corpus/templates/brain/app/hooks/use-distillation-bridge.ts +18 -9
  38. package/corpus/templates/brain/app/hooks/use-navigation-state.ts +3 -0
  39. package/corpus/templates/brain/app/i18n-data.ts +731 -2
  40. package/corpus/templates/brain/app/lib/brain.ts +33 -1
  41. package/corpus/templates/brain/app/routes/ops.tsx +47 -0
  42. package/corpus/templates/brain/app/routes/review.tsx +41 -2
  43. package/corpus/templates/brain/app/routes/search.tsx +33 -2
  44. package/corpus/templates/brain/app/routes/settings.tsx +158 -0
  45. package/corpus/templates/brain/app/routes/sources.tsx +31 -0
  46. package/corpus/templates/brain/changelog/2026-07-19-added-private-source-safe-semantic-search.md +6 -0
  47. package/corpus/templates/brain/jobs/process-ingest-queue.ts +270 -36
  48. package/corpus/templates/brain/package.json +1 -0
  49. package/corpus/templates/brain/server/db/index.ts +18 -0
  50. package/corpus/templates/brain/server/db/schema.ts +197 -1
  51. package/corpus/templates/brain/server/jobs/distillation-queue.ts +12 -5
  52. package/corpus/templates/brain/server/lib/audiences.ts +708 -0
  53. package/corpus/templates/brain/server/lib/brain-health.ts +251 -38
  54. package/corpus/templates/brain/server/lib/brain.ts +877 -50
  55. package/corpus/templates/brain/server/lib/capture-sanitization.ts +217 -53
  56. package/corpus/templates/brain/server/lib/connectors.ts +556 -70
  57. package/corpus/templates/brain/server/lib/demo.ts +17 -4
  58. package/corpus/templates/brain/server/lib/hybrid-search.ts +363 -0
  59. package/corpus/templates/brain/server/lib/ingest-queue.ts +104 -0
  60. package/corpus/templates/brain/server/lib/privacy-readiness.ts +24 -0
  61. package/corpus/templates/brain/server/lib/search-index-contracts.ts +72 -0
  62. package/corpus/templates/brain/server/lib/search-index.ts +714 -0
  63. package/corpus/templates/brain/server/lib/search.ts +120 -4
  64. package/corpus/templates/brain/server/lib/sensitivity-policy.ts +174 -0
  65. package/corpus/templates/brain/server/lib/slack-events.ts +281 -0
  66. package/corpus/templates/brain/server/onboarding.ts +29 -0
  67. package/corpus/templates/brain/server/plugins/db.ts +204 -0
  68. package/corpus/templates/brain/server/register-secrets.ts +46 -0
  69. package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +36 -16
  70. package/corpus/templates/brain/server/routes/api/_agent-native/brain/slack-events.post.ts +28 -0
  71. package/corpus/templates/brain/shared/types.ts +10 -0
  72. package/dist/cli/code-agent-executor.d.ts +8 -9
  73. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  74. package/dist/cli/code-agent-executor.js +9 -3
  75. package/dist/cli/code-agent-executor.js.map +1 -1
  76. package/dist/cli/code-agent-runs.d.ts +5 -0
  77. package/dist/cli/code-agent-runs.d.ts.map +1 -1
  78. package/dist/cli/code-agent-runs.js +33 -2
  79. package/dist/cli/code-agent-runs.js.map +1 -1
  80. package/dist/cli/migration-codemod.d.ts.map +1 -1
  81. package/dist/cli/migration-codemod.js +48 -10
  82. package/dist/cli/migration-codemod.js.map +1 -1
  83. package/dist/cli/multi-frontier-runs.d.ts +131 -0
  84. package/dist/cli/multi-frontier-runs.d.ts.map +1 -0
  85. package/dist/cli/multi-frontier-runs.js +529 -0
  86. package/dist/cli/multi-frontier-runs.js.map +1 -0
  87. package/dist/coding-tools/index.d.ts +1 -0
  88. package/dist/coding-tools/index.d.ts.map +1 -1
  89. package/dist/coding-tools/index.js +25 -7
  90. package/dist/coding-tools/index.js.map +1 -1
  91. package/dist/collab/awareness.d.ts +2 -2
  92. package/dist/collab/awareness.d.ts.map +1 -1
  93. package/dist/embeddings/index.d.ts +24 -0
  94. package/dist/embeddings/index.d.ts.map +1 -0
  95. package/dist/embeddings/index.js +172 -0
  96. package/dist/embeddings/index.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/notifications/routes.d.ts +3 -3
  102. package/dist/progress/routes.d.ts +1 -1
  103. package/dist/resources/handlers.d.ts +1 -1
  104. package/dist/search/index.d.ts +88 -0
  105. package/dist/search/index.d.ts.map +1 -0
  106. package/dist/search/index.js +267 -0
  107. package/dist/search/index.js.map +1 -0
  108. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  109. package/package.json +3 -1
@@ -1,17 +1,20 @@
1
1
  import { getCredentialContext } from "@agent-native/core/server";
2
2
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
3
- import { and, desc, eq } from "drizzle-orm";
3
+ import { and, desc, eq, inArray } from "drizzle-orm";
4
4
 
5
5
  import type {
6
6
  BrainCaptureKind,
7
7
  BrainSourceProvider,
8
8
  } from "../../shared/types.js";
9
9
  import { getDb, schema } from "../db/index.js";
10
+ import { listAccessibleAudienceIds } from "./audiences.js";
10
11
  import {
12
+ BrainCaptureBlockedError,
11
13
  createCapture,
12
14
  nanoid,
13
15
  nowIso,
14
16
  parseJson,
17
+ readBrainSettings,
15
18
  serializeCapture,
16
19
  stableJson,
17
20
  } from "./brain.js";
@@ -31,6 +34,19 @@ export interface ConnectorSyncResult {
31
34
  stats?: Record<string, unknown>;
32
35
  }
33
36
 
37
+ async function createConnectorCapture(
38
+ values: Parameters<typeof createCapture>[0],
39
+ ) {
40
+ try {
41
+ return { capture: await createCapture(values), blocked: false };
42
+ } catch (error) {
43
+ if (error instanceof BrainCaptureBlockedError) {
44
+ return { capture: null, blocked: true };
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
34
50
  export interface SlackPilotOptions {
35
51
  readHistory?: boolean;
36
52
  channelRefs?: string[];
@@ -145,6 +161,7 @@ interface SlackChannelCursor {
145
161
 
146
162
  interface SlackSyncCursor {
147
163
  channels?: Record<string, SlackChannelCursor>;
164
+ publicChannelOffset?: number;
148
165
  retry?: RetryCursor;
149
166
  lastRunAt?: string;
150
167
  }
@@ -164,6 +181,7 @@ interface SlackChannel {
164
181
  is_channel?: boolean;
165
182
  is_group?: boolean;
166
183
  is_archived?: boolean;
184
+ is_private?: boolean;
167
185
  }
168
186
 
169
187
  interface SlackMessage {
@@ -175,6 +193,7 @@ interface SlackMessage {
175
193
  text?: string;
176
194
  ts?: string;
177
195
  thread_ts?: string;
196
+ reactions?: Array<{ name?: string; count?: number }>;
178
197
  }
179
198
 
180
199
  interface SlackHistoryResponse {
@@ -183,6 +202,10 @@ interface SlackHistoryResponse {
183
202
  response_metadata?: { next_cursor?: string };
184
203
  }
185
204
 
205
+ interface SlackRepliesResponse {
206
+ messages?: SlackMessage[];
207
+ }
208
+
186
209
  interface SlackInfoResponse {
187
210
  channel?: SlackChannel;
188
211
  }
@@ -192,6 +215,19 @@ interface SlackListResponse {
192
215
  response_metadata?: { next_cursor?: string };
193
216
  }
194
217
 
218
+ interface SlackMembersResponse {
219
+ members?: string[];
220
+ response_metadata?: { next_cursor?: string };
221
+ }
222
+
223
+ interface SlackUserInfoResponse {
224
+ user?: { profile?: { email?: string } };
225
+ }
226
+
227
+ type SlackUserEmailCache = Map<string, string | null>;
228
+
229
+ const SLACK_USER_LOOKUP_CONCURRENCY = 4;
230
+
195
231
  interface SlackPermalinkResponse {
196
232
  permalink?: string;
197
233
  }
@@ -702,10 +738,6 @@ function readableJson(value: unknown): string {
702
738
  return JSON.stringify(value, null, 2);
703
739
  }
704
740
 
705
- function slackUserLabel(message: SlackMessage): string {
706
- return message.username ?? message.user ?? message.bot_id ?? "unknown";
707
- }
708
-
709
741
  function slackDirectConversationFromRef(
710
742
  channelRef: string,
711
743
  ): SlackChannel | null {
@@ -726,22 +758,89 @@ function isUsableSlackChannel(channel: SlackChannel): boolean {
726
758
  );
727
759
  }
728
760
 
729
- function normalizeSlackMessageContent(
730
- channel: SlackChannel,
731
- message: SlackMessage,
732
- ): string {
733
- const label = slackUserLabel(message);
734
- const time = isoFromSlackTs(message.ts) ?? message.ts ?? "unknown time";
735
- const thread =
736
- message.thread_ts && message.thread_ts !== message.ts
737
- ? `\nThread: ${message.thread_ts}`
738
- : "";
739
- return [
740
- `Slack #${channel.name ?? channel.id} at ${time}`,
741
- `User: ${label}${thread}`,
742
- "",
743
- message.text ?? "",
744
- ].join("\n");
761
+ function slackReactionCount(message: SlackMessage) {
762
+ return (message.reactions ?? []).reduce(
763
+ (total, reaction) => total + Math.max(0, Number(reaction.count) || 0),
764
+ 0,
765
+ );
766
+ }
767
+
768
+ function isSlackTextMessage(message: SlackMessage) {
769
+ return (
770
+ message.type === "message" &&
771
+ typeof message.text === "string" &&
772
+ message.text.trim().length > 0 &&
773
+ typeof message.ts === "string"
774
+ );
775
+ }
776
+
777
+ export interface SlackThreadCapture {
778
+ externalId: string;
779
+ title: string;
780
+ content: string;
781
+ capturedAt: string;
782
+ metadata: Record<string, unknown>;
783
+ }
784
+
785
+ /**
786
+ * Builds the only Slack representation Brain persists. It deliberately omits
787
+ * Slack user ids, display names, and the raw Events/Web API payload.
788
+ */
789
+ export function normalizeSlackThreadCapture(input: {
790
+ channel: SlackChannel;
791
+ messages: SlackMessage[];
792
+ permalink?: string | null;
793
+ syncRunId?: string;
794
+ }): SlackThreadCapture | null {
795
+ const messages = input.messages
796
+ .filter(isSlackTextMessage)
797
+ .sort((left, right) => Number(left.ts) - Number(right.ts));
798
+ const root = messages[0];
799
+ if (!root?.ts) return null;
800
+
801
+ let offset = 0;
802
+ const safeSegments = messages.map((message, index) => {
803
+ const capturedAt = isoFromSlackTs(message.ts) ?? message.ts!;
804
+ const text = `${capturedAt}\n${message.text!.trim()}`;
805
+ const startOffset = offset;
806
+ offset += text.length;
807
+ if (index < messages.length - 1) offset += 2;
808
+ return {
809
+ id: `slack:${input.channel.id}:${message.ts}`,
810
+ capturedAt,
811
+ sourceUrl: input.permalink ?? undefined,
812
+ text,
813
+ startOffset,
814
+ endOffset: startOffset + text.length,
815
+ reactionCount: slackReactionCount(message),
816
+ };
817
+ });
818
+ const content = safeSegments.map((segment) => segment.text).join("\n\n");
819
+ const threadTs = root.thread_ts ?? root.ts;
820
+ return {
821
+ externalId: `slack:${input.channel.id}:${threadTs}`,
822
+ title: `#${input.channel.name ?? input.channel.id} thread ${isoFromSlackTs(threadTs) ?? threadTs}`,
823
+ content,
824
+ capturedAt: isoFromSlackTs(threadTs) ?? nowIso(),
825
+ metadata: {
826
+ provider: "slack",
827
+ connector: "slack",
828
+ ...(input.syncRunId ? { syncRunId: input.syncRunId } : {}),
829
+ channelId: input.channel.id,
830
+ channelName: input.channel.name,
831
+ threadTs,
832
+ messageCount: safeSegments.length,
833
+ reactionCount: safeSegments.reduce(
834
+ (total, segment) => total + segment.reactionCount,
835
+ 0,
836
+ ),
837
+ sourceUrl: input.permalink ?? null,
838
+ permalink: input.permalink ?? null,
839
+ // These offsets are against `content`, the safe persisted capture, never
840
+ // against a provider payload.
841
+ safeSegments,
842
+ },
843
+ };
745
844
  }
746
845
 
747
846
  async function slackApi<T>(
@@ -810,6 +909,60 @@ async function resolveSlackChannelByName(
810
909
  return null;
811
910
  }
812
911
 
912
+ function matchesPublicChannelExclusion(
913
+ channel: SlackChannel,
914
+ patterns: string[],
915
+ ) {
916
+ return patterns.some((pattern) => {
917
+ try {
918
+ const expression = new RegExp(pattern, "i");
919
+ return expression.test(channel.id) || expression.test(channel.name ?? "");
920
+ } catch {
921
+ return false;
922
+ }
923
+ });
924
+ }
925
+
926
+ async function listPublicSlackChannels(
927
+ token: string,
928
+ exclusionPatterns: string[],
929
+ ): Promise<{ channels: SlackChannel[]; excluded: number }> {
930
+ const channels: SlackChannel[] = [];
931
+ const seenCursors = new Set<string>();
932
+ let cursor: string | undefined;
933
+ let excluded = 0;
934
+ for (;;) {
935
+ const page = await slackApi<SlackListResponse>(
936
+ token,
937
+ "conversations.list",
938
+ {
939
+ types: "public_channel",
940
+ exclude_archived: true,
941
+ limit: 200,
942
+ cursor,
943
+ },
944
+ );
945
+ for (const channel of page.channels ?? []) {
946
+ if (
947
+ channel.is_archived ||
948
+ channel.is_private ||
949
+ channel.is_channel === false
950
+ ) {
951
+ continue;
952
+ }
953
+ if (matchesPublicChannelExclusion(channel, exclusionPatterns)) {
954
+ excluded += 1;
955
+ continue;
956
+ }
957
+ channels.push(channel);
958
+ }
959
+ cursor = page.response_metadata?.next_cursor;
960
+ if (!cursor || seenCursors.has(cursor)) break;
961
+ seenCursors.add(cursor);
962
+ }
963
+ return { channels, excluded };
964
+ }
965
+
813
966
  async function resolveSlackChannel(
814
967
  token: string,
815
968
  channelRef: string,
@@ -848,6 +1001,121 @@ async function slackPermalink(
848
1001
  return data.permalink ?? null;
849
1002
  }
850
1003
 
1004
+ async function slackPrivateChannelMemberEmails(
1005
+ token: string,
1006
+ channelId: string,
1007
+ userEmailCache: SlackUserEmailCache,
1008
+ ): Promise<string[] | null> {
1009
+ const memberIds = new Set<string>();
1010
+ const seenCursors = new Set<string>();
1011
+ let cursor: string | undefined;
1012
+ while (true) {
1013
+ const response = await slackApi<SlackMembersResponse>(
1014
+ token,
1015
+ "conversations.members",
1016
+ { channel: channelId, limit: 1_000, cursor },
1017
+ );
1018
+ for (const memberId of response.members ?? []) {
1019
+ if (memberId) memberIds.add(memberId);
1020
+ }
1021
+ const nextCursor = response.response_metadata?.next_cursor?.trim();
1022
+ if (!nextCursor) break;
1023
+ if (seenCursors.has(nextCursor)) return null;
1024
+ seenCursors.add(nextCursor);
1025
+ cursor = nextCursor;
1026
+ }
1027
+ const userIds = Array.from(memberIds);
1028
+ if (!userIds.length) return null;
1029
+ if (
1030
+ userIds.some(
1031
+ (userId) => userEmailCache.has(userId) && !userEmailCache.get(userId),
1032
+ )
1033
+ ) {
1034
+ return null;
1035
+ }
1036
+
1037
+ const uncachedUserIds = userIds.filter(
1038
+ (userId) => !userEmailCache.has(userId),
1039
+ );
1040
+ for (
1041
+ let offset = 0;
1042
+ offset < uncachedUserIds.length;
1043
+ offset += SLACK_USER_LOOKUP_CONCURRENCY
1044
+ ) {
1045
+ const batch = uncachedUserIds.slice(
1046
+ offset,
1047
+ offset + SLACK_USER_LOOKUP_CONCURRENCY,
1048
+ );
1049
+ await Promise.all(
1050
+ batch.map(async (userId) => {
1051
+ const user = await slackApi<SlackUserInfoResponse>(
1052
+ token,
1053
+ "users.info",
1054
+ { user: userId },
1055
+ );
1056
+ const email = user.user?.profile?.email?.trim().toLowerCase() ?? null;
1057
+ userEmailCache.set(userId, email || null);
1058
+ }),
1059
+ );
1060
+ if (batch.some((userId) => !userEmailCache.get(userId))) return null;
1061
+ }
1062
+
1063
+ const emails: string[] = [];
1064
+ for (const userId of userIds) {
1065
+ const email = userEmailCache.get(userId);
1066
+ if (!email) return null;
1067
+ emails.push(email);
1068
+ }
1069
+ return Array.from(new Set(emails)).sort();
1070
+ }
1071
+
1072
+ function configuredSlackChannelIds(config: Record<string, unknown>) {
1073
+ return new Set(
1074
+ slackChannelRefsFromConfig(config).filter((value) =>
1075
+ /^[CG][A-Z0-9]+$/i.test(value),
1076
+ ),
1077
+ );
1078
+ }
1079
+
1080
+ async function createSlackThreadCapture(input: {
1081
+ source: SourceRow;
1082
+ channel: SlackChannel;
1083
+ messages: SlackMessage[];
1084
+ permalink: string | null;
1085
+ syncRunId?: string;
1086
+ memberEmails?: string[] | null;
1087
+ }) {
1088
+ const normalized = normalizeSlackThreadCapture({
1089
+ channel: input.channel,
1090
+ messages: input.messages,
1091
+ permalink: input.permalink,
1092
+ syncRunId: input.syncRunId,
1093
+ });
1094
+ if (!normalized) return { capture: null, blocked: false };
1095
+ try {
1096
+ const capture = await createCapture({
1097
+ sourceId: input.source.id,
1098
+ externalId: normalized.externalId,
1099
+ title: normalized.title,
1100
+ kind: "message",
1101
+ content: normalized.content,
1102
+ capturedAt: normalized.capturedAt,
1103
+ metadata: normalized.metadata,
1104
+ audience: {
1105
+ kind: input.channel.is_private ? "slack-private-channel" : "org",
1106
+ memberEmails: input.memberEmails ?? undefined,
1107
+ upstreamRefHash: input.channel.id,
1108
+ },
1109
+ });
1110
+ return { capture, blocked: false };
1111
+ } catch (error) {
1112
+ if (error instanceof BrainCaptureBlockedError) {
1113
+ return { capture: null, blocked: true };
1114
+ }
1115
+ throw error;
1116
+ }
1117
+ }
1118
+
851
1119
  export async function testSlackConnection(
852
1120
  options: {
853
1121
  channelRefs?: string[];
@@ -1659,10 +1927,32 @@ async function githubRefsFromLinkedSources(
1659
1927
  Number(options.stats.linkedSourcesSkipped) + 1;
1660
1928
  continue;
1661
1929
  }
1930
+ const audienceIds = await listAccessibleAudienceIds([sourceId]);
1931
+ if (!audienceIds.length) continue;
1662
1932
  const captures = await db
1663
- .select()
1933
+ .select({
1934
+ id: schema.brainRawCaptures.id,
1935
+ title: schema.brainRawCaptures.title,
1936
+ content: schema.brainRawCaptures.content,
1937
+ metadataJson: schema.brainRawCaptures.metadataJson,
1938
+ })
1664
1939
  .from(schema.brainRawCaptures)
1665
- .where(eq(schema.brainRawCaptures.sourceId, sourceId))
1940
+ .innerJoin(
1941
+ schema.brainCaptureAudiences,
1942
+ eq(schema.brainCaptureAudiences.captureId, schema.brainRawCaptures.id),
1943
+ )
1944
+ .innerJoin(
1945
+ schema.brainAudiences,
1946
+ eq(schema.brainAudiences.id, schema.brainCaptureAudiences.audienceId),
1947
+ )
1948
+ .where(
1949
+ and(
1950
+ eq(schema.brainRawCaptures.sourceId, sourceId),
1951
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
1952
+ inArray(schema.brainCaptureAudiences.audienceId, audienceIds),
1953
+ eq(schema.brainAudiences.kind, "org"),
1954
+ ),
1955
+ )
1666
1956
  .orderBy(desc(schema.brainRawCaptures.capturedAt))
1667
1957
  .limit(options.captureLimit);
1668
1958
 
@@ -1675,7 +1965,6 @@ async function githubRefsFromLinkedSources(
1675
1965
  capture.metadataJson,
1676
1966
  {},
1677
1967
  );
1678
- const raw = objectValue(metadata.raw);
1679
1968
  const linkedFrom = {
1680
1969
  sourceId,
1681
1970
  captureId: capture.id,
@@ -1684,7 +1973,6 @@ async function githubRefsFromLinkedSources(
1684
1973
  };
1685
1974
  const candidates = [
1686
1975
  capture.content,
1687
- typeof raw.text === "string" ? raw.text : "",
1688
1976
  typeof metadata.sourceUrl === "string" ? metadata.sourceUrl : "",
1689
1977
  typeof metadata.permalink === "string" ? metadata.permalink : "",
1690
1978
  ].join("\n");
@@ -1741,10 +2029,11 @@ async function syncFromConfiguredItems(
1741
2029
  const config = parseJson<Record<string, unknown>>(source.configJson, {});
1742
2030
  const items = transcriptItems(config);
1743
2031
  const captures = [];
2032
+ let sensitivityBlocked = 0;
1744
2033
 
1745
2034
  try {
1746
2035
  for (const item of items) {
1747
- const capture = await createCapture({
2036
+ const captureResult = await createConnectorCapture({
1748
2037
  sourceId: source.id,
1749
2038
  externalId: item.externalId,
1750
2039
  title: item.title,
@@ -1757,9 +2046,14 @@ async function syncFromConfiguredItems(
1757
2046
  syncRunId: runId,
1758
2047
  },
1759
2048
  });
1760
- captures.push(serializeCapture(capture));
2049
+ if (captureResult.capture)
2050
+ captures.push(serializeCapture(captureResult.capture));
2051
+ if (captureResult.blocked) sensitivityBlocked += 1;
1761
2052
  }
1762
- await finishRun(runId, "success", { capturesCreated: captures.length });
2053
+ await finishRun(runId, "success", {
2054
+ capturesCreated: captures.length,
2055
+ sensitivityBlocked,
2056
+ });
1763
2057
  await getDb()
1764
2058
  .update(schema.brainSources)
1765
2059
  .set({
@@ -1841,6 +2135,12 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1841
2135
  ],
1842
2136
  "slack",
1843
2137
  );
2138
+ const includePublicChannels = configuredBoolean(
2139
+ config,
2140
+ ["includePublicChannels"],
2141
+ false,
2142
+ "slack",
2143
+ );
1844
2144
  const limit = configuredNumber(config, ["historyLimit", "limit"], 15, {
1845
2145
  min: 1,
1846
2146
  max: 15,
@@ -1877,11 +2177,17 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1877
2177
  const captures = [];
1878
2178
  const stats: Record<string, unknown> = {
1879
2179
  configuredChannels: channelRefs.length,
2180
+ includePublicChannels,
2181
+ discoveredPublicChannels: 0,
2182
+ excludedPublicChannels: 0,
2183
+ eligibleChannels: 0,
1880
2184
  scannedChannels: 0,
1881
2185
  rejectedChannels: 0,
1882
2186
  missingChannels: 0,
1883
2187
  messagesSeen: 0,
1884
2188
  capturesCreated: 0,
2189
+ threadsFetched: 0,
2190
+ sensitivityBlocked: 0,
1885
2191
  rateLimited: false,
1886
2192
  };
1887
2193
 
@@ -1892,15 +2198,17 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1892
2198
  "slack",
1893
2199
  config,
1894
2200
  );
1895
- if (!channelRefs.length) {
2201
+ if (!channelRefs.length && !includePublicChannels) {
1896
2202
  throw new Error(
1897
- "Slack source must configure channelIds, channels, or allowedChannels",
2203
+ "Slack source must configure channelIds, channels, allowedChannels, or includePublicChannels",
1898
2204
  );
1899
2205
  }
1900
2206
 
1901
- let permalinkCalls = 0;
1902
-
1903
- for (const channelRef of channelRefs.slice(0, maxChannels)) {
2207
+ const settings = await readBrainSettings();
2208
+ const publicExclusionPatterns =
2209
+ settings.publicChannelExclusionPatterns ?? [];
2210
+ const channelsById = new Map<string, SlackChannel>();
2211
+ for (const channelRef of channelRefs) {
1904
2212
  const channel = await resolveSlackChannel(token, channelRef);
1905
2213
  if (!channel) {
1906
2214
  stats.missingChannels = Number(stats.missingChannels) + 1;
@@ -1910,6 +2218,68 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1910
2218
  stats.rejectedChannels = Number(stats.rejectedChannels) + 1;
1911
2219
  continue;
1912
2220
  }
2221
+ const isExplicitId = /^[CG][A-Z0-9]+$/i.test(channelRef);
2222
+ if (channel.is_private && !isExplicitId) {
2223
+ stats.rejectedChannels = Number(stats.rejectedChannels) + 1;
2224
+ continue;
2225
+ }
2226
+ if (
2227
+ !channel.is_private &&
2228
+ matchesPublicChannelExclusion(channel, publicExclusionPatterns)
2229
+ ) {
2230
+ stats.excludedPublicChannels = Number(stats.excludedPublicChannels) + 1;
2231
+ continue;
2232
+ }
2233
+ channelsById.set(channel.id, channel);
2234
+ }
2235
+ if (includePublicChannels) {
2236
+ const discovered = await listPublicSlackChannels(
2237
+ token,
2238
+ publicExclusionPatterns,
2239
+ );
2240
+ stats.discoveredPublicChannels = discovered.channels.length;
2241
+ stats.excludedPublicChannels =
2242
+ Number(stats.excludedPublicChannels) + discovered.excluded;
2243
+ for (const channel of discovered.channels) {
2244
+ channelsById.set(channel.id, channel);
2245
+ }
2246
+ }
2247
+ const channels = [...channelsById.values()];
2248
+ stats.eligibleChannels = channels.length;
2249
+ const userEmailCache: SlackUserEmailCache = new Map();
2250
+
2251
+ const channelsToScan = includePublicChannels
2252
+ ? (() => {
2253
+ const ordered = [...channels].sort((left, right) =>
2254
+ left.id.localeCompare(right.id),
2255
+ );
2256
+ if (!ordered.length) return ordered;
2257
+ const offset =
2258
+ Math.max(0, cursor.publicChannelOffset ?? 0) % ordered.length;
2259
+ const rotated = [
2260
+ ...ordered.slice(offset),
2261
+ ...ordered.slice(0, offset),
2262
+ ];
2263
+ nextCursor.publicChannelOffset =
2264
+ (offset + Math.min(maxChannels, rotated.length)) % ordered.length;
2265
+ return rotated.slice(0, maxChannels);
2266
+ })()
2267
+ : channels.slice(0, maxChannels);
2268
+
2269
+ let permalinkCalls = 0;
2270
+
2271
+ for (const channel of channelsToScan) {
2272
+ const privateMemberEmails = channel.is_private
2273
+ ? await slackPrivateChannelMemberEmails(
2274
+ token,
2275
+ channel.id,
2276
+ userEmailCache,
2277
+ )
2278
+ : null;
2279
+ if (channel.is_private && !privateMemberEmails?.length) {
2280
+ stats.rejectedChannels = Number(stats.rejectedChannels) + 1;
2281
+ continue;
2282
+ }
1913
2283
 
1914
2284
  stats.scannedChannels = Number(stats.scannedChannels) + 1;
1915
2285
  const channelCursor = nextCursor.channels?.[channel.id] ?? {};
@@ -1939,13 +2309,7 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1939
2309
  "conversations.history",
1940
2310
  params,
1941
2311
  );
1942
- const messages = (data.messages ?? []).filter(
1943
- (message) =>
1944
- message.type === "message" &&
1945
- typeof message.text === "string" &&
1946
- message.text.trim() &&
1947
- typeof message.ts === "string",
1948
- );
2312
+ const messages = (data.messages ?? []).filter(isSlackTextMessage);
1949
2313
  stats.messagesSeen = Number(stats.messagesSeen) + messages.length;
1950
2314
 
1951
2315
  const newest = newestSlackTs(messages);
@@ -1953,36 +2317,35 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
1953
2317
  channelCursor.pendingLatestTs = newest;
1954
2318
  }
1955
2319
 
2320
+ const fetchedThreadTs = new Set<string>();
1956
2321
  for (const message of messages) {
2322
+ const threadTs = message.thread_ts ?? message.ts;
2323
+ if (!threadTs || fetchedThreadTs.has(threadTs)) continue;
2324
+ fetchedThreadTs.add(threadTs);
1957
2325
  let permalink: string | null = null;
1958
2326
  if (permalinkCalls < permalinkLimit) {
1959
- permalink = await slackPermalink(token, channel.id, message.ts);
2327
+ permalink = await slackPermalink(token, channel.id, threadTs);
1960
2328
  permalinkCalls += 1;
1961
2329
  }
1962
- const capture = await createCapture({
1963
- sourceId: source.id,
1964
- externalId: `slack:${channel.id}:${message.ts}`,
1965
- title: `#${channel.name ?? channel.id} message ${isoFromSlackTs(message.ts) ?? message.ts}`,
1966
- kind: "message",
1967
- content: normalizeSlackMessageContent(channel, message),
1968
- capturedAt: isoFromSlackTs(message.ts) ?? nowIso(),
1969
- metadata: {
1970
- provider: "slack",
1971
- connector: "slack",
1972
- syncRunId: runId,
1973
- channelId: channel.id,
1974
- channelName: channel.name,
1975
- ts: message.ts,
1976
- threadTs: message.thread_ts,
1977
- user: message.user,
1978
- username: message.username,
1979
- botId: message.bot_id,
1980
- sourceUrl: permalink,
1981
- permalink,
1982
- raw: message,
1983
- },
2330
+ const thread = await slackApi<SlackRepliesResponse>(
2331
+ token,
2332
+ "conversations.replies",
2333
+ { channel: channel.id, ts: threadTs, limit: 1_000 },
2334
+ );
2335
+ stats.threadsFetched = Number(stats.threadsFetched) + 1;
2336
+ const captureResult = await createSlackThreadCapture({
2337
+ source,
2338
+ channel,
2339
+ messages: thread.messages?.length ? thread.messages : [message],
2340
+ permalink,
2341
+ syncRunId: runId,
2342
+ memberEmails: privateMemberEmails,
1984
2343
  });
1985
- captures.push(serializeCapture(capture));
2344
+ if (captureResult.capture)
2345
+ captures.push(serializeCapture(captureResult.capture));
2346
+ if (captureResult.blocked) {
2347
+ stats.sensitivityBlocked = Number(stats.sensitivityBlocked) + 1;
2348
+ }
1986
2349
  }
1987
2350
 
1988
2351
  const nextPage = data.response_metadata?.next_cursor;
@@ -2076,6 +2439,117 @@ async function syncSlack(source: SourceRow): Promise<ConnectorSyncResult> {
2076
2439
  }
2077
2440
  }
2078
2441
 
2442
+ export async function refreshSlackThreadCapture(
2443
+ source: SourceRow,
2444
+ payloadJson: string,
2445
+ ) {
2446
+ const payload = parseJson<Record<string, unknown>>(payloadJson, {});
2447
+ const channelId =
2448
+ typeof payload.channelId === "string" ? payload.channelId.trim() : "";
2449
+ const threadTs =
2450
+ typeof payload.threadTs === "string" ? payload.threadTs.trim() : "";
2451
+ if (!/^[CG][A-Z0-9]+$/i.test(channelId) || !slackTsFromDateish(threadTs)) {
2452
+ throw new Error("Slack thread refresh requires a channelId and threadTs");
2453
+ }
2454
+
2455
+ const config = parseJson<Record<string, unknown>>(source.configJson, {});
2456
+ const includePublicChannels = configuredBoolean(
2457
+ config,
2458
+ ["includePublicChannels"],
2459
+ false,
2460
+ "slack",
2461
+ );
2462
+ const explicitlyConfigured = configuredSlackChannelIds(config).has(channelId);
2463
+ if (
2464
+ !explicitlyConfigured &&
2465
+ !(includePublicChannels && channelId.startsWith("C"))
2466
+ ) {
2467
+ throw new Error(
2468
+ `Slack thread refresh rejected for channel ${channelId}: it is not in this source's public or explicit channel scope`,
2469
+ );
2470
+ }
2471
+ const runId = await createRun(source);
2472
+ try {
2473
+ const token = await requireConnectorCredential(
2474
+ "SLACK_BOT_TOKEN",
2475
+ "Slack",
2476
+ "slack",
2477
+ config,
2478
+ );
2479
+ const channel = await resolveSlackChannel(token, channelId);
2480
+ if (!channel || !isUsableSlackChannel(channel)) {
2481
+ throw new Error(`Slack channel ${channelId} is unavailable for refresh`);
2482
+ }
2483
+ if (
2484
+ !channel.is_private &&
2485
+ matchesPublicChannelExclusion(
2486
+ channel,
2487
+ (await readBrainSettings()).publicChannelExclusionPatterns ?? [],
2488
+ )
2489
+ ) {
2490
+ throw new Error(
2491
+ `Slack thread refresh rejected for channel ${channelId}: it matches a Brain public-channel exclusion`,
2492
+ );
2493
+ }
2494
+ const memberEmails = channel.is_private
2495
+ ? await slackPrivateChannelMemberEmails(token, channel.id, new Map())
2496
+ : null;
2497
+ if (channel.is_private && !memberEmails?.length) {
2498
+ throw new Error(
2499
+ `Slack private channel ${channel.id} has no resolvable member emails; refusing to refresh without an ACL`,
2500
+ );
2501
+ }
2502
+ const [thread, permalink] = await Promise.all([
2503
+ slackApi<SlackRepliesResponse>(token, "conversations.replies", {
2504
+ channel: channel.id,
2505
+ ts: threadTs,
2506
+ limit: 1_000,
2507
+ }),
2508
+ slackPermalink(token, channel.id, threadTs),
2509
+ ]);
2510
+ const captureResult = await createSlackThreadCapture({
2511
+ source,
2512
+ channel,
2513
+ messages: thread.messages ?? [],
2514
+ permalink,
2515
+ syncRunId: runId,
2516
+ memberEmails,
2517
+ });
2518
+ const stats = {
2519
+ channelId: channel.id,
2520
+ threadTs,
2521
+ threadsFetched: 1,
2522
+ capturesCreated: captureResult.capture ? 1 : 0,
2523
+ sensitivityBlocked: captureResult.blocked ? 1 : 0,
2524
+ };
2525
+ await finishRun(runId, "success", stats);
2526
+ await getDb()
2527
+ .update(schema.brainSources)
2528
+ .set({
2529
+ lastSyncedAt: nowIso(),
2530
+ lastError: null,
2531
+ status: "active",
2532
+ updatedAt: nowIso(),
2533
+ })
2534
+ .where(
2535
+ and(
2536
+ accessFilter(schema.brainSources, schema.brainSourceShares),
2537
+ eq(schema.brainSources.id, source.id),
2538
+ ),
2539
+ );
2540
+ return {
2541
+ runId,
2542
+ capture: captureResult.capture
2543
+ ? serializeCapture(captureResult.capture)
2544
+ : null,
2545
+ };
2546
+ } catch (error) {
2547
+ const message = error instanceof Error ? error.message : String(error);
2548
+ await finishRun(runId, "error", { capturesCreated: 0 }, message);
2549
+ throw error;
2550
+ }
2551
+ }
2552
+
2079
2553
  async function syncGranola(source: SourceRow): Promise<ConnectorSyncResult> {
2080
2554
  const config = parseJson<Record<string, unknown>>(source.configJson, {});
2081
2555
  if (isFixtureConfig(config) || transcriptItems(config).length > 0) {
@@ -2146,7 +2620,7 @@ async function syncGranola(source: SourceRow): Promise<ConnectorSyncResult> {
2146
2620
  ...listed,
2147
2621
  ...note,
2148
2622
  });
2149
- const capture = await createCapture({
2623
+ const captureResult = await createConnectorCapture({
2150
2624
  sourceId: source.id,
2151
2625
  externalId: normalized.externalId,
2152
2626
  title: normalized.title,
@@ -2159,7 +2633,11 @@ async function syncGranola(source: SourceRow): Promise<ConnectorSyncResult> {
2159
2633
  syncRunId: runId,
2160
2634
  },
2161
2635
  });
2162
- captures.push(serializeCapture(capture));
2636
+ if (captureResult.capture)
2637
+ captures.push(serializeCapture(captureResult.capture));
2638
+ if (captureResult.blocked) {
2639
+ stats.sensitivityBlocked = Number(stats.sensitivityBlocked ?? 0) + 1;
2640
+ }
2163
2641
 
2164
2642
  const candidateUpdatedAt = note.updated_at ?? listed.updated_at;
2165
2643
  if (
@@ -2449,7 +2927,7 @@ async function syncGitHub(source: SourceRow): Promise<ConnectorSyncResult> {
2449
2927
  ref.linkedFrom,
2450
2928
  );
2451
2929
  importedExternalIds.add(normalized.externalId);
2452
- const capture = await createCapture({
2930
+ const captureResult = await createConnectorCapture({
2453
2931
  sourceId: source.id,
2454
2932
  externalId: normalized.externalId,
2455
2933
  title: normalized.title,
@@ -2461,7 +2939,11 @@ async function syncGitHub(source: SourceRow): Promise<ConnectorSyncResult> {
2461
2939
  syncRunId: runId,
2462
2940
  },
2463
2941
  });
2464
- captures.push(serializeCapture(capture));
2942
+ if (captureResult.capture)
2943
+ captures.push(serializeCapture(captureResult.capture));
2944
+ if (captureResult.blocked) {
2945
+ stats.sensitivityBlocked = Number(stats.sensitivityBlocked ?? 0) + 1;
2946
+ }
2465
2947
  stats.itemsSeen = Number(stats.itemsSeen) + 1;
2466
2948
  stats.linkedRefsImported = Number(stats.linkedRefsImported) + 1;
2467
2949
  }
@@ -2511,7 +2993,7 @@ async function syncGitHub(source: SourceRow): Promise<ConnectorSyncResult> {
2511
2993
 
2512
2994
  const normalized = normalizeGitHubIssue(repo, item, context);
2513
2995
  importedExternalIds.add(normalized.externalId);
2514
- const capture = await createCapture({
2996
+ const captureResult = await createConnectorCapture({
2515
2997
  sourceId: source.id,
2516
2998
  externalId: normalized.externalId,
2517
2999
  title: normalized.title,
@@ -2523,7 +3005,11 @@ async function syncGitHub(source: SourceRow): Promise<ConnectorSyncResult> {
2523
3005
  syncRunId: runId,
2524
3006
  },
2525
3007
  });
2526
- captures.push(serializeCapture(capture));
3008
+ if (captureResult.capture)
3009
+ captures.push(serializeCapture(captureResult.capture));
3010
+ if (captureResult.blocked) {
3011
+ stats.sensitivityBlocked = Number(stats.sensitivityBlocked ?? 0) + 1;
3012
+ }
2527
3013
 
2528
3014
  const candidateUpdatedAt = item.updated_at;
2529
3015
  if (