@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
@@ -15,6 +15,42 @@ in the public `template-brain.md` overview and previously sat inline in
15
15
  intentionally not in the public docs. Read it when you are operating, rolling
16
16
  out, or debugging Brain internals rather than answering an ordinary question.
17
17
 
18
+ ## Privacy, Evidence Audiences, And Search
19
+
20
+ Brain applies the deterministic hard-category screen before any privacy model.
21
+ It blocks performance/discipline, layoffs or termination, compensation,
22
+ recruiting, health/accommodation, investigation, privileged legal, credentials,
23
+ and personal data. An approved privacy classifier may make handling stricter;
24
+ it cannot override a deterministic hit. When no classifier is configured,
25
+ Brain is in degraded deterministic-only mode: clearly clean company material
26
+ may be allowed, while uncertainty is quarantined and `get-brain-health` reports
27
+ the missing configuration.
28
+
29
+ Quarantined re-fetchable material from Slack, GitHub, or Granola keeps only a
30
+ metadata receipt because the content can be re-fetched during an authorized
31
+ review. Push-only generic and Clips payloads instead use an encrypted,
32
+ short-TTL private side store that no search, agent, source editor, log,
33
+ citation, or distillation surface can read; expiry converts it to a suppression
34
+ receipt. Review records policy metadata, never a shortcut to raw sensitive
35
+ content. HR-blocked evidence is never declassifiable: write a new,
36
+ non-identifying reviewed statement if it has durable organizational value.
37
+
38
+ Every allowed capture and derived row has an audience id and ACL hash. Public
39
+ and organization material use the trivial organization audience. Private Slack
40
+ material uses its channel membership audience, and meeting material uses its
41
+ attendee audience. Retrieval prefilters by audience before FTS/vector ranking;
42
+ answers spanning multiple sources may only cite the intersection of their
43
+ evidence audiences. Private Slack audiences fail closed when membership has not
44
+ been refreshed for 15 minutes, so removal upstream cannot leave indefinite
45
+ Brain access.
46
+
47
+ Semantic indexing starts when a capture is `allowed`, rather than waiting for
48
+ distillation. A derived row is current only when its staleness key matches
49
+ `contentHash + BRAIN_SEARCH_INDEX_VERSION + sensitivityPolicyVersion + aclHash`.
50
+ Changing content, policy, or membership invalidates indexed artifacts,
51
+ knowledge/proposal evidence, and mirrored canonical resources before they can
52
+ be served.
53
+
18
54
  ## Search model layers
19
55
 
20
56
  Brain search has three layers:
@@ -60,8 +96,11 @@ pnpm --filter brain action create-source \
60
96
  ```
61
97
 
62
98
  The connector verifies each configured conversation before reading history and
63
- rejects DMs and MPIMs. Cursor state is stored on the source so each sync can
64
- pick up where the last one stopped, including after Slack rate limiting.
99
+ rejects DMs and MPIMs. Public discovery is opt-in and exclusion patterns are
100
+ applied before capture. Private channels are never silently joined: an operator
101
+ must manually invite the Slack app before validation and membership sync. Cursor
102
+ state is stored on the source so each sync can pick up where the last one
103
+ stopped, including after Slack rate limiting.
65
104
 
66
105
  Use `test-slack-connection` before a production backfill. It validates the Slack
67
106
  bot token with `auth.test` and, when channel refs are provided, checks channel
@@ -121,7 +160,8 @@ recommended rollout steps without returning raw capture bodies.
121
160
 
122
161
  Recommended production rollout:
123
162
 
124
- 1. Start with one or two high-signal channels and channel IDs.
163
+ 1. Start with one or two high-signal public channels and explicitly invited
164
+ private channels; never use discovery as implicit private-channel consent.
125
165
  2. Keep `autoSync: false` until review quality is proven.
126
166
  3. Run `test-slack-connection`, then `run-slack-pilot` without history.
127
167
  4. Run one explicit `run-slack-pilot --readHistory true` sample when the report
@@ -246,8 +286,11 @@ with `mode: "retrieval"`.
246
286
 
247
287
  The repository-level `pnpm test` command includes `pnpm test:brain-evals`, which
248
288
  runs Brain's product-demo and retrieval action evals against a disposable local
249
- SQLite database. The CI/prep eval path is fully seeded and offline; it does not
250
- require production Slack, Granola, Clips, or any external workspace data.
289
+ SQLite database. `pnpm test:brain-privacy-evals` is a separate CI lane with
290
+ must-block sensitive fixtures and must-allow benign false-positive fixtures
291
+ (such as API-limit raises, bonus features, and medical-device customers). Both
292
+ paths are fully seeded and offline; neither requires production Slack, Granola,
293
+ Clips, or external workspace data.
251
294
 
252
295
  ## Generic ingest payload validation
253
296
 
@@ -17,18 +17,48 @@ Use Brain actions rather than raw SQL.
17
17
  for a capture that's already queued/processing refreshes the handoff
18
18
  instructions instead of creating a duplicate queue row.
19
19
  4. Before writing knowledge, call `get-capture` and copy short exact quotes.
20
- `get-capture` redacts `title`/`content`/`metadata` by default; pass
21
- `includeRawContent: true` (requires `editor` access on the source) to get
22
- the real text needed for an exact-substring quote.
20
+ Quotes and offsets always reference the persisted safe capture, never an
21
+ upstream raw payload. `get-capture` redacts `title`/`content`/`metadata` by
22
+ default; `includeRawContent: true` only reveals allowed, source-accessible
23
+ capture content and never bypasses a sensitivity disposition.
23
24
  5. Call `write-knowledge` with `evidence` entries whose `quote` fields are exact capture substrings — `validateEvidence` throws otherwise.
24
25
  6. If `write-knowledge` returns `mode: "proposal"`, leave it in review unless the user asks to approve. See AGENTS.md for the exact tier/confidence conditions that trigger a proposal.
25
26
 
27
+ ## Privacy, Quarantine, And Safe Captures
28
+
29
+ Every ingest first receives a deterministic sensitivity screen. Performance,
30
+ discipline, termination/layoff, compensation, recruiting, health or
31
+ accommodation, investigation, privileged legal, credential, and personal-data
32
+ signals can only tighten handling; no model or workspace instruction can lower
33
+ that boundary.
34
+
35
+ - `allowed` captures persist safe content and may be indexed immediately. This
36
+ is independent of distillation: semantic coverage must not wait for an agent
37
+ to author a memory.
38
+ - `quarantined` or `suppressed` content is unavailable to search, citation,
39
+ distillation, source editors, agent tools, and logs. Review shows only the
40
+ minimal policy metadata needed to operate the queue.
41
+ - Re-fetchable providers (Slack, GitHub, Granola) retain metadata-only
42
+ quarantine records. Push-only `generic` and `clips` material is retained only
43
+ in the encrypted, short-TTL private quarantine store; expiry becomes a
44
+ suppression receipt.
45
+ - If no approved privacy classifier is configured, deterministic-only mode
46
+ allows clearly clean, company-relevant material and quarantines uncertainty.
47
+ Treat the health/setup warning as a requirement to configure the classifier
48
+ before broad ingestion.
49
+
50
+ Administrators may review a disposition but may not declassify HR-blocked
51
+ evidence. A broader statement must be a newly reviewed, non-identifying memory
52
+ with no private quotes, links, or identities.
53
+
26
54
  ## Capture Sanitization (Transcripts)
27
55
 
28
56
  Transcript-kind captures are sanitized **before storage** by default
29
57
  (`shouldSanitizeCaptureBeforeStorage` — true whenever `kind === "transcript"`,
30
58
  unless `captureSanitizationEnabled: false` in settings or a per-capture
31
59
  `metadata.sanitizeBeforeStorage` / source-config override says otherwise).
60
+ That override may skip relevance-oriented transcript cleanup, but deterministic
61
+ privacy and PII scrubbing always run and raw input is never retained.
32
62
  Sanitization always strips, regardless of settings:
33
63
 
34
64
  - Recruiting/hiring/candidate-evaluation content (`RECRUITING_SIGNAL`).
@@ -46,19 +76,20 @@ company-relevant survives, the stored content becomes the literal string "No
46
76
  company-relevant content retained from this capture." — treat that string as
47
77
  "this capture had nothing worth distilling," not as an error.
48
78
 
49
- ## Search: Two Distinct Actions, No Vector Index
79
+ ## Search: Scoped Hybrid Retrieval
50
80
 
51
- - `search-knowledge` — SQL text match over `title`/`summary`/`body` of
52
- **distilled knowledge only**. Use for "what does Brain officially know about
53
- X."
81
+ - `search-knowledge` — scoped retrieval over **distilled knowledge only**. Use
82
+ for "what does Brain officially know about X."
54
83
  - `search-everything` — broader pass across knowledge, raw captures, and
55
84
  sources in one call, plus `federatedCoverage` (delegation hints for other
56
- apps). Use this as the default first search for an open-ended question;
57
- narrow with `type: "knowledge" | "capture" | "source"` when you already know
58
- which record type you need.
59
- - Neither uses embeddings/vector search matching is deterministic SQL LIKE
60
- scoring. Don't describe results as "semantically similar"; they matched on
61
- literal term overlap.
85
+ apps). It uses full-text and available semantic signals after applying source,
86
+ project, kind, and audience filters. Use it as the default first search for
87
+ an open-ended question; narrow with `type: "knowledge" | "capture" |
88
+ "source"` when you already know which record type you need.
89
+ - Audience filtering happens before ranking. Public and organization sources
90
+ use the cheap organization audience; private channels and meetings use their
91
+ restricted audience. A multi-source answer must use the intersection of the
92
+ cited evidence audiences.
62
93
 
63
94
  Follow `sourcePolicy` for how much of `search-everything`'s output an answer
64
95
  may lean on: `strict` means reviewed knowledge only, `balanced` means raw
@@ -76,5 +107,6 @@ directly.
76
107
  - `ingestion-and-connectors` — source creation, health states, sync scheduling,
77
108
  and credential resolution order.
78
109
  - `brain-runbook` — internal architecture and ops detail (Slack rollout,
79
- distillation worker, scheduled sync cron, demo/eval seeding).
110
+ privacy quarantine, semantic index, distillation worker, scheduled sync cron,
111
+ demo/eval seeding).
80
112
  - `ask-across-everything`, `security`, `sharing`.
@@ -8,16 +8,47 @@ from approved company knowledge, with links back to the source.
8
8
 
9
9
  Brain ingests approved Slack channels, meetings, transcripts, GitHub issues/PRs,
10
10
  and webhook captures, distills them into reviewable knowledge, and answers with
11
- exact evidence quotes and source links instead of guesses.
11
+ exact evidence quotes and source links instead of guesses. It can search both
12
+ organization-wide material and explicitly invited private channels or
13
+ attendee-scoped meetings without widening their audience.
12
14
 
13
15
  ## Features
14
16
 
15
17
  - Company chat that answers from cited, reviewed knowledge — not hallucinations.
16
18
  - Source connectors for Slack, Granola, GitHub, Clips, and generic webhooks.
17
- - Pre-storage privacy filter and review queue before knowledge becomes durable.
18
- - Portable SQL text search and agentic query expansion — no vector database required.
19
+ - Deterministic privacy screening before storage, with an optional approved
20
+ privacy classifier for richer review decisions.
21
+ - Hybrid full-text and semantic search over allowed captures and knowledge,
22
+ filtered by the requesting user's evidence audience before ranking.
23
+ - Private-channel and meeting evidence stays audience-scoped; multi-source
24
+ answers use only the intersection of their evidence audiences.
25
+ - Sensitive sources are suppressed or quarantined before search, citations,
26
+ distillation, and source editors can see their content.
19
27
  - Read-only, citation-backed retrieval exposed to other apps over A2A.
20
28
 
29
+ ## Privacy posture
30
+
31
+ Brain never treats a connected provider as permission to index everything.
32
+ Slack public-channel discovery is configurable, while private channels require a
33
+ manual app invite and membership sync. Meetings use their attendee audience.
34
+
35
+ Hard privacy categories such as performance discussions, layoffs, compensation,
36
+ recruiting, health accommodations, investigations, privileged legal material,
37
+ and credentials are blocked before they become searchable content. Re-fetchable
38
+ providers keep only a metadata receipt in quarantine. Push-only generic and
39
+ Clips payloads use an encrypted, short-lived private quarantine store and turn
40
+ into a suppression receipt at expiry.
41
+
42
+ Without an approved privacy classifier, Brain runs in deterministic-only
43
+ degraded mode: clearly clean, company-relevant material may proceed; ambiguous
44
+ or sensitive-looking material is quarantined and health surfaces the setup
45
+ warning. Configure the classifier before relying on higher-throughput ingest.
46
+
47
+ `sanitizeBeforeStorage: false` only disables relevance-oriented transcript
48
+ cleanup. Brain still runs deterministic privacy and PII scrubbing before
49
+ persisting titles, content, metadata, safe segments, or search artifacts; the
50
+ setting never retains raw input.
51
+
21
52
  ## Develop locally
22
53
 
23
54
  Scaffold your own copy and run it:
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
8
8
  import {
9
+ assertDerivedAudienceAccess,
9
10
  nowIso,
10
11
  parseJson,
11
12
  serializeProposal,
@@ -30,6 +31,7 @@ export default defineAction({
30
31
  run: async ({ proposalId, reviewerNotes, publishCanonical }) => {
31
32
  const access = await assertAccess("brain-proposal", proposalId, "editor");
32
33
  const proposal = access.resource;
34
+ await assertDerivedAudienceAccess(proposal);
33
35
  if (proposal.status !== "pending") {
34
36
  throw new Error(`Proposal ${proposalId} is already ${proposal.status}`);
35
37
  }
@@ -1,4 +1,5 @@
1
1
  import { defineAction } from "@agent-native/core";
2
+ import { getDbExec } from "@agent-native/core/db";
2
3
  import { and, desc, eq } from "drizzle-orm";
3
4
  import { z } from "zod";
4
5
 
@@ -12,6 +13,8 @@ import {
12
13
  stableJson,
13
14
  } from "../server/lib/brain.js";
14
15
 
16
+ const LEASE_MS = 15 * 60 * 1000;
17
+
15
18
  export default defineAction({
16
19
  description:
17
20
  "Claim a queued Brain capture distillation item before handing it to an agent worker.",
@@ -41,45 +44,39 @@ export default defineAction({
41
44
 
42
45
  const now = nowIso();
43
46
  const claimToken = nanoid(16);
47
+ const leaseExpiresAt = new Date(Date.parse(now) + LEASE_MS).toISOString();
44
48
  const payload = parseJson<Record<string, unknown>>(queue.payloadJson, {});
45
- await db
46
- .update(schema.brainIngestQueue)
47
- .set({
48
- status: "processing",
49
- attempts: queue.attempts + 1,
50
- payloadJson: stableJson({
51
- ...payload,
52
- claimToken,
53
- claimedAt: now,
54
- claimedBy: "brain-agent",
55
- }),
56
- error: null,
57
- runAfter: null,
58
- updatedAt: now,
59
- })
60
- .where(
61
- and(
62
- eq(schema.brainIngestQueue.id, queue.id),
63
- eq(schema.brainIngestQueue.status, "queued"),
64
- ),
65
- );
49
+ const result = await getDbExec().execute({
50
+ sql: `UPDATE brain_ingest_queue
51
+ SET status = ?, attempts = ?, payload_json = ?, error = NULL,
52
+ run_after = NULL, lease_token = ?, lease_expires_at = ?, updated_at = ?
53
+ WHERE id = ? AND status = ? AND updated_at = ?`,
54
+ args: [
55
+ "processing",
56
+ queue.attempts + 1,
57
+ stableJson({ ...payload, claimedAt: now, claimedBy: "brain-agent" }),
58
+ claimToken,
59
+ leaseExpiresAt,
60
+ now,
61
+ queue.id,
62
+ "queued",
63
+ queue.updatedAt,
64
+ ],
65
+ });
66
+ if (result.rowsAffected === 0) return { claimed: false, queueItem: null };
66
67
 
67
68
  const [updated] = await db
68
69
  .select()
69
70
  .from(schema.brainIngestQueue)
70
71
  .where(eq(schema.brainIngestQueue.id, queue.id))
71
72
  .limit(1);
72
- const updatedPayload = parseJson<Record<string, unknown>>(
73
- updated?.payloadJson,
74
- {},
75
- );
76
73
  const claimed =
77
- updated?.status === "processing" &&
78
- updatedPayload.claimToken === claimToken;
74
+ updated?.status === "processing" && updated?.leaseToken === claimToken;
79
75
 
80
76
  return {
81
77
  claimed,
82
78
  queueItem: updated ? serializeDistillationQueue(updated) : null,
79
+ claimToken: claimed ? claimToken : null,
83
80
  };
84
81
  },
85
82
  });
@@ -26,7 +26,7 @@ export default defineAction({
26
26
  .string()
27
27
  .optional()
28
28
  .describe("Optional signed-ingest bearer token; stored only as a hash"),
29
- visibility: z.enum(["private", "org", "public"]).default("org"),
29
+ visibility: z.enum(["private", "org"]).default("org"),
30
30
  }),
31
31
  run: async (args) => {
32
32
  const config = { ...args.config };
@@ -2,7 +2,10 @@ import { defineAction } from "@agent-native/core";
2
2
  import { resolveAccess } from "@agent-native/core/sharing";
3
3
  import { z } from "zod";
4
4
 
5
- import { serializeKnowledge } from "../server/lib/brain.js";
5
+ import {
6
+ assertDerivedAudienceAccess,
7
+ serializeKnowledge,
8
+ } from "../server/lib/brain.js";
6
9
 
7
10
  export default defineAction({
8
11
  description: "Get one Brain knowledge item by ID.",
@@ -14,12 +17,13 @@ export default defineAction({
14
17
  publicAgent: {
15
18
  expose: true,
16
19
  readOnly: true,
17
- requiresAuth: false,
20
+ requiresAuth: true,
18
21
  isConsequential: false,
19
22
  },
20
23
  run: async ({ id }) => {
21
24
  const access = await resolveAccess("brain-knowledge", id);
22
25
  if (!access) return { knowledge: null };
26
+ await assertDerivedAudienceAccess(access.resource);
23
27
  return {
24
28
  knowledge: serializeKnowledge(access.resource),
25
29
  accessRole: access.role,
@@ -1,9 +1,10 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { accessFilter } from "@agent-native/core/sharing";
3
- import { and, desc, eq, inArray, or } from "drizzle-orm";
3
+ import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { listAccessibleAudienceIds } from "../server/lib/audiences.js";
7
8
  import {
8
9
  getAccessibleSource,
9
10
  parseJson,
@@ -451,6 +452,22 @@ export default defineAction({
451
452
  run: async ({ sourceId, targetChannel }) => {
452
453
  const access = await getAccessibleSource(sourceId);
453
454
  const db = getDb();
455
+ const accessibleAudienceIds = await listAccessibleAudienceIds([sourceId]);
456
+ const accessibleCaptureAudienceFilter = accessibleAudienceIds.length
457
+ ? inArray(schema.brainCaptureAudiences.audienceId, accessibleAudienceIds)
458
+ : sql`1=0`;
459
+ const accessibleDerivedAudienceFilter = or(
460
+ isNull(schema.brainKnowledge.captureId),
461
+ accessibleAudienceIds.length
462
+ ? inArray(schema.brainKnowledge.audienceId, accessibleAudienceIds)
463
+ : sql`1=0`,
464
+ )!;
465
+ const accessibleProposalAudienceFilter = or(
466
+ isNull(schema.brainProposals.captureId),
467
+ accessibleAudienceIds.length
468
+ ? inArray(schema.brainProposals.audienceId, accessibleAudienceIds)
469
+ : sql`1=0`,
470
+ )!;
454
471
 
455
472
  const [latestSyncRun] = await db
456
473
  .select()
@@ -465,7 +482,17 @@ export default defineAction({
465
482
  status: schema.brainRawCaptures.status,
466
483
  })
467
484
  .from(schema.brainRawCaptures)
468
- .where(eq(schema.brainRawCaptures.sourceId, sourceId));
485
+ .innerJoin(
486
+ schema.brainCaptureAudiences,
487
+ eq(schema.brainCaptureAudiences.captureId, schema.brainRawCaptures.id),
488
+ )
489
+ .where(
490
+ and(
491
+ eq(schema.brainRawCaptures.sourceId, sourceId),
492
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
493
+ accessibleCaptureAudienceFilter,
494
+ ),
495
+ );
469
496
 
470
497
  const recentCaptureRows = await db
471
498
  .select({
@@ -480,17 +507,24 @@ export default defineAction({
480
507
  updatedAt: schema.brainRawCaptures.updatedAt,
481
508
  })
482
509
  .from(schema.brainRawCaptures)
483
- .where(eq(schema.brainRawCaptures.sourceId, sourceId))
510
+ .innerJoin(
511
+ schema.brainCaptureAudiences,
512
+ eq(schema.brainCaptureAudiences.captureId, schema.brainRawCaptures.id),
513
+ )
514
+ .where(
515
+ and(
516
+ eq(schema.brainRawCaptures.sourceId, sourceId),
517
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
518
+ accessibleCaptureAudienceFilter,
519
+ ),
520
+ )
484
521
  .orderBy(desc(schema.brainRawCaptures.capturedAt))
485
522
  .limit(RECENT_LIMIT);
486
523
 
487
524
  const captureIds = captureRows.map((capture) => capture.id);
488
525
  const queueSourceFilter = captureIds.length
489
- ? or(
490
- eq(schema.brainIngestQueue.sourceId, sourceId),
491
- inArray(schema.brainIngestQueue.captureId, captureIds),
492
- )!
493
- : eq(schema.brainIngestQueue.sourceId, sourceId);
526
+ ? inArray(schema.brainIngestQueue.captureId, captureIds)
527
+ : sql`1=0`;
494
528
 
495
529
  const queueRows = await db
496
530
  .select({
@@ -532,6 +566,7 @@ export default defineAction({
532
566
  and(
533
567
  accessFilter(schema.brainKnowledge, schema.brainKnowledgeShares),
534
568
  eq(schema.brainKnowledge.sourceId, sourceId),
569
+ accessibleDerivedAudienceFilter,
535
570
  ),
536
571
  )
537
572
  .orderBy(desc(schema.brainKnowledge.updatedAt));
@@ -556,6 +591,7 @@ export default defineAction({
556
591
  and(
557
592
  accessFilter(schema.brainProposals, schema.brainProposalShares),
558
593
  eq(schema.brainProposals.sourceId, sourceId),
594
+ accessibleProposalAudienceFilter,
559
595
  ),
560
596
  )
561
597
  .orderBy(desc(schema.brainProposals.updatedAt));
@@ -2,6 +2,7 @@ import { defineAction } from "@agent-native/core";
2
2
  import { z } from "zod";
3
3
 
4
4
  import {
5
+ BrainCaptureBlockedError,
5
6
  createCapture,
6
7
  ensureManualSource,
7
8
  serializeCapture,
@@ -31,15 +32,26 @@ export default defineAction({
31
32
  ? null
32
33
  : await ensureManualSource(args.sourceTitle ?? "Manual imports");
33
34
  const sourceId = args.sourceId ?? source!.id;
34
- const capture = await createCapture({
35
- sourceId,
36
- externalId: args.externalId,
37
- title: args.title,
38
- kind: args.kind,
39
- content: args.content,
40
- capturedAt: args.capturedAt,
41
- metadata: args.metadata,
42
- });
35
+ let capture;
36
+ try {
37
+ capture = await createCapture({
38
+ sourceId,
39
+ externalId: args.externalId,
40
+ title: args.title,
41
+ kind: args.kind,
42
+ content: args.content,
43
+ capturedAt: args.capturedAt,
44
+ metadata: args.metadata,
45
+ });
46
+ } catch (error) {
47
+ if (!(error instanceof BrainCaptureBlockedError)) throw error;
48
+ return {
49
+ source: source ? serializeSource(source) : undefined,
50
+ capture: undefined,
51
+ sensitivityReceipt: error.receipt,
52
+ nextAction: undefined,
53
+ };
54
+ }
43
55
  return {
44
56
  source: source ? serializeSource(source) : undefined,
45
57
  capture: serializeCapture(capture),
@@ -1,7 +1,9 @@
1
1
  import { defineAction } from "@agent-native/core";
2
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
2
3
  import { z } from "zod";
3
4
 
4
5
  import {
6
+ BrainCaptureBlockedError,
5
7
  createCapture,
6
8
  ensureManualSource,
7
9
  serializeCapture,
@@ -26,23 +28,49 @@ export default defineAction({
26
28
  enqueueDistillation: z.coerce.boolean().default(true),
27
29
  }),
28
30
  run: async (args) => {
31
+ const importerEmail = getRequestUserEmail()?.trim().toLowerCase();
32
+ let participants = args.participants;
33
+ if (!participants.length) {
34
+ if (!importerEmail) {
35
+ throw new Error(
36
+ "Importing a transcript without participants requires an authenticated importer.",
37
+ );
38
+ }
39
+ participants = [importerEmail];
40
+ }
29
41
  const source = args.sourceId
30
42
  ? null
31
43
  : await ensureManualSource(args.sourceTitle);
32
- const capture = await createCapture({
33
- sourceId: args.sourceId ?? source!.id,
34
- externalId: args.externalId,
35
- title: args.title,
36
- kind: "transcript",
37
- content: args.transcript,
38
- capturedAt: args.capturedAt,
39
- metadata: {
40
- ...(args.metadata ?? {}),
41
- participants: args.participants,
42
- sourceUrl: args.sourceUrl,
43
- tags: args.tags,
44
- },
45
- });
44
+ let capture;
45
+ try {
46
+ capture = await createCapture({
47
+ sourceId: args.sourceId ?? source!.id,
48
+ externalId: args.externalId,
49
+ title: args.title,
50
+ kind: "transcript",
51
+ content: args.transcript,
52
+ capturedAt: args.capturedAt,
53
+ metadata: {
54
+ ...(args.metadata ?? {}),
55
+ participants,
56
+ sourceUrl: args.sourceUrl,
57
+ tags: args.tags,
58
+ },
59
+ audience: {
60
+ kind: "meeting",
61
+ memberEmails: participants,
62
+ upstreamRefHash: args.externalId,
63
+ },
64
+ });
65
+ } catch (error) {
66
+ if (!(error instanceof BrainCaptureBlockedError)) throw error;
67
+ return {
68
+ source: source ? serializeSource(source) : undefined,
69
+ capture: undefined,
70
+ sensitivityReceipt: error.receipt,
71
+ nextAction: undefined,
72
+ };
73
+ }
46
74
  return {
47
75
  source: source ? serializeSource(source) : undefined,
48
76
  capture: serializeCapture(capture),
@@ -4,6 +4,7 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { listAccessibleAudienceIds } from "../server/lib/audiences.js";
7
8
  import {
8
9
  latestDistillationQueuesForCaptures,
9
10
  parseJson,
@@ -98,9 +99,13 @@ export default defineAction({
98
99
 
99
100
  const sourceIds = sourceRows.map((source) => source.id);
100
101
  if (!sourceIds.length) return { count: 0, captures: [] };
102
+ const audienceIds = await listAccessibleAudienceIds(sourceIds);
103
+ if (!audienceIds.length) return { count: 0, captures: [] };
101
104
  const sourceMap = new Map(sourceRows.map((source) => [source.id, source]));
102
105
  const captureClauses = [
103
106
  inArray(schema.brainRawCaptures.sourceId, sourceIds),
107
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
108
+ inArray(schema.brainCaptureAudiences.audienceId, audienceIds),
104
109
  ];
105
110
  if (args.status) {
106
111
  captureClauses.push(eq(schema.brainRawCaptures.status, args.status));
@@ -118,7 +123,7 @@ export default defineAction({
118
123
  ? Math.min(args.previewLength * 4, 4000)
119
124
  : 0;
120
125
  const rows = await db
121
- .select({
126
+ .selectDistinct({
122
127
  id: schema.brainRawCaptures.id,
123
128
  sourceId: schema.brainRawCaptures.sourceId,
124
129
  externalId: schema.brainRawCaptures.externalId,
@@ -136,6 +141,10 @@ export default defineAction({
136
141
  : {}),
137
142
  })
138
143
  .from(schema.brainRawCaptures)
144
+ .innerJoin(
145
+ schema.brainCaptureAudiences,
146
+ eq(schema.brainCaptureAudiences.captureId, schema.brainRawCaptures.id),
147
+ )
139
148
  .where(and(...captureClauses))
140
149
  .orderBy(desc(schema.brainRawCaptures.capturedAt))
141
150
  .limit(args.limit);