@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
@@ -11,7 +11,9 @@ import { and, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm";
11
11
 
12
12
  import type { BrainEvidence } from "../../shared/types.js";
13
13
  import { getDb, schema } from "../db/index.js";
14
+ import { listAccessibleAudienceIds } from "./audiences.js";
14
15
  import { parseJson, safeCitationUrl } from "./brain.js";
16
+ import { hybridSearchArtifacts } from "./hybrid-search.js";
15
17
 
16
18
  export type UniversalSearchType = "knowledge" | "capture" | "source";
17
19
 
@@ -35,10 +37,14 @@ export interface UniversalSearchResult {
35
37
  captureTitle?: string | null;
36
38
  quote?: string | null;
37
39
  sourceUrl?: string | null;
40
+ preview?: string | null;
41
+ verbatim?: boolean;
38
42
  } | null;
39
43
  confidence: number | null;
40
44
  updatedAt: string;
41
45
  score: number;
46
+ reasons?: string[];
47
+ lane?: "lexical" | "semantic" | "hybrid" | "like";
42
48
  }
43
49
 
44
50
  export type FederatedDelegationTarget = "analytics" | "mail" | "dispatch";
@@ -364,12 +370,27 @@ async function searchKnowledgeResults(
364
370
  terms: string[],
365
371
  limit: number,
366
372
  ): Promise<UniversalSearchResult[]> {
373
+ const audienceIds = await listAccessibleAudienceIds();
374
+ const accessibleSourceExists = sql`exists (
375
+ select 1 from ${schema.brainSources}
376
+ where ${schema.brainSources.id} = ${schema.brainKnowledge.sourceId}
377
+ and ${accessFilter(schema.brainSources, schema.brainSourceShares)}
378
+ )`;
367
379
  const rows = await getDb()
368
380
  .select()
369
381
  .from(schema.brainKnowledge)
370
382
  .where(
371
383
  and(
372
384
  accessFilter(schema.brainKnowledge, schema.brainKnowledgeShares),
385
+ or(
386
+ sql`${schema.brainKnowledge.captureId} IS NULL`,
387
+ audienceIds.length
388
+ ? and(
389
+ accessibleSourceExists,
390
+ inArray(schema.brainKnowledge.audienceId, audienceIds),
391
+ )
392
+ : undefined,
393
+ ),
373
394
  eq(schema.brainKnowledge.status, "published"),
374
395
  anyColumnMatches(
375
396
  [
@@ -437,6 +458,8 @@ async function searchCaptureResults(
437
458
  terms: string[],
438
459
  limit: number,
439
460
  ): Promise<UniversalSearchResult[]> {
461
+ const audienceIds = await listAccessibleAudienceIds();
462
+ if (!audienceIds.length) return [];
440
463
  const accessibleSourceExists = sql`exists (
441
464
  select 1 from ${schema.brainSources}
442
465
  where ${schema.brainSources.id} = ${schema.brainRawCaptures.sourceId}
@@ -448,6 +471,12 @@ async function searchCaptureResults(
448
471
  .where(
449
472
  and(
450
473
  accessibleSourceExists,
474
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
475
+ sql`exists (
476
+ select 1 from ${schema.brainCaptureAudiences}
477
+ where ${schema.brainCaptureAudiences.captureId} = ${schema.brainRawCaptures.id}
478
+ and ${inArray(schema.brainCaptureAudiences.audienceId, audienceIds)}
479
+ )`,
451
480
  anyColumnMatches(
452
481
  [
453
482
  schema.brainRawCaptures.title,
@@ -481,7 +510,9 @@ async function searchCaptureResults(
481
510
  citation: {
482
511
  captureId: row.id,
483
512
  captureTitle: redactSensitiveText(row.title),
484
- quote: snippet,
513
+ quote: null,
514
+ preview: snippet,
515
+ verbatim: false,
485
516
  sourceUrl,
486
517
  },
487
518
  confidence: null,
@@ -560,23 +591,99 @@ export async function searchEverythingRows(args: {
560
591
  query: string;
561
592
  type?: UniversalSearchType | "all";
562
593
  provider?: string;
594
+ kind?: string;
595
+ projectId?: string;
563
596
  status?: string;
564
597
  limit?: number;
565
598
  }): Promise<UniversalSearchResult[]> {
566
599
  const terms = normalizeSearchTerms(args.query);
567
600
  if (!terms.length) return [];
601
+ const projectSourceIds = args.projectId
602
+ ? (
603
+ await getDb()
604
+ .select({ sourceId: schema.brainProjectSources.sourceId })
605
+ .from(schema.brainProjectSources)
606
+ .innerJoin(
607
+ schema.brainProjects,
608
+ eq(schema.brainProjects.id, schema.brainProjectSources.projectId),
609
+ )
610
+ .where(
611
+ and(
612
+ eq(schema.brainProjectSources.projectId, args.projectId),
613
+ accessFilter(schema.brainProjects, schema.brainProjectShares),
614
+ ),
615
+ )
616
+ ).map((row) => row.sourceId)
617
+ : undefined;
618
+ if (projectSourceIds && !projectSourceIds.length) return [];
619
+ const projectSourceSet = projectSourceIds
620
+ ? new Set(projectSourceIds)
621
+ : undefined;
568
622
  const limit = args.limit ?? 25;
569
623
  const perTypeLimit = Math.max(limit, 10);
570
624
  const searches: Array<Promise<UniversalSearchResult[]>> = [];
571
625
  if (!args.type || args.type === "all" || args.type === "knowledge") {
572
626
  searches.push(searchKnowledgeResults(terms, perTypeLimit));
573
627
  }
574
- if (!args.type || args.type === "all" || args.type === "capture") {
628
+ if (
629
+ (!args.type || args.type === "all" || args.type === "capture") &&
630
+ !args.kind
631
+ ) {
575
632
  searches.push(searchCaptureResults(terms, perTypeLimit));
576
633
  }
577
634
  if (!args.type || args.type === "all" || args.type === "source") {
578
635
  searches.push(searchSourceResults(terms, perTypeLimit));
579
636
  }
637
+ if (!args.type || args.type === "all" || args.type === "capture") {
638
+ searches.push(
639
+ hybridSearchArtifacts({
640
+ query: args.query,
641
+ provider: args.provider,
642
+ kind: args.kind,
643
+ projectId: args.projectId,
644
+ limit: perTypeLimit,
645
+ })
646
+ .then(async (artifacts) => {
647
+ const sources = await accessibleSourceMap(
648
+ artifacts.map((artifact) => artifact.sourceId),
649
+ );
650
+ return artifacts.flatMap((artifact) => {
651
+ const source = sources.get(artifact.sourceId);
652
+ if (!source) return [];
653
+ const snippet = redactSensitiveText(
654
+ buildSnippet(artifact.text, terms),
655
+ );
656
+ return [
657
+ {
658
+ type: "capture" as const,
659
+ id: artifact.captureId,
660
+ title: redactSensitiveText(artifact.title),
661
+ snippet,
662
+ summary: snippet,
663
+ status: "indexed",
664
+ provider: source.provider,
665
+ source: serializeSourceInfo(source),
666
+ sourceUrl: null,
667
+ citation: {
668
+ captureId: artifact.captureId,
669
+ captureTitle: redactSensitiveText(artifact.title),
670
+ quote: null,
671
+ preview: snippet,
672
+ verbatim: false,
673
+ sourceUrl: null,
674
+ },
675
+ confidence: null,
676
+ updatedAt: artifact.capturedAt,
677
+ score: artifact.score * 100,
678
+ reasons: artifact.reasons,
679
+ lane: artifact.lane,
680
+ },
681
+ ];
682
+ });
683
+ })
684
+ .catch(() => []),
685
+ );
686
+ }
580
687
  const provider = args.provider?.toLowerCase();
581
688
  const status = args.status?.toLowerCase();
582
689
  const results = (await Promise.all(searches))
@@ -590,7 +697,10 @@ export async function searchEverythingRows(args: {
590
697
  const resultStatus = result.status.toLowerCase();
591
698
  const providerMatches = !provider || resultProvider === provider;
592
699
  const statusMatches = !status || resultStatus === status;
593
- return providerMatches && statusMatches;
700
+ const projectMatches =
701
+ !projectSourceSet ||
702
+ Boolean(result.source?.id && projectSourceSet.has(result.source.id));
703
+ return providerMatches && statusMatches && projectMatches;
594
704
  })
595
705
  .sort(
596
706
  (a, b) =>
@@ -598,7 +708,13 @@ export async function searchEverythingRows(args: {
598
708
  Date.parse(b.updatedAt) - Date.parse(a.updatedAt) ||
599
709
  a.title.localeCompare(b.title),
600
710
  );
601
- return results.slice(0, limit);
711
+ const deduped = new Map<string, UniversalSearchResult>();
712
+ for (const result of results) {
713
+ const key = `${result.type}:${result.id}`;
714
+ const current = deduped.get(key);
715
+ if (!current || result.score > current.score) deduped.set(key, result);
716
+ }
717
+ return Array.from(deduped.values()).slice(0, limit);
602
718
  }
603
719
 
604
720
  async function readBrainSourceProviderCoverage(): Promise<
@@ -0,0 +1,174 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ BRAIN_SENSITIVITY_POLICY_VERSION,
5
+ type BrainSafeSegment,
6
+ type BrainSensitivityCategory,
7
+ type BrainSensitivityDecision,
8
+ } from "./search-index-contracts.js";
9
+
10
+ export const MAX_CLASSIFIER_OUTPUT_CHARS = 80_000;
11
+ export const classifierDecisionSchema = z
12
+ .object({
13
+ disposition: z.enum(["allowed", "suppressed", "quarantined"]),
14
+ categories: z
15
+ .array(
16
+ z.enum([
17
+ "performance",
18
+ "discipline",
19
+ "termination",
20
+ "layoff-reorg",
21
+ "compensation",
22
+ "recruiting",
23
+ "health-accommodation",
24
+ "investigation",
25
+ "privileged-legal",
26
+ "secret-credential",
27
+ "personal",
28
+ ]),
29
+ )
30
+ .max(12),
31
+ safeContent: z.string().max(MAX_CLASSIFIER_OUTPUT_CHARS),
32
+ safeSegments: z
33
+ .array(
34
+ z.object({
35
+ text: z.string().min(1).max(8_000),
36
+ sourceUrl: z.string().url().optional(),
37
+ }),
38
+ )
39
+ .max(50),
40
+ })
41
+ .strict();
42
+
43
+ const HARD_CATEGORY_PATTERNS: ReadonlyArray<
44
+ readonly [BrainSensitivityCategory, RegExp]
45
+ > = [
46
+ [
47
+ "performance",
48
+ /\b(performance review|under[- ]?perform(?:er|ing)?|low performer|performance concern)\b/i,
49
+ ],
50
+ [
51
+ "discipline",
52
+ /\b(performance improvement plan|start (?:a )?pip|(?:place(?:d)?|put) (?:them|her|him|the employee) on (?:a )?pip|written warning|disciplinary action|final warning)\b/i,
53
+ ],
54
+ [
55
+ "termination",
56
+ /\b(employment termination|terminate the employee|terminat(?:e|ed|ion) (?:their|his|her|the employee'?s?) employment|fired|fire\s+(?:them|her|him)|severance (?:agreement|package|pay))\b/i,
57
+ ],
58
+ [
59
+ "layoff-reorg",
60
+ /\b(layoffs?|rif|reorg(?:anization)?|rightsizing|roles? impacted|role eliminat(?:ion|ed)|workforce reduction)\b/i,
61
+ ],
62
+ [
63
+ "compensation",
64
+ /\b(compensation (?:review|discussion|adjustment|change)|salary|(?:annual|signing|performance|retention) bonus|bonus (?:amount|payout|target)|equity grant|stock options?|pay band|payroll|pay raise|salary increase|comp adjustment)\b/i,
65
+ ],
66
+ [
67
+ "recruiting",
68
+ /\b(candidate|applicant|interview(?:er|ing)?|recruit(?:er|ing)?|resume|résumé|reference check|job offer|shortlist|hiring panel)\b/i,
69
+ ],
70
+ [
71
+ "health-accommodation",
72
+ /\b(health condition|medical (?:condition|diagnosis|leave|record|accommodation)|doctor(?:'s)? note|disability accommodation|workplace accommodation|fmla|mental health (?:condition|leave|accommodation)|pregnan(?:t|cy))\b/i,
73
+ ],
74
+ [
75
+ "investigation",
76
+ /\b(investigation|investigat(?:e|ing|ed)|harassment complaint|misconduct|whistleblower|ethics complaint)\b/i,
77
+ ],
78
+ [
79
+ "privileged-legal",
80
+ /\b(attorney[- ]client|legal privilege|privileged and confidential|outside counsel|litigation hold)\b/i,
81
+ ],
82
+ [
83
+ "secret-credential",
84
+ /\b(?:password|passcode|secret|api[- ]?key|access[- ]?token|private[- ]?key)\s*[:=]|\b(?:sk|pk|rk|ghp|gho|ghu|github_pat)[_-][A-Za-z0-9_=-]{12,}\b/i,
85
+ ],
86
+ ];
87
+
88
+ const PERSONAL_PATTERN =
89
+ /\b(home address|social security|ssn|birthday|spouse|husband|wife|children?)\b/i;
90
+ const PROMPT_INJECTION_PATTERN =
91
+ /\b(ignore (?:previous|all|privacy)|override (?:the |all )?(?:policy|rules)|reveal (?:the )?(?:secret|private)|persist every)\b/i;
92
+ export interface DeterministicSensitivityScreen {
93
+ categories: BrainSensitivityCategory[];
94
+ sensitiveLines: string[];
95
+ safeLines: string[];
96
+ }
97
+
98
+ export function screenSensitivityDeterministically(
99
+ content: string,
100
+ ): DeterministicSensitivityScreen {
101
+ const categories = new Set<BrainSensitivityCategory>();
102
+ const sensitiveLines: string[] = [];
103
+ const safeLines: string[] = [];
104
+
105
+ for (const rawLine of content.split(/\r?\n/g)) {
106
+ const line = rawLine.trim();
107
+ if (!line) continue;
108
+ let sensitive = false;
109
+ for (const [category, pattern] of HARD_CATEGORY_PATTERNS) {
110
+ if (!pattern.test(line)) continue;
111
+ categories.add(category);
112
+ sensitive = true;
113
+ }
114
+ if (PERSONAL_PATTERN.test(line)) {
115
+ categories.add("personal");
116
+ sensitive = true;
117
+ }
118
+ if (PROMPT_INJECTION_PATTERN.test(line)) sensitive = true;
119
+ (sensitive ? sensitiveLines : safeLines).push(line);
120
+ }
121
+
122
+ return { categories: [...categories], sensitiveLines, safeLines };
123
+ }
124
+
125
+ export function fallbackSensitivityDecision(
126
+ content: string,
127
+ capturedAt: string,
128
+ ): BrainSensitivityDecision {
129
+ const screen = screenSensitivityDeterministically(content);
130
+ const safeContent = screen.safeLines
131
+ .join("\n")
132
+ .slice(0, MAX_CLASSIFIER_OUTPUT_CHARS);
133
+ const safeSegments: BrainSafeSegment[] = safeContent
134
+ ? [{ id: "safe-1", capturedAt, text: safeContent, reactionCount: 0 }]
135
+ : [];
136
+ const disposition =
137
+ screen.categories.length || screen.sensitiveLines.length || !safeContent
138
+ ? "quarantined"
139
+ : "allowed";
140
+ return {
141
+ disposition,
142
+ categories: screen.categories,
143
+ confidenceBand:
144
+ disposition === "allowed" || screen.categories.length
145
+ ? "deterministic"
146
+ : "uncertain",
147
+ policyVersion: BRAIN_SENSITIVITY_POLICY_VERSION,
148
+ safeSegments,
149
+ safeContent,
150
+ classifier: "deterministic",
151
+ };
152
+ }
153
+
154
+ export function deterministicQuarantineDecision(
155
+ content: string,
156
+ capturedAt: string,
157
+ ): BrainSensitivityDecision | null {
158
+ const screen = screenSensitivityDeterministically(content);
159
+ if (!screen.categories.length) return null;
160
+ const safeContent = screen.safeLines
161
+ .join("\n")
162
+ .slice(0, MAX_CLASSIFIER_OUTPUT_CHARS);
163
+ return {
164
+ disposition: "suppressed",
165
+ categories: screen.categories,
166
+ confidenceBand: "deterministic",
167
+ policyVersion: BRAIN_SENSITIVITY_POLICY_VERSION,
168
+ safeSegments: safeContent
169
+ ? [{ id: "safe-1", capturedAt, text: safeContent, reactionCount: 0 }]
170
+ : [],
171
+ safeContent,
172
+ classifier: "deterministic",
173
+ };
174
+ }
@@ -0,0 +1,281 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { and, eq } from "drizzle-orm";
4
+
5
+ import { getDb, schema } from "../db/index.js";
6
+ import { parseJson, retireUpstreamDeletedCapture } from "./brain.js";
7
+ import { enqueueBrainOperation } from "./ingest-queue.js";
8
+ import { resolveSourceCredential } from "./source-credentials.js";
9
+
10
+ export interface SlackEventsEnvelope {
11
+ type?: string;
12
+ challenge?: string;
13
+ team_id?: string;
14
+ api_app_id?: string;
15
+ event?: {
16
+ type?: string;
17
+ subtype?: string;
18
+ channel?: string;
19
+ ts?: string;
20
+ thread_ts?: string;
21
+ deleted_ts?: string;
22
+ previous_message?: { ts?: string; thread_ts?: string };
23
+ item?: { type?: string; channel?: string; ts?: string };
24
+ };
25
+ }
26
+
27
+ type SlackSource = typeof schema.brainSources.$inferSelect;
28
+
29
+ function objectValue(value: unknown): Record<string, unknown> {
30
+ return value && typeof value === "object"
31
+ ? (value as Record<string, unknown>)
32
+ : {};
33
+ }
34
+
35
+ function stringValue(value: unknown): string | undefined {
36
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
37
+ }
38
+
39
+ function configuredString(
40
+ config: Record<string, unknown>,
41
+ keys: string[],
42
+ ): string | undefined {
43
+ for (const candidate of [config, objectValue(config.slack)]) {
44
+ for (const key of keys) {
45
+ const value = stringValue(candidate[key]);
46
+ if (value) return value;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function configuredChannelIds(config: Record<string, unknown>) {
53
+ const result = new Set<string>();
54
+ for (const candidate of [config, objectValue(config.slack)]) {
55
+ for (const key of [
56
+ "channelIds",
57
+ "channels",
58
+ "allowedChannels",
59
+ "allowlistedChannels",
60
+ "allowList",
61
+ ]) {
62
+ const raw = candidate[key];
63
+ const values =
64
+ typeof raw === "string"
65
+ ? raw.split(",")
66
+ : Array.isArray(raw)
67
+ ? raw.filter((value): value is string => typeof value === "string")
68
+ : [];
69
+ for (const value of values) {
70
+ const id = value.trim().replace(/^#/, "");
71
+ if (/^[CG][A-Z0-9]+$/i.test(id)) result.add(id);
72
+ }
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
78
+ function includesPublicChannels(config: Record<string, unknown>) {
79
+ for (const candidate of [config, objectValue(config.slack)]) {
80
+ const value = candidate.includePublicChannels;
81
+ if (value === true) return true;
82
+ if (
83
+ typeof value === "string" &&
84
+ ["true", "1", "yes", "on"].includes(value.toLowerCase())
85
+ ) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export function slackEventDirective(payload: SlackEventsEnvelope) {
93
+ const event = payload.event;
94
+ if (!event) return null;
95
+ if (event.type === "message") {
96
+ const retiring = event.subtype === "message_deleted";
97
+ const threadTs = retiring
98
+ ? (event.previous_message?.thread_ts ??
99
+ event.previous_message?.ts ??
100
+ event.deleted_ts)
101
+ : (event.thread_ts ?? event.ts);
102
+ return event.channel && threadTs
103
+ ? {
104
+ action: retiring ? ("retire" as const) : ("refresh" as const),
105
+ channelId: event.channel,
106
+ threadTs,
107
+ }
108
+ : null;
109
+ }
110
+ if (
111
+ (event.type === "reaction_added" || event.type === "reaction_removed") &&
112
+ event.item?.type === "message" &&
113
+ event.item.channel &&
114
+ event.item.ts
115
+ ) {
116
+ return {
117
+ action: "refresh" as const,
118
+ channelId: event.item.channel,
119
+ threadTs: event.item.ts,
120
+ };
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function sourceMatchesEvent(
126
+ source: SlackSource,
127
+ payload: SlackEventsEnvelope,
128
+ location: { channelId: string; threadTs: string } | null,
129
+ ) {
130
+ const config = parseJson<Record<string, unknown>>(source.configJson, {});
131
+ const teamId = configuredString(config, ["slackTeamId", "teamId"]);
132
+ const appId = configuredString(config, ["slackAppId", "appId"]);
133
+ if (!teamId || teamId !== payload.team_id) return false;
134
+ if (appId && appId !== payload.api_app_id) return false;
135
+ if (!location) return payload.type === "url_verification";
136
+ return (
137
+ configuredChannelIds(config).has(location.channelId) ||
138
+ (includesPublicChannels(config) && /^C[A-Z0-9]+$/i.test(location.channelId))
139
+ );
140
+ }
141
+
142
+ export function parseSlackEventsEnvelope(
143
+ rawBody: string,
144
+ ): SlackEventsEnvelope | null {
145
+ try {
146
+ const parsed = JSON.parse(rawBody) as unknown;
147
+ return parsed && typeof parsed === "object"
148
+ ? (parsed as SlackEventsEnvelope)
149
+ : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ export function verifySlackEventSignature(input: {
156
+ rawBody: string;
157
+ timestamp?: string;
158
+ signature?: string;
159
+ signingSecret?: string;
160
+ nowMs?: number;
161
+ }) {
162
+ if (!input.timestamp || !input.signature || !input.signingSecret)
163
+ return false;
164
+ const timestamp = Number.parseInt(input.timestamp, 10);
165
+ if (!Number.isFinite(timestamp)) return false;
166
+ if (Math.abs((input.nowMs ?? Date.now()) / 1_000 - timestamp) > 300)
167
+ return false;
168
+ const expected = `v0=${crypto
169
+ .createHmac("sha256", input.signingSecret)
170
+ .update(`v0:${input.timestamp}:${input.rawBody}`)
171
+ .digest("hex")}`;
172
+ try {
173
+ return crypto.timingSafeEqual(
174
+ Buffer.from(input.signature),
175
+ Buffer.from(expected),
176
+ );
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ async function matchingSlackSource(
183
+ payload: SlackEventsEnvelope,
184
+ location: { channelId: string; threadTs: string } | null,
185
+ ) {
186
+ const sources = await getDb()
187
+ .select()
188
+ .from(schema.brainSources)
189
+ // guard:allow-unscoped — Slack's signed Events API has no user session;
190
+ // each candidate must match its configured workspace and channel before use.
191
+ .where(
192
+ and(
193
+ eq(schema.brainSources.provider, "slack"),
194
+ eq(schema.brainSources.status, "active"),
195
+ ),
196
+ );
197
+ return (
198
+ sources.find((source) => sourceMatchesEvent(source, payload, location)) ??
199
+ null
200
+ );
201
+ }
202
+
203
+ async function sourceSigningSecret(source: SlackSource) {
204
+ const config = parseJson<Record<string, unknown>>(source.configJson, {});
205
+ return resolveSourceCredential({
206
+ provider: "slack",
207
+ key: "SLACK_SIGNING_SECRET",
208
+ ctx: { userEmail: source.ownerEmail, orgId: source.orgId },
209
+ workspaceConnectionId: configuredString(config, ["workspaceConnectionId"]),
210
+ });
211
+ }
212
+
213
+ export async function retireSlackThreadCapture(input: {
214
+ sourceId: string;
215
+ channelId: string;
216
+ threadTs: string;
217
+ }) {
218
+ return retireUpstreamDeletedCapture({
219
+ sourceId: input.sourceId,
220
+ externalId: `slack:${input.channelId}:${input.threadTs}`,
221
+ provider: "slack",
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Verify a Brain Slack event and either enqueue an ID-only refresh or retire a
227
+ * deleted upstream thread. The raw event body is never stored or logged.
228
+ */
229
+ export async function enqueueSlackThreadRefreshFromEvent(input: {
230
+ rawBody: string;
231
+ timestamp?: string;
232
+ signature?: string;
233
+ }): Promise<
234
+ | { status: "invalid" | "ignored" | "queued"; challenge?: string }
235
+ | { status: "missing-signing-secret" }
236
+ > {
237
+ const payload = parseSlackEventsEnvelope(input.rawBody);
238
+ if (!payload?.team_id) return { status: "invalid" };
239
+ const directive = slackEventDirective(payload);
240
+ const source = await matchingSlackSource(payload, directive);
241
+ if (!source) return { status: "ignored" };
242
+ const signingSecret = await sourceSigningSecret(source);
243
+ if (!signingSecret) return { status: "missing-signing-secret" };
244
+ if (
245
+ !verifySlackEventSignature({
246
+ rawBody: input.rawBody,
247
+ timestamp: input.timestamp,
248
+ signature: input.signature,
249
+ signingSecret,
250
+ })
251
+ ) {
252
+ return { status: "invalid" };
253
+ }
254
+ if (
255
+ payload.type === "url_verification" &&
256
+ typeof payload.challenge === "string"
257
+ ) {
258
+ return { status: "queued", challenge: payload.challenge };
259
+ }
260
+ if (!directive) return { status: "ignored" };
261
+ if (directive.action === "retire") {
262
+ await retireSlackThreadCapture({
263
+ sourceId: source.id,
264
+ channelId: directive.channelId,
265
+ threadTs: directive.threadTs,
266
+ });
267
+ return { status: "queued" };
268
+ }
269
+ await enqueueBrainOperation({
270
+ operation: "slack-thread-refresh",
271
+ dedupeKey: `slack-thread-refresh:${source.id}:${directive.channelId}:${directive.threadTs}`,
272
+ sourceId: source.id,
273
+ priority: 20,
274
+ payload: {
275
+ teamId: payload.team_id,
276
+ channelId: directive.channelId,
277
+ threadTs: directive.threadTs,
278
+ },
279
+ });
280
+ return { status: "queued" };
281
+ }
@@ -0,0 +1,29 @@
1
+ import { registerOnboardingStep } from "@agent-native/core/onboarding";
2
+
3
+ import { readBrainSettings } from "./lib/brain.js";
4
+ import { brainPrivacyReadiness } from "./lib/privacy-readiness.js";
5
+
6
+ registerOnboardingStep({
7
+ id: "brain-privacy-classifier",
8
+ order: 16,
9
+ required: false,
10
+ title: "Configure Brain privacy classification",
11
+ description:
12
+ "Choose the approved model and engine that review captures before storage. Until configured, deterministic-clean content can be stored but uncertain content is quarantined and unavailable to search or agents.",
13
+ methods: [
14
+ {
15
+ id: "settings",
16
+ kind: "link",
17
+ primary: true,
18
+ label: "Open Brain privacy settings",
19
+ payload: { url: "/settings", external: false },
20
+ },
21
+ ],
22
+ isComplete: async () => {
23
+ try {
24
+ return brainPrivacyReadiness(await readBrainSettings()).configured;
25
+ } catch {
26
+ return false;
27
+ }
28
+ },
29
+ });