@rizom/brain 0.2.0-alpha.571 → 0.2.0-alpha.572

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 (31) hide show
  1. package/dist/brain.js +43 -43
  2. package/dist/chunks/{app-ja7zfrpv.js → app-jppjcek0.js} +2 -2
  3. package/dist/chunks/{blog-post-adapter-y0nc909j.js → blog-post-adapter-5ky66e4b.js} +2 -2
  4. package/dist/chunks/{index-d3hzn0rh.js → index-bqy9dj1e.js} +3 -3
  5. package/dist/chunks/{index-d3hzn0rh.js.map → index-bqy9dj1e.js.map} +1 -1
  6. package/dist/chunks/{index-bn4x134s.js → index-cyd0szb1.js} +2 -2
  7. package/dist/chunks/{index-zpecy4b3.js → index-gd468361.js} +2 -2
  8. package/dist/chunks/{index-j8bphmj9.js → index-m8rkcte9.js} +68 -68
  9. package/dist/chunks/{index-j8bphmj9.js.map → index-m8rkcte9.js.map} +5 -5
  10. package/dist/chunks/{index-pfz3rkfm.js → index-spkq7v5w.js} +3 -3
  11. package/dist/chunks/{index-2qm1f6n3.js → index-ycq4hkye.js} +2 -2
  12. package/dist/chunks/{linkedin-template-n7sg02gm.js → linkedin-template-d8wkzt2f.js} +2 -2
  13. package/dist/chunks/{rss-datasource-w95r50c3.js → rss-datasource-h3wey6pn.js} +2 -2
  14. package/dist/entities.js +1 -1
  15. package/dist/git-broker.js +50 -50
  16. package/dist/index.js +1 -1
  17. package/dist/interfaces.js +1 -1
  18. package/dist/model.js +42 -42
  19. package/dist/model.js.map +1 -1
  20. package/dist/plugins.js +1 -1
  21. package/dist/services.js +1 -1
  22. package/dist/ui/studio-chunks/main-bv2vdnze.js.map +1 -1
  23. package/package.json +1 -1
  24. /package/dist/chunks/{app-ja7zfrpv.js.map → app-jppjcek0.js.map} +0 -0
  25. /package/dist/chunks/{blog-post-adapter-y0nc909j.js.map → blog-post-adapter-5ky66e4b.js.map} +0 -0
  26. /package/dist/chunks/{index-bn4x134s.js.map → index-cyd0szb1.js.map} +0 -0
  27. /package/dist/chunks/{index-zpecy4b3.js.map → index-gd468361.js.map} +0 -0
  28. /package/dist/chunks/{index-pfz3rkfm.js.map → index-spkq7v5w.js.map} +0 -0
  29. /package/dist/chunks/{index-2qm1f6n3.js.map → index-ycq4hkye.js.map} +0 -0
  30. /package/dist/chunks/{linkedin-template-n7sg02gm.js.map → linkedin-template-d8wkzt2f.js.map} +0 -0
  31. /package/dist/chunks/{rss-datasource-w95r50c3.js.map → rss-datasource-h3wey6pn.js.map} +0 -0
package/dist/plugins.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- import{Po as p,Qo as m,Rm as t,Ro as l,Sm as o,So as c,Tm as s,To as g,Um as r,Uo as u,Vm as i,Wo as h,Yo as R}from"./chunks/index-j8bphmj9.js";import"./chunks/index-h4r9bme7.js";import"./chunks/index-9pecdvaq.js";import"./chunks/index-h3qhb4jf.js";import{Mr as e,Tr as n,Vr as a}from"./chunks/index-6exvt0sj.js";import"./chunks/index-971fjpjf.js";export{a as AgentResponseSchema,i as AnchorProfileSchema,m as AppInfoSchema,h as BaseMessageSchema,r as BrainCharacterSchema,p as ChatContextSchema,o as ConversationSchema,t as ExtensionMetadataSchema,R as MessageResponseSchema,s as MessageSchema,e as PendingConfirmationSchema,l as RuntimeHealthCheckSchema,c as RuntimeQueueSignalsSchema,u as RuntimeReadinessSchema,g as RuntimeResourceSignalsSchema,n as ToolResultDataSchema};
2
+ import{Po as p,Qo as m,Rm as t,Ro as l,Sm as o,So as c,Tm as s,To as g,Um as r,Uo as u,Vm as i,Wo as h,Yo as R}from"./chunks/index-m8rkcte9.js";import"./chunks/index-h4r9bme7.js";import"./chunks/index-9pecdvaq.js";import"./chunks/index-h3qhb4jf.js";import{Mr as e,Tr as n,Vr as a}from"./chunks/index-6exvt0sj.js";import"./chunks/index-971fjpjf.js";export{a as AgentResponseSchema,i as AnchorProfileSchema,m as AppInfoSchema,h as BaseMessageSchema,r as BrainCharacterSchema,p as ChatContextSchema,o as ConversationSchema,t as ExtensionMetadataSchema,R as MessageResponseSchema,s as MessageSchema,e as PendingConfirmationSchema,l as RuntimeHealthCheckSchema,c as RuntimeQueueSignalsSchema,u as RuntimeReadinessSchema,g as RuntimeResourceSignalsSchema,n as ToolResultDataSchema};
3
3
 
4
4
  //# debugId=DE443C49B974181B64756E2164756E21
5
5
  //# sourceMappingURL=plugins.js.map
package/dist/services.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- import{eo as o,fo as t,go as n,io as r,jn as i,mo as a,no as c,oo as p,po as s}from"./chunks/index-j8bphmj9.js";import"./chunks/index-h4r9bme7.js";import"./chunks/index-9pecdvaq.js";import"./chunks/index-h3qhb4jf.js";import{Lq as e}from"./chunks/index-6exvt0sj.js";import"./chunks/index-971fjpjf.js";export{i as contentGenerationResultSchema,p as defineAccountSettings,o as defineDashboardWidget,n as defineEntityCatalog,a as defineJob,s as defineServicePlugin,t as defineStudioWorkspace,c as defineTool,r as defineWorkspaceAction,e as z};
2
+ import{eo as o,fo as t,go as n,io as r,jn as i,mo as a,no as c,oo as p,po as s}from"./chunks/index-m8rkcte9.js";import"./chunks/index-h4r9bme7.js";import"./chunks/index-9pecdvaq.js";import"./chunks/index-h3qhb4jf.js";import{Lq as e}from"./chunks/index-6exvt0sj.js";import"./chunks/index-971fjpjf.js";export{i as contentGenerationResultSchema,p as defineAccountSettings,o as defineDashboardWidget,n as defineEntityCatalog,a as defineJob,s as defineServicePlugin,t as defineStudioWorkspace,c as defineTool,r as defineWorkspaceAction,e as z};
3
3
 
4
4
  //# debugId=BAF8F6AE19C1E31864756E2164756E21
5
5
  //# sourceMappingURL=services.js.map
@@ -232,7 +232,7 @@
232
232
  "import {\n generateMarkdown,\n readMarkdownFrontmatter,\n} from \"@brains/utils/markdown-frontmatter\";\nimport { z } from \"@brains/utils/zod\";\nimport { objectKeys } from \"@brains/utils/object-keys\";\nimport { isPlainRecord } from \"@brains/utils/predicates\";\nimport type { BaseEntity, ContentVisibility } from \"./types\";\n\n/**\n * Configuration for frontmatter handling\n */\nexport interface FrontmatterConfig<T extends BaseEntity> {\n /**\n * Fields to explicitly include in frontmatter\n * If not specified, includes all non-system fields\n */\n includeFields?: (keyof T)[];\n\n /**\n * Fields to exclude from frontmatter\n * By default excludes: id, entityType, content, created, updated\n */\n excludeFields?: (keyof T)[];\n\n /**\n * Custom serializers for complex fields\n */\n customSerializers?: {\n [K in keyof T]?: (value: T[K]) => unknown;\n };\n}\n\nexport interface FrontmatterValidationSchema<T> {\n parse(data: unknown): T;\n}\n\n// Default system fields that should not be in frontmatter\nconst DEFAULT_SYSTEM_FIELDS: Array<keyof BaseEntity> = [\n \"id\",\n \"entityType\",\n \"content\",\n \"contentHash\",\n \"created\",\n \"updated\",\n \"visibility\",\n];\n\n/**\n * Extract metadata fields from an entity for frontmatter\n * Returns only non-system fields by default\n */\nexport function extractMetadata<T extends BaseEntity>(\n entity: T,\n config?: FrontmatterConfig<T>,\n): Record<string, unknown> {\n const { includeFields, excludeFields = [], customSerializers } = config ?? {};\n const excludedFieldNames = new Set<string>([\n ...DEFAULT_SYSTEM_FIELDS.map(String),\n ...excludeFields.map(String),\n ]);\n\n const metadata: Record<string, unknown> = {};\n\n // Get all fields from the entity\n const allFields = objectKeys(entity);\n\n // Determine which fields to include\n let fieldsToProcess: Array<keyof T & string>;\n if (includeFields) {\n // If includeFields is specified, only include those\n fieldsToProcess = includeFields\n .filter((field) => typeof field === \"string\")\n .filter((field) => !excludedFieldNames.has(field));\n } else {\n // Otherwise include all fields except excluded ones\n fieldsToProcess = allFields.filter(\n (field) => !excludedFieldNames.has(String(field)),\n );\n }\n\n // Process each field\n for (const field of fieldsToProcess) {\n const value = entity[field];\n\n // Skip undefined values\n if (value === undefined) {\n continue;\n }\n\n // Use custom serializer if available\n if (customSerializers && field in customSerializers) {\n const serializer = customSerializers[field];\n if (serializer) {\n metadata[field] = serializer(value);\n }\n } else {\n metadata[field] = value;\n }\n }\n\n return metadata;\n}\n\n/**\n * Generate markdown with frontmatter from content and metadata\n */\nexport function generateMarkdownWithFrontmatter(\n content: string,\n metadata: Record<string, unknown>,\n): string {\n // null is dropped alongside undefined. Schemas model an absent value as\n // `.nullable().default(null)` to keep section content JSON-serializable, so\n // parsing a file that simply omits a key yields an explicit null. Writing\n // that back would grow `key: null` lines in hand-authored markdown on every\n // sync, so absence has to round-trip as absence.\n const cleaned = Object.fromEntries(\n Object.entries(metadata).filter(([, v]) => v !== undefined && v !== null),\n );\n\n // Checked after cleaning: metadata consisting only of absent values must\n // produce bare content, not an empty frontmatter block.\n if (Object.keys(cleaned).length === 0) {\n return content;\n }\n\n return generateMarkdown(cleaned, content);\n}\n\n/**\n * Helper to convert all Date objects to ISO strings recursively\n */\nfunction convertDatesToStrings(obj: unknown): unknown {\n if (obj instanceof Date) {\n return obj.toISOString();\n }\n if (Array.isArray(obj)) {\n return obj.map(convertDatesToStrings);\n }\n if (obj !== null && typeof obj === \"object\") {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n result[key] = convertDatesToStrings(value);\n }\n return result;\n }\n return obj;\n}\n\n/**\n * Parse markdown with frontmatter into content and metadata\n */\nexport function parseMarkdownWithFrontmatter<T>(\n markdown: string,\n schema: FrontmatterValidationSchema<T>,\n _options?: { cache?: boolean },\n): {\n content: string;\n metadata: T;\n} {\n // The shared reader retains no documents or mutable parsed objects.\n const { content, data } = readMarkdownFrontmatter(markdown);\n\n // Convert all Date objects to strings before parsing with Zod\n const normalizedData = convertDatesToStrings(data);\n\n return {\n content: content.trim(),\n metadata: schema.parse(normalizedData),\n };\n}\n\nconst titledFrontmatterSchema = z.object({ title: z.string().optional() });\n\n/**\n * An entity's own name: its metadata title, else the title in its\n * frontmatter, as on types that keep metadata empty; null when neither names it.\n */\nexport function entityTitle(\n entity: Pick<BaseEntity, \"metadata\" | \"content\">,\n): string | null {\n const metadataTitle = entity.metadata[\"title\"];\n if (typeof metadataTitle === \"string\") return metadataTitle;\n return (\n parseMarkdownWithFrontmatter(entity.content, titledFrontmatterSchema)\n .metadata.title ?? null\n );\n}\n\n/**\n * Generate frontmatter string from metadata\n */\nexport function generateFrontmatter(metadata: Record<string, unknown>): string {\n if (Object.keys(metadata).length === 0) {\n return \"\";\n }\n\n const fullMarkdown = generateMarkdown(metadata, \"\");\n\n // Extract just the frontmatter part\n const match = fullMarkdown.match(/^---\\n[\\s\\S]*?\\n---/);\n return match ? match[0] : \"\";\n}\n\n/**\n * Inverse of `applyVisibilityToMarkdown` for the adapter parse path: the\n * export pipeline injects the system-owned `visibility` key into every\n * non-public file's frontmatter, so it must be removed again before a domain\n * frontmatter schema validates — a strict adapter schema must accept its own\n * exported file on re-import. Module-internal: consumed by\n * `BaseEntityAdapter.parseFrontMatter`, not part of the package API.\n */\nexport function stripSystemVisibility(data: unknown): unknown {\n if (!isPlainRecord(data)) {\n return data;\n }\n const { visibility: _visibility, ...rest } = data;\n return rest;\n}\n\nconst visibilityFrontmatterSchema = z.object({\n visibility: z\n .enum([\"public\", \"shared\", \"restricted\", \"private\"])\n .optional()\n .transform((value): ContentVisibility | undefined => {\n if (value === undefined) return undefined;\n return value === \"private\" ? \"restricted\" : value;\n }),\n});\n\n/**\n * Read the visibility a markdown file declares, or undefined when it declares\n * none.\n *\n * Absence must stay distinguishable from an explicit \"public\".\n * `applyVisibilityToMarkdown` omits the key entirely for public entities, so a\n * file without it is the normal shape of exported content and carries no\n * opinion about visibility. Collapsing that to \"public\" makes every merge over\n * an existing entity a silent demotion.\n */\nexport function extractVisibilityFromMarkdown(\n markdown: string,\n): ContentVisibility | undefined {\n const parsed = readMarkdownFrontmatter(markdown);\n return visibilityFrontmatterSchema.parse(parsed.data).visibility;\n}\n\n/**\n * The text of a markdown document without its frontmatter block. It never\n * throws: a block it cannot recognise is left in place.\n */\nexport function markdownBody(markdown: string): string {\n return markdown\n .replace(/^---\\r?\\n[\\s\\S]*?\\r?\\n---[^\\S\\r\\n]*(?:\\r?\\n|$)/, \"\")\n .trim();\n}\n\nexport function hasVisibilityFrontmatter(markdown: string): boolean {\n const frontmatterMatch = markdown.match(/^---\\r?\\n[\\s\\S]*?\\r?\\n---/);\n const visibilityMatch = frontmatterMatch?.[0].match(/^visibility:/m);\n return visibilityMatch !== null && visibilityMatch !== undefined;\n}\n\nexport function applyVisibilityToMarkdown(\n markdown: string,\n visibility: ContentVisibility,\n): string {\n if (visibility === \"public\" && !hasVisibilityFrontmatter(markdown)) {\n return markdown;\n }\n\n const parsed = readMarkdownFrontmatter(markdown);\n if (parsed.data === null || parsed.data === undefined) {\n throw new TypeError(\"Cannot convert undefined or null to object\");\n }\n const fields: [string, unknown][] = Object.entries(parsed.data);\n const frontmatter = Object.fromEntries(\n fields.filter(([key]) => key !== \"visibility\"),\n );\n\n if (visibility !== \"public\") {\n frontmatter[\"visibility\"] = visibility;\n }\n\n return generateMarkdownWithFrontmatter(parsed.content.trim(), frontmatter);\n}\n",
233
233
  "import { getErrorMessage } from \"@brains/utils/error\";\nimport { z } from \"@brains/utils/zod\";\n\nconst validationIssuesErrorSchema = z.looseObject({\n issues: z.array(\n z.looseObject({\n message: z.string(),\n }),\n ),\n});\n\n// Source runners and packed plugins can carry separate class copies.\nconst entityValidationErrorSchema = z.object({\n name: z.literal(\"EntityValidationError\"),\n entityType: z.string(),\n originalError: z.unknown(),\n});\n\nexport class EntityValidationError extends Error {\n public readonly entityType: string;\n public readonly originalError: unknown;\n public readonly phase: \"schema\" | \"persist\";\n\n constructor(\n entityType: string,\n originalError: unknown,\n phase: \"schema\" | \"persist\" = \"schema\",\n ) {\n super(\n `Invalid entity data for ${entityType}: ${getErrorMessage(originalError)}`,\n );\n this.name = \"EntityValidationError\";\n this.entityType = entityType;\n this.originalError = originalError;\n this.phase = phase;\n }\n}\n\n// Read by shape, as a validator's error can come from another class copy.\nconst schemaPhaseErrorSchema = z.object({\n name: z.literal(\"EntityValidationError\"),\n phase: z.literal(\"schema\"),\n});\n\n/** A validation error that finds the content itself invalid, not a live policy refusing it. */\nexport function isSchemaPhaseValidationError(error: unknown): boolean {\n return schemaPhaseErrorSchema.safeParse(error).success;\n}\n\nexport function hasValidationIssues(error: unknown): boolean {\n return validationIssuesErrorSchema.safeParse(error).success;\n}\n\nexport function isEntityValidationError(error: unknown): boolean {\n return (\n error instanceof EntityValidationError ||\n entityValidationErrorSchema.safeParse(error).success ||\n hasValidationIssues(error)\n );\n}\n\nexport function toEntityValidationError(\n entityType: string,\n error: unknown,\n phase: \"schema\" | \"persist\" = \"schema\",\n): EntityValidationError | undefined {\n if (error instanceof EntityValidationError) {\n return error.phase === phase\n ? error\n : new EntityValidationError(entityType, error.originalError, phase);\n }\n const wrapped = entityValidationErrorSchema.safeParse(error);\n if (wrapped.success)\n return new EntityValidationError(\n entityType,\n wrapped.data.originalError,\n phase,\n );\n return hasValidationIssues(error)\n ? new EntityValidationError(entityType, error, phase)\n : undefined;\n}\n\n/** The brain keeps no embeddings, so nothing is semantically near anything. */\nexport class SemanticIndexingDisabledError extends Error {\n constructor() {\n super(\"Semantic indexing is disabled for this Brain instance\");\n this.name = \"SemanticIndexingDisabledError\";\n }\n}\n",
234
234
  "import { sql, type SQL } from \"drizzle-orm\";\nimport { entities } from \"./schema/entities\";\n\nconst statusExpr = sql`json_extract(${entities.metadata}, '$.status')`;\n\n/**\n * The publish gate for one entity type. A type that declares its published\n * statuses gets exactly those — no status is not published. Any other type\n * keeps the default lifecycle: published, active, or no status at all.\n */\nexport function publishedStatusCondition(publishedStatuses?: string[]): SQL {\n if (publishedStatuses && publishedStatuses.length > 0) {\n return sql`${statusExpr} IN (${sql.join(\n publishedStatuses.map((status) => sql`${status}`),\n sql`, `,\n )})`;\n }\n return sql`(${statusExpr} = 'published' OR ${statusExpr} = 'active' OR ${statusExpr} IS NULL)`;\n}\n\n/**\n * The publish gate across entity types, for queries such as search that span\n * them: each declaring type (`gates`, statuses by type) by its own statuses,\n * every other type by the default lifecycle.\n */\nexport function publishedAcrossTypesCondition(\n gates: Record<string, string[]>,\n): SQL {\n const declared = Object.entries(gates).filter(\n ([, statuses]) => statuses.length > 0,\n );\n if (declared.length === 0) return publishedStatusCondition();\n const byType = declared.map(\n ([entityType, statuses]) =>\n sql`(${entities.entityType} = ${entityType} AND ${publishedStatusCondition(statuses)})`,\n );\n const undeclared = sql`(${entities.entityType} NOT IN (${sql.join(\n declared.map(([entityType]) => sql`${entityType}`),\n sql`, `,\n )}) AND ${publishedStatusCondition()})`;\n return sql`(${sql.join([...byType, undeclared], sql` OR `)})`;\n}\n",
235
- "import type { EntitySearchDB } from \"./db\";\nimport {\n getVisibleContentVisibilities,\n type BaseEntity,\n type ContentVisibility,\n type SearchResult,\n type SearchOptions,\n type NearestToEntityRequest,\n type ProjectSemanticSpaceRequest,\n type SemanticSpaceProjection,\n} from \"./types\";\nimport type { IEmbeddingService } from \"./embedding-types\";\nimport { markdownBody } from \"./frontmatter\";\nimport type { EntitySerializer } from \"./entity-serializer\";\nimport { type Logger } from \"@brains/utils/logger\";\nimport { z } from \"@brains/utils/zod\";\nimport { sql, and, asc, desc, inArray, type SQL } from \"drizzle-orm\";\nimport { entities } from \"./schema/entities\";\nimport { SemanticIndexingDisabledError } from \"./errors\";\nimport { publishedAcrossTypesCondition } from \"./published-condition\";\nimport {\n buildSemanticSpaceProjection,\n type SemanticEmbedding,\n} from \"./semantic-space\";\n\n/**\n * The one embedding call search makes: it embeds the prepared query.\n *\n * IEmbeddingService also generates in batches and reports its dimensions;\n * asking for all of it meant a test could not supply one function without\n * asserting it was the whole service.\n */\nexport type QueryEmbedder = Pick<IEmbeddingService, \"generateEmbedding\">;\n\nexport const MAX_SEARCH_QUERY_CHARS = 12_000;\nconst MAX_VECTOR_DISTANCE = 0.82;\n\nfunction prepareSearchQuery(\n query: string,\n logger?: Logger,\n maxChars: number = MAX_SEARCH_QUERY_CHARS,\n): string {\n const normalizedQuery = query.trim().replace(/\\s+/g, \" \");\n\n if (normalizedQuery.length <= maxChars) {\n return normalizedQuery;\n }\n\n logger?.warn(\"Truncating search query that exceeds max length\", {\n originalLength: normalizedQuery.length,\n truncatedLength: maxChars,\n });\n\n return normalizedQuery.slice(0, maxChars);\n}\n\n/**\n * Schema for search options (excluding tags)\n */\nconst searchOptionsSchema = z.object({\n limit: z.number().int().positive().optional().default(20),\n offset: z.number().int().min(0).optional().default(0),\n types: z.array(z.string()).optional().default([]),\n excludeTypes: z.array(z.string()).optional().default([]),\n weight: z.record(z.string(), z.number()).optional(),\n visibilityScope: z.enum([\"public\", \"shared\", \"restricted\"]).optional(),\n includeUngenerated: z.boolean().optional().default(false),\n publishedOnly: z.boolean().optional().default(false),\n minScore: z.number().min(0).optional(),\n signal: z.instanceof(AbortSignal).optional(),\n});\n\nconst entityMetadataSchema = z.preprocess(\n (value) => {\n if (typeof value !== \"string\") return value;\n return JSON.parse(value);\n },\n z.record(z.string(), z.unknown()),\n);\n\n/**\n * EntitySearch handles all search operations for entities\n * Extracted from EntityService for single responsibility\n */\nexport class EntitySearch {\n private db: EntitySearchDB;\n private embeddingService: QueryEmbedder;\n private serializer: EntitySerializer;\n private logger: Logger;\n private readonly embeddingsEnabled: boolean;\n /** Published statuses by entity type, for the types that declare them. */\n private readonly publishGates: () => Record<string, string[]>;\n\n constructor(\n db: EntitySearchDB,\n embeddingService: QueryEmbedder,\n serializer: EntitySerializer,\n logger: Logger,\n embeddingsEnabled = true,\n publishGates: () => Record<string, string[]> = () => ({}),\n ) {\n this.db = db;\n this.embeddingService = embeddingService;\n this.serializer = serializer;\n this.logger = logger.child(\"EntitySearch\");\n this.embeddingsEnabled = embeddingsEnabled;\n this.publishGates = publishGates;\n }\n\n /**\n * Search entities by query using vector similarity\n */\n public async search(\n query: string,\n options?: SearchOptions,\n ): Promise<SearchResult<BaseEntity>[]> {\n const validatedOptions = searchOptionsSchema.parse(options ?? {});\n const {\n offset,\n types,\n excludeTypes,\n weight,\n visibilityScope,\n includeUngenerated,\n publishedOnly,\n minScore,\n signal,\n limit,\n } = validatedOptions;\n signal?.throwIfAborted();\n\n // Check if we have weights to apply\n const hasWeights = weight && Object.keys(weight).length > 0;\n const preparedQuery = prepareSearchQuery(query, this.logger);\n\n this.logger.debug(\n `Searching entities with query (${preparedQuery.length} chars)`,\n );\n\n if (!this.embeddingsEnabled) {\n return this.searchLexically(preparedQuery, {\n limit,\n offset,\n types,\n excludeTypes,\n weight,\n visibilityScope,\n includeUngenerated,\n publishedOnly,\n minScore,\n signal,\n });\n }\n\n // Generate embedding for the query\n const vector = (\n await (signal\n ? this.embeddingService.generateEmbedding(preparedQuery, signal)\n : this.embeddingService.generateEmbedding(preparedQuery))\n ).embedding;\n signal?.throwIfAborted();\n\n // Convert Float32Array to JSON array for SQL\n const embeddingArray = JSON.stringify(Array.from(vector));\n\n const weightMultiplier = this.buildWeightMultiplier(\n hasWeights ? weight : undefined,\n );\n\n // Build type filter conditions for drizzle\n const typeConditions: SQL[] = [];\n if (types.length > 0) {\n typeConditions.push(\n sql`${entities.entityType} IN (${sql.join(\n types.map((t) => sql`${t}`),\n sql`, `,\n )})`,\n );\n }\n if (excludeTypes.length > 0) {\n typeConditions.push(\n sql`${entities.entityType} NOT IN (${sql.join(\n excludeTypes.map((t) => sql`${t}`),\n sql`, `,\n )})`,\n );\n }\n\n return this.searchWithAttachedDb(\n embeddingArray,\n weightMultiplier,\n [\n ...typeConditions,\n ...this.buildVisibilityConditions(visibilityScope),\n ...this.buildGenerationStatusConditions(includeUngenerated),\n ...this.buildPublishedConditions(publishedOnly),\n ],\n limit,\n offset,\n preparedQuery,\n minScore,\n signal,\n );\n }\n\n private async searchLexically(\n query: string,\n options: {\n readonly limit: number;\n readonly offset: number;\n readonly types: string[];\n readonly excludeTypes: string[];\n readonly weight: Record<string, number> | undefined;\n readonly visibilityScope: ContentVisibility | undefined;\n readonly includeUngenerated: boolean;\n readonly publishedOnly: boolean;\n readonly minScore: number | undefined;\n readonly signal: AbortSignal | undefined;\n },\n ): Promise<SearchResult<BaseEntity>[]> {\n if (!query) return [];\n const ftsQuery = '\"' + query.replace(/\"/g, '\"\"') + '\"';\n const conditions: SQL[] = [sql`entity_fts MATCH ${ftsQuery}`];\n if (options.types.length > 0) {\n conditions.push(inArray(entities.entityType, options.types));\n }\n if (options.excludeTypes.length > 0) {\n conditions.push(\n sql`${entities.entityType} NOT IN (${sql.join(\n options.excludeTypes.map((type) => sql`${type}`),\n sql`, `,\n )})`,\n );\n }\n conditions.push(\n ...this.buildVisibilityConditions(options.visibilityScope),\n ...this.buildGenerationStatusConditions(options.includeUngenerated),\n ...this.buildPublishedConditions(options.publishedOnly),\n );\n\n // bm25() returns lower-is-better negative relevance. Map it to (0.5, 1)\n // so the system search default threshold still admits lexical matches,\n // while stronger matches remain ordered above weaker ones.\n const multiplier = this.buildWeightMultiplier(options.weight);\n const weightedScore = sql<number>`(0.5 + 0.5 * ((-bm25(entity_fts)) / (1.0 - bm25(entity_fts)))) * (${multiplier})`;\n if (options.minScore !== undefined) {\n conditions.push(sql`${weightedScore} >= ${options.minScore}`);\n }\n const results = await this.db\n .select({\n id: entities.id,\n entityType: entities.entityType,\n content: entities.content,\n contentHash: entities.contentHash,\n visibility: entities.visibility,\n created: entities.created,\n updated: entities.updated,\n metadata: entities.metadata,\n weighted_score: weightedScore.as(\"weighted_score\"),\n })\n .from(entities)\n .innerJoin(\n sql`entity_fts`,\n sql`entity_fts.entity_id = ${entities.id} AND entity_fts.entity_type = ${entities.entityType}`,\n )\n .where(and(...conditions))\n .orderBy(sql`weighted_score DESC`)\n .limit(options.limit)\n .offset(options.offset);\n options.signal?.throwIfAborted();\n return this.mapSearchResults(results, query);\n }\n\n private buildVisibilityConditions(\n visibilityScope?: ContentVisibility,\n ): SQL[] {\n // Fail closed: undefined scope filters to public-only.\n const scope: ContentVisibility = visibilityScope ?? \"public\";\n if (scope === \"restricted\") {\n return [];\n }\n return [inArray(entities.visibility, getVisibleContentVisibilities(scope))];\n }\n\n private buildGenerationStatusConditions(includeUngenerated: boolean): SQL[] {\n if (includeUngenerated) return [];\n return [\n sql`(json_extract(${entities.metadata}, '$.status') IS NULL OR json_extract(${entities.metadata}, '$.status') NOT IN ('generating', 'failed'))`,\n ];\n }\n\n private buildPublishedConditions(publishedOnly: boolean): SQL[] {\n return publishedOnly\n ? [publishedAcrossTypesCondition(this.publishGates())]\n : [];\n }\n\n /**\n * FTS5 boost weight. When a keyword match is found, this fraction of the\n * final score comes from FTS5 rank, the rest from vector similarity.\n * 0.3 = 30% keyword, 70% semantic.\n */\n private static readonly FTS_ALPHA = 0.3;\n\n /**\n * Build a parameterized CASE expression for entity-type score multipliers.\n * Weight keys may be caller-provided, so avoid raw SQL string interpolation.\n */\n private buildWeightMultiplier(weight?: Record<string, number>): SQL<number> {\n const entries = Object.entries(weight ?? {}).filter(([, multiplier]) =>\n Number.isFinite(multiplier),\n );\n\n if (entries.length === 0) {\n return sql`1.0`;\n }\n\n const cases = entries.map(\n ([entityType, multiplier]) =>\n sql`WHEN ${entities.entityType} = ${entityType} THEN ${multiplier}`,\n );\n\n return sql`CASE ${sql.join(cases, sql` `)} ELSE 1.0 END`;\n }\n\n /**\n * Execute search against an attached embedding database (aliased as \"emb\").\n */\n private async searchWithAttachedDb(\n embeddingArray: string,\n weightMultiplier: SQL,\n typeConditions: SQL[],\n limit: number,\n offset: number,\n query: string,\n minScore: number | undefined,\n signal?: AbortSignal,\n ): Promise<SearchResult<BaseEntity>[]> {\n const alpha = EntitySearch.FTS_ALPHA;\n\n // Vector similarity score (0..1, higher is better)\n const vectorScore = sql<number>`(1.0 - vector_distance_cos(emb_e.embedding, vector32(${embeddingArray})) / 2.0) * (${weightMultiplier})`;\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${embeddingArray}))`;\n\n // FTS5 keyword boost via subquery: 1.0 when matched, 0.0 when not.\n // Wrap in double quotes for phrase matching — prevents special characters\n // (?, *, OR, AND, etc.) from being parsed as FTS5 operators.\n const ftsQuery = '\"' + query.replace(/\"/g, '\"\"') + '\"';\n const ftsBoost = sql<number>`CASE WHEN EXISTS (\n SELECT 1 FROM entity_fts WHERE entity_fts MATCH ${ftsQuery}\n AND entity_id = ${entities.id} AND entity_type = ${entities.entityType}\n ) THEN 1.0 ELSE 0.0 END`;\n\n // Combined score: (1-α)*vector + α*keyword_match\n const combinedScore = sql<number>`(${1 - alpha} * ${vectorScore}) + (${alpha} * ${ftsBoost})`;\n\n const results = await this.db\n .select({\n id: entities.id,\n entityType: entities.entityType,\n content: entities.content,\n contentHash: entities.contentHash,\n visibility: entities.visibility,\n created: entities.created,\n updated: entities.updated,\n metadata: entities.metadata,\n distance: distanceExpr,\n weighted_score: combinedScore,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n sql`${distanceExpr} < ${MAX_VECTOR_DISTANCE}`,\n ...(minScore !== undefined\n ? [sql`${combinedScore} >= ${minScore}`]\n : []),\n ...typeConditions,\n ),\n )\n .orderBy(desc(combinedScore))\n .limit(limit)\n .offset(offset);\n signal?.throwIfAborted();\n return this.mapSearchResults(results, query);\n }\n\n /**\n * Search entities by type and query\n */\n public async searchEntities(\n entityType: string,\n query: string,\n options?: { limit?: number },\n ): Promise<SearchResult[]> {\n // Build search options with the entity type filter\n const searchOptions: SearchOptions = {\n types: [entityType],\n limit: options?.limit ?? 20,\n offset: 0,\n sortBy: \"relevance\",\n sortDirection: \"desc\",\n };\n\n return this.search(query, searchOptions);\n }\n\n /**\n * Project visible entity embeddings into a provider-independent semantic\n * space. Raw vectors never cross the entity-service boundary.\n */\n public async projectSemanticSpace(\n request: ProjectSemanticSpaceRequest,\n ): Promise<SemanticSpaceProjection> {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const listed = request.entities\n ? new Set(request.entities.map(referenceKey))\n : undefined;\n const pointTypes = request.entities\n ? new Set(request.entities.map(({ entityType }) => entityType))\n : request.types && request.types.length > 0\n ? new Set(request.types)\n : undefined;\n\n const embeddings = (\n await this.readEmbeddings(\n pointTypes ? Array.from(pointTypes) : undefined,\n request.visibilityScope,\n request.entities?.map(({ entityId }) => entityId),\n )\n ).filter((embedding) => !listed || listed.has(referenceKey(embedding)));\n const originReference = request.origin;\n // An origin of a type outside the points is read alone: reading its whole\n // type to find one vector makes a page per entry quadratic.\n const origin = originReference\n ? (embeddings.find(\n (embedding) =>\n embedding.entityId === originReference.entityId &&\n embedding.entityType === originReference.entityType,\n ) ??\n (pointTypes?.has(originReference.entityType) === false\n ? (\n await this.readEmbeddings(\n [originReference.entityType],\n request.visibilityScope,\n [originReference.entityId],\n )\n )[0]\n : undefined))\n : undefined;\n const points = embeddings.filter((embedding) => {\n const matchesPointType = pointTypes?.has(embedding.entityType) ?? true;\n const isOrigin =\n embedding.entityId === originReference?.entityId &&\n embedding.entityType === originReference.entityType;\n return matchesPointType && !isOrigin;\n });\n\n return buildSemanticSpaceProjection(points, {\n ...(origin && { origin }),\n ...(request.maxNeighborDistance !== undefined && {\n maxNeighborDistance: request.maxNeighborDistance,\n }),\n });\n }\n\n /** Read and decode vectors from the attached embedding database. */\n private async readEmbeddings(\n types?: string[],\n visibilityScope?: ContentVisibility,\n entityIds?: readonly string[],\n ): Promise<SemanticEmbedding[]> {\n const conditions = this.buildVisibilityConditions(visibilityScope);\n if (types && types.length > 0) {\n conditions.push(inArray(entities.entityType, types));\n }\n if (entityIds !== undefined) {\n conditions.push(inArray(entities.id, [...entityIds]));\n }\n\n const embeddingExpr = sql<Float32Array>`emb_e.embedding`.mapWith({\n mapFromDriverValue(value: unknown): Float32Array {\n if (!ArrayBuffer.isView(value)) {\n throw new TypeError(\n \"Expected embedding blob to be an ArrayBuffer view\",\n );\n }\n\n const source = new Uint8Array(\n value.buffer,\n value.byteOffset,\n value.byteLength,\n );\n if (source.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0) {\n throw new RangeError(\n \"Embedding blob byte length must be divisible by 4\",\n );\n }\n\n const copy = new Uint8Array(source.byteLength);\n copy.set(source);\n return new Float32Array(copy.buffer);\n },\n });\n\n return this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n embedding: embeddingExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(conditions.length > 0 ? and(...conditions) : undefined)\n .orderBy(asc(entities.entityType), asc(entities.id));\n }\n\n /**\n * Visible entities of the given types nearest an entity's stored embedding,\n * closest first. The origin is read alone and within the same scope; an\n * origin out of scope or without an embedding has no neighbours.\n */\n public async nearestToEntity(\n request: NearestToEntityRequest,\n ): Promise<\n Array<{ entityId: string; entityType: string; distance: number }>\n > {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const [origin] = await this.readEmbeddings(\n [request.origin.entityType],\n request.visibilityScope,\n [request.origin.entityId],\n );\n if (!origin || request.types.length === 0) return [];\n\n const originVector = JSON.stringify(Array.from(origin.embedding));\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${originVector}))`;\n const query = this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n distance: distanceExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n ...this.buildVisibilityConditions(request.visibilityScope),\n inArray(entities.entityType, request.types),\n sql`NOT (${entities.id} = ${request.origin.entityId} AND ${entities.entityType} = ${request.origin.entityType})`,\n request.maxDistance !== undefined\n ? sql`${distanceExpr} <= ${request.maxDistance}`\n : undefined,\n ),\n )\n .orderBy(sql`${distanceExpr} ASC`);\n return request.limit !== undefined ? query.limit(request.limit) : query;\n }\n\n /**\n * Return embedded entities with their raw cosine distance to the query,\n * sorted closest first. Without filters every embedded entity is returned,\n * for diagnostics and threshold tuning; `types` and `maxDistance` narrow it\n * in the query for callers that look for one close match.\n */\n public async searchWithDistances(\n query: string,\n filters: {\n readonly types?: string[] | undefined;\n readonly maxDistance?: number | undefined;\n } = {},\n ): Promise<\n Array<{ entityId: string; entityType: string; distance: number }>\n > {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const preparedQuery = prepareSearchQuery(query, this.logger);\n const { embedding: queryEmbedding } =\n await this.embeddingService.generateEmbedding(preparedQuery);\n const embeddingArray = JSON.stringify(Array.from(queryEmbedding));\n\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${embeddingArray}))`;\n\n const results = await this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n distance: distanceExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n filters.types && filters.types.length > 0\n ? inArray(entities.entityType, filters.types)\n : undefined,\n filters.maxDistance !== undefined\n ? sql`${distanceExpr} <= ${filters.maxDistance}`\n : undefined,\n ),\n )\n .orderBy(sql`${distanceExpr} ASC`);\n\n return results;\n }\n\n /**\n * Transform raw query rows into SearchResult objects\n */\n private mapSearchResults(\n results: Array<{\n id: string;\n entityType: string;\n content: string;\n contentHash: string;\n visibility: ContentVisibility;\n created: number;\n updated: number;\n metadata: unknown;\n weighted_score: number;\n }>,\n query: string,\n ): SearchResult<BaseEntity>[] {\n const searchResults: SearchResult<BaseEntity>[] = [];\n\n for (const row of results) {\n try {\n const metadata = entityMetadataSchema.parse(row.metadata);\n\n const entity = this.serializer.reconstructEntity({\n id: row.id,\n entityType: row.entityType,\n content: row.content,\n contentHash: row.contentHash,\n visibility: row.visibility,\n created: row.created,\n updated: row.updated,\n metadata,\n });\n\n searchResults.push({\n entity,\n score: row.weighted_score,\n excerpt: this.createExcerpt(row.content, query),\n });\n } catch (error) {\n this.logger.error(`Failed to parse entity during search: ${error}`);\n }\n }\n\n const queryPreview =\n query.length > 50 ? query.substring(0, 50) + \"...\" : query;\n this.logger.debug(\n `Found ${searchResults.length} results for query \"${queryPreview}\"`,\n );\n\n return searchResults;\n }\n\n /**\n * Create an excerpt from content based on query\n */\n private createExcerpt(markdown: string, query: string): string {\n // A reader of the result sees its text, not its frontmatter.\n const content = markdownBody(markdown);\n const maxLength = 200;\n const queryLower = query.toLowerCase();\n const contentLower = content.toLowerCase();\n\n // Find the position of the query in the content\n const position = contentLower.indexOf(queryLower);\n\n if (position !== -1) {\n // Extract text around the query\n const start = Math.max(0, position - 50);\n const end = Math.min(content.length, position + queryLower.length + 50);\n let excerpt = content.slice(start, end);\n\n // Add ellipsis if needed\n if (start > 0) excerpt = \"...\" + excerpt;\n if (end < content.length) excerpt = excerpt + \"...\";\n\n return excerpt;\n }\n\n // If query not found, return beginning of content\n return (\n content.slice(0, maxLength) + (content.length > maxLength ? \"...\" : \"\")\n );\n }\n}\n\n/** One key for an entity reference: type and id. */\nfunction referenceKey(reference: {\n entityType: string;\n entityId: string;\n}): string {\n return `${reference.entityType}:${reference.entityId}`;\n}\n",
235
+ "import type { EntitySearchDB } from \"./db\";\nimport {\n getVisibleContentVisibilities,\n type BaseEntity,\n type ContentVisibility,\n type SearchResult,\n type SearchOptions,\n type NearestToEntityRequest,\n type ProjectSemanticSpaceRequest,\n type SemanticSpaceProjection,\n} from \"./types\";\nimport type { IEmbeddingService } from \"./embedding-types\";\nimport { markdownBody } from \"./frontmatter\";\nimport type { EntitySerializer } from \"./entity-serializer\";\nimport { type Logger } from \"@brains/utils/logger\";\nimport { z } from \"@brains/utils/zod\";\nimport { sql, and, asc, desc, inArray, type SQL } from \"drizzle-orm\";\nimport { entities } from \"./schema/entities\";\nimport { SemanticIndexingDisabledError } from \"./errors\";\nimport { publishedAcrossTypesCondition } from \"./published-condition\";\nimport {\n buildSemanticSpaceProjection,\n type SemanticEmbedding,\n} from \"./semantic-space\";\n\n/**\n * The one embedding call search makes: it embeds the prepared query.\n *\n * IEmbeddingService also generates in batches and reports its dimensions;\n * asking for all of it meant a test could not supply one function without\n * asserting it was the whole service.\n */\nexport type QueryEmbedder = Pick<IEmbeddingService, \"generateEmbedding\">;\n\nexport const MAX_SEARCH_QUERY_CHARS = 12_000;\nconst MAX_VECTOR_DISTANCE = 0.82;\n\nfunction prepareSearchQuery(\n query: string,\n logger?: Logger,\n maxChars: number = MAX_SEARCH_QUERY_CHARS,\n): string {\n const normalizedQuery = query.trim().replace(/\\s+/g, \" \");\n\n if (normalizedQuery.length <= maxChars) {\n return normalizedQuery;\n }\n\n logger?.warn(\"Truncating search query that exceeds max length\", {\n originalLength: normalizedQuery.length,\n truncatedLength: maxChars,\n });\n\n return normalizedQuery.slice(0, maxChars);\n}\n\n/**\n * Schema for search options (excluding tags)\n */\nconst searchOptionsSchema = z.object({\n limit: z.number().int().positive().optional().default(20),\n offset: z.number().int().min(0).optional().default(0),\n types: z.array(z.string()).optional().default([]),\n excludeTypes: z.array(z.string()).optional().default([]),\n weight: z.record(z.string(), z.number()).optional(),\n visibilityScope: z.enum([\"public\", \"shared\", \"restricted\"]).optional(),\n includeUngenerated: z.boolean().optional().default(false),\n publishedOnly: z.boolean().optional().default(false),\n minScore: z.number().min(0).optional(),\n signal: z.instanceof(AbortSignal).optional(),\n});\n\nconst entityMetadataSchema = z.preprocess(\n (value) => {\n if (typeof value !== \"string\") return value;\n return JSON.parse(value);\n },\n z.record(z.string(), z.unknown()),\n);\n\n/**\n * EntitySearch handles all search operations for entities\n * Extracted from EntityService for single responsibility\n */\nexport class EntitySearch {\n private db: EntitySearchDB;\n private embeddingService: QueryEmbedder;\n private serializer: EntitySerializer;\n private logger: Logger;\n private readonly embeddingsEnabled: boolean;\n /** Published statuses by entity type, for the types that declare them. */\n private readonly publishGates: () => Record<string, string[]>;\n /**\n * The types this brain loads. Rows of any other type, left by a plugin the\n * brain no longer runs, cannot be read back, so search never returns them.\n */\n private readonly registeredTypes: (() => readonly string[]) | undefined;\n\n constructor(\n db: EntitySearchDB,\n embeddingService: QueryEmbedder,\n serializer: EntitySerializer,\n logger: Logger,\n embeddingsEnabled = true,\n publishGates: () => Record<string, string[]> = () => ({}),\n registeredTypes?: () => readonly string[],\n ) {\n this.db = db;\n this.embeddingService = embeddingService;\n this.serializer = serializer;\n this.logger = logger.child(\"EntitySearch\");\n this.embeddingsEnabled = embeddingsEnabled;\n this.publishGates = publishGates;\n this.registeredTypes = registeredTypes;\n }\n\n /** The requested types, or every type, narrowed to those the brain loads. */\n private searchableTypes(requested: string[]): string[] {\n const registered = this.registeredTypes?.();\n if (!registered) return requested;\n const candidates = requested.length > 0 ? requested : registered;\n return candidates.filter((type) => registered.includes(type));\n }\n\n /**\n * Search entities by query using vector similarity\n */\n public async search(\n query: string,\n options?: SearchOptions,\n ): Promise<SearchResult<BaseEntity>[]> {\n const validatedOptions = searchOptionsSchema.parse(options ?? {});\n const {\n offset,\n types: requestedTypes,\n excludeTypes,\n weight,\n visibilityScope,\n includeUngenerated,\n publishedOnly,\n minScore,\n signal,\n limit,\n } = validatedOptions;\n signal?.throwIfAborted();\n const types = this.searchableTypes(requestedTypes);\n if (this.registeredTypes && types.length === 0) return [];\n\n // Check if we have weights to apply\n const hasWeights = weight && Object.keys(weight).length > 0;\n const preparedQuery = prepareSearchQuery(query, this.logger);\n\n this.logger.debug(\n `Searching entities with query (${preparedQuery.length} chars)`,\n );\n\n if (!this.embeddingsEnabled) {\n return this.searchLexically(preparedQuery, {\n limit,\n offset,\n types,\n excludeTypes,\n weight,\n visibilityScope,\n includeUngenerated,\n publishedOnly,\n minScore,\n signal,\n });\n }\n\n // Generate embedding for the query\n const vector = (\n await (signal\n ? this.embeddingService.generateEmbedding(preparedQuery, signal)\n : this.embeddingService.generateEmbedding(preparedQuery))\n ).embedding;\n signal?.throwIfAborted();\n\n // Convert Float32Array to JSON array for SQL\n const embeddingArray = JSON.stringify(Array.from(vector));\n\n const weightMultiplier = this.buildWeightMultiplier(\n hasWeights ? weight : undefined,\n );\n\n // Build type filter conditions for drizzle\n const typeConditions: SQL[] = [];\n if (types.length > 0) {\n typeConditions.push(\n sql`${entities.entityType} IN (${sql.join(\n types.map((t) => sql`${t}`),\n sql`, `,\n )})`,\n );\n }\n if (excludeTypes.length > 0) {\n typeConditions.push(\n sql`${entities.entityType} NOT IN (${sql.join(\n excludeTypes.map((t) => sql`${t}`),\n sql`, `,\n )})`,\n );\n }\n\n return this.searchWithAttachedDb(\n embeddingArray,\n weightMultiplier,\n [\n ...typeConditions,\n ...this.buildVisibilityConditions(visibilityScope),\n ...this.buildGenerationStatusConditions(includeUngenerated),\n ...this.buildPublishedConditions(publishedOnly),\n ],\n limit,\n offset,\n preparedQuery,\n minScore,\n signal,\n );\n }\n\n private async searchLexically(\n query: string,\n options: {\n readonly limit: number;\n readonly offset: number;\n readonly types: string[];\n readonly excludeTypes: string[];\n readonly weight: Record<string, number> | undefined;\n readonly visibilityScope: ContentVisibility | undefined;\n readonly includeUngenerated: boolean;\n readonly publishedOnly: boolean;\n readonly minScore: number | undefined;\n readonly signal: AbortSignal | undefined;\n },\n ): Promise<SearchResult<BaseEntity>[]> {\n if (!query) return [];\n const ftsQuery = '\"' + query.replace(/\"/g, '\"\"') + '\"';\n const conditions: SQL[] = [sql`entity_fts MATCH ${ftsQuery}`];\n if (options.types.length > 0) {\n conditions.push(inArray(entities.entityType, options.types));\n }\n if (options.excludeTypes.length > 0) {\n conditions.push(\n sql`${entities.entityType} NOT IN (${sql.join(\n options.excludeTypes.map((type) => sql`${type}`),\n sql`, `,\n )})`,\n );\n }\n conditions.push(\n ...this.buildVisibilityConditions(options.visibilityScope),\n ...this.buildGenerationStatusConditions(options.includeUngenerated),\n ...this.buildPublishedConditions(options.publishedOnly),\n );\n\n // bm25() returns lower-is-better negative relevance. Map it to (0.5, 1)\n // so the system search default threshold still admits lexical matches,\n // while stronger matches remain ordered above weaker ones.\n const multiplier = this.buildWeightMultiplier(options.weight);\n const weightedScore = sql<number>`(0.5 + 0.5 * ((-bm25(entity_fts)) / (1.0 - bm25(entity_fts)))) * (${multiplier})`;\n if (options.minScore !== undefined) {\n conditions.push(sql`${weightedScore} >= ${options.minScore}`);\n }\n const results = await this.db\n .select({\n id: entities.id,\n entityType: entities.entityType,\n content: entities.content,\n contentHash: entities.contentHash,\n visibility: entities.visibility,\n created: entities.created,\n updated: entities.updated,\n metadata: entities.metadata,\n weighted_score: weightedScore.as(\"weighted_score\"),\n })\n .from(entities)\n .innerJoin(\n sql`entity_fts`,\n sql`entity_fts.entity_id = ${entities.id} AND entity_fts.entity_type = ${entities.entityType}`,\n )\n .where(and(...conditions))\n .orderBy(sql`weighted_score DESC`)\n .limit(options.limit)\n .offset(options.offset);\n options.signal?.throwIfAborted();\n return this.mapSearchResults(results, query);\n }\n\n private buildVisibilityConditions(\n visibilityScope?: ContentVisibility,\n ): SQL[] {\n // Fail closed: undefined scope filters to public-only.\n const scope: ContentVisibility = visibilityScope ?? \"public\";\n if (scope === \"restricted\") {\n return [];\n }\n return [inArray(entities.visibility, getVisibleContentVisibilities(scope))];\n }\n\n private buildGenerationStatusConditions(includeUngenerated: boolean): SQL[] {\n if (includeUngenerated) return [];\n return [\n sql`(json_extract(${entities.metadata}, '$.status') IS NULL OR json_extract(${entities.metadata}, '$.status') NOT IN ('generating', 'failed'))`,\n ];\n }\n\n private buildPublishedConditions(publishedOnly: boolean): SQL[] {\n return publishedOnly\n ? [publishedAcrossTypesCondition(this.publishGates())]\n : [];\n }\n\n /**\n * FTS5 boost weight. When a keyword match is found, this fraction of the\n * final score comes from FTS5 rank, the rest from vector similarity.\n * 0.3 = 30% keyword, 70% semantic.\n */\n private static readonly FTS_ALPHA = 0.3;\n\n /**\n * Build a parameterized CASE expression for entity-type score multipliers.\n * Weight keys may be caller-provided, so avoid raw SQL string interpolation.\n */\n private buildWeightMultiplier(weight?: Record<string, number>): SQL<number> {\n const entries = Object.entries(weight ?? {}).filter(([, multiplier]) =>\n Number.isFinite(multiplier),\n );\n\n if (entries.length === 0) {\n return sql`1.0`;\n }\n\n const cases = entries.map(\n ([entityType, multiplier]) =>\n sql`WHEN ${entities.entityType} = ${entityType} THEN ${multiplier}`,\n );\n\n return sql`CASE ${sql.join(cases, sql` `)} ELSE 1.0 END`;\n }\n\n /**\n * Execute search against an attached embedding database (aliased as \"emb\").\n */\n private async searchWithAttachedDb(\n embeddingArray: string,\n weightMultiplier: SQL,\n typeConditions: SQL[],\n limit: number,\n offset: number,\n query: string,\n minScore: number | undefined,\n signal?: AbortSignal,\n ): Promise<SearchResult<BaseEntity>[]> {\n const alpha = EntitySearch.FTS_ALPHA;\n\n // Vector similarity score (0..1, higher is better)\n const vectorScore = sql<number>`(1.0 - vector_distance_cos(emb_e.embedding, vector32(${embeddingArray})) / 2.0) * (${weightMultiplier})`;\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${embeddingArray}))`;\n\n // FTS5 keyword boost via subquery: 1.0 when matched, 0.0 when not.\n // Wrap in double quotes for phrase matching — prevents special characters\n // (?, *, OR, AND, etc.) from being parsed as FTS5 operators.\n const ftsQuery = '\"' + query.replace(/\"/g, '\"\"') + '\"';\n const ftsBoost = sql<number>`CASE WHEN EXISTS (\n SELECT 1 FROM entity_fts WHERE entity_fts MATCH ${ftsQuery}\n AND entity_id = ${entities.id} AND entity_type = ${entities.entityType}\n ) THEN 1.0 ELSE 0.0 END`;\n\n // Combined score: (1-α)*vector + α*keyword_match\n const combinedScore = sql<number>`(${1 - alpha} * ${vectorScore}) + (${alpha} * ${ftsBoost})`;\n\n const results = await this.db\n .select({\n id: entities.id,\n entityType: entities.entityType,\n content: entities.content,\n contentHash: entities.contentHash,\n visibility: entities.visibility,\n created: entities.created,\n updated: entities.updated,\n metadata: entities.metadata,\n distance: distanceExpr,\n weighted_score: combinedScore,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n sql`${distanceExpr} < ${MAX_VECTOR_DISTANCE}`,\n ...(minScore !== undefined\n ? [sql`${combinedScore} >= ${minScore}`]\n : []),\n ...typeConditions,\n ),\n )\n .orderBy(desc(combinedScore))\n .limit(limit)\n .offset(offset);\n signal?.throwIfAborted();\n return this.mapSearchResults(results, query);\n }\n\n /**\n * Search entities by type and query\n */\n public async searchEntities(\n entityType: string,\n query: string,\n options?: { limit?: number },\n ): Promise<SearchResult[]> {\n // Build search options with the entity type filter\n const searchOptions: SearchOptions = {\n types: [entityType],\n limit: options?.limit ?? 20,\n offset: 0,\n sortBy: \"relevance\",\n sortDirection: \"desc\",\n };\n\n return this.search(query, searchOptions);\n }\n\n /**\n * Project visible entity embeddings into a provider-independent semantic\n * space. Raw vectors never cross the entity-service boundary.\n */\n public async projectSemanticSpace(\n request: ProjectSemanticSpaceRequest,\n ): Promise<SemanticSpaceProjection> {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const listed = request.entities\n ? new Set(request.entities.map(referenceKey))\n : undefined;\n const pointTypes = request.entities\n ? new Set(request.entities.map(({ entityType }) => entityType))\n : request.types && request.types.length > 0\n ? new Set(request.types)\n : undefined;\n\n const embeddings = (\n await this.readEmbeddings(\n pointTypes ? Array.from(pointTypes) : undefined,\n request.visibilityScope,\n request.entities?.map(({ entityId }) => entityId),\n )\n ).filter((embedding) => !listed || listed.has(referenceKey(embedding)));\n const originReference = request.origin;\n // An origin of a type outside the points is read alone: reading its whole\n // type to find one vector makes a page per entry quadratic.\n const origin = originReference\n ? (embeddings.find(\n (embedding) =>\n embedding.entityId === originReference.entityId &&\n embedding.entityType === originReference.entityType,\n ) ??\n (pointTypes?.has(originReference.entityType) === false\n ? (\n await this.readEmbeddings(\n [originReference.entityType],\n request.visibilityScope,\n [originReference.entityId],\n )\n )[0]\n : undefined))\n : undefined;\n const points = embeddings.filter((embedding) => {\n const matchesPointType = pointTypes?.has(embedding.entityType) ?? true;\n const isOrigin =\n embedding.entityId === originReference?.entityId &&\n embedding.entityType === originReference.entityType;\n return matchesPointType && !isOrigin;\n });\n\n return buildSemanticSpaceProjection(points, {\n ...(origin && { origin }),\n ...(request.maxNeighborDistance !== undefined && {\n maxNeighborDistance: request.maxNeighborDistance,\n }),\n });\n }\n\n /** Read and decode vectors from the attached embedding database. */\n private async readEmbeddings(\n types?: string[],\n visibilityScope?: ContentVisibility,\n entityIds?: readonly string[],\n ): Promise<SemanticEmbedding[]> {\n const conditions = this.buildVisibilityConditions(visibilityScope);\n if (types && types.length > 0) {\n conditions.push(inArray(entities.entityType, types));\n }\n if (entityIds !== undefined) {\n conditions.push(inArray(entities.id, [...entityIds]));\n }\n\n const embeddingExpr = sql<Float32Array>`emb_e.embedding`.mapWith({\n mapFromDriverValue(value: unknown): Float32Array {\n if (!ArrayBuffer.isView(value)) {\n throw new TypeError(\n \"Expected embedding blob to be an ArrayBuffer view\",\n );\n }\n\n const source = new Uint8Array(\n value.buffer,\n value.byteOffset,\n value.byteLength,\n );\n if (source.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0) {\n throw new RangeError(\n \"Embedding blob byte length must be divisible by 4\",\n );\n }\n\n const copy = new Uint8Array(source.byteLength);\n copy.set(source);\n return new Float32Array(copy.buffer);\n },\n });\n\n return this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n embedding: embeddingExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(conditions.length > 0 ? and(...conditions) : undefined)\n .orderBy(asc(entities.entityType), asc(entities.id));\n }\n\n /**\n * Visible entities of the given types nearest an entity's stored embedding,\n * closest first. The origin is read alone and within the same scope; an\n * origin out of scope or without an embedding has no neighbours.\n */\n public async nearestToEntity(\n request: NearestToEntityRequest,\n ): Promise<\n Array<{ entityId: string; entityType: string; distance: number }>\n > {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const [origin] = await this.readEmbeddings(\n [request.origin.entityType],\n request.visibilityScope,\n [request.origin.entityId],\n );\n if (!origin || request.types.length === 0) return [];\n\n const originVector = JSON.stringify(Array.from(origin.embedding));\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${originVector}))`;\n const query = this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n distance: distanceExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n ...this.buildVisibilityConditions(request.visibilityScope),\n inArray(entities.entityType, request.types),\n sql`NOT (${entities.id} = ${request.origin.entityId} AND ${entities.entityType} = ${request.origin.entityType})`,\n request.maxDistance !== undefined\n ? sql`${distanceExpr} <= ${request.maxDistance}`\n : undefined,\n ),\n )\n .orderBy(sql`${distanceExpr} ASC`);\n return request.limit !== undefined ? query.limit(request.limit) : query;\n }\n\n /**\n * Return embedded entities with their raw cosine distance to the query,\n * sorted closest first. Without filters every embedded entity is returned,\n * for diagnostics and threshold tuning; `types` and `maxDistance` narrow it\n * in the query for callers that look for one close match.\n */\n public async searchWithDistances(\n query: string,\n filters: {\n readonly types?: string[] | undefined;\n readonly maxDistance?: number | undefined;\n } = {},\n ): Promise<\n Array<{ entityId: string; entityType: string; distance: number }>\n > {\n if (!this.embeddingsEnabled) {\n throw new SemanticIndexingDisabledError();\n }\n const preparedQuery = prepareSearchQuery(query, this.logger);\n const { embedding: queryEmbedding } =\n await this.embeddingService.generateEmbedding(preparedQuery);\n const embeddingArray = JSON.stringify(Array.from(queryEmbedding));\n\n const distanceExpr = sql<number>`vector_distance_cos(emb_e.embedding, vector32(${embeddingArray}))`;\n\n const results = await this.db\n .select({\n entityId: entities.id,\n entityType: entities.entityType,\n distance: distanceExpr,\n })\n .from(entities)\n .innerJoin(\n sql`emb.embeddings AS emb_e`,\n sql`${entities.id} = emb_e.entity_id AND ${entities.entityType} = emb_e.entity_type`,\n )\n .where(\n and(\n filters.types && filters.types.length > 0\n ? inArray(entities.entityType, filters.types)\n : undefined,\n filters.maxDistance !== undefined\n ? sql`${distanceExpr} <= ${filters.maxDistance}`\n : undefined,\n ),\n )\n .orderBy(sql`${distanceExpr} ASC`);\n\n return results;\n }\n\n /**\n * Transform raw query rows into SearchResult objects\n */\n private mapSearchResults(\n results: Array<{\n id: string;\n entityType: string;\n content: string;\n contentHash: string;\n visibility: ContentVisibility;\n created: number;\n updated: number;\n metadata: unknown;\n weighted_score: number;\n }>,\n query: string,\n ): SearchResult<BaseEntity>[] {\n const searchResults: SearchResult<BaseEntity>[] = [];\n\n for (const row of results) {\n try {\n const metadata = entityMetadataSchema.parse(row.metadata);\n\n const entity = this.serializer.reconstructEntity({\n id: row.id,\n entityType: row.entityType,\n content: row.content,\n contentHash: row.contentHash,\n visibility: row.visibility,\n created: row.created,\n updated: row.updated,\n metadata,\n });\n\n searchResults.push({\n entity,\n score: row.weighted_score,\n excerpt: this.createExcerpt(row.content, query),\n });\n } catch (error) {\n this.logger.error(`Failed to parse entity during search: ${error}`);\n }\n }\n\n const queryPreview =\n query.length > 50 ? query.substring(0, 50) + \"...\" : query;\n this.logger.debug(\n `Found ${searchResults.length} results for query \"${queryPreview}\"`,\n );\n\n return searchResults;\n }\n\n /**\n * Create an excerpt from content based on query\n */\n private createExcerpt(markdown: string, query: string): string {\n // A reader of the result sees its text, not its frontmatter.\n const content = markdownBody(markdown);\n const maxLength = 200;\n const queryLower = query.toLowerCase();\n const contentLower = content.toLowerCase();\n\n // Find the position of the query in the content\n const position = contentLower.indexOf(queryLower);\n\n if (position !== -1) {\n // Extract text around the query\n const start = Math.max(0, position - 50);\n const end = Math.min(content.length, position + queryLower.length + 50);\n let excerpt = content.slice(start, end);\n\n // Add ellipsis if needed\n if (start > 0) excerpt = \"...\" + excerpt;\n if (end < content.length) excerpt = excerpt + \"...\";\n\n return excerpt;\n }\n\n // If query not found, return beginning of content\n return (\n content.slice(0, maxLength) + (content.length > maxLength ? \"...\" : \"\")\n );\n }\n}\n\n/** One key for an entity reference: type and id. */\nfunction referenceKey(reference: {\n entityType: string;\n entityId: string;\n}): string {\n return `${reference.entityType}:${reference.entityId}`;\n}\n",
236
236
  "import {\n generateMarkdown,\n readMarkdownFrontmatter,\n} from \"@brains/utils/markdown-frontmatter\";\nimport { z } from \"@brains/utils/zod\";\nimport type { FrontmatterSchema } from \"./types\";\n\nconst recordSchema = z.record(z.string(), z.unknown());\nconst policyKeys = new Set([\n \"id\",\n \"entityType\",\n \"content\",\n \"contentHash\",\n \"created\",\n \"updated\",\n \"visibility\",\n]);\n\n/** How a registered field whose stored value fails its own schema is treated. */\nexport type InvalidFieldPolicy = \"reject\" | \"omit\";\n\n/** Projection is derived from source, never from a caller's cached metadata. */\nexport function projectFrontmatterExtensions(\n source: Record<string, unknown>,\n metadata: Record<string, unknown>,\n extensions: readonly FrontmatterSchema[],\n invalid: InvalidFieldPolicy = \"reject\",\n): Record<string, unknown> {\n if (extensions.length === 0) return metadata;\n const projected = { ...metadata };\n for (const extension of extensions) {\n const keys = Object.keys(extension.shape);\n const input = Object.fromEntries(\n keys\n .filter((key) => Object.hasOwn(source, key))\n .map((key) => [key, source[key]]),\n );\n // Parse the whole extension, including cross-field refinements. Only this\n // extension's own keys are read, so another field's error never lands here.\n const result = extension.safeParse(input);\n if (!result.success && invalid === \"reject\") throw result.error;\n for (const key of keys) {\n delete projected[key];\n if (\n result.success &&\n Object.hasOwn(source, key) &&\n result.data[key] !== undefined &&\n !policyKeys.has(key)\n ) {\n projected[key] = result.data[key];\n }\n }\n }\n return projected;\n}\n\n/** Preserve unclaimed source fields even after their extension is disabled.\n * Export deliberately does not validate/repair malformed persisted values.\n */\nexport function preserveSourceFrontmatter(\n sourceContent: string,\n serializedContent: string,\n ownerSchema: FrontmatterSchema | undefined,\n extensions: readonly FrontmatterSchema[],\n groupingFields: readonly string[] = [],\n retiredFields: readonly string[] = [],\n): string {\n if (!ownerSchema) return serializedContent;\n // A nested path retires a key inside its object, never the object itself.\n const retired = new Set(retiredFields.filter((path) => !path.includes(\".\")));\n const source = readMarkdownFrontmatter(sourceContent);\n const serialized = readMarkdownFrontmatter(serializedContent);\n const sourceFields = recordSchema.parse(source.data);\n const serializedFields = recordSchema.parse(serialized.data);\n const extensionKeys = new Set([\n ...extensions.flatMap((schema) => Object.keys(schema.shape)),\n ...groupingFields,\n ]);\n const additions = Object.entries(sourceFields).filter(\n ([key, value]) =>\n !policyKeys.has(key) &&\n !retired.has(key) &&\n (!Object.hasOwn(ownerSchema.shape, key) || extensionKeys.has(key)) &&\n (!Object.hasOwn(serializedFields, key) ||\n JSON.stringify(serializedFields[key]) !== JSON.stringify(value)),\n );\n const removals = [\n ...[...extensionKeys].filter(\n (key) =>\n !Object.hasOwn(sourceFields, key) &&\n Object.hasOwn(serializedFields, key),\n ),\n ...[...retired].filter((key) => Object.hasOwn(serializedFields, key)),\n ];\n const merged = { ...serializedFields };\n for (const key of removals) delete merged[key];\n for (const [key, value] of additions) merged[key] = value;\n const result = dropFields(merged, retiredFields) ?? merged;\n if (additions.length === 0 && removals.length === 0 && result === merged)\n return serializedContent;\n // Unlike the domain formatter, preserve explicit null in unclaimed fields.\n return Object.keys(result).length > 0\n ? generateMarkdown(result, serialized.content)\n : serialized.content;\n}\n\n/** Keys a type never writes: its retired and its translated fields. */\nexport function unwrittenFields(config: {\n retiredFields?: readonly string[] | undefined;\n translatedFields?: readonly string[] | undefined;\n}): readonly string[] {\n return [...(config.retiredFields ?? []), ...(config.translatedFields ?? [])];\n}\n\nfunction isPlainRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * The record without the given fields: a top-level key, or a key one level\n * down as `parent.key`. Undefined when the record holds none of them.\n */\nexport function dropFields(\n record: Record<string, unknown>,\n paths: readonly string[],\n): Record<string, unknown> | undefined {\n const result = paths.reduce<Record<string, unknown>>((current, path) => {\n const [parent, key] = path.split(\".\", 2);\n if (parent === undefined || !Object.hasOwn(current, parent)) return current;\n if (key === undefined) {\n const { [parent]: _dropped, ...rest } = current;\n return rest;\n }\n const nested = current[parent];\n if (!isPlainRecord(nested) || !Object.hasOwn(nested, key)) return current;\n const { [key]: _dropped, ...kept } = nested;\n return { ...current, [parent]: kept };\n }, record);\n return result === record ? undefined : result;\n}\n\n/** Markdown with the given frontmatter keys removed; unchanged when it has none of them. */\nexport function withoutFrontmatterFields(\n content: string,\n fields: readonly string[],\n): string {\n if (fields.length === 0) return content;\n const parsed = readMarkdownFrontmatter(content);\n const data = recordSchema.safeParse(parsed.data);\n if (!data.success) return content;\n const kept = dropFields(data.data, fields);\n if (!kept) return content;\n return Object.keys(kept).length > 0\n ? generateMarkdown(kept, parsed.content)\n : parsed.content;\n}\n",
237
237
  "import { z } from \"@brains/utils/zod\";\nimport { canonicalContentVisibilitySchema } from \"./visibility\";\n\nexport const entityGroupingSchema: z.ZodObject<{\n key: z.ZodString;\n label: z.ZodString;\n field: z.ZodString;\n types: z.ZodArray<z.ZodString>;\n}> = z.strictObject({\n key: z\n .string()\n .min(1)\n .max(80)\n .regex(/^[a-z][a-z0-9-]*$/),\n label: z.string().trim().min(1).max(100),\n field: z\n .string()\n .min(1)\n .max(80)\n .regex(/^[a-z][a-z0-9-]*$/),\n types: z\n .array(z.string().min(1).max(100))\n .min(1)\n .max(100)\n .refine(\n (types) => new Set(types).size === types.length,\n \"Grouping types must be unique\",\n ),\n});\nexport type EntityGrouping = z.infer<typeof entityGroupingSchema>;\n\n/** One ordering vocabulary, shared by the queries, the routes and the UI. */\nexport const groupingSortSchema: z.ZodEnum<{\n \"updated-desc\": \"updated-desc\";\n \"updated-asc\": \"updated-asc\";\n \"created-desc\": \"created-desc\";\n \"created-asc\": \"created-asc\";\n}> = z.enum([\"updated-desc\", \"updated-asc\", \"created-desc\", \"created-asc\"]);\nexport type GroupingSort = z.infer<typeof groupingSortSchema>;\n\nexport const GROUPING_PAGE_LIMIT = 50;\nexport const GROUPING_MAX_PAGE_LIMIT = 100;\n/** Bounds every grouping read, whoever builds the request. */\nexport const groupingKeySchema: z.ZodString = z.string().min(1).max(80);\nexport const groupingValueSchema: z.ZodString = z.string().max(10000);\nexport const groupingSearchSchema: z.ZodString = z.string().max(200);\n\nexport const queryGroupingCatalogSchema: z.ZodObject<{\n grouping: z.ZodString;\n entityTypes: z.ZodArray<z.ZodString>;\n visibilityScope: z.ZodOptional<typeof canonicalContentVisibilitySchema>;\n limit: z.ZodDefault<z.ZodNumber>;\n offset: z.ZodDefault<z.ZodNumber>;\n signal: z.ZodOptional<z.ZodCustom<AbortSignal, AbortSignal>>;\n}> = z.object({\n grouping: groupingKeySchema,\n /** Required caller-admitted set; it can only narrow the declared types. */\n entityTypes: z.array(z.string().min(1)).max(100),\n visibilityScope: canonicalContentVisibilitySchema.optional(),\n limit: z\n .number()\n .int()\n .min(1)\n .max(GROUPING_MAX_PAGE_LIMIT)\n .default(GROUPING_PAGE_LIMIT),\n offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),\n signal: z.instanceof(AbortSignal).optional(),\n});\n\nexport const queryGroupingMembersSchema: z.ZodObject<{\n grouping: z.ZodString;\n entityTypes: z.ZodArray<z.ZodString>;\n visibilityScope: z.ZodOptional<typeof canonicalContentVisibilitySchema>;\n limit: z.ZodDefault<z.ZodNumber>;\n offset: z.ZodDefault<z.ZodNumber>;\n signal: z.ZodOptional<z.ZodCustom<AbortSignal, AbortSignal>>;\n value: z.ZodString;\n q: z.ZodOptional<z.ZodString>;\n sort: z.ZodDefault<typeof groupingSortSchema>;\n}> = queryGroupingCatalogSchema.extend({\n value: groupingValueSchema,\n q: groupingSearchSchema.optional(),\n sort: groupingSortSchema.default(\"updated-desc\"),\n});\n\n/** Usage is a distinct entry total plus counts for a bounded set of exact values. */\nexport const queryGroupingUsageSchema: z.ZodObject<{\n grouping: z.ZodString;\n entityTypes: z.ZodArray<z.ZodString>;\n visibilityScope: z.ZodOptional<typeof canonicalContentVisibilitySchema>;\n signal: z.ZodOptional<z.ZodCustom<AbortSignal, AbortSignal>>;\n values: z.ZodDefault<z.ZodArray<z.ZodString>>;\n}> = queryGroupingCatalogSchema.omit({ limit: true, offset: true }).extend({\n values: z.array(groupingValueSchema).max(GROUPING_MAX_PAGE_LIMIT).default([]),\n});\nexport type QueryGroupingUsageRequest = z.input<\n typeof queryGroupingUsageSchema\n>;\nexport interface EntityGroupingUsage {\n /** Entries with at least one text membership, including values outside a list. */\n entries: number;\n /** Request order and literals preserved; unused values have a zero count. */\n values: Array<{ value: string; count: number }>;\n}\n\nexport type QueryGroupingCatalogRequest = z.input<\n typeof queryGroupingCatalogSchema\n>;\nexport type QueryGroupingMembersRequest = z.input<\n typeof queryGroupingMembersSchema\n>;\n\nexport interface EntityGroupingCatalog {\n values: Array<{ value: string; count: number }>;\n total: number;\n}\nexport const GROUPING_RESERVED_FIELDS: ReadonlySet<string> = new Set([\n \"id\",\n \"entityType\",\n \"entity-type\",\n \"content\",\n \"contentHash\",\n \"content-hash\",\n \"created\",\n \"updated\",\n \"visibility\",\n \"metadata\",\n \"__proto__\",\n \"constructor\",\n \"prototype\",\n]);\n",
238
238
  "import { z } from \"@brains/utils/zod\";\nimport { sql, type SQL, type SQLWrapper } from \"drizzle-orm\";\n\nexport type EntityIdPath = [string, ...string[]];\nexport type EntityIdPathInput = readonly [string, ...string[]];\n\nconst SEPARATOR = \":\";\n\nconst storedSegmentSchema = z\n .string()\n .refine((segment) => !segment.includes(SEPARATOR), {\n message: `Entity ID path segments cannot contain '${SEPARATOR}'`,\n });\n\n/** Navigation of existing identity is not validation of a new filesystem-safe path. */\nexport const storedEntityIdPathSchema: z.ZodType<EntityIdPath, unknown> =\n z.tuple([storedSegmentSchema], storedSegmentSchema);\n\nconst entityIdPathSegmentSchema = storedSegmentSchema\n .min(1)\n .refine((segment) => segment !== \".\" && segment !== \"..\", {\n message: \"Entity ID path segments cannot be dot segments\",\n })\n .refine((segment) => !segment.includes(\"/\") && !segment.includes(\"\\\\\"), {\n message: \"Entity ID path segments cannot contain path separators\",\n })\n .refine((segment) => !segment.includes(\"\\0\"), {\n message: \"Entity ID path segments cannot contain null bytes\",\n });\n\n/** Validates author input once; typed paths past this boundary are trusted. */\nexport const entityIdPathSchema: z.ZodType<EntityIdPath, unknown> = z.tuple(\n [entityIdPathSegmentSchema],\n entityIdPathSegmentSchema,\n);\n\n/** Serialize segments at a storage boundary; validate new author input separately. */\nexport function encodeEntityIdPath(path: EntityIdPathInput): string {\n return path.join(SEPARATOR);\n}\n\n/**\n * Decode stored identity losslessly, including values predating authoring validation.\n * Do not filter empty segments or normalize path-like values here: re-encoding must\n * reproduce the original ID. Filesystem placement belongs to directory-sync.\n */\nexport function decodeEntityIdPath(id: string): EntityIdPath {\n const [first = \"\", ...rest] = id.split(SEPARATOR);\n return [first, ...rest];\n}\n\n/** Database projection uses the same separator as encoding, never a second parser. */\nexport function entityIdHierarchyExpressions(\n id: SQLWrapper,\n prefix: EntityIdPathInput | null,\n): { withinPrefix: SQL; directChild: SQL; childName: SQL<ArrayBuffer> } {\n const prefixId =\n prefix === null ? \"\" : encodeEntityIdPath(prefix) + SEPARATOR;\n // Byte comparisons are case-sensitive, treat LIKE metacharacters literally,\n // and preserve Unicode and embedded NULs (SQLite text length stops at NUL).\n const bytes = sql`CAST(${id} AS BLOB)`;\n const prefixBytes = sql`CAST(${prefixId} AS BLOB)`;\n const remainder = sql`substr(${bytes}, length(${prefixBytes}) + 1)`;\n const separatorAt = sql`instr(${remainder}, CAST(${SEPARATOR} AS BLOB))`;\n return {\n // A half-open range preserves literal, binary prefix matching and lets\n // SQLite seek its existing ID index. The terminal separator supplies the\n // upper bound; callers never construct it or know its representation.\n withinPrefix:\n prefix === null\n ? sql`1 = 1`\n : sql`${id} >= ${prefixId} AND ${id} < ${encodeEntityIdPath(prefix) + String.fromCharCode(SEPARATOR.charCodeAt(0) + 1)}`,\n directChild: sql`${separatorAt} = 0`,\n childName: sql<ArrayBuffer>`substr(${remainder}, 1, ${separatorAt} - 1)`,\n };\n}\n",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizom/brain",
3
- "version": "0.2.0-alpha.571",
3
+ "version": "0.2.0-alpha.572",
4
4
  "description": "Brain runtime + CLI — scaffold, run, and manage AI brain instances",
5
5
  "keywords": [
6
6
  "brain",