486 lines
18 KiB
Markdown
486 lines
18 KiB
Markdown
---
|
|
title: Plugin System
|
|
slug: guides/plugins
|
|
public: true
|
|
category: guides
|
|
template: guide
|
|
order: 4
|
|
description: Replace any Brainy subsystem — distance functions, embeddings, vector index, metadata index, aggregation — with a custom implementation or optional native acceleration.
|
|
next:
|
|
- guides/storage-adapters
|
|
---
|
|
|
|
# Plugin Development Guide
|
|
|
|
Brainy has a plugin system that allows third-party packages to replace internal subsystems with custom implementations. This is how `@soulcraft/cor` provides optional native acceleration, and it's the same system available to any developer.
|
|
|
|
## Architecture Overview
|
|
|
|
Brainy's plugin system uses **named providers** — string keys mapped to implementations. During `init()`, brainy:
|
|
|
|
1. Imports each package listed in the `plugins` config array
|
|
2. Activates each plugin, passing a `BrainyPluginContext`
|
|
3. The plugin calls `context.registerProvider(key, implementation)` for each subsystem it provides
|
|
4. Brainy checks each provider key and wires the implementation into its internal pipeline
|
|
|
|
Installing the first-party accelerator is the opt-in: with the default config, brainy probes for `@soulcraft/cor` and loads it when present. Everything except "not installed" fails **loud** — a present-but-broken accelerator makes `init()` throw rather than silently degrading to the JS engines.
|
|
|
|
```typescript
|
|
const brain = new Brainy() // @soulcraft/cor auto-detected when installed
|
|
const pinned = new Brainy({ plugins: ['@soulcraft/cor'] }) // or pin exactly what loads
|
|
const plain = new Brainy({ plugins: [] }) // or opt out of detection entirely
|
|
```
|
|
|
|
| `plugins` value | Behavior |
|
|
|---|---|
|
|
| `undefined` (default) | Guarded auto-detection of `@soulcraft/cor`: not installed → no plugins, silently; installed → loads + announces; installed-but-broken → `init()` throws |
|
|
| `false` / `[]` | No plugins, no detection (explicit opt-out) |
|
|
| `['@soulcraft/cor']` | Load only the listed packages; a listed plugin that fails to load throws |
|
|
|
|
Plugins registered programmatically via `brain.use(plugin)` are always activated regardless of the `plugins` config.
|
|
|
|
If no plugin provides a given key, brainy uses its built-in JavaScript implementation. This means brainy works perfectly standalone — plugins only enhance performance or add capabilities.
|
|
|
|
## Creating a Plugin
|
|
|
|
### 1. Implement the `BrainyPlugin` interface
|
|
|
|
```typescript
|
|
import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin'
|
|
|
|
const myPlugin: BrainyPlugin = {
|
|
name: 'my-brainy-plugin', // Must be unique (typically your npm package name)
|
|
|
|
async activate(context: BrainyPluginContext): Promise<boolean> {
|
|
// Register your providers here
|
|
context.registerProvider('distance', myFastDistanceFunction)
|
|
|
|
// Return true if activation succeeded, false to skip
|
|
return true
|
|
},
|
|
|
|
async deactivate(): Promise<void> {
|
|
// Optional cleanup when brainy.close() is called
|
|
}
|
|
}
|
|
|
|
export default myPlugin
|
|
```
|
|
|
|
### 2. Package exports
|
|
|
|
Your package must export the plugin as the default export so brainy's plugin loader can resolve it:
|
|
|
|
```typescript
|
|
// index.ts
|
|
export { default } from './plugin.js'
|
|
```
|
|
|
|
### 3. Registration
|
|
|
|
**Config-based:** List your package name in the brainy config:
|
|
|
|
```typescript
|
|
const brain = new Brainy({
|
|
plugins: ['my-brainy-plugin']
|
|
})
|
|
await brain.init()
|
|
```
|
|
|
|
**Programmatic registration:** For plugins not installed as npm packages, use `brain.use()`:
|
|
|
|
```typescript
|
|
import { Brainy } from '@soulcraft/brainy'
|
|
import myPlugin from './my-plugin.js'
|
|
|
|
const brain = new Brainy()
|
|
brain.use(myPlugin)
|
|
await brain.init()
|
|
```
|
|
|
|
## Provider Keys Reference
|
|
|
|
Each key has a specific expected signature. Brainy checks for these during `init()` and wires them into the appropriate code paths.
|
|
|
|
### Core Providers
|
|
|
|
#### `distance`
|
|
**Type:** `(a: number[], b: number[]) => number`
|
|
|
|
Replaces the default cosine distance function used in vector search and neural APIs. This is the highest-impact single provider — it's called for every vector comparison.
|
|
|
|
```typescript
|
|
context.registerProvider('distance', (a: number[], b: number[]): number => {
|
|
// Your SIMD-accelerated or GPU distance calculation
|
|
return myFastCosineDistance(a, b)
|
|
})
|
|
```
|
|
|
|
#### `embeddings`
|
|
**Type:** `(text: string | string[]) => Promise<number[] | number[][]>`
|
|
|
|
Replaces the built-in WASM embedding engine. Called for every `brain.add()`, `brain.update()`, and `brain.find()` operation that involves text.
|
|
|
|
```typescript
|
|
context.registerProvider('embeddings', async (text: string | string[]) => {
|
|
if (Array.isArray(text)) {
|
|
return myEngine.embedBatch(text)
|
|
}
|
|
return myEngine.embed(text)
|
|
})
|
|
```
|
|
|
|
#### `embedBatch`
|
|
**Type:** `(texts: string[]) => Promise<number[][]>`
|
|
|
|
Dedicated batch embedding provider. When registered, brainy uses this for bulk operations (import, reindex, batch add) instead of calling the `embeddings` provider N times. This enables true single-forward-pass batch processing.
|
|
|
|
Priority order for batch operations:
|
|
1. `embedBatch` provider (single forward pass — fastest)
|
|
2. `embeddings` provider with `Promise.all()` (N individual calls)
|
|
3. Built-in WASM batch API (fallback)
|
|
|
|
```typescript
|
|
context.registerProvider('embedBatch', async (texts: string[]) => {
|
|
// Process all texts in a single forward pass
|
|
return myEngine.batchEmbed(texts)
|
|
})
|
|
```
|
|
|
|
### Index Providers
|
|
|
|
> **Write-path invariant (the change-feed contract).** Every canonical
|
|
> mutation flows through Brainy's generation-store commit points — index
|
|
> providers are invoked *inside* that commit and never originate canonical
|
|
> writes of their own. The `brain.onChange` change feed is emitted from those
|
|
> commit points and relies on this: **a plugin must never introduce a write
|
|
> path that bypasses the generation-store commit.** If a future provider ever
|
|
> needs a direct native ingest path, it must either route through the commit
|
|
> or emit equivalent change events — otherwise every `onChange` consumer
|
|
> (live UIs, cache invalidation, realtime sync) silently develops a blind
|
|
> spot.
|
|
|
|
#### `vector`
|
|
**Type:** `(config: object, distanceFunction: Function, options: object) => VectorIndexProvider-compatible`
|
|
|
|
Factory function that creates a vector index instance. The returned object must implement the `VectorIndexProvider` public API:
|
|
|
|
- `addItem(item: { id: string, vector: number[] }): Promise<string>`
|
|
- `search(queryVector: number[], k: number, filter?, options?): Promise<Array<[string, number]>>`
|
|
- `removeItem(id: string): Promise<boolean>`
|
|
- `size(): number`
|
|
- `clear(): void`
|
|
- `flush(): Promise<number>`
|
|
- `rebuild(options?): Promise<void>`
|
|
- `getDirtyNodeCount(): number`
|
|
- `getPersistMode(): 'immediate' | 'deferred'`
|
|
- `getEntryPointId(): string | null`
|
|
- `getMaxLevel(): number`
|
|
- `getDimension(): number | null`
|
|
- `getConfig(): object`
|
|
- `getDistanceFunction(): Function`
|
|
- `enableCOW(parent): void`
|
|
- `setUseParallelization(boolean): void`
|
|
|
|
For type-aware indexes (separate graph per noun type), also implement:
|
|
- `getIndexForType(type: string): VectorIndexProvider` (duck-typed detection)
|
|
- `search(queryVector, k, type?, filter?, options?): Promise<Array<[string, number]>>`
|
|
|
|
```typescript
|
|
context.registerProvider('vector', (config, distanceFn, options) => {
|
|
return new MyNativeVectorIndex(config, distanceFn, options)
|
|
})
|
|
```
|
|
|
|
#### The readiness contract (all three index providers)
|
|
|
|
A provider that **persists its derived index** should implement the optional readiness
|
|
members so a warm reopen never pays a redundant rebuild-from-canonical:
|
|
|
|
- **`init?(): Promise<void>`** — eager cold-load. Brainy awaits it once during
|
|
`brain.init()`, after the metadata provider's `init()` (the id-mapper hydrates first)
|
|
and **before the rebuild gate**.
|
|
- **`isReady?(): boolean`** — honest durability signal. `true` ⇔ the persisted index is
|
|
loaded (or cheaply demand-loadable) and consistent with what was last persisted. When
|
|
exposed, the rebuild gate defers to this signal **instead of** the `size() === 0` /
|
|
`totalEntries === 0` heuristics — a disk-native index may report 0 resident entries
|
|
while fully durable. Never return `true` if the durable state failed to load: the
|
|
signal is honest in both directions, and a not-ready provider gets its rebuild even
|
|
when `size() > 0`.
|
|
- **`isMigrating?(): boolean`** — while `true`, the provider owns its index (background
|
|
migration); brainy skips its rebuild entirely.
|
|
|
|
Providers that implement none of these keep the size/count heuristics — correct for
|
|
engines whose `rebuild()` *is* their load path (like brainy's built-in JS vector index).
|
|
|
|
#### `metadataIndex`
|
|
**Type:** `(storage: StorageAdapter) => MetadataIndexManager-compatible`
|
|
|
|
Factory function that creates a metadata index. The returned object must implement the `MetadataIndexManager` interface including `init()`, `addEntity()`, `removeEntity()`, `query()`, `flush()`, `clear()`, etc.
|
|
|
|
```typescript
|
|
context.registerProvider('metadataIndex', (storage) => {
|
|
return new MyNativeMetadataIndex(storage)
|
|
})
|
|
```
|
|
|
|
#### `graphIndex`
|
|
**Type:** `(storage: StorageAdapter) => GraphAdjacencyIndex-compatible`
|
|
|
|
Factory function that creates a graph adjacency index for relationship tracking (verbs/triples). Must implement the `GraphAdjacencyIndex` interface including `addVerb()`, `getVerbsBySource()`, `getVerbsByTarget()`, `flush()`, etc.
|
|
|
|
```typescript
|
|
context.registerProvider('graphIndex', (storage) => {
|
|
return new MyNativeGraphIndex(storage)
|
|
})
|
|
```
|
|
|
|
#### `aggregation`
|
|
**Type:** `(storage: StorageAdapter) => AggregationProvider-compatible`
|
|
|
|
Factory function that creates an aggregation engine for write-time incremental SUM/COUNT/AVG/MIN/MAX with GROUP BY and time windows. The returned object must implement the `AggregationProvider` interface.
|
|
|
|
```typescript
|
|
context.registerProvider('aggregation', (storage) => {
|
|
return new MyNativeAggregationEngine(storage)
|
|
})
|
|
```
|
|
|
|
When provided by an optional native acceleration plugin (such as `@soulcraft/cor`), this enables:
|
|
- Compiled source filters (vs per-entity JS object traversal)
|
|
- Precise MIN/MAX via sorted data structures (vs lazy recompute)
|
|
- Parallel aggregate rebuild across CPU cores
|
|
- SIMD-accelerated timestamp bucketing
|
|
|
|
### Utility Providers
|
|
|
|
#### `cache`
|
|
**Type:** `UnifiedCache`
|
|
|
|
Replaces the global `UnifiedCache` singleton used for VFS path resolution, semantic caching, and vector index caching. Must implement the `UnifiedCache` interface (available from `@soulcraft/brainy/internals`).
|
|
|
|
```typescript
|
|
import type { UnifiedCache } from '@soulcraft/brainy/internals'
|
|
|
|
context.registerProvider('cache', myNativeCache)
|
|
```
|
|
|
|
#### `entityIdMapper`
|
|
**Type:** `(storage: StorageAdapter) => EntityIdMapper-compatible`
|
|
|
|
Factory for bidirectional UUID ↔ integer mapping used by roaring bitmaps. Must implement `getOrAssign()`, `getUuid()`, `getInt()`, `has()`, `remove()`, `flush()`, `clear()`.
|
|
|
|
#### `roaring`
|
|
**Type:** `RoaringBitmap32 class`
|
|
|
|
Replacement for the roaring bitmap implementation. Used internally by the metadata index for set operations. Must be API-compatible with `roaring-wasm`.
|
|
|
|
#### `msgpack`
|
|
**Type:** `{ encode: (data: any) => Buffer, decode: (buffer: Buffer) => any }`
|
|
|
|
Native msgpack encode/decode for SSTable serialization.
|
|
|
|
### Analytics Providers (Native-Only)
|
|
|
|
These provider keys have **no JavaScript fallback** — they represent capabilities that require native code (SIMD, mmap, sub-microsecond latency). They are available when an optional native acceleration plugin (such as `@soulcraft/cor`) is installed.
|
|
|
|
Use `brain.getProvider('analytics:hyperloglog')` to check availability. Returns `undefined` if no plugin provides it.
|
|
|
|
#### `analytics:hyperloglog`
|
|
Approximate distinct counts. Count unique values (e.g., unique merchants) across millions of records using ~16KB of memory with ~1% error. Each update is O(1).
|
|
|
|
#### `analytics:tdigest`
|
|
Streaming percentiles. Compute P50/P90/P95/P99 from streaming data without storing all values. Uses ~4KB per digest with ~1% accuracy at the tails.
|
|
|
|
#### `analytics:countmin`
|
|
Frequency estimation. Find the most common values (e.g., top-K merchants) using ~40KB with 0.1% error. O(1) per update.
|
|
|
|
#### `analytics:anomaly`
|
|
Real-time anomaly detection. Flag statistically unusual values at write-time using exponentially weighted moving averages. 64 bytes per group, sub-microsecond decisions.
|
|
|
|
#### `aggregation:mmap`
|
|
Persistent aggregate storage via memory-mapped files. Aggregate state survives process crashes without explicit flush. Zero serialization overhead.
|
|
|
|
---
|
|
|
|
## Storage Adapter Plugins
|
|
|
|
Plugins can register custom storage backends that users reference by name.
|
|
|
|
### Implementing a Storage Adapter
|
|
|
|
```typescript
|
|
import type { StorageAdapterFactory } from '@soulcraft/brainy/plugin'
|
|
import type { StorageAdapter } from '@soulcraft/brainy'
|
|
|
|
class MyStorageAdapter implements StorageAdapter {
|
|
async init(): Promise<void> { /* ... */ }
|
|
async saveNoun(noun: HNSWNoun): Promise<void> { /* ... */ }
|
|
async getNoun(id: string): Promise<HNSWNounWithMetadata | null> { /* ... */ }
|
|
async deleteNoun(id: string): Promise<void> { /* ... */ }
|
|
// ... implement all StorageAdapter methods
|
|
}
|
|
```
|
|
|
|
### Registering a Storage Adapter
|
|
|
|
```typescript
|
|
context.registerProvider('storage:my-backend', {
|
|
name: 'my-backend',
|
|
create: (config: Record<string, unknown>) => {
|
|
return new MyStorageAdapter(config)
|
|
}
|
|
} satisfies StorageAdapterFactory)
|
|
```
|
|
|
|
Users can then use your storage:
|
|
|
|
```typescript
|
|
const brain = new Brainy({ storage: 'my-backend', myBackendOption: 'value' })
|
|
```
|
|
|
|
## Import Paths
|
|
|
|
Brainy provides three entry points for plugin developers:
|
|
|
|
| Import Path | Contents | Stability |
|
|
|-------------|----------|-----------|
|
|
| `@soulcraft/brainy` | Public API, types, StorageAdapter | Stable (semver) |
|
|
| `@soulcraft/brainy/plugin` | BrainyPlugin, BrainyPluginContext, StorageAdapterFactory | Stable (semver) |
|
|
| `@soulcraft/brainy/internals` | UnifiedCache, EntityIdMapper, logger utilities | Internal (may change between minor versions) |
|
|
|
|
## Diagnostics
|
|
|
|
Brainy provides a `diagnostics()` method to verify plugin wiring:
|
|
|
|
```typescript
|
|
const brain = new Brainy()
|
|
await brain.init()
|
|
|
|
const diag = brain.diagnostics()
|
|
console.log(diag)
|
|
// {
|
|
// version: '7.14.0',
|
|
// plugins: { active: ['my-plugin'], count: 1 },
|
|
// providers: {
|
|
// metadataIndex: { source: 'default' },
|
|
// graphIndex: { source: 'default' },
|
|
// embeddings: { source: 'plugin' },
|
|
// embedBatch: { source: 'plugin' },
|
|
// distance: { source: 'plugin' },
|
|
// vector: { source: 'default' },
|
|
// ...
|
|
// },
|
|
// indexes: {
|
|
// vector: { size: 0, type: 'JsHnswVectorIndex' },
|
|
// metadata: { type: 'MetadataIndexManager', initialized: true },
|
|
// graph: { type: 'GraphAdjacencyIndex', initialized: true, wiredToStorage: true }
|
|
// }
|
|
// }
|
|
```
|
|
|
|
The CLI also supports diagnostics:
|
|
|
|
```bash
|
|
brainy diagnostics
|
|
```
|
|
|
|
### Init-Time Summary
|
|
|
|
When a plugin is active, brainy automatically logs a provider summary after `init()`:
|
|
|
|
```
|
|
[brainy] Plugin activated: @soulcraft/cor
|
|
[brainy] Providers: 8/10 native (@soulcraft/cor) | default: vector, cache
|
|
```
|
|
|
|
This tells you at a glance how many subsystems are accelerated and which ones are falling back to JavaScript. The log respects `config.silent`.
|
|
|
|
### Fail-Fast for Production
|
|
|
|
Use `requireProviders()` after `init()` to guarantee specific providers are plugin-supplied. This prevents silent fallback to JavaScript in deployments where you expect native acceleration:
|
|
|
|
```typescript
|
|
const brain = new Brainy()
|
|
await brain.init()
|
|
|
|
// Throws immediately if any of these are using JS fallback
|
|
brain.requireProviders(['distance', 'embeddings', 'metadataIndex', 'graphIndex'])
|
|
```
|
|
|
|
If a required provider is missing, the error message tells you exactly what's wrong:
|
|
|
|
```
|
|
[brainy] Required providers using JS fallback: graphIndex.
|
|
Active plugins: @soulcraft/cor.
|
|
These providers must be supplied by a plugin for this deployment.
|
|
Check plugin installation, license, and native module availability.
|
|
```
|
|
|
|
This is the recommended pattern for production deployments with paid plugins — fail at startup rather than silently degrading performance.
|
|
|
|
## Complete Example: Distance Acceleration Plugin
|
|
|
|
A minimal but useful plugin that provides SIMD-accelerated distance calculations:
|
|
|
|
```typescript
|
|
// simd-distance-plugin/src/plugin.ts
|
|
import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin'
|
|
|
|
// Hypothetical native module
|
|
import { simdCosineDistance } from './native.js'
|
|
|
|
const simdDistancePlugin: BrainyPlugin = {
|
|
name: 'brainy-simd-distance',
|
|
|
|
async activate(context: BrainyPluginContext): Promise<boolean> {
|
|
// Check if SIMD is available on this platform
|
|
if (!checkSimdSupport()) {
|
|
console.log('[simd-distance] SIMD not available, skipping')
|
|
return false // Don't activate — brainy uses JS fallback
|
|
}
|
|
|
|
context.registerProvider('distance', simdCosineDistance)
|
|
return true
|
|
}
|
|
}
|
|
|
|
export default simdDistancePlugin
|
|
```
|
|
|
|
```json
|
|
// simd-distance-plugin/package.json
|
|
{
|
|
"name": "brainy-simd-distance",
|
|
"main": "./dist/plugin.js",
|
|
"types": "./dist/plugin.d.ts",
|
|
"peerDependencies": {
|
|
"@soulcraft/brainy": ">=7.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```typescript
|
|
import { Brainy } from '@soulcraft/brainy'
|
|
|
|
const brain = new Brainy({ plugins: ['brainy-simd-distance'] })
|
|
await brain.init()
|
|
|
|
// Verify it's active
|
|
const diag = brain.diagnostics()
|
|
console.log(diag.providers.distance) // { source: 'plugin' }
|
|
```
|
|
|
|
## Design Principles
|
|
|
|
1. **Brainy works perfectly without plugins.** Every provider has a JavaScript fallback. Plugins only improve performance or add capabilities.
|
|
|
|
2. **Provider keys are string-based.** The plugin system is not coupled to any specific plugin. Any package can register any provider.
|
|
|
|
3. **Clean separation.** Plugins access brainy through the documented `BrainyPluginContext` interface. No direct access to internal classes is needed.
|
|
|
|
4. **Fail-safe activation.** If a plugin throws during `activate()`, brainy logs a warning and continues with defaults. A broken plugin never prevents brainy from working.
|
|
|
|
5. **Lifecycle management.** `deactivate()` is called during `brainy.close()` for resource cleanup. Native resources, connections, and file handles should be released here.
|