open-brainy/src/mcp/brainyMCPClient.ts
David Snelling 5f3a2ca7d5 fix: internal subtype consistency + brain.audit() diagnostic + improved enforcement errors
Brainy 7.30 shipped opt-in subtype enforcement; SDK 3.20.0 then registered
SDK_CORE_VOCABULARY on every consumer's brain (Event, Collection, Message,
Contract, Media, Document NounTypes). On 2026-06-08 Venue's /book flow went 500
because their brain.add({ type: NounType.Event, ... }) call sites lacked
subtype. An audit of Brainy's OWN source revealed 14 HIGH-risk internal write
paths that also omit subtype — any consumer running the same vocabulary would
have hit Brainy's infrastructure paths next. 7.30.1 closes both gaps before
8.0 makes strict mode the default.

Additive across the board. Zero behavior change for consumers not using strict
mode. Every change is JS-side — Cortex needs no work for 7.30.1.

NEW — brain.audit() diagnostic
- Read-only method walking storage.getNouns() / getVerbs() pagination
- Returns { entitiesWithoutSubtype: { type: count }, relationshipsWithoutSubtype,
  total, scanned, recommendation }
- VFS infrastructure entities excluded by default (they bypass enforcement via
  isVFSEntity marker); pass { includeVFS: true } to surface them
- The companion to migrateField (7.x) and fillSubtypes (8.0): tells consumers
  exactly what would break under strict enforcement, deterministically

NEW — Improved enforcement error messages
- Caller's source location extracted from Error().stack so users see their own
  call site, not a Brainy internal frame
- Specific guidance branches: registered vocabulary → "Pass one of: a, b, c";
  brain-wide strict mode → mentions the except clause; otherwise → registration
  recipe via brain.requireSubtype()
- Documentation link to the canonical migration recipe
- Same shape for noun and verb enforcement

NEW — CLI --subtype flag
- brainy add and brainy relate gain -s/--subtype <value>
- Defaults to 'cli-add' / 'cli-relate' so the CLI works against strict-mode
  brains without the user needing to know the vocabulary in advance

INTERNAL — every Brainy write path now sets subtype
- VFS Contains edges (5 sites at lines 503/905/1694/1772/1886) → 'vfs-contains'
- VFS symlink entity → 'vfs-symlink' (NEW — distinct from 'vfs-file')
- VFS copy-file → preserves source subtype, falls back to 'vfs-file'
- VFS symlink also adopts the isVFSEntity infrastructure marker so it bypasses
  enforcement in strict mode
- Aggregation materializer (Measurement entities) → 'materialized-aggregate'
- ImportCoordinator (3 sites): document → 'import-source'; entities →
  options.defaultSubtype ?? 'imported'; placeholder → 'import-placeholder'
- SmartImportOrchestrator (4 entity sites + 2 batch relate sites): same
  precedence (extractor → options.defaultSubtype → 'imported')
- EntityDeduplicator → candidate.subtype ?? 'imported'
- UniversalImportAPI → extractor → 'extracted' for both entities and relations
- NeuralImport → adds defaultSubtype to NeuralImportOptions; precedence same
- GoogleSheetsIntegration → request body 'subtype' ?? 'imported-from-sheets'
- ODataIntegration → request body 'Subtype' ?? 'imported-from-odata'
- MCP client message storage → 'mcp-message' (also fixes pre-existing missing
  data field and missing type by aliasing from the prior text field)

Side-effect fix: storage.getNouns() paginated now surfaces subtype to top-level
- Single-noun getNoun() already did this in 7.30; the paginated path was missed
- Without this fix brain.audit() saw missing subtype on entities that actually
  had one (caught by the strict-mode self-test before release)

NEW — tests/integration/strict-mode-self-test.test.ts (13 tests)
- Creates a brain under the exact SDK_CORE_VOCABULARY shape Venue hit + brain-
  wide strict mode
- Exercises every internal Brainy path: VFS root + mkdir + writeFile + cp + mv
  + ln + symlink; aggregation engine; audit diagnostic with includeVFS toggle
- Validates error message UX: caller location, vocabulary guidance, brain-wide
  strict mode guidance, off-vocabulary value reporting

Docs
- New "Strict mode in practice" section in docs/guides/subtypes-and-facets.md
  covering the SDK_CORE_VOCABULARY pattern, 4-step migration recipe
  (audit → migrateField → hand-fix → re-audit), the Brainy-internal label
  reference table, and an 8.0 forward-look on fillSubtypes()
- docs/api/README.md: new audit() entry, strict-mode tips on add() and relate()
- RELEASES.md: full 7.30.1 entry

Cortex parity (forward-looking, not blocking 7.30.1)
- 6th open question added to .strategy/BRAINY-8.0-SUBTYPE-CONTRACT.md: native
  fast path for audit() and fillSubtypes() via column-store null-subtype
  bitmap for billion-scale brains
- Cortex should add a parity test mirroring strict-mode-self-test.test.ts
  against their native paths to catch any latent bug where native writes
  bypass JS validation
- Brainy-internal subtype labels become a documented part of the 8.0 contract
  (useful for Cortex telemetry surfacing Brainy-managed infrastructure %)

Verification
- npx tsc --noEmit: clean
- npm test: 1468/1468 unit
- 7.29 noun integration suite: 26/26 (no regression)
- 7.30 verb subtype + enforcement integration suite: 30/30 (no regression)
- New strict-mode-self-test integration suite: 13/13
- npm run build: clean
- Closed-source product reference audit: clean

Addresses VE-SUBTYPE-MIGRATION (Venue's reported request) and ships internal
labels Venue did NOT ask for but that would have broken them next under their
own vocabulary registration.
2026-06-08 11:31:47 -07:00

322 lines
No EOL
7.9 KiB
TypeScript

/**
* BrainyMCPClient
*
* Client for connecting Claude instances to the Brain Jar Broadcast Server
* Utilizes Brainy for persistent memory and vector search capabilities
*/
import WebSocket from 'ws'
import { Brainy } from '../brainy.js'
import { NounType } from '../types/graphTypes.js'
import { v4 as uuidv4 } from '../universal/uuid.js'
interface ClientOptions {
name: string // e.g., 'Jarvis' or 'Picasso'
role: string // e.g., 'Backend Systems' or 'Frontend Design'
serverUrl?: string // Default: ws://localhost:8765
autoReconnect?: boolean
useBrainyMemory?: boolean // Store messages in Brainy for persistence
}
interface Message {
id: string
from: string
to?: string | string[]
type: 'message' | 'notification' | 'sync' | 'heartbeat' | 'identify'
event?: string
data: any
timestamp: number
}
export class BrainyMCPClient {
private socket?: WebSocket
private options: Required<ClientOptions>
private brainy?: Brainy
private messageHandlers: Map<string, (message: Message) => void> = new Map()
private reconnectTimeout?: NodeJS.Timeout
private isConnected = false
constructor(options: ClientOptions) {
this.options = {
serverUrl: 'ws://localhost:8765',
autoReconnect: true,
useBrainyMemory: true,
...options
}
}
/**
* Initialize Brainy for persistent memory
*/
private async initBrainy() {
if (this.options.useBrainyMemory && !this.brainy) {
this.brainy = new Brainy({
storage: {
requestPersistentStorage: true
}
})
await this.brainy.init()
console.log(`🧠 Brainy memory initialized for ${this.options.name}`)
}
}
/**
* Connect to the broadcast server
*/
async connect(): Promise<void> {
// Initialize Brainy first
await this.initBrainy()
return new Promise((resolve, reject) => {
try {
this.socket = new WebSocket(this.options.serverUrl)
this.socket.on('open', () => {
console.log(`${this.options.name} connected to Brain Jar Broadcast`)
this.isConnected = true
// Identify ourselves
this.send({
type: 'identify',
data: {
name: this.options.name,
role: this.options.role
}
})
resolve()
})
this.socket.on('message', async (data) => {
try {
const message = JSON.parse(data.toString()) as Message
await this.handleMessage(message)
} catch (error) {
console.error('Error parsing message:', error)
}
})
this.socket.on('close', () => {
console.log(`${this.options.name} disconnected from Brain Jar`)
this.isConnected = false
if (this.options.autoReconnect) {
this.scheduleReconnect()
}
})
this.socket.on('error', (error) => {
console.error(`Connection error for ${this.options.name}:`, error)
reject(error)
})
} catch (error) {
reject(error)
}
})
}
/**
* Handle incoming message
*/
private async handleMessage(message: Message) {
// Store in Brainy for persistent memory. Subtype `'mcp-message'` marks
// these as MCP-protocol messages so consumers can filter / count them via
// `find({ type: NounType.Message, subtype: 'mcp-message' })` and so
// enforcement consumers registering a vocabulary on NounType.Message don't
// reject MCP traffic (added 7.30.1; also fixes the pre-existing missing
// `data` field by aliasing from the prior `text` field).
if (this.brainy && message.type === 'message') {
try {
await this.brainy.add({
data: `${message.from}: ${JSON.stringify(message.data)}`,
type: NounType.Message,
subtype: 'mcp-message',
metadata: {
messageId: message.id,
from: message.from,
to: message.to,
timestamp: message.timestamp,
messageType: message.type,
event: message.event
}
})
} catch (error) {
console.error('Error storing message in Brainy:', error)
}
}
// Handle sync messages (receive history)
if (message.type === 'sync' && message.data.history) {
console.log(`📜 ${this.options.name} received ${message.data.history.length} historical messages`)
// Store history in Brainy with the same subtype as live messages.
if (this.brainy) {
for (const histMsg of message.data.history) {
await this.brainy.add({
data: `${histMsg.from}: ${JSON.stringify(histMsg.data)}`,
type: NounType.Message,
subtype: 'mcp-message',
metadata: {
...histMsg
}
})
}
}
}
// Call registered handlers
const handler = this.messageHandlers.get(message.type)
if (handler) {
handler(message)
}
// Call universal handler
const universalHandler = this.messageHandlers.get('*')
if (universalHandler) {
universalHandler(message)
}
}
/**
* Send a message
*/
send(message: Partial<Message>) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
console.error(`${this.options.name} is not connected`)
return
}
const fullMessage: Message = {
id: message.id || uuidv4(),
from: this.options.name,
type: message.type || 'message',
data: message.data || {},
timestamp: Date.now(),
...message
}
this.socket.send(JSON.stringify(fullMessage))
}
/**
* Send a message to specific agent(s)
*/
sendTo(recipient: string | string[], data: any) {
this.send({
to: recipient,
type: 'message',
data
})
}
/**
* Broadcast to all agents
*/
broadcast(data: any) {
this.send({
type: 'message',
data
})
}
/**
* Register a message handler
*/
on(type: string, handler: (message: Message) => void) {
this.messageHandlers.set(type, handler)
}
/**
* Remove a message handler
*/
off(type: string) {
this.messageHandlers.delete(type)
}
/**
* Search historical messages using Brainy's vector search
*/
async searchMemory(query: string, limit = 10): Promise<any[]> {
if (!this.brainy) {
console.warn('Brainy memory not initialized')
return []
}
const results = await this.brainy.search(query, limit)
return results.map(r => ({
...r.metadata,
relevance: r.score
}))
}
/**
* Get recent messages from Brainy memory
*/
async getRecentMessages(limit = 20): Promise<any[]> {
if (!this.brainy) {
console.warn('Brainy memory not initialized')
return []
}
// Search for recent activity
const results = await this.brainy.search('recent messages communication', limit)
return results
.map(r => r.metadata)
.sort((a: any, b: any) => b.timestamp - a.timestamp)
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
}
this.reconnectTimeout = setTimeout(() => {
console.log(`🔄 ${this.options.name} attempting to reconnect...`)
this.connect().catch(error => {
console.error('Reconnection failed:', error)
this.scheduleReconnect()
})
}, 5000)
}
/**
* Disconnect from server
*/
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
}
if (this.socket) {
this.socket.close(1000, 'Client disconnecting')
this.socket = undefined
}
this.isConnected = false
}
/**
* Check if connected
*/
getIsConnected(): boolean {
return this.isConnected
}
/**
* Get agent info
*/
getAgentInfo() {
return {
name: this.options.name,
role: this.options.role,
connected: this.isConnected
}
}
}
// Export for both environments
export default BrainyMCPClient