@agent-native/core 0.111.4 → 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 (104) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -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/multi-frontier-runs.ts +841 -0
  7. package/corpus/core/src/coding-tools/index.ts +27 -8
  8. package/corpus/core/src/embeddings/index.ts +233 -0
  9. package/corpus/core/src/index.ts +2 -0
  10. package/corpus/core/src/search/index.ts +413 -0
  11. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +48 -5
  12. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +46 -14
  13. package/corpus/templates/brain/README.md +34 -3
  14. package/corpus/templates/brain/actions/approve-proposal.ts +2 -0
  15. package/corpus/templates/brain/actions/claim-distillation.ts +24 -27
  16. package/corpus/templates/brain/actions/create-source.ts +1 -1
  17. package/corpus/templates/brain/actions/get-knowledge.ts +6 -2
  18. package/corpus/templates/brain/actions/get-pilot-report.ts +44 -8
  19. package/corpus/templates/brain/actions/import-capture.ts +21 -9
  20. package/corpus/templates/brain/actions/import-transcript.ts +42 -14
  21. package/corpus/templates/brain/actions/list-captures.ts +10 -1
  22. package/corpus/templates/brain/actions/list-distillation-queue.ts +39 -14
  23. package/corpus/templates/brain/actions/list-projects.ts +51 -0
  24. package/corpus/templates/brain/actions/list-proposals.ts +80 -4
  25. package/corpus/templates/brain/actions/list-sources.ts +17 -3
  26. package/corpus/templates/brain/actions/manage-project.ts +118 -0
  27. package/corpus/templates/brain/actions/mark-capture-distilled.ts +68 -20
  28. package/corpus/templates/brain/actions/navigate.ts +1 -0
  29. package/corpus/templates/brain/actions/reject-proposal.ts +6 -1
  30. package/corpus/templates/brain/actions/resanitize-captures.ts +159 -33
  31. package/corpus/templates/brain/actions/review-proposal.ts +2 -0
  32. package/corpus/templates/brain/actions/search-everything.ts +10 -1
  33. package/corpus/templates/brain/actions/search-knowledge.ts +1 -1
  34. package/corpus/templates/brain/actions/set-settings.ts +36 -1
  35. package/corpus/templates/brain/actions/update-proposal.ts +2 -0
  36. package/corpus/templates/brain/app/hooks/use-distillation-bridge.ts +18 -9
  37. package/corpus/templates/brain/app/hooks/use-navigation-state.ts +3 -0
  38. package/corpus/templates/brain/app/i18n-data.ts +731 -2
  39. package/corpus/templates/brain/app/lib/brain.ts +33 -1
  40. package/corpus/templates/brain/app/routes/ops.tsx +47 -0
  41. package/corpus/templates/brain/app/routes/review.tsx +41 -2
  42. package/corpus/templates/brain/app/routes/search.tsx +33 -2
  43. package/corpus/templates/brain/app/routes/settings.tsx +158 -0
  44. package/corpus/templates/brain/app/routes/sources.tsx +31 -0
  45. package/corpus/templates/brain/changelog/2026-07-19-added-private-source-safe-semantic-search.md +6 -0
  46. package/corpus/templates/brain/jobs/process-ingest-queue.ts +270 -36
  47. package/corpus/templates/brain/package.json +1 -0
  48. package/corpus/templates/brain/server/db/index.ts +18 -0
  49. package/corpus/templates/brain/server/db/schema.ts +197 -1
  50. package/corpus/templates/brain/server/jobs/distillation-queue.ts +12 -5
  51. package/corpus/templates/brain/server/lib/audiences.ts +708 -0
  52. package/corpus/templates/brain/server/lib/brain-health.ts +251 -38
  53. package/corpus/templates/brain/server/lib/brain.ts +877 -50
  54. package/corpus/templates/brain/server/lib/capture-sanitization.ts +217 -53
  55. package/corpus/templates/brain/server/lib/connectors.ts +556 -70
  56. package/corpus/templates/brain/server/lib/demo.ts +17 -4
  57. package/corpus/templates/brain/server/lib/hybrid-search.ts +363 -0
  58. package/corpus/templates/brain/server/lib/ingest-queue.ts +104 -0
  59. package/corpus/templates/brain/server/lib/privacy-readiness.ts +24 -0
  60. package/corpus/templates/brain/server/lib/search-index-contracts.ts +72 -0
  61. package/corpus/templates/brain/server/lib/search-index.ts +714 -0
  62. package/corpus/templates/brain/server/lib/search.ts +120 -4
  63. package/corpus/templates/brain/server/lib/sensitivity-policy.ts +174 -0
  64. package/corpus/templates/brain/server/lib/slack-events.ts +281 -0
  65. package/corpus/templates/brain/server/onboarding.ts +29 -0
  66. package/corpus/templates/brain/server/plugins/db.ts +204 -0
  67. package/corpus/templates/brain/server/register-secrets.ts +46 -0
  68. package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +36 -16
  69. package/corpus/templates/brain/server/routes/api/_agent-native/brain/slack-events.post.ts +28 -0
  70. package/corpus/templates/brain/shared/types.ts +10 -0
  71. package/dist/cli/code-agent-executor.d.ts +8 -9
  72. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  73. package/dist/cli/code-agent-executor.js +9 -3
  74. package/dist/cli/code-agent-executor.js.map +1 -1
  75. package/dist/cli/code-agent-runs.d.ts +5 -0
  76. package/dist/cli/code-agent-runs.d.ts.map +1 -1
  77. package/dist/cli/code-agent-runs.js +33 -2
  78. package/dist/cli/code-agent-runs.js.map +1 -1
  79. package/dist/cli/multi-frontier-runs.d.ts +131 -0
  80. package/dist/cli/multi-frontier-runs.d.ts.map +1 -0
  81. package/dist/cli/multi-frontier-runs.js +529 -0
  82. package/dist/cli/multi-frontier-runs.js.map +1 -0
  83. package/dist/coding-tools/index.d.ts +1 -0
  84. package/dist/coding-tools/index.d.ts.map +1 -1
  85. package/dist/coding-tools/index.js +25 -7
  86. package/dist/coding-tools/index.js.map +1 -1
  87. package/dist/collab/awareness.d.ts +2 -2
  88. package/dist/collab/awareness.d.ts.map +1 -1
  89. package/dist/embeddings/index.d.ts +24 -0
  90. package/dist/embeddings/index.d.ts.map +1 -0
  91. package/dist/embeddings/index.js +172 -0
  92. package/dist/embeddings/index.js.map +1 -0
  93. package/dist/index.d.ts +2 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +2 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/notifications/routes.d.ts +3 -3
  98. package/dist/progress/routes.d.ts +1 -1
  99. package/dist/resources/handlers.d.ts +1 -1
  100. package/dist/search/index.d.ts +88 -0
  101. package/dist/search/index.d.ts.map +1 -0
  102. package/dist/search/index.js +267 -0
  103. package/dist/search/index.js.map +1 -0
  104. package/package.json +3 -1
@@ -0,0 +1,714 @@
1
+ import { getDbExec, isPostgres } from "@agent-native/core/db";
2
+ import {
3
+ availableEmbeddingFamilies,
4
+ defaultEmbeddingFamily,
5
+ type EmbeddingFamily,
6
+ } from "@agent-native/core/embeddings";
7
+ import {
8
+ deletePgVectors,
9
+ deletePostgresFtsDocuments,
10
+ ensurePgVectorIndex,
11
+ upsertPgVector,
12
+ upsertPostgresFtsDocument,
13
+ } from "@agent-native/core/search";
14
+ import { completeText } from "@agent-native/core/server";
15
+ import { and, eq, inArray } from "drizzle-orm";
16
+ import { z } from "zod";
17
+
18
+ import { getDb, schema } from "../db/index.js";
19
+ import { nanoid, nowIso } from "./brain.js";
20
+ import {
21
+ BRAIN_SEARCH_INDEX_VERSION,
22
+ BRAIN_SENSITIVITY_POLICY_VERSION,
23
+ type BrainSearchStalenessKey,
24
+ } from "./search-index-contracts.js";
25
+
26
+ const artifactSchema = z.object({
27
+ title: z.string().min(1).max(240),
28
+ question: z.string().max(1_200).default(""),
29
+ summary: z.string().max(2_400).default(""),
30
+ resolution: z.string().max(2_400).default(""),
31
+ systems: z.array(z.string().max(120)).max(24).default([]),
32
+ codeRefs: z.array(z.string().max(240)).max(32).default([]),
33
+ });
34
+
35
+ const SEARCH_NAMESPACE = "brain";
36
+ export const BRAIN_BURST_SIZE = 850;
37
+ export const BRAIN_BURST_OVERLAP = 160;
38
+ export const BRAIN_MAX_EMBEDDED_BURSTS = 12;
39
+
40
+ export type BrainSearchArtifact = z.infer<typeof artifactSchema>;
41
+
42
+ export interface SearchIndexCapture {
43
+ id: string;
44
+ sourceId: string;
45
+ title: string;
46
+ content: string;
47
+ contentHash: string | null;
48
+ sensitivityDisposition: "pending" | "allowed";
49
+ sensitivityPolicyVersion: string | null;
50
+ audienceAclHash: string | null;
51
+ capturedAt: string;
52
+ }
53
+
54
+ export interface SearchIndexAudience {
55
+ audienceId: string;
56
+ aclHash: string;
57
+ }
58
+
59
+ function normalize(value: string): string {
60
+ return value.replace(/\s+/g, " ").trim();
61
+ }
62
+
63
+ export function indexStalenessKey(input: {
64
+ contentHash: string;
65
+ aclHash: string;
66
+ indexVersion?: string;
67
+ sensitivityPolicyVersion?: string;
68
+ }): BrainSearchStalenessKey {
69
+ return {
70
+ contentHash: input.contentHash,
71
+ aclHash: input.aclHash,
72
+ indexVersion: input.indexVersion ?? BRAIN_SEARCH_INDEX_VERSION,
73
+ sensitivityPolicyVersion:
74
+ input.sensitivityPolicyVersion ?? BRAIN_SENSITIVITY_POLICY_VERSION,
75
+ };
76
+ }
77
+
78
+ export function canIndexCapture(capture: SearchIndexCapture): boolean {
79
+ return (
80
+ capture.sensitivityDisposition === "allowed" &&
81
+ Boolean(capture.contentHash) &&
82
+ Boolean(capture.audienceAclHash) &&
83
+ Boolean(capture.sensitivityPolicyVersion)
84
+ );
85
+ }
86
+
87
+ export function indexSnapshotMatches(
88
+ current: SearchIndexCapture,
89
+ expected: SearchIndexCapture,
90
+ audienceAclHash: string,
91
+ ): boolean {
92
+ return (
93
+ canIndexCapture(current) &&
94
+ current.id === expected.id &&
95
+ current.sourceId === expected.sourceId &&
96
+ current.contentHash === expected.contentHash &&
97
+ current.sensitivityPolicyVersion === expected.sensitivityPolicyVersion &&
98
+ current.audienceAclHash === audienceAclHash
99
+ );
100
+ }
101
+
102
+ async function currentIndexSnapshotMatches(
103
+ capture: SearchIndexCapture,
104
+ audience: SearchIndexAudience,
105
+ ) {
106
+ const [current] = await getDb()
107
+ .select({
108
+ id: schema.brainRawCaptures.id,
109
+ sourceId: schema.brainRawCaptures.sourceId,
110
+ title: schema.brainRawCaptures.title,
111
+ content: schema.brainRawCaptures.content,
112
+ contentHash: schema.brainRawCaptures.contentHash,
113
+ sensitivityDisposition: schema.brainRawCaptures.sensitivityDisposition,
114
+ sensitivityPolicyVersion:
115
+ schema.brainRawCaptures.sensitivityPolicyVersion,
116
+ audienceAclHash: schema.brainRawCaptures.audienceAclHash,
117
+ capturedAt: schema.brainRawCaptures.capturedAt,
118
+ assignmentAclHash: schema.brainCaptureAudiences.aclHash,
119
+ })
120
+ .from(schema.brainRawCaptures)
121
+ .innerJoin(
122
+ schema.brainCaptureAudiences,
123
+ and(
124
+ eq(schema.brainCaptureAudiences.captureId, schema.brainRawCaptures.id),
125
+ eq(schema.brainCaptureAudiences.audienceId, audience.audienceId),
126
+ ),
127
+ )
128
+ .where(eq(schema.brainRawCaptures.id, capture.id))
129
+ .limit(1);
130
+ return Boolean(
131
+ current &&
132
+ current.assignmentAclHash === audience.aclHash &&
133
+ indexSnapshotMatches(
134
+ current as SearchIndexCapture,
135
+ capture,
136
+ audience.aclHash,
137
+ ),
138
+ );
139
+ }
140
+
141
+ export function deterministicArtifact(input: {
142
+ title: string;
143
+ content: string;
144
+ }): BrainSearchArtifact {
145
+ const content = normalize(input.content);
146
+ const sentences = content.split(/(?<=[.!?])\s+/).filter(Boolean);
147
+ const summary = sentences.slice(0, 3).join(" ").slice(0, 2_400);
148
+ return {
149
+ title:
150
+ normalize(input.title) ||
151
+ sentences[0]?.slice(0, 240) ||
152
+ "Untitled capture",
153
+ question: "",
154
+ summary,
155
+ resolution: sentences.slice(3, 6).join(" ").slice(0, 2_400),
156
+ systems: [],
157
+ codeRefs: [],
158
+ };
159
+ }
160
+
161
+ function parseArtifact(value: string): BrainSearchArtifact | null {
162
+ try {
163
+ return artifactSchema.parse(JSON.parse(value));
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /** Narrow, bounded extraction. Indexing still succeeds deterministically if no model is configured. */
170
+ export async function extractSearchArtifact(input: {
171
+ title: string;
172
+ content: string;
173
+ complete?: typeof completeText;
174
+ }): Promise<BrainSearchArtifact> {
175
+ const fallback = deterministicArtifact(input);
176
+ const complete = input.complete ?? completeText;
177
+ const prompt = `Return only JSON matching {title,question,summary,resolution,systems,codeRefs}. Do not include sensitive data or invent facts.\nTITLE: ${input.title}\nCONTENT: ${input.content.slice(0, 12_000)}`;
178
+ for (let attempt = 0; attempt < 2; attempt += 1) {
179
+ try {
180
+ const result = await complete({
181
+ appId: "brain",
182
+ input:
183
+ attempt === 0
184
+ ? prompt
185
+ : `${prompt}\nPrevious output was invalid JSON. Return JSON only.`,
186
+ maxOutputTokens: 900,
187
+ temperature: 0,
188
+ timeoutMs: 12_000,
189
+ });
190
+ const parsed = parseArtifact(result.text);
191
+ if (parsed) return parsed;
192
+ } catch {
193
+ // A model is an optimization, never an indexing availability dependency.
194
+ }
195
+ }
196
+ return fallback;
197
+ }
198
+
199
+ export function artifactText(artifact: BrainSearchArtifact): string {
200
+ return [
201
+ artifact.title,
202
+ artifact.question,
203
+ artifact.summary,
204
+ artifact.resolution,
205
+ ...artifact.systems,
206
+ ...artifact.codeRefs,
207
+ ]
208
+ .map(normalize)
209
+ .filter(Boolean)
210
+ .join("\n");
211
+ }
212
+
213
+ export function burstText(
214
+ content: string,
215
+ size = 850,
216
+ overlap = 160,
217
+ ): string[] {
218
+ return burstRows(content, size, overlap).map((burst) => burst.content);
219
+ }
220
+
221
+ export function burstRows(
222
+ content: string,
223
+ size = BRAIN_BURST_SIZE,
224
+ overlap = BRAIN_BURST_OVERLAP,
225
+ ) {
226
+ if (!content.trim()) return [];
227
+ const rows: Array<{
228
+ content: string;
229
+ startOffset: number;
230
+ endOffset: number;
231
+ }> = [];
232
+ for (
233
+ let startOffset = 0;
234
+ startOffset < content.length;
235
+ startOffset += Math.max(1, size - overlap)
236
+ ) {
237
+ const burstContent = content.slice(startOffset, startOffset + size);
238
+ rows.push({
239
+ content: burstContent,
240
+ startOffset,
241
+ endOffset: startOffset + burstContent.length,
242
+ });
243
+ if (startOffset + size >= content.length) break;
244
+ }
245
+ return rows;
246
+ }
247
+
248
+ async function configuredEmbeddingFamily(): Promise<EmbeddingFamily | null> {
249
+ const families = await availableEmbeddingFamilies();
250
+ return defaultEmbeddingFamily(families);
251
+ }
252
+
253
+ async function indexExternalSearchLanes(input: {
254
+ artifactId: string;
255
+ artifact: BrainSearchArtifact;
256
+ artifactBody: string;
257
+ burstIds: string[];
258
+ burstBodies: string[];
259
+ audienceId: string;
260
+ sourceId: string;
261
+ aclHash: string;
262
+ contentHash: string;
263
+ sensitivityPolicyVersion: string;
264
+ indexVersion: string;
265
+ now: string;
266
+ }) {
267
+ const dbExec = getDbExec();
268
+ await upsertPostgresFtsDocument(dbExec, {
269
+ chunkId: input.artifactId,
270
+ itemVersionId: input.contentHash,
271
+ title: input.artifact.title,
272
+ summary: input.artifact.summary,
273
+ body: input.artifactBody,
274
+ audienceIds: [input.audienceId],
275
+ updatedAt: input.now,
276
+ namespace: SEARCH_NAMESPACE,
277
+ });
278
+ const family = await configuredEmbeddingFamily();
279
+ if (!family || !isPostgres()) return;
280
+ const targets = [
281
+ {
282
+ targetType: "artifact" as const,
283
+ targetId: input.artifactId,
284
+ vectorKey: `artifact:${input.artifactId}`,
285
+ text: input.artifactBody,
286
+ },
287
+ ...input.burstIds.slice(0, BRAIN_MAX_EMBEDDED_BURSTS).map((id, index) => ({
288
+ targetType: "burst" as const,
289
+ targetId: id,
290
+ vectorKey: `burst:${id}`,
291
+ text: input.burstBodies[index] ?? "",
292
+ })),
293
+ ].filter((target) => target.text.trim());
294
+ const vectors = await family.embed(
295
+ targets.map((target) => ({ text: target.text })),
296
+ "document",
297
+ );
298
+ await ensurePgVectorIndex(dbExec, family.dimensions, {
299
+ namespace: SEARCH_NAMESPACE,
300
+ });
301
+ const db = getDb();
302
+ for (const [index, target] of targets.entries()) {
303
+ const vector = vectors[index];
304
+ if (!vector) continue;
305
+ await upsertPgVector(
306
+ dbExec,
307
+ {
308
+ vectorKey: target.vectorKey,
309
+ embeddingSetId: family.id,
310
+ dimensions: family.dimensions,
311
+ vector,
312
+ audienceIds: [input.audienceId],
313
+ updatedAt: input.now,
314
+ },
315
+ { namespace: SEARCH_NAMESPACE, indexInitialized: true },
316
+ );
317
+ await db
318
+ .insert(schema.brainSearchEmbeddings)
319
+ .values({
320
+ id: nanoid(),
321
+ targetType: target.targetType,
322
+ targetId: target.targetId,
323
+ vectorKey: target.vectorKey,
324
+ sourceId: input.sourceId,
325
+ audienceId: input.audienceId,
326
+ aclHash: input.aclHash,
327
+ embeddingSetId: family.id,
328
+ dimensions: family.dimensions,
329
+ contentHash: input.contentHash,
330
+ sensitivityPolicyVersion: input.sensitivityPolicyVersion,
331
+ indexVersion: input.indexVersion,
332
+ status: "active",
333
+ createdAt: input.now,
334
+ updatedAt: input.now,
335
+ })
336
+ .onConflictDoUpdate({
337
+ target: [
338
+ schema.brainSearchEmbeddings.embeddingSetId,
339
+ schema.brainSearchEmbeddings.targetType,
340
+ schema.brainSearchEmbeddings.targetId,
341
+ ],
342
+ set: {
343
+ vectorKey: target.vectorKey,
344
+ sourceId: input.sourceId,
345
+ audienceId: input.audienceId,
346
+ aclHash: input.aclHash,
347
+ dimensions: family.dimensions,
348
+ contentHash: input.contentHash,
349
+ sensitivityPolicyVersion: input.sensitivityPolicyVersion,
350
+ indexVersion: input.indexVersion,
351
+ status: "active",
352
+ updatedAt: input.now,
353
+ },
354
+ });
355
+ }
356
+ }
357
+
358
+ async function retireExternalSearchLanesForArtifacts(
359
+ artifactIds: string[],
360
+ now: string,
361
+ ) {
362
+ if (!artifactIds.length) return;
363
+ const db = getDb();
364
+ const bursts = await db
365
+ .select({ id: schema.brainSearchBursts.id })
366
+ .from(schema.brainSearchBursts)
367
+ .where(inArray(schema.brainSearchBursts.artifactId, artifactIds));
368
+ const targetIds = [...artifactIds, ...bursts.map((burst) => burst.id)];
369
+ const embeddings = await db
370
+ .select({
371
+ vectorKey: schema.brainSearchEmbeddings.vectorKey,
372
+ dimensions: schema.brainSearchEmbeddings.dimensions,
373
+ })
374
+ .from(schema.brainSearchEmbeddings)
375
+ .where(
376
+ and(
377
+ eq(schema.brainSearchEmbeddings.status, "active"),
378
+ inArray(schema.brainSearchEmbeddings.targetId, targetIds),
379
+ ),
380
+ );
381
+ await db
382
+ .update(schema.brainSearchEmbeddings)
383
+ .set({ status: "stale", updatedAt: now })
384
+ .where(
385
+ and(
386
+ eq(schema.brainSearchEmbeddings.status, "active"),
387
+ inArray(schema.brainSearchEmbeddings.targetId, targetIds),
388
+ ),
389
+ );
390
+ if (!isPostgres()) return;
391
+ try {
392
+ await deletePostgresFtsDocuments(
393
+ getDbExec(),
394
+ artifactIds,
395
+ SEARCH_NAMESPACE,
396
+ );
397
+ } catch {
398
+ // Authoritative SQL metadata filters stale external candidates.
399
+ }
400
+ for (const dimensions of new Set(
401
+ embeddings.map((embedding) => embedding.dimensions),
402
+ )) {
403
+ try {
404
+ await deletePgVectors(
405
+ getDbExec(),
406
+ {
407
+ dimensions,
408
+ vectorKeys: embeddings
409
+ .filter((embedding) => embedding.dimensions === dimensions)
410
+ .map((embedding) => embedding.vectorKey),
411
+ namespace: SEARCH_NAMESPACE,
412
+ },
413
+ true,
414
+ );
415
+ } catch {
416
+ // Other dimensions and the FTS lane can still be cleaned independently.
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Writes only allowed captures with an explicit audience assignment. Callers own
423
+ * enqueueing; this helper deliberately refuses pending/quarantined material.
424
+ */
425
+ export async function indexCaptureForSearch(input: {
426
+ capture: SearchIndexCapture;
427
+ audience: SearchIndexAudience;
428
+ artifact?: BrainSearchArtifact;
429
+ id: string;
430
+ now?: string;
431
+ }): Promise<{ indexed: boolean; reason?: string }> {
432
+ if (!canIndexCapture(input.capture)) {
433
+ return { indexed: false, reason: "capture-not-indexable" };
434
+ }
435
+ if (input.capture.audienceAclHash !== input.audience.aclHash) {
436
+ return { indexed: false, reason: "audience-acl-mismatch" };
437
+ }
438
+ const artifact =
439
+ input.artifact ?? (await extractSearchArtifact(input.capture));
440
+ if (!(await currentIndexSnapshotMatches(input.capture, input.audience))) {
441
+ return { indexed: false, reason: "stale-capture-snapshot" };
442
+ }
443
+ const now = input.now ?? new Date().toISOString();
444
+ const key = indexStalenessKey({
445
+ contentHash: input.capture.contentHash!,
446
+ aclHash: input.audience.aclHash,
447
+ sensitivityPolicyVersion: input.capture.sensitivityPolicyVersion!,
448
+ });
449
+ const db = getDb();
450
+ const activeArtifacts = await db
451
+ .select({ id: schema.brainSearchArtifacts.id })
452
+ .from(schema.brainSearchArtifacts)
453
+ .where(
454
+ and(
455
+ eq(schema.brainSearchArtifacts.captureId, input.capture.id),
456
+ eq(schema.brainSearchArtifacts.status, "active"),
457
+ ),
458
+ );
459
+ await db
460
+ .update(schema.brainSearchArtifacts)
461
+ .set({ status: "stale", updatedAt: now })
462
+ .where(
463
+ and(
464
+ eq(schema.brainSearchArtifacts.captureId, input.capture.id),
465
+ eq(schema.brainSearchArtifacts.status, "active"),
466
+ ),
467
+ );
468
+ await retireExternalSearchLanesForArtifacts(
469
+ activeArtifacts.map((artifact) => artifact.id),
470
+ now,
471
+ );
472
+ await db
473
+ .insert(schema.brainSearchArtifacts)
474
+ .values({
475
+ id: input.id,
476
+ captureId: input.capture.id,
477
+ sourceId: input.capture.sourceId,
478
+ audienceId: input.audience.audienceId,
479
+ aclHash: key.aclHash,
480
+ title: artifact.title,
481
+ question: artifact.question,
482
+ summary: artifact.summary,
483
+ resolution: artifact.resolution,
484
+ systemsJson: JSON.stringify(artifact.systems),
485
+ codeRefsJson: JSON.stringify(artifact.codeRefs),
486
+ contentHash: key.contentHash,
487
+ sensitivityPolicyVersion: key.sensitivityPolicyVersion,
488
+ indexVersion: key.indexVersion,
489
+ status: "active",
490
+ capturedAt: input.capture.capturedAt,
491
+ createdAt: now,
492
+ updatedAt: now,
493
+ })
494
+ .onConflictDoUpdate({
495
+ target: [
496
+ schema.brainSearchArtifacts.captureId,
497
+ schema.brainSearchArtifacts.contentHash,
498
+ schema.brainSearchArtifacts.indexVersion,
499
+ ],
500
+ set: {
501
+ sourceId: input.capture.sourceId,
502
+ audienceId: input.audience.audienceId,
503
+ aclHash: key.aclHash,
504
+ title: artifact.title,
505
+ question: artifact.question,
506
+ summary: artifact.summary,
507
+ resolution: artifact.resolution,
508
+ systemsJson: JSON.stringify(artifact.systems),
509
+ codeRefsJson: JSON.stringify(artifact.codeRefs),
510
+ sensitivityPolicyVersion: key.sensitivityPolicyVersion,
511
+ status: "active",
512
+ capturedAt: input.capture.capturedAt,
513
+ updatedAt: now,
514
+ },
515
+ });
516
+ const [storedArtifact] = await db
517
+ .select({ id: schema.brainSearchArtifacts.id })
518
+ .from(schema.brainSearchArtifacts)
519
+ .where(
520
+ and(
521
+ eq(schema.brainSearchArtifacts.captureId, input.capture.id),
522
+ eq(schema.brainSearchArtifacts.contentHash, key.contentHash),
523
+ eq(schema.brainSearchArtifacts.indexVersion, key.indexVersion),
524
+ ),
525
+ )
526
+ .limit(1);
527
+ if (!storedArtifact)
528
+ return { indexed: false, reason: "artifact-write-failed" };
529
+ const bursts = burstRows(input.capture.content);
530
+ const burstIds: string[] = [];
531
+ const burstBodies: string[] = [];
532
+ for (const [ordinal, burst] of bursts.entries()) {
533
+ const burstId = `${storedArtifact.id}:b${ordinal}`;
534
+ const contextualText = [artifactText(artifact), burst.content]
535
+ .filter(Boolean)
536
+ .join("\n");
537
+ await db
538
+ .insert(schema.brainSearchBursts)
539
+ .values({
540
+ id: burstId,
541
+ artifactId: storedArtifact.id,
542
+ captureId: input.capture.id,
543
+ sourceId: input.capture.sourceId,
544
+ audienceId: input.audience.audienceId,
545
+ aclHash: key.aclHash,
546
+ ordinal,
547
+ startOffset: burst.startOffset,
548
+ endOffset: burst.endOffset,
549
+ content: burst.content,
550
+ contextualText,
551
+ charCount: burst.content.length,
552
+ rareTermScore: 0,
553
+ reactionCount: 0,
554
+ indexed: ordinal < BRAIN_MAX_EMBEDDED_BURSTS ? 1 : 0,
555
+ contentHash: key.contentHash,
556
+ indexVersion: key.indexVersion,
557
+ createdAt: now,
558
+ updatedAt: now,
559
+ })
560
+ .onConflictDoUpdate({
561
+ target: [
562
+ schema.brainSearchBursts.captureId,
563
+ schema.brainSearchBursts.contentHash,
564
+ schema.brainSearchBursts.indexVersion,
565
+ schema.brainSearchBursts.ordinal,
566
+ ],
567
+ set: {
568
+ artifactId: storedArtifact.id,
569
+ sourceId: input.capture.sourceId,
570
+ audienceId: input.audience.audienceId,
571
+ aclHash: key.aclHash,
572
+ startOffset: burst.startOffset,
573
+ endOffset: burst.endOffset,
574
+ content: burst.content,
575
+ contextualText,
576
+ charCount: burst.content.length,
577
+ indexed: ordinal < BRAIN_MAX_EMBEDDED_BURSTS ? 1 : 0,
578
+ updatedAt: now,
579
+ },
580
+ });
581
+ burstIds.push(burstId);
582
+ burstBodies.push(contextualText);
583
+ }
584
+ try {
585
+ await indexExternalSearchLanes({
586
+ artifactId: storedArtifact.id,
587
+ artifact,
588
+ artifactBody: artifactText(artifact),
589
+ burstIds,
590
+ burstBodies,
591
+ audienceId: input.audience.audienceId,
592
+ sourceId: input.capture.sourceId,
593
+ aclHash: key.aclHash,
594
+ contentHash: key.contentHash,
595
+ sensitivityPolicyVersion: key.sensitivityPolicyVersion,
596
+ indexVersion: key.indexVersion,
597
+ now,
598
+ });
599
+ } catch {
600
+ // SQL artifacts remain searchable when an optional external lane is unavailable.
601
+ }
602
+ if (!(await currentIndexSnapshotMatches(input.capture, input.audience))) {
603
+ const staleAt = nowIso();
604
+ await db
605
+ .update(schema.brainSearchArtifacts)
606
+ .set({ status: "stale", updatedAt: staleAt })
607
+ .where(eq(schema.brainSearchArtifacts.id, storedArtifact.id));
608
+ await retireExternalSearchLanesForArtifacts([storedArtifact.id], staleAt);
609
+ return { indexed: false, reason: "stale-capture-snapshot" };
610
+ }
611
+ return { indexed: true };
612
+ }
613
+
614
+ /** Queue-worker entrypoint. A capture without an active audience is deliberately not searchable. */
615
+ export async function indexBrainCapture(captureId: string): Promise<{
616
+ indexed: number;
617
+ reason?: string;
618
+ }> {
619
+ const db = getDb();
620
+ const [capture] = await db
621
+ .select()
622
+ .from(schema.brainRawCaptures)
623
+ .where(eq(schema.brainRawCaptures.id, captureId))
624
+ .limit(1);
625
+ if (!capture || capture.sensitivityDisposition !== "allowed") {
626
+ await unindexBrainCapture(captureId);
627
+ return { indexed: 0, reason: "capture-not-allowed" };
628
+ }
629
+ const audiences = await db
630
+ .select({
631
+ audienceId: schema.brainCaptureAudiences.audienceId,
632
+ aclHash: schema.brainCaptureAudiences.aclHash,
633
+ })
634
+ .from(schema.brainCaptureAudiences)
635
+ .where(eq(schema.brainCaptureAudiences.captureId, captureId));
636
+ let indexed = 0;
637
+ for (const audience of audiences) {
638
+ const result = await indexCaptureForSearch({
639
+ capture: {
640
+ ...capture,
641
+ sensitivityDisposition: capture.sensitivityDisposition,
642
+ },
643
+ audience,
644
+ id: nanoid(),
645
+ now: nowIso(),
646
+ });
647
+ if (result.indexed) indexed += 1;
648
+ }
649
+ return indexed ? { indexed } : { indexed: 0, reason: "no-active-audience" };
650
+ }
651
+
652
+ export async function unindexBrainCapture(captureId: string): Promise<void> {
653
+ const now = nowIso();
654
+ const db = getDb();
655
+ const artifacts = await db
656
+ .select({ id: schema.brainSearchArtifacts.id })
657
+ .from(schema.brainSearchArtifacts)
658
+ .where(eq(schema.brainSearchArtifacts.captureId, captureId));
659
+ const bursts = await db
660
+ .select({ id: schema.brainSearchBursts.id })
661
+ .from(schema.brainSearchBursts)
662
+ .where(eq(schema.brainSearchBursts.captureId, captureId));
663
+ const targetIds = [
664
+ ...artifacts.map((artifact) => artifact.id),
665
+ ...bursts.map((burst) => burst.id),
666
+ ];
667
+ const embeddings = targetIds.length
668
+ ? await db
669
+ .select({
670
+ vectorKey: schema.brainSearchEmbeddings.vectorKey,
671
+ dimensions: schema.brainSearchEmbeddings.dimensions,
672
+ })
673
+ .from(schema.brainSearchEmbeddings)
674
+ .where(
675
+ and(
676
+ eq(schema.brainSearchEmbeddings.status, "active"),
677
+ inArray(schema.brainSearchEmbeddings.targetId, targetIds),
678
+ ),
679
+ )
680
+ : [];
681
+ await db
682
+ .update(schema.brainSearchArtifacts)
683
+ .set({ status: "deleted", updatedAt: now })
684
+ .where(eq(schema.brainSearchArtifacts.captureId, captureId));
685
+ await db
686
+ .update(schema.brainSearchEmbeddings)
687
+ .set({ status: "deleted", updatedAt: now })
688
+ .where(inArray(schema.brainSearchEmbeddings.targetId, targetIds));
689
+ if (!isPostgres()) return;
690
+ try {
691
+ await deletePostgresFtsDocuments(
692
+ getDbExec(),
693
+ artifacts.map((artifact) => artifact.id),
694
+ SEARCH_NAMESPACE,
695
+ );
696
+ for (const dimensions of new Set(
697
+ embeddings.map((embedding) => embedding.dimensions),
698
+ )) {
699
+ await deletePgVectors(
700
+ getDbExec(),
701
+ {
702
+ dimensions,
703
+ vectorKeys: embeddings
704
+ .filter((embedding) => embedding.dimensions === dimensions)
705
+ .map((embedding) => embedding.vectorKey),
706
+ namespace: SEARCH_NAMESPACE,
707
+ },
708
+ true,
709
+ );
710
+ }
711
+ } catch {
712
+ // Metadata status is authoritative; optional Postgres lanes can be swept later.
713
+ }
714
+ }