@rizom/brain 0.2.0-alpha.145 → 0.2.0-alpha.147
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.
- package/dist/brain.js +854 -1000
- package/dist/entities.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +57 -0
- package/dist/plugins.js +24 -24
- package/dist/plugins.js.map +7 -7
- package/dist/services.js.map +1 -1
- package/dist/site.js +5 -5
- package/dist/site.js.map +6 -6
- package/dist/ui/cms-app.js +550 -0
- package/dist/ui/cms-app.js.map +317 -0
- package/package.json +1 -1
package/dist/services.js.map
CHANGED
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
"import * as core from \"../core/index.js\";\nimport * as schemas from \"./schemas.js\";\nexport function string(params) {\n return core._coercedString(schemas.ZodString, params);\n}\nexport function number(params) {\n return core._coercedNumber(schemas.ZodNumber, params);\n}\nexport function boolean(params) {\n return core._coercedBoolean(schemas.ZodBoolean, params);\n}\nexport function bigint(params) {\n return core._coercedBigint(schemas.ZodBigInt, params);\n}\nexport function date(params) {\n return core._coercedDate(schemas.ZodDate, params);\n}\n",
|
|
163
163
|
"export * as core from \"../core/index.js\";\nexport * from \"./schemas.js\";\nexport * from \"./checks.js\";\nexport * from \"./errors.js\";\nexport * from \"./parse.js\";\nexport * from \"./compat.js\";\n// zod-specified\nimport { config } from \"../core/index.js\";\nimport en from \"../locales/en.js\";\nconfig(en());\nexport { globalRegistry, registry, config, $output, $input, $brand, clone, regexes, treeifyError, prettifyError, formatError, flattenError, TimePrecision, util, NEVER, } from \"../core/index.js\";\nexport { toJSONSchema } from \"../core/json-schema-processors.js\";\nexport { fromJSONSchema } from \"./from-json-schema.js\";\nexport * as locales from \"../locales/index.js\";\n// iso\n// must be exported from top-level\n// https://github.com/colinhacks/zod/issues/4491\nexport { ZodISODateTime, ZodISODate, ZodISOTime, ZodISODuration } from \"./iso.js\";\nexport * as iso from \"./iso.js\";\nexport * as coerce from \"./coerce.js\";\n",
|
|
164
164
|
"import { z } from \"@brains/utils/zod\";\n\nconst canonicalContentVisibilitySchema: z.ZodEnum<{\n public: \"public\";\n shared: \"shared\";\n restricted: \"restricted\";\n}> = z.enum([\"public\", \"shared\", \"restricted\"]);\n\nexport type ContentVisibility = z.infer<\n typeof canonicalContentVisibilitySchema\n>;\nexport type RawContentVisibility = ContentVisibility | \"private\";\n\nexport const contentVisibilitySchema: z.ZodPipe<\n z.ZodOptional<\n z.ZodUnion<\n readonly [\n typeof canonicalContentVisibilitySchema,\n z.ZodLiteral<\"private\">,\n ]\n >\n >,\n z.ZodTransform<ContentVisibility, RawContentVisibility | undefined>\n> = z\n .union([canonicalContentVisibilitySchema, z.literal(\"private\")])\n .optional()\n .transform((value): ContentVisibility => {\n if (value === undefined) return \"public\";\n if (value === \"private\") return \"restricted\";\n return value;\n });\n\nexport function normalizeContentVisibility(\n visibility: RawContentVisibility | undefined,\n): ContentVisibility {\n return contentVisibilitySchema.parse(visibility);\n}\n\nconst visibleContentVisibilitiesByScope: Record<\n ContentVisibility,\n ContentVisibility[]\n> = {\n public: [\"public\"],\n shared: [\"public\", \"shared\"],\n restricted: [\"public\", \"shared\", \"restricted\"],\n};\n\nexport function getVisibleContentVisibilities(\n scope: ContentVisibility,\n): ContentVisibility[] {\n return visibleContentVisibilitiesByScope[scope];\n}\n\nexport function isVisibleWithinScope(\n visibility: ContentVisibility | undefined,\n scope: ContentVisibility,\n): boolean {\n return getVisibleContentVisibilities(scope).includes(visibility ?? \"public\");\n}\n\n/**\n * Map a caller's permission level to the content-visibility scope they may see.\n * public → public (only public content)\n * trusted → shared (public + shared)\n * anchor → restricted (public + shared + restricted)\n *\n * Defaults to \"public\" when no permission level is provided, so missing\n * context fails closed.\n */\nexport function permissionToVisibilityScope(\n level: \"anchor\" | \"trusted\" | \"public\" | undefined,\n): ContentVisibility {\n if (level === \"anchor\") return \"restricted\";\n if (level === \"trusted\") return \"shared\";\n return \"public\";\n}\n\n/**\n * Whether a caller at `level` is allowed to author or update an entity at\n * `visibility`. A user may only write content at a visibility they themselves\n * can read — otherwise they could ghost-write content into a higher trust\n * level than their permission, which is a write-side escalation vector.\n *\n * public → may write \"public\"\n * trusted → may write \"public\" | \"shared\"\n * anchor → may write \"public\" | \"shared\" | \"restricted\"\n */\nexport function canWriteVisibility(\n level: \"anchor\" | \"trusted\" | \"public\" | undefined,\n visibility: ContentVisibility,\n): boolean {\n return isVisibleWithinScope(visibility, permissionToVisibilityScope(level));\n}\n",
|
|
165
|
-
"import { z } from \"@brains/utils/zod\";\nimport {\n contentVisibilitySchema,\n type ContentVisibility,\n type RawContentVisibility,\n} from \"./visibility\";\n\n/**\n * Entity type for unstructured notes (the \"note\" entity type).\n * Used as a sentinel for the default catch-all markdown file shape —\n * no typed frontmatter schema required, content is the entire file body.\n */\nexport const NOTE_ENTITY_TYPE = \"note\";\n\n/**\n * Embedding job data - minimal data for job queue\n * Content is NOT stored to avoid large base64 data in job queue\n * (which would end up in dashboard hydration props JSON)\n * Handler fetches fresh content from entity when processing\n */\nexport interface EmbeddingJobData {\n id: string;\n entityType: string;\n /** Hash of content at job creation time - for staleness detection */\n contentHash: string;\n operation: \"create\" | \"update\";\n}\n\n/**\n * Options for entity mutation operations (create, update, upsert)\n */\nexport interface EntityMutationEventContext {\n conversationId?: string;\n channelId?: string;\n runId?: string;\n toolCallId?: string;\n}\n\nexport interface EntityJobOptions {\n priority?: number;\n maxRetries?: number;\n eventContext?: EntityMutationEventContext;\n}\n\nexport {\n contentVisibilitySchema,\n normalizeContentVisibility,\n getVisibleContentVisibilities,\n isVisibleWithinScope,\n permissionToVisibilityScope,\n canWriteVisibility,\n} from \"./visibility\";\nexport type { ContentVisibility, RawContentVisibility } from \"./visibility\";\n\n/**\n * Options for entity creation (extends EntityJobOptions with deduplication)\n */\nexport interface CreateEntityOptions extends EntityJobOptions {\n deduplicateId?: boolean;\n}\n\n/**\n * Result of an entity mutation that triggers an embedding job.\n * When skipped is true, content was unchanged — no DB write, no event, no embedding job.\n */\nexport interface EntityMutationResult {\n entityId: string;\n jobId: string;\n skipped: boolean;\n}\n\n/**\n * Input for adapter-validated direct creation from finalized markdown.\n */\nexport interface CreateEntityFromMarkdownInput {\n entityType: string;\n id: string;\n markdown: string;\n}\n\n/**\n * Data for storing an embedding for an entity\n */\nexport interface StoreEmbeddingData {\n entityId: string;\n entityType: string;\n embedding: Float32Array;\n contentHash: string;\n}\n\n/**\n * Base entity schema that all entities must extend\n */\nexport const baseEntitySchema: z.ZodObject<{\n id: z.ZodString;\n entityType: z.ZodString;\n content: z.ZodString;\n created: z.ZodString;\n updated: z.ZodString;\n visibility: typeof contentVisibilitySchema;\n metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;\n contentHash: z.ZodString;\n}> = z.object({\n id: z.string(),\n entityType: z.string(),\n content: z.string(),\n created: z.string().datetime(),\n updated: z.string().datetime(),\n visibility: contentVisibilitySchema,\n metadata: z.record(z.string(), z.unknown()),\n contentHash: z.string(),\n});\n\nconst canonicalContentVisibilityParserSchema: z.ZodEnum<{\n public: \"public\";\n shared: \"shared\";\n restricted: \"restricted\";\n}> = z.enum([\"public\", \"shared\", \"restricted\"]);\n\n/** Zod 4-owned parser base for entity schemas whose registration slot is structural. */\nexport const baseEntityParserSchema: z.ZodObject<{\n id: z.ZodString;\n entityType: z.ZodString;\n content: z.ZodString;\n created: z.ZodString;\n updated: z.ZodString;\n visibility: z.ZodPipe<\n z.ZodOptional<\n z.ZodUnion<\n readonly [\n typeof canonicalContentVisibilityParserSchema,\n z.ZodLiteral<\"private\">,\n ]\n >\n >,\n z.ZodTransform<ContentVisibility, RawContentVisibility | undefined>\n >;\n metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;\n contentHash: z.ZodString;\n}> = z.object({\n id: z.string(),\n entityType: z.string(),\n content: z.string(),\n created: z.string().datetime(),\n updated: z.string().datetime(),\n visibility: z\n .union([canonicalContentVisibilityParserSchema, z.literal(\"private\")])\n .optional()\n .transform((value): ContentVisibility => {\n if (value === undefined) return \"public\";\n if (value === \"private\") return \"restricted\";\n return value;\n }),\n metadata: z.record(z.string(), z.unknown()),\n contentHash: z.string(),\n});\n\n/** Shared empty frontmatter schema for entity types with no typed frontmatter. */\nexport const emptyFrontmatterSchema: z.ZodObject<Record<string, never>> =\n z.object({});\n\nexport type EntitySchema<T> = z.ZodType<T, unknown>;\n/** @deprecated Use EntitySchema<T>. */\nexport type EntitySchemaParser<T> = EntitySchema<T>;\n\nexport type UnknownEntitySchema = EntitySchema<unknown>;\nexport type FrontmatterSchema = z.ZodObject<z.ZodRawShape>;\n\n/**\n * Base entity type - generic to support typed metadata\n * TMetadata defaults to Record<string, unknown> for backward compatibility\n */\nexport interface BaseEntity<TMetadata = Record<string, unknown>> {\n id: string;\n entityType: string;\n content: string;\n created: string;\n updated: string;\n visibility: ContentVisibility;\n metadata: TMetadata;\n /** SHA256 hash of content for change detection */\n contentHash: string;\n}\n\n/**\n * Entity input type for creation - allows partial entities with optional system fields\n * contentHash is excluded because it's computed automatically by the entity service\n */\nexport type EntityInput<T extends BaseEntity> = Omit<\n T,\n \"id\" | \"created\" | \"updated\" | \"contentHash\" | \"visibility\"\n> & {\n id?: string;\n created?: string;\n updated?: string;\n visibility?: RawContentVisibility;\n};\n\n/**\n * Search result type\n */\nexport interface SearchResult<T extends BaseEntity = BaseEntity> {\n entity: T;\n score: number;\n excerpt: string;\n}\n\n/**\n * Normalized system_create input shape used by plugin create interceptors.\n */\nexport interface CreateCoverImageInput {\n generate?: boolean | undefined;\n prompt?: string | undefined;\n}\n\nexport interface CreateFromAttachmentInput {\n kind: \"entity-attachment\";\n sourceEntityType: string;\n sourceEntityId: string;\n attachmentType: string;\n}\n\nexport interface CreateFromUploadInput {\n kind: \"upload\";\n id: string;\n}\n\nexport interface CreateFromConversationMessageInput {\n kind: \"conversation-message\";\n messageId?: string | undefined;\n}\n\nexport type CreateFromInput =\n | CreateFromAttachmentInput\n | CreateFromUploadInput\n | CreateFromConversationMessageInput;\n\nexport type CreateTransform = \"extract-markdown\" | \"preserve\";\n\nexport interface CreateInput {\n entityType: string;\n prompt?: string;\n title?: string;\n content?: string;\n url?: string;\n from?: CreateFromInput;\n transform?: CreateTransform;\n replace?: boolean;\n sourceEntityType?: string;\n sourceEntityId?: string;\n sourceEntityIds?: string[];\n targetEntityType?: string;\n targetEntityId?: string;\n coverImage?: boolean | CreateCoverImageInput;\n}\n\n/**\n * Minimal caller context forwarded to plugin create interceptors.\n */\nexport interface CreateExecutionContext {\n interfaceType: string;\n userId: string;\n channelId?: string;\n channelName?: string;\n}\n\n/**\n * Result returned to system_create when a plugin fully handles creation.\n */\nexport const createResultAttachmentSchema: z.ZodObject<{\n mediaType: z.ZodString;\n url: z.ZodString;\n downloadUrl: z.ZodOptional<z.ZodString>;\n previewUrl: z.ZodOptional<z.ZodString>;\n filename: z.ZodOptional<z.ZodString>;\n sizeBytes: z.ZodOptional<z.ZodNumber>;\n source: z.ZodOptional<\n z.ZodObject<{\n entityType: z.ZodOptional<z.ZodString>;\n entityId: z.ZodOptional<z.ZodString>;\n attachmentType: z.ZodOptional<z.ZodString>;\n }>\n >;\n}> = z.object({\n mediaType: z.string(),\n url: z.string(),\n downloadUrl: z.string().optional(),\n previewUrl: z.string().optional(),\n filename: z.string().optional(),\n sizeBytes: z.number().optional(),\n source: z\n .object({\n entityType: z.string().optional(),\n entityId: z.string().optional(),\n attachmentType: z.string().optional(),\n })\n .optional(),\n});\n\nexport type CreateResultAttachment = z.infer<\n typeof createResultAttachmentSchema\n>;\n\nexport type CreateResult =\n | {\n success: true;\n data: {\n entityId?: string;\n jobId?: string;\n status: string;\n attachment?: CreateResultAttachment;\n };\n }\n | { success: false; error: string };\n\n/**\n * Plugin create interceptors can either fully handle creation,\n * or continue with a rewritten normalized input.\n */\nexport type CreateInterceptionResult =\n | { kind: \"handled\"; result: CreateResult }\n | { kind: \"continue\"; input: CreateInput };\n\nexport type CreateInterceptor = (\n input: CreateInput,\n executionContext: CreateExecutionContext,\n) => Promise<CreateInterceptionResult>;\n\nexport interface UploadSaveInput {\n upload: CreateFromUploadInput;\n title?: string;\n}\n\nexport type UploadSaveHandler = (\n input: UploadSaveInput,\n executionContext: CreateExecutionContext,\n) => Promise<CreateResult>;\n\nexport interface UploadSaveHandlerRegistration {\n entityType: string;\n mediaTypes: string[];\n handler: UploadSaveHandler;\n}\n\n/**\n * Called before an entity is persisted (on create or update). Throws to reject\n * the write with an operator-facing error. Use this for cross-entity invariants\n * the per-entity Zod schema cannot express.\n */\nexport type PersistValidator<T extends BaseEntity = BaseEntity> = (\n entity: T,\n context: { operation: \"create\" | \"update\" },\n) => Promise<void>;\n\n/**\n * Minimal event bus contract used by entity-service for lifecycle events.\n * Kept structural to avoid coupling this package to a concrete messaging service.\n */\nexport interface EntityEventBus {\n send(request: {\n type: string;\n payload: Record<string, unknown>;\n sender: string;\n target?: string;\n metadata?: Record<string, unknown>;\n broadcast?: boolean;\n }): Promise<unknown>;\n}\n\n/**\n * Interface for entity adapter - handles conversion between entities and markdown\n * following the hybrid storage model\n *\n * @template TEntity - The full entity type\n * @template TMetadata - The metadata type (defaults to Record<string, unknown>)\n */\nexport interface EntityAdapter<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n> {\n entityType: string;\n\n /**\n * One declarative sentence describing what this entity type is. This is the\n * data the model reads to select `entityType`, replacing hardcoded example\n * phrasings in system instructions. Required: every type must define itself.\n */\n purpose: string;\n\n schema: EntitySchema<TEntity>;\n\n // Convert entity to markdown content (may include frontmatter for entity-specific fields)\n toMarkdown(entity: TEntity): string;\n\n // Extract entity-specific fields from markdown\n // Returns Partial<TEntity> as core fields come from database\n fromMarkdown(markdown: string): Partial<TEntity>;\n\n // Extract metadata from entity for search/filtering - now strongly typed\n extractMetadata(entity: TEntity): TMetadata;\n\n // Parse frontmatter metadata from markdown\n parseFrontMatter<TFrontmatter>(\n markdown: string,\n schema: { parse(data: unknown): TFrontmatter },\n ): TFrontmatter;\n\n // Generate frontmatter for markdown\n generateFrontMatter(entity: TEntity): string;\n\n /** Optional: Zod schema for frontmatter fields. Used by CMS config generation. */\n frontmatterSchema?: z.ZodObject<z.ZodRawShape>;\n\n /** Optional: Declares this entity type is a singleton (one file, e.g., identity/identity.md). Used by CMS to generate files collection. */\n isSingleton?: boolean;\n\n /** Optional: Whether this entity has a free-form markdown body below frontmatter. Defaults to true. When false, CMS omits the body widget. */\n hasBody?: boolean;\n\n /** Returns a markdown body template with section headings for this entity type. Empty string for free-form entities. */\n getBodyTemplate(): string;\n\n /** Optional: Declares that this entity type supports cover images via coverImageId in frontmatter */\n supportsCoverImage?: boolean;\n\n /** Optional: Extract coverImageId from entity content/frontmatter */\n getCoverImageId?(entity: TEntity): string | undefined;\n\n /**\n * Optional: build the markdown content and metadata for a queued-generation stub.\n * When undefined, this entity type does not support prompt-based queued creation\n * via system_create; the tool will reject the call rather than silently degrade.\n * The returned metadata must satisfy this entity's metadata schema (with\n * status set to \"generating\"); central code only stamps id/timestamps/visibility.\n */\n buildStub?(input: { id: string; title: string }): {\n content: string;\n metadata: TMetadata;\n };\n}\n\n/**\n * Sort field specification for multi-field sorting\n */\nexport interface SortField {\n /** Field to sort by - \"created\", \"updated\", or a metadata field name */\n field: string;\n /** Sort direction */\n direction: \"asc\" | \"desc\";\n /** Sort NULL values before non-NULL values (default: false / SQLite default) */\n nullsFirst?: boolean;\n}\n\n/**\n * List entities options\n * Generic over metadata type for type-safe filtering\n */\nexport interface ListOptions<TMetadata = Record<string, unknown>> {\n limit?: number;\n offset?: number;\n /** Multi-field sorting - supports system fields (created, updated) and metadata fields */\n sortFields?: SortField[];\n filter?: {\n // Typed metadata filter - partial match on metadata fields\n metadata?: Partial<TMetadata>;\n visibilityScope?: ContentVisibility;\n };\n /** Filter to only entities with metadata.status = \"published\" */\n publishedOnly?: boolean;\n}\n\n/**\n * Search options\n */\nexport interface SearchOptions {\n limit?: number;\n offset?: number;\n types?: string[];\n excludeTypes?: string[];\n sortBy?: \"relevance\" | \"created\" | \"updated\";\n sortDirection?: \"asc\" | \"desc\";\n /** Score multipliers per entity type - applied after initial search */\n weight?: Record<string, number>;\n visibilityScope?: ContentVisibility;\n /** Include queued/failed generation stubs in search results (default: false) */\n includeUngenerated?: boolean;\n /** Minimum relevance score to return. Omit for no score cutoff. */\n minScore?: number;\n}\n\n/**\n * Configuration for entity type registration\n */\nexport interface EntityTypeConfig {\n /** Score multiplier for search results (default: 1.0) */\n weight?: number;\n /** Whether to generate embeddings for this entity type (default: true).\n * Set to false for entity types with non-textual content (e.g., images). */\n embeddable?: boolean;\n /** Whether this entity type may be used as source material for derived projections (default: true).\n * Set to false for projection outputs that would create feedback loops. */\n projectionSource?: boolean;\n /** Publish semantics for status-bearing entity types. Statuses listed here\n * represent publication commitment/execution states and require the\n * `publish` entity action when entered or modified. */\n publish?: {\n publishStatuses: string[];\n };\n}\n\n/**\n * Core entity service interface for read-only operations\n * Used by core plugins that need entity access but shouldn't modify entities\n */\nexport interface GetEntityRequest {\n entityType: string;\n id: string;\n /**\n * Optional visibility scope. Undefined fails closed to \"public\" — callers\n * with elevated access must opt up explicitly.\n */\n visibilityScope?: ContentVisibility;\n}\n\nexport type GetEntityRawRequest = GetEntityRequest;\n\nexport interface ListEntitiesRequest {\n entityType: string;\n options?: ListOptions | undefined;\n}\n\nexport interface CountEntitiesRequest {\n entityType: string;\n options?: Pick<ListOptions, \"publishedOnly\" | \"filter\"> | undefined;\n}\n\nexport interface CreateEntityRequest<T extends BaseEntity> {\n entity: EntityInput<T>;\n options?: CreateEntityOptions | undefined;\n}\n\nexport interface CreateEntityFromMarkdownRequest {\n input: CreateEntityFromMarkdownInput;\n options?: CreateEntityOptions | undefined;\n}\n\nexport interface UpdateEntityRequest<T extends BaseEntity> {\n entity: T;\n options?: EntityJobOptions | undefined;\n}\n\nexport interface DeleteEntityRequest {\n entityType: string;\n id: string;\n}\n\nexport interface UpsertEntityRequest<T extends BaseEntity> {\n entity: T;\n options?: EntityJobOptions | undefined;\n}\n\nexport interface EntitySearchRequest {\n query: string;\n options?: SearchOptions | undefined;\n}\n\nexport interface SearchWithDistancesRequest {\n query: string;\n}\n\n/**\n * Context passed to all DataSource operations\n * Contains internal state that should not be mixed with user query parameters\n */\nexport interface BaseDataSourceContext {\n /**\n * Whether to filter to only published/complete content\n * Set by site-builder: true for production, false for preview\n */\n publishedOnly?: boolean;\n\n /**\n * Scoped entity service that auto-applies publishedOnly filter\n * Datasources should use this instead of their injected entityService\n * to ensure consistent filtering behavior across environments\n */\n entityService: EntityService;\n}\n\nexport type DataSourceSchema<T> = z.ZodType<T, unknown>;\n\n/**\n * DataSource Interface\n *\n * Provides data for templates through fetch, generate, or transform operations.\n * DataSources are registered in the DataSourceRegistry and referenced by templates\n * via their dataSourceId property.\n */\nexport interface DataSource {\n /**\n * Unique identifier for this data source\n */\n id: string;\n\n /**\n * Human-readable name for this data source\n */\n name: string;\n\n /**\n * Optional description of what this data source provides\n */\n description?: string;\n\n /**\n * Optional: Fetch existing data\n * Used by data sources that aggregate or retrieve data (e.g., dashboard stats, API data)\n * DataSources validate output using the provided schema\n * @param query - Query parameters for fetching data\n * @param outputSchema - Schema for validating output data\n * @param context - Context with environment\n */\n fetch?: <T>(\n query: unknown,\n outputSchema: z.ZodSchema<T>,\n context: BaseDataSourceContext,\n ) => Promise<T>;\n\n /**\n * Optional: Generate new content\n * Used by data sources that create content (e.g., AI-generated content, reports)\n */\n generate?: <T>(request: unknown, schema: z.ZodSchema<T>) => Promise<T>;\n\n /**\n * Optional: Transform content between formats\n * Used by data sources that convert content (e.g., markdown to HTML, data formatting)\n */\n transform?: <T>(\n content: unknown,\n format: string,\n schema: z.ZodSchema<T>,\n ) => Promise<T>;\n}\n\n/**\n * DataSource capabilities for discovery and validation\n */\nexport interface DataSourceCapabilities {\n canFetch: boolean;\n canGenerate: boolean;\n canTransform: boolean;\n}\n\nexport interface ICoreEntityService {\n // Read-only operations\n getEntity<T extends BaseEntity>(request: GetEntityRequest): Promise<T | null>;\n\n /**\n * Get entity without content resolution (raw)\n * Used internally to avoid recursion when resolving image references\n */\n getEntityRaw<T extends BaseEntity>(\n request: GetEntityRawRequest,\n ): Promise<T | null>;\n\n listEntities<T extends BaseEntity>(\n request: ListEntitiesRequest,\n ): Promise<T[]>;\n\n search<T extends BaseEntity = BaseEntity>(\n request: EntitySearchRequest,\n ): Promise<SearchResult<T>[]>;\n\n // Entity type information\n getEntityTypes(): string[];\n hasEntityType(type: string): boolean;\n\n // Entity counts\n countEntities(request: CountEntitiesRequest): Promise<number>;\n /**\n * Group counts by entity type. Fails closed: undefined visibilityScope\n * filters to public-only counts so aggregate insights cannot reveal\n * non-public entity existence.\n */\n getEntityCounts(\n visibilityScope?: ContentVisibility,\n ): Promise<Array<{ entityType: string; count: number }>>;\n\n /** Get configuration for a specific entity type */\n getEntityTypeConfig(type: string): EntityTypeConfig;\n\n /** Get weight map for all registered entity types with non-default weights */\n getWeightMap(): Record<string, number>;\n}\n\n/**\n * Entity service interface for managing brain entities\n */\nexport interface IEntitiesNamespace {\n /** Register a new entity type with schema and adapter */\n register<TEntity extends BaseEntity>(\n entityType: string,\n schema: EntitySchema<TEntity>,\n adapter: EntityAdapter<TEntity>,\n config?: EntityTypeConfig,\n ): void;\n\n /**\n * Get the adapter for an entity type.\n *\n * Returns the structural `EntityAdapter<BaseEntity>` view — namespace\n * consumers don't narrow by entity type. For typed access tied to a\n * specific `TEntity`, use the underlying `EntityRegistry.getAdapter<T>`\n * directly (see `entity-serializer.ts`).\n */\n getAdapter(entityType: string): EntityAdapter<BaseEntity> | undefined;\n\n /** Extend an adapter's frontmatterSchema with additional fields */\n extendFrontmatterSchema(\n type: string,\n extension: z.ZodObject<z.ZodRawShape>,\n ): void;\n\n /** Get effective frontmatter schema (base + extensions) for an entity type */\n getEffectiveFrontmatterSchema(\n type: string,\n ): z.ZodObject<z.ZodRawShape> | undefined;\n\n /** Update an existing entity */\n update<TEntity extends BaseEntity>(\n entity: TEntity,\n ): Promise<{ entityId: string; jobId: string }>;\n\n /** Register a data source for dynamic content */\n registerDataSource(dataSource: DataSource): void;\n\n /** Register a create interceptor for this plugin's entity type */\n registerCreateInterceptor(\n entityType: string,\n interceptor: CreateInterceptor,\n ): void;\n\n /** Register a raw-upload durable save handler for this plugin's entity type */\n registerUploadSaveHandler(registration: UploadSaveHandlerRegistration): void;\n}\n\nexport interface EmbeddingBackfillResult {\n queued: number;\n skipped: number;\n}\n\nexport interface EmbeddingFailureReference {\n entityId: string;\n entityType: string;\n contentHash: string;\n}\n\nexport interface EmbeddingIndexStats {\n missingEmbeddings: number;\n staleEmbeddings: number;\n failedEmbeddings: number;\n /** Entities whose type generates embeddings (non-embeddable types excluded). */\n embeddableEntities: number;\n /** Embeddable entities whose embedding is present and current. */\n embeddedEntities: number;\n}\n\nexport interface IndexReadinessOptions {\n timeoutMs: number;\n intervalMs?: number;\n}\n\nexport interface IndexReadinessStatus extends EmbeddingIndexStats {\n ready: boolean;\n degraded: boolean;\n activeEmbeddingJobs: number;\n}\n\nexport interface EntityService extends ICoreEntityService {\n // Mutations\n createEntity<T extends BaseEntity>(\n request: CreateEntityRequest<T>,\n ): Promise<EntityMutationResult>;\n createEntityFromMarkdown(\n request: CreateEntityFromMarkdownRequest,\n ): Promise<EntityMutationResult>;\n updateEntity<T extends BaseEntity>(\n request: UpdateEntityRequest<T>,\n ): Promise<EntityMutationResult>;\n deleteEntity(request: DeleteEntityRequest): Promise<boolean>;\n upsertEntity<T extends BaseEntity>(\n request: UpsertEntityRequest<T>,\n ): Promise<EntityMutationResult & { created: boolean }>;\n storeEmbedding(data: StoreEmbeddingData): Promise<void>;\n backfillMissingEmbeddings(): Promise<EmbeddingBackfillResult>;\n isIndexReady(): boolean;\n awaitIndexReady(\n options: IndexReadinessOptions,\n ): Promise<IndexReadinessStatus>;\n\n // Serialization\n serializeEntity(entity: BaseEntity): string;\n deserializeEntity(markdown: string, entityType: string): Partial<BaseEntity>;\n\n // Counts\n countEmbeddings(): Promise<number>;\n\n // Diagnostics\n searchWithDistances(\n request: SearchWithDistancesRequest,\n ): Promise<Array<{ entityId: string; entityType: string; distance: number }>>;\n\n // Lifecycle\n initialize(): Promise<void>;\n\n // Job status\n getAsyncJobStatus(jobId: string): Promise<{\n status: \"pending\" | \"processing\" | \"completed\" | \"failed\";\n error?: string;\n } | null>;\n}\n\n/**\n * Entity Registry interface for managing entity types and their schemas/adapters\n */\nexport interface EntityRegistry {\n registerEntityType<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n >(\n type: string,\n schema: UnknownEntitySchema,\n adapter: EntityAdapter<TEntity, TMetadata>,\n config?: EntityTypeConfig,\n ): void;\n\n getSchema(type: string): UnknownEntitySchema;\n\n getAdapter<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n >(\n type: string,\n ): EntityAdapter<TEntity, TMetadata>;\n\n hasEntityType(type: string): boolean;\n\n validateEntity(type: string, entity: unknown): BaseEntity;\n\n getAllEntityTypes(): string[];\n\n /** Get configuration for a specific entity type */\n getEntityTypeConfig(type: string): EntityTypeConfig;\n\n /** Get weight map for all registered entity types with non-default weights */\n getWeightMap(): Record<string, number>;\n\n registerCreateInterceptor(type: string, interceptor: CreateInterceptor): void;\n\n getCreateInterceptor(type: string): CreateInterceptor | undefined;\n\n registerUploadSaveHandler(registration: UploadSaveHandlerRegistration): void;\n\n getUploadSaveHandler(\n mediaType: string,\n ): UploadSaveHandlerRegistration | undefined;\n\n registerPersistValidator(type: string, validator: PersistValidator): void;\n\n getPersistValidator(type: string): PersistValidator | undefined;\n\n /**\n * Extend an adapter's frontmatterSchema with additional fields.\n * Used by plugins to add domain-specific fields (e.g., professional-site adds tagline to profile).\n * Extensions are merged into the effective schema returned by getEffectiveFrontmatterSchema().\n */\n extendFrontmatterSchema(\n type: string,\n extension: z.ZodObject<z.ZodRawShape>,\n ): void;\n\n /**\n * Get the effective frontmatter schema for an entity type,\n * with all registered extensions merged in.\n * Returns undefined if the adapter has no frontmatterSchema.\n */\n getEffectiveFrontmatterSchema(\n type: string,\n ): z.ZodObject<z.ZodRawShape> | undefined;\n}\n\n/**\n * Database configuration for entity service\n */\nexport type { DbConfig as EntityDbConfig } from \"@brains/contracts\";\n",
|
|
165
|
+
"import { z } from \"@brains/utils/zod\";\nimport {\n contentVisibilitySchema,\n type ContentVisibility,\n type RawContentVisibility,\n} from \"./visibility\";\n\n/**\n * Entity type for unstructured notes (the \"note\" entity type).\n * Used as a sentinel for the default catch-all markdown file shape —\n * no typed frontmatter schema required, content is the entire file body.\n */\nexport const NOTE_ENTITY_TYPE = \"note\";\n\n/**\n * Embedding job data - minimal data for job queue\n * Content is NOT stored to avoid large base64 data in job queue\n * (which would end up in dashboard hydration props JSON)\n * Handler fetches fresh content from entity when processing\n */\nexport interface EmbeddingJobData {\n id: string;\n entityType: string;\n /** Hash of content at job creation time - for staleness detection */\n contentHash: string;\n operation: \"create\" | \"update\";\n}\n\n/**\n * Options for entity mutation operations (create, update, upsert)\n */\nexport interface EntityMutationEventContext {\n conversationId?: string;\n channelId?: string;\n runId?: string;\n toolCallId?: string;\n}\n\nexport interface EntityJobOptions {\n priority?: number;\n maxRetries?: number;\n eventContext?: EntityMutationEventContext;\n}\n\nexport {\n contentVisibilitySchema,\n normalizeContentVisibility,\n getVisibleContentVisibilities,\n isVisibleWithinScope,\n permissionToVisibilityScope,\n canWriteVisibility,\n} from \"./visibility\";\nexport type { ContentVisibility, RawContentVisibility } from \"./visibility\";\n\n/**\n * Options for entity creation (extends EntityJobOptions with deduplication)\n */\nexport interface CreateEntityOptions extends EntityJobOptions {\n deduplicateId?: boolean;\n}\n\n/**\n * Result of an entity mutation that triggers an embedding job.\n * When skipped is true, content was unchanged — no DB write, no event, no embedding job.\n */\nexport interface EntityMutationResult {\n entityId: string;\n jobId: string;\n skipped: boolean;\n}\n\n/**\n * Input for adapter-validated direct creation from finalized markdown.\n */\nexport interface CreateEntityFromMarkdownInput {\n entityType: string;\n id: string;\n markdown: string;\n}\n\n/**\n * Data for storing an embedding for an entity\n */\nexport interface StoreEmbeddingData {\n entityId: string;\n entityType: string;\n embedding: Float32Array;\n contentHash: string;\n}\n\n/**\n * Base entity schema that all entities must extend\n */\nexport const baseEntitySchema: z.ZodObject<{\n id: z.ZodString;\n entityType: z.ZodString;\n content: z.ZodString;\n created: z.ZodString;\n updated: z.ZodString;\n visibility: typeof contentVisibilitySchema;\n metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;\n contentHash: z.ZodString;\n}> = z.object({\n id: z.string(),\n entityType: z.string(),\n content: z.string(),\n created: z.string().datetime(),\n updated: z.string().datetime(),\n visibility: contentVisibilitySchema,\n metadata: z.record(z.string(), z.unknown()),\n contentHash: z.string(),\n});\n\nconst canonicalContentVisibilityParserSchema: z.ZodEnum<{\n public: \"public\";\n shared: \"shared\";\n restricted: \"restricted\";\n}> = z.enum([\"public\", \"shared\", \"restricted\"]);\n\n/** Zod 4-owned parser base for entity schemas whose registration slot is structural. */\nexport const baseEntityParserSchema: z.ZodObject<{\n id: z.ZodString;\n entityType: z.ZodString;\n content: z.ZodString;\n created: z.ZodString;\n updated: z.ZodString;\n visibility: z.ZodPipe<\n z.ZodOptional<\n z.ZodUnion<\n readonly [\n typeof canonicalContentVisibilityParserSchema,\n z.ZodLiteral<\"private\">,\n ]\n >\n >,\n z.ZodTransform<ContentVisibility, RawContentVisibility | undefined>\n >;\n metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;\n contentHash: z.ZodString;\n}> = z.object({\n id: z.string(),\n entityType: z.string(),\n content: z.string(),\n created: z.string().datetime(),\n updated: z.string().datetime(),\n visibility: z\n .union([canonicalContentVisibilityParserSchema, z.literal(\"private\")])\n .optional()\n .transform((value): ContentVisibility => {\n if (value === undefined) return \"public\";\n if (value === \"private\") return \"restricted\";\n return value;\n }),\n metadata: z.record(z.string(), z.unknown()),\n contentHash: z.string(),\n});\n\n/** Shared empty frontmatter schema for entity types with no typed frontmatter. */\nexport const emptyFrontmatterSchema: z.ZodObject<Record<string, never>> =\n z.object({});\n\nexport type EntitySchema<T> = z.ZodType<T, unknown>;\n/** @deprecated Use EntitySchema<T>. */\nexport type EntitySchemaParser<T> = EntitySchema<T>;\n\nexport type UnknownEntitySchema = EntitySchema<unknown>;\nexport type FrontmatterSchema = z.ZodObject<z.ZodRawShape>;\n\n/**\n * Base entity type - generic to support typed metadata\n * TMetadata defaults to Record<string, unknown> for backward compatibility\n */\nexport interface BaseEntity<TMetadata = Record<string, unknown>> {\n id: string;\n entityType: string;\n content: string;\n created: string;\n updated: string;\n visibility: ContentVisibility;\n metadata: TMetadata;\n /** SHA256 hash of content for change detection */\n contentHash: string;\n}\n\n/**\n * Entity input type for creation - allows partial entities with optional system fields\n * contentHash is excluded because it's computed automatically by the entity service\n */\nexport type EntityInput<T extends BaseEntity> = Omit<\n T,\n \"id\" | \"created\" | \"updated\" | \"contentHash\" | \"visibility\"\n> & {\n id?: string;\n created?: string;\n updated?: string;\n visibility?: RawContentVisibility;\n};\n\n/**\n * Search result type\n */\nexport interface SearchResult<T extends BaseEntity = BaseEntity> {\n entity: T;\n score: number;\n excerpt: string;\n}\n\n/**\n * Normalized system_create input shape used by plugin create interceptors.\n */\nexport interface CreateCoverImageInput {\n generate?: boolean | undefined;\n prompt?: string | undefined;\n}\n\nexport interface CreateFromAttachmentInput {\n kind: \"entity-attachment\";\n sourceEntityType: string;\n sourceEntityId: string;\n attachmentType: string;\n}\n\nexport interface CreateFromUploadInput {\n kind: \"upload\";\n id: string;\n}\n\nexport interface CreateFromConversationMessageInput {\n kind: \"conversation-message\";\n messageId?: string | undefined;\n}\n\nexport type CreateFromInput =\n | CreateFromAttachmentInput\n | CreateFromUploadInput\n | CreateFromConversationMessageInput;\n\nexport type CreateTransform = \"extract-markdown\" | \"preserve\";\n\nexport interface CreateInput {\n entityType: string;\n prompt?: string;\n title?: string;\n content?: string;\n url?: string;\n from?: CreateFromInput;\n transform?: CreateTransform;\n replace?: boolean;\n sourceEntityType?: string;\n sourceEntityId?: string;\n sourceEntityIds?: string[];\n targetEntityType?: string;\n targetEntityId?: string;\n coverImage?: boolean | CreateCoverImageInput;\n}\n\n/**\n * Minimal caller context forwarded to plugin create interceptors.\n */\nexport interface CreateExecutionContext {\n interfaceType: string;\n userId: string;\n channelId?: string;\n channelName?: string;\n}\n\n/**\n * Result returned to system_create when a plugin fully handles creation.\n */\nexport const createResultAttachmentSchema: z.ZodObject<{\n mediaType: z.ZodString;\n url: z.ZodString;\n downloadUrl: z.ZodOptional<z.ZodString>;\n previewUrl: z.ZodOptional<z.ZodString>;\n filename: z.ZodOptional<z.ZodString>;\n sizeBytes: z.ZodOptional<z.ZodNumber>;\n source: z.ZodOptional<\n z.ZodObject<{\n entityType: z.ZodOptional<z.ZodString>;\n entityId: z.ZodOptional<z.ZodString>;\n attachmentType: z.ZodOptional<z.ZodString>;\n }>\n >;\n}> = z.object({\n mediaType: z.string(),\n url: z.string(),\n downloadUrl: z.string().optional(),\n previewUrl: z.string().optional(),\n filename: z.string().optional(),\n sizeBytes: z.number().optional(),\n source: z\n .object({\n entityType: z.string().optional(),\n entityId: z.string().optional(),\n attachmentType: z.string().optional(),\n })\n .optional(),\n});\n\nexport type CreateResultAttachment = z.infer<\n typeof createResultAttachmentSchema\n>;\n\nexport type CreateResult =\n | {\n success: true;\n data: {\n entityId?: string;\n jobId?: string;\n status: string;\n attachment?: CreateResultAttachment;\n };\n }\n | { success: false; error: string };\n\n/**\n * Plugin create interceptors can either fully handle creation,\n * or continue with a rewritten normalized input.\n */\nexport type CreateInterceptionResult =\n | { kind: \"handled\"; result: CreateResult }\n | { kind: \"continue\"; input: CreateInput };\n\nexport type CreateInterceptor = (\n input: CreateInput,\n executionContext: CreateExecutionContext,\n) => Promise<CreateInterceptionResult>;\n\nexport interface UploadSaveInput {\n upload: CreateFromUploadInput;\n title?: string;\n}\n\nexport type UploadSaveHandler = (\n input: UploadSaveInput,\n executionContext: CreateExecutionContext,\n) => Promise<CreateResult>;\n\nexport interface UploadSaveHandlerRegistration {\n entityType: string;\n mediaTypes: string[];\n handler: UploadSaveHandler;\n}\n\n/**\n * Called before an entity is persisted (on create or update). Throws to reject\n * the write with an operator-facing error. Use this for cross-entity invariants\n * the per-entity Zod schema cannot express.\n */\nexport type PersistValidator<T extends BaseEntity = BaseEntity> = (\n entity: T,\n context: { operation: \"create\" | \"update\" },\n) => Promise<void>;\n\n/**\n * Minimal event bus contract used by entity-service for lifecycle events.\n * Kept structural to avoid coupling this package to a concrete messaging service.\n */\nexport interface EntityEventBus {\n send(request: {\n type: string;\n payload: Record<string, unknown>;\n sender: string;\n target?: string;\n metadata?: Record<string, unknown>;\n broadcast?: boolean;\n }): Promise<unknown>;\n}\n\n/**\n * Interface for entity adapter - handles conversion between entities and markdown\n * following the hybrid storage model\n *\n * @template TEntity - The full entity type\n * @template TMetadata - The metadata type (defaults to Record<string, unknown>)\n */\nexport interface EntityAdapter<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n> {\n entityType: string;\n\n /**\n * One declarative sentence describing what this entity type is. This is the\n * data the model reads to select `entityType`, replacing hardcoded example\n * phrasings in system instructions. Required: every type must define itself.\n */\n purpose: string;\n\n schema: EntitySchema<TEntity>;\n\n // Convert entity to markdown content (may include frontmatter for entity-specific fields)\n toMarkdown(entity: TEntity): string;\n\n // Extract entity-specific fields from markdown\n // Returns Partial<TEntity> as core fields come from database\n fromMarkdown(markdown: string): Partial<TEntity>;\n\n // Extract metadata from entity for search/filtering - now strongly typed\n extractMetadata(entity: TEntity): TMetadata;\n\n // Parse frontmatter metadata from markdown\n parseFrontMatter<TFrontmatter>(\n markdown: string,\n schema: { parse(data: unknown): TFrontmatter },\n ): TFrontmatter;\n\n // Generate frontmatter for markdown\n generateFrontMatter(entity: TEntity): string;\n\n /** Optional: Zod schema for frontmatter fields. Used by CMS config generation. */\n frontmatterSchema?: z.ZodObject<z.ZodRawShape>;\n\n /** Optional: Declares this entity type is a singleton (one file, e.g., identity/identity.md). Used by CMS to generate files collection. */\n isSingleton?: boolean;\n\n /** Optional: Whether this entity has a free-form markdown body below frontmatter. Defaults to true. When false, CMS omits the body widget. */\n hasBody?: boolean;\n\n /** Returns a markdown body template with section headings for this entity type. Empty string for free-form entities. */\n getBodyTemplate(): string;\n\n /** Optional: Declares that this entity type supports cover images via coverImageId in frontmatter */\n supportsCoverImage?: boolean;\n\n /** Optional: Extract coverImageId from entity content/frontmatter */\n getCoverImageId?(entity: TEntity): string | undefined;\n\n /**\n * Optional: build the markdown content and metadata for a queued-generation stub.\n * When undefined, this entity type does not support prompt-based queued creation\n * via system_create; the tool will reject the call rather than silently degrade.\n * The returned metadata must satisfy this entity's metadata schema (with\n * status set to \"generating\"); central code only stamps id/timestamps/visibility.\n */\n buildStub?(input: { id: string; title: string }): {\n content: string;\n metadata: TMetadata;\n };\n}\n\n/**\n * Sort field specification for multi-field sorting\n */\nexport interface SortField {\n /** Field to sort by - \"created\", \"updated\", or a metadata field name */\n field: string;\n /** Sort direction */\n direction: \"asc\" | \"desc\";\n /** Sort NULL values before non-NULL values (default: false / SQLite default) */\n nullsFirst?: boolean;\n}\n\n/**\n * List entities options\n * Generic over metadata type for type-safe filtering\n */\nexport interface ListOptions<TMetadata = Record<string, unknown>> {\n limit?: number;\n offset?: number;\n /** Multi-field sorting - supports system fields (created, updated) and metadata fields */\n sortFields?: SortField[];\n filter?: {\n // Typed metadata filter - partial match on metadata fields\n metadata?: Partial<TMetadata>;\n visibilityScope?: ContentVisibility;\n };\n /** Filter to only entities with metadata.status = \"published\" */\n publishedOnly?: boolean;\n}\n\n/**\n * Search options\n */\nexport interface SearchOptions {\n limit?: number;\n offset?: number;\n types?: string[];\n excludeTypes?: string[];\n sortBy?: \"relevance\" | \"created\" | \"updated\";\n sortDirection?: \"asc\" | \"desc\";\n /** Score multipliers per entity type - applied after initial search */\n weight?: Record<string, number>;\n visibilityScope?: ContentVisibility;\n /** Include queued/failed generation stubs in search results (default: false) */\n includeUngenerated?: boolean;\n /** Minimum relevance score to return. Omit for no score cutoff. */\n minScore?: number;\n}\n\n/**\n * Configuration for entity type registration\n */\nexport interface EntityTypeConfig {\n /** Score multiplier for search results (default: 1.0) */\n weight?: number;\n /** Whether to generate embeddings for this entity type (default: true).\n * Set to false for entity types with non-textual content (e.g., images). */\n embeddable?: boolean;\n /** Whether this entity type may be used as source material for derived projections (default: true).\n * Set to false for projection outputs that would create feedback loops. */\n projectionSource?: boolean;\n /** Publish semantics for status-bearing entity types. Statuses listed here\n * represent publication commitment/execution states and require the\n * `publish` entity action when entered or modified. */\n publish?: {\n publishStatuses: string[];\n };\n}\n\n/**\n * Core entity service interface for read-only operations\n * Used by core plugins that need entity access but shouldn't modify entities\n */\nexport interface GetEntityRequest {\n entityType: string;\n id: string;\n /**\n * Optional visibility scope. Undefined fails closed to \"public\" — callers\n * with elevated access must opt up explicitly.\n */\n visibilityScope?: ContentVisibility;\n}\n\nexport type GetEntityRawRequest = GetEntityRequest;\n\nexport interface ListEntitiesRequest {\n entityType: string;\n options?: ListOptions | undefined;\n}\n\nexport interface CountEntitiesRequest {\n entityType: string;\n options?: Pick<ListOptions, \"publishedOnly\" | \"filter\"> | undefined;\n}\n\nexport interface CreateEntityRequest<T extends BaseEntity> {\n entity: EntityInput<T>;\n options?: CreateEntityOptions | undefined;\n}\n\nexport interface CreateEntityFromMarkdownRequest {\n input: CreateEntityFromMarkdownInput;\n options?: CreateEntityOptions | undefined;\n}\n\nexport interface UpdateEntityRequest<T extends BaseEntity> {\n entity: T;\n options?: EntityJobOptions | undefined;\n}\n\nexport interface DeleteEntityRequest {\n entityType: string;\n id: string;\n}\n\nexport interface UpsertEntityRequest<T extends BaseEntity> {\n entity: T;\n options?: EntityJobOptions | undefined;\n}\n\nexport interface EntitySearchRequest {\n query: string;\n options?: SearchOptions | undefined;\n}\n\nexport interface SearchWithDistancesRequest {\n query: string;\n}\n\n/**\n * Context passed to all DataSource operations\n * Contains internal state that should not be mixed with user query parameters\n */\nexport interface BaseDataSourceContext {\n /**\n * Whether to filter to only published/complete content\n * Set by site-builder: true for production, false for preview\n */\n publishedOnly?: boolean;\n\n /**\n * Scoped entity service that auto-applies publishedOnly filter\n * Datasources should use this instead of their injected entityService\n * to ensure consistent filtering behavior across environments\n */\n entityService: EntityService;\n}\n\nexport type DataSourceSchema<T> = z.ZodType<T, unknown>;\n\n/**\n * DataSource Interface\n *\n * Provides data for templates through fetch, generate, or transform operations.\n * DataSources are registered in the DataSourceRegistry and referenced by templates\n * via their dataSourceId property.\n */\nexport interface DataSource {\n /**\n * Unique identifier for this data source\n */\n id: string;\n\n /**\n * Human-readable name for this data source\n */\n name: string;\n\n /**\n * Optional description of what this data source provides\n */\n description?: string;\n\n /**\n * Optional: Fetch existing data\n * Used by data sources that aggregate or retrieve data (e.g., dashboard stats, API data)\n * DataSources validate output using the provided schema\n * @param query - Query parameters for fetching data\n * @param outputSchema - Schema for validating output data\n * @param context - Context with environment\n */\n fetch?: <T>(\n query: unknown,\n outputSchema: z.ZodSchema<T>,\n context: BaseDataSourceContext,\n ) => Promise<T>;\n\n /**\n * Optional: Generate new content\n * Used by data sources that create content (e.g., AI-generated content, reports)\n */\n generate?: <T>(request: unknown, schema: z.ZodSchema<T>) => Promise<T>;\n\n /**\n * Optional: Transform content between formats\n * Used by data sources that convert content (e.g., markdown to HTML, data formatting)\n */\n transform?: <T>(\n content: unknown,\n format: string,\n schema: z.ZodSchema<T>,\n ) => Promise<T>;\n}\n\n/**\n * DataSource capabilities for discovery and validation\n */\nexport interface DataSourceCapabilities {\n canFetch: boolean;\n canGenerate: boolean;\n canTransform: boolean;\n}\n\nexport interface ICoreEntityService {\n // Read-only operations\n getEntity<T extends BaseEntity>(request: GetEntityRequest): Promise<T | null>;\n\n /**\n * Get entity without content resolution (raw)\n * Used internally to avoid recursion when resolving image references\n */\n getEntityRaw<T extends BaseEntity>(\n request: GetEntityRawRequest,\n ): Promise<T | null>;\n\n listEntities<T extends BaseEntity>(\n request: ListEntitiesRequest,\n ): Promise<T[]>;\n\n search<T extends BaseEntity = BaseEntity>(\n request: EntitySearchRequest,\n ): Promise<SearchResult<T>[]>;\n\n // Entity type information\n getEntityTypes(): string[];\n hasEntityType(type: string): boolean;\n\n // Entity counts\n countEntities(request: CountEntitiesRequest): Promise<number>;\n /**\n * Group counts by entity type. Fails closed: undefined visibilityScope\n * filters to public-only counts so aggregate insights cannot reveal\n * non-public entity existence.\n */\n getEntityCounts(\n visibilityScope?: ContentVisibility,\n ): Promise<Array<{ entityType: string; count: number }>>;\n\n /** Get configuration for a specific entity type */\n getEntityTypeConfig(type: string): EntityTypeConfig;\n\n /** Get weight map for all registered entity types with non-default weights */\n getWeightMap(): Record<string, number>;\n}\n\n/**\n * Entity service interface for managing brain entities\n */\nexport interface IEntitiesNamespace {\n /** Register a new entity type with schema and adapter */\n register<TEntity extends BaseEntity>(\n entityType: string,\n schema: EntitySchema<TEntity>,\n adapter: EntityAdapter<TEntity>,\n config?: EntityTypeConfig,\n ): void;\n\n /**\n * Get the adapter for an entity type.\n *\n * Returns the structural `EntityAdapter<BaseEntity>` view — namespace\n * consumers don't narrow by entity type. For typed access tied to a\n * specific `TEntity`, use the underlying `EntityRegistry.getAdapter<T>`\n * directly (see `entity-serializer.ts`).\n */\n getAdapter(entityType: string): EntityAdapter<BaseEntity> | undefined;\n\n /** Extend an adapter's frontmatterSchema with additional fields */\n extendFrontmatterSchema(\n type: string,\n extension: z.ZodObject<z.ZodRawShape>,\n ): void;\n\n /** Get effective frontmatter schema (base + extensions) for an entity type */\n getEffectiveFrontmatterSchema(\n type: string,\n ): z.ZodObject<z.ZodRawShape> | undefined;\n\n /** Update an existing entity */\n update<TEntity extends BaseEntity>(\n entity: TEntity,\n ): Promise<{ entityId: string; jobId: string }>;\n\n /** Register a data source for dynamic content */\n registerDataSource(dataSource: DataSource): void;\n\n /** Register a create interceptor for this plugin's entity type */\n registerCreateInterceptor(\n entityType: string,\n interceptor: CreateInterceptor,\n ): void;\n\n /** Register a raw-upload durable save handler for this plugin's entity type */\n registerUploadSaveHandler(registration: UploadSaveHandlerRegistration): void;\n\n /** Look up the registered upload-save handler claiming a media type */\n getUploadSaveHandler(\n mediaType: string,\n ): UploadSaveHandlerRegistration | undefined;\n}\n\nexport interface EmbeddingBackfillResult {\n queued: number;\n skipped: number;\n}\n\nexport interface EmbeddingFailureReference {\n entityId: string;\n entityType: string;\n contentHash: string;\n}\n\nexport interface EmbeddingIndexStats {\n missingEmbeddings: number;\n staleEmbeddings: number;\n failedEmbeddings: number;\n /** Entities whose type generates embeddings (non-embeddable types excluded). */\n embeddableEntities: number;\n /** Embeddable entities whose embedding is present and current. */\n embeddedEntities: number;\n}\n\nexport interface IndexReadinessOptions {\n timeoutMs: number;\n intervalMs?: number;\n}\n\nexport interface IndexReadinessStatus extends EmbeddingIndexStats {\n ready: boolean;\n degraded: boolean;\n activeEmbeddingJobs: number;\n}\n\nexport interface EntityService extends ICoreEntityService {\n // Mutations\n createEntity<T extends BaseEntity>(\n request: CreateEntityRequest<T>,\n ): Promise<EntityMutationResult>;\n createEntityFromMarkdown(\n request: CreateEntityFromMarkdownRequest,\n ): Promise<EntityMutationResult>;\n updateEntity<T extends BaseEntity>(\n request: UpdateEntityRequest<T>,\n ): Promise<EntityMutationResult>;\n deleteEntity(request: DeleteEntityRequest): Promise<boolean>;\n upsertEntity<T extends BaseEntity>(\n request: UpsertEntityRequest<T>,\n ): Promise<EntityMutationResult & { created: boolean }>;\n storeEmbedding(data: StoreEmbeddingData): Promise<void>;\n backfillMissingEmbeddings(): Promise<EmbeddingBackfillResult>;\n isIndexReady(): boolean;\n awaitIndexReady(\n options: IndexReadinessOptions,\n ): Promise<IndexReadinessStatus>;\n\n // Serialization\n serializeEntity(entity: BaseEntity): string;\n deserializeEntity(markdown: string, entityType: string): Partial<BaseEntity>;\n\n // Counts\n countEmbeddings(): Promise<number>;\n\n // Diagnostics\n searchWithDistances(\n request: SearchWithDistancesRequest,\n ): Promise<Array<{ entityId: string; entityType: string; distance: number }>>;\n\n // Lifecycle\n initialize(): Promise<void>;\n\n // Job status\n getAsyncJobStatus(jobId: string): Promise<{\n status: \"pending\" | \"processing\" | \"completed\" | \"failed\";\n error?: string;\n } | null>;\n}\n\n/**\n * Entity Registry interface for managing entity types and their schemas/adapters\n */\nexport interface EntityRegistry {\n registerEntityType<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n >(\n type: string,\n schema: UnknownEntitySchema,\n adapter: EntityAdapter<TEntity, TMetadata>,\n config?: EntityTypeConfig,\n ): void;\n\n getSchema(type: string): UnknownEntitySchema;\n\n getAdapter<\n TEntity extends BaseEntity<TMetadata>,\n TMetadata = Record<string, unknown>,\n >(\n type: string,\n ): EntityAdapter<TEntity, TMetadata>;\n\n hasEntityType(type: string): boolean;\n\n validateEntity(type: string, entity: unknown): BaseEntity;\n\n getAllEntityTypes(): string[];\n\n /** Get configuration for a specific entity type */\n getEntityTypeConfig(type: string): EntityTypeConfig;\n\n /** Get weight map for all registered entity types with non-default weights */\n getWeightMap(): Record<string, number>;\n\n registerCreateInterceptor(type: string, interceptor: CreateInterceptor): void;\n\n getCreateInterceptor(type: string): CreateInterceptor | undefined;\n\n registerUploadSaveHandler(registration: UploadSaveHandlerRegistration): void;\n\n getUploadSaveHandler(\n mediaType: string,\n ): UploadSaveHandlerRegistration | undefined;\n\n registerPersistValidator(type: string, validator: PersistValidator): void;\n\n getPersistValidator(type: string): PersistValidator | undefined;\n\n /**\n * Extend an adapter's frontmatterSchema with additional fields.\n * Used by plugins to add domain-specific fields (e.g., professional-site adds tagline to profile).\n * Extensions are merged into the effective schema returned by getEffectiveFrontmatterSchema().\n */\n extendFrontmatterSchema(\n type: string,\n extension: z.ZodObject<z.ZodRawShape>,\n ): void;\n\n /**\n * Get the effective frontmatter schema for an entity type,\n * with all registered extensions merged in.\n * Returns undefined if the adapter has no frontmatterSchema.\n */\n getEffectiveFrontmatterSchema(\n type: string,\n ): z.ZodObject<z.ZodRawShape> | undefined;\n}\n\n/**\n * Database configuration for entity service\n */\nexport type { DbConfig as EntityDbConfig } from \"@brains/contracts\";\n",
|
|
166
166
|
"import { openSync, writeSync } from \"node:fs\";\n\n/**\n * Logger interface for consistent logging across the application\n * Simplified version without Winston dependency\n */\n\n/**\n * Log levels\n */\nexport const LogLevel = {\n SILLY: 0,\n VERBOSE: 1,\n DEBUG: 2,\n INFO: 3,\n WARN: 4,\n ERROR: 5,\n NONE: 6,\n} as const;\nexport type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];\n\n/**\n * Logger implementation with Component Interface Standardization pattern\n */\nexport type LogFormat = \"text\" | \"json\";\n\nexport interface LoggerOptions {\n level?: LogLevel;\n context?: string;\n useStderr?: boolean;\n format?: LogFormat;\n /** Path to a log file. Always writes JSON, one line per entry. */\n logFile?: string;\n}\n\nexport class Logger {\n /** The singleton instance */\n private static instance: Logger | null = null;\n\n private level: LogLevel;\n private context: string | undefined;\n private useStderr: boolean;\n private format: LogFormat;\n private logFile: string | undefined;\n private fileHandle: number | undefined;\n\n /**\n * Private constructor to enforce singleton pattern\n */\n private constructor(options: LoggerOptions = {}, fileHandle?: number) {\n this.level = options.level ?? LogLevel.INFO;\n this.context = options.context ?? undefined;\n this.useStderr = options.useStderr ?? false;\n this.format = options.format ?? \"text\";\n this.logFile = options.logFile;\n\n if (fileHandle !== undefined) {\n // Reuse parent's file handle (child logger)\n this.fileHandle = fileHandle;\n } else if (this.logFile) {\n try {\n this.fileHandle = openSync(this.logFile, \"a\");\n } catch {\n // Silently fail — logging should never crash the app\n }\n }\n }\n\n /**\n * Get the singleton instance of Logger\n */\n public static getInstance(options?: LoggerOptions): Logger {\n if (!Logger.instance) {\n Logger.instance = new Logger(options);\n } else if (options?.useStderr !== undefined) {\n // Update useStderr if explicitly provided\n Logger.instance.useStderr = options.useStderr;\n }\n return Logger.instance;\n }\n\n /**\n * Reset the singleton instance (primarily for testing)\n */\n public static resetInstance(): void {\n Logger.instance = null;\n }\n\n /**\n * Create a fresh instance without affecting the singleton\n */\n public static createFresh(options?: LoggerOptions): Logger {\n return new Logger(options);\n }\n\n /**\n * Format a log entry for output.\n * Text: [timestamp] [context] message\n * JSON: {\"ts\":\"...\",\"level\":\"...\",\"ctx\":\"...\",\"msg\":\"...\",\"data\":[...]}\n */\n private formatEntry(level: string, message: string, args: unknown[]): string {\n const timestamp = new Date().toISOString();\n if (this.format === \"json\") {\n const entry: Record<string, unknown> = {\n ts: timestamp,\n level,\n msg: message,\n };\n if (this.context) entry[\"ctx\"] = this.context;\n if (args.length > 0) entry[\"data\"] = args;\n return JSON.stringify(entry);\n }\n return this.context\n ? `[${timestamp}] [${this.context}] ${message}`\n : `[${timestamp}] ${message}`;\n }\n\n /**\n * Log a message at the 'silly' level\n */\n /**\n * Write a formatted log entry to the appropriate output stream.\n * JSON format: single string argument (no spread).\n * Text format: message + spread args for console formatting.\n */\n private write(\n consoleFn: (...data: unknown[]) => void,\n level: string,\n message: string,\n args: unknown[],\n ): void {\n // Console output\n if (this.format === \"json\") {\n consoleFn(this.formatEntry(level, message, args));\n } else {\n if (args.length > 0) {\n consoleFn(this.formatEntry(level, message, []), ...args);\n } else {\n consoleFn(this.formatEntry(level, message, []));\n }\n }\n\n // File output — always JSON\n if (this.fileHandle !== undefined) {\n const jsonLine = this.formatJsonEntry(level, message, args);\n try {\n writeSync(this.fileHandle, jsonLine + \"\\n\");\n } catch {\n // Silently fail\n }\n }\n }\n\n /**\n * Format a JSON log entry (used for file output regardless of console format).\n */\n private formatJsonEntry(\n level: string,\n message: string,\n args: unknown[],\n ): string {\n const entry: Record<string, unknown> = {\n ts: new Date().toISOString(),\n level,\n msg: message,\n };\n if (this.context) entry[\"ctx\"] = this.context;\n if (args.length > 0) entry[\"data\"] = args;\n return JSON.stringify(entry);\n }\n\n public silly(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.SILLY) {\n this.write(console.debug.bind(console), \"silly\", message, args);\n }\n }\n\n public verbose(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.VERBOSE) {\n this.write(console.debug.bind(console), \"verbose\", message, args);\n }\n }\n\n public debug(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.DEBUG) {\n this.write(console.debug.bind(console), \"debug\", message, args);\n }\n }\n\n public info(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.INFO) {\n const fn = this.useStderr\n ? console.error.bind(console)\n : console.info.bind(console);\n this.write(fn, \"info\", message, args);\n }\n }\n\n public warn(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.WARN) {\n this.write(console.warn.bind(console), \"warn\", message, args);\n }\n }\n\n public error(message: string, ...args: unknown[]): void {\n if (this.level <= LogLevel.ERROR) {\n this.write(console.error.bind(console), \"error\", message, args);\n }\n }\n\n /**\n * Create a child logger with a specific context\n */\n public child(context: string): Logger {\n // Pass file handle directly so children don't open new handles\n const child = new Logger(\n {\n level: this.level,\n context,\n useStderr: this.useStderr,\n format: this.format,\n },\n this.fileHandle,\n );\n return child;\n }\n\n /**\n * Configure the logger to use stderr for all output\n * Useful for MCP servers that need stdout for JSON-RPC\n */\n public setUseStderr(useStderr: boolean): void {\n this.useStderr = useStderr;\n }\n}\n\n// Export default logger instance\nconst defaultLogger: Logger = Logger.getInstance();\n\nexport default defaultLogger;\n",
|
|
167
167
|
"import { Logger } from \"@brains/utils/logger\";\nimport { type ProgressReporter } from \"@brains/utils/progress\";\nimport { z } from \"@brains/utils/zod\";\nimport type {\n EntityService as IEntityService,\n EmbeddingJobData,\n EntityEventBus,\n} from \"../types\";\nimport type { IEmbeddingService } from \"../embedding-types\";\nimport type { JobHandler } from \"@brains/job-queue\";\nimport { internalFullScope } from \"../internal-scope\";\n/**\n * Zod schema for embedding job data validation\n * Content is NOT in job data - fetched fresh from entity when processing\n */\nconst embeddingJobDataSchema = z.object({\n id: z.string().min(1, \"Entity ID is required\"),\n entityType: z.string().min(1, \"Entity type is required\"),\n contentHash: z.string().min(1, \"Content hash is required\"),\n operation: z.enum([\"create\", \"update\"]),\n});\n\n/**\n * Job handler for embedding generation\n * Processes entities to generate embeddings using the EmbeddingService\n * Implements Component Interface Standardization pattern\n */\nexport class EmbeddingJobHandler implements JobHandler<\"embedding\"> {\n private static instance: EmbeddingJobHandler | null = null;\n private logger: Logger;\n private embeddingService: IEmbeddingService;\n private entityService: IEntityService;\n private messageBus?: EntityEventBus;\n\n /**\n * Get the singleton instance\n */\n public static getInstance(\n entityService: IEntityService,\n embeddingService: IEmbeddingService,\n messageBus?: EntityEventBus,\n ): EmbeddingJobHandler {\n EmbeddingJobHandler.instance ??= new EmbeddingJobHandler(\n entityService,\n embeddingService,\n messageBus,\n );\n return EmbeddingJobHandler.instance;\n }\n\n /**\n * Reset the singleton instance (primarily for testing)\n */\n public static resetInstance(): void {\n EmbeddingJobHandler.instance = null;\n }\n\n /**\n * Create a fresh instance without affecting the singleton\n */\n public static createFresh(\n entityService: IEntityService,\n embeddingService: IEmbeddingService,\n messageBus?: EntityEventBus,\n ): EmbeddingJobHandler {\n return new EmbeddingJobHandler(entityService, embeddingService, messageBus);\n }\n\n /**\n * Private constructor to enforce singleton pattern\n */\n private constructor(\n entityService: IEntityService,\n embeddingService: IEmbeddingService,\n messageBus?: EntityEventBus,\n ) {\n this.logger = Logger.getInstance().child(\"EmbeddingJobHandler\");\n this.embeddingService = embeddingService;\n this.entityService = entityService;\n if (messageBus) {\n this.messageBus = messageBus;\n }\n }\n\n /**\n * Process an embedding job\n * Generates embedding for entity content and stores it in the embeddings table\n * Entity must already exist in entities table (stored immediately by createEntity/updateEntity)\n */\n public async process(\n data: EmbeddingJobData,\n jobId: string,\n // Embedding jobs are enqueued silent, so progress is never emitted\n _progressReporter: ProgressReporter,\n ): Promise<void> {\n try {\n this.logger.debug(\"Processing embedding job\", {\n jobId,\n entityId: data.id,\n entityType: data.entityType,\n contentHash: data.contentHash,\n });\n\n // Fetch fresh entity - content is NOT stored in job data to avoid\n // large base64 data bloating job queue and dashboard hydration props.\n const currentEntity = await this.entityService.getEntity({\n entityType: data.entityType,\n id: data.id,\n visibilityScope: internalFullScope(\n \"embedding regeneration must index every entity, no user surface\",\n ),\n });\n\n if (!currentEntity) {\n this.logger.warn(\"Entity no longer exists, skipping embedding job\", {\n jobId,\n entityId: data.id,\n entityType: data.entityType,\n operation: data.operation,\n });\n return;\n }\n\n // Check if content has changed since job was queued (staleness detection)\n if (currentEntity.contentHash !== data.contentHash) {\n this.logger.info(\n \"Entity content changed since job created, skipping stale embedding\",\n {\n jobId,\n entityId: data.id,\n entityType: data.entityType,\n jobContentHash: data.contentHash,\n currentContentHash: currentEntity.contentHash,\n },\n );\n return;\n }\n\n // Generate embedding using fresh content from entity\n const { embedding, usage } =\n await this.embeddingService.generateEmbedding(currentEntity.content);\n\n // Log usage event for monitoring\n this.logger.info(\"ai:usage\", {\n operation: \"embedding\",\n provider: \"openai\",\n model: \"text-embedding-3-small\",\n inputTokens: usage.tokens,\n outputTokens: 0,\n });\n\n // Store the embedding in the embeddings table\n await this.entityService.storeEmbedding({\n entityId: data.id,\n entityType: data.entityType,\n embedding,\n contentHash: data.contentHash,\n });\n\n // Emit entity:embedding:ready event after successful save\n // Note: entity:created is now emitted by createEntity() when entity is first persisted\n if (this.messageBus) {\n this.logger.debug(\n `Emitting entity:embedding:ready event for ${data.entityType}:${data.id}`,\n );\n\n await this.messageBus.send({\n type: \"entity:embedding:ready\",\n payload: {\n entityType: data.entityType,\n entityId: data.id,\n entity: currentEntity,\n },\n sender: \"entity-service\",\n broadcast: true,\n });\n }\n\n this.logger.debug(\"Embedding job completed successfully\", {\n jobId,\n entityId: data.id,\n embeddingDimensions: embedding.length,\n });\n } catch (error) {\n this.logger.error(\"Embedding job failed\", {\n jobId,\n entityId: data.id,\n entityType: data.entityType,\n error,\n });\n throw error;\n }\n }\n\n /**\n * Handle embedding job errors\n */\n public async onError(\n error: Error,\n data: EmbeddingJobData,\n jobId: string,\n ): Promise<void> {\n this.logger.error(\"Embedding job error handler called\", {\n jobId,\n entityId: data.id,\n entityType: data.entityType,\n contentHash: data.contentHash,\n errorMessage: error.message,\n errorStack: error.stack,\n });\n }\n\n /**\n * Validate and parse embedding job data using Zod schema\n * Ensures type safety and data integrity\n */\n public validateAndParse(data: unknown): EmbeddingJobData | null {\n const parsed = embeddingJobDataSchema.safeParse(data);\n if (!parsed.success) {\n this.logger.warn(\"Invalid embedding job data\", {\n data,\n validationError: parsed.error.issues,\n });\n return null;\n }\n\n this.logger.debug(\"Embedding job data validation successful\", {\n entityId: parsed.data.id,\n entityType: parsed.data.entityType,\n contentHash: parsed.data.contentHash,\n });\n\n return parsed.data;\n }\n}\n",
|
|
168
168
|
"import type { EntityDB } from \"./db\";\nimport {\n getVisibleContentVisibilities,\n type BaseEntity,\n type ContentVisibility,\n type SearchResult,\n type SearchOptions,\n} from \"./types\";\nimport type { IEmbeddingService } from \"./embedding-types\";\nimport type { EntitySerializer } from \"./entity-serializer\";\nimport { type Logger } from \"@brains/utils/logger\";\nimport { z } from \"@brains/utils/zod\";\nimport { sql, and, desc, inArray, type SQL } from \"drizzle-orm\";\nimport { entities } from \"./schema/entities\";\n\nexport const MAX_SEARCH_QUERY_CHARS = 12_000;\nconst MAX_VECTOR_DISTANCE = 0.82;\n\nexport function 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 minScore: z.number().min(0).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: EntityDB;\n private embeddingService: IEmbeddingService;\n private serializer: EntitySerializer;\n private logger: Logger;\n\n constructor(\n db: EntityDB,\n embeddingService: IEmbeddingService,\n serializer: EntitySerializer,\n logger: Logger,\n ) {\n this.db = db;\n this.embeddingService = embeddingService;\n this.serializer = serializer;\n this.logger = logger.child(\"EntitySearch\");\n }\n\n /**\n * Search entities by query using vector similarity\n */\n public async search<T extends BaseEntity = BaseEntity>(\n query: string,\n options?: SearchOptions,\n ): Promise<SearchResult<T>[]> {\n const validatedOptions = searchOptionsSchema.parse(options ?? {});\n const {\n limit,\n offset,\n types,\n excludeTypes,\n weight,\n visibilityScope,\n includeUngenerated,\n minScore,\n } = validatedOptions;\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 // Generate embedding for the query\n const { embedding: queryEmbedding } =\n await this.embeddingService.generateEmbedding(preparedQuery);\n\n // Convert Float32Array to JSON array for SQL\n const embeddingArray = JSON.stringify(Array.from(queryEmbedding));\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<T>(\n embeddingArray,\n weightMultiplier,\n [\n ...typeConditions,\n ...this.buildVisibilityConditions(visibilityScope),\n ...this.buildGenerationStatusConditions(includeUngenerated),\n ],\n limit,\n offset,\n preparedQuery,\n minScore,\n );\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 /**\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 {\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<T extends BaseEntity = BaseEntity>(\n embeddingArray: string,\n weightMultiplier: SQL,\n typeConditions: SQL[],\n limit: number,\n offset: number,\n query: string,\n minScore: number | undefined,\n ): Promise<SearchResult<T>[]> {\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\n return this.mapSearchResults<T>(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 * Return all embedded entities with their raw cosine distance to the query.\n * No threshold filter — used for diagnostics and threshold tuning.\n * Results sorted by distance ascending (closest first).\n */\n public async searchWithDistances(\n query: string,\n ): Promise<\n Array<{ entityId: string; entityType: string; distance: number }>\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 .orderBy(sql`${distanceExpr} ASC`);\n\n return results;\n }\n\n /**\n * Transform raw query rows into SearchResult objects\n */\n private mapSearchResults<T extends BaseEntity = BaseEntity>(\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<T>[] {\n const searchResults: SearchResult<T>[] = [];\n\n for (const row of results) {\n try {\n const metadata = entityMetadataSchema.parse(row.metadata);\n\n const entity = this.serializer.reconstructEntity<T>({\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(content: string, query: string): string {\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",
|