fix(interfaces): resolve 8 critical bugs across web/tiara/persona layers

P0 Critical Fixes:
- Fix import ./tiara → ./claude-flow in plugin/builtin/index.ts
- Create AgentOrchestrator type bridge for council/tiara integration (src/tiara.ts)
- Fix hardcoded state ID in memory-bridge.ts (was causing cross-persona data loss)

P1 Core Functionality:
- Add persona isolation to memory bridge namespace (personas now have private memory)
- Load SKILL.md content into drone prompts (skills now available to drones)
- Inject persona-specific tools from AGENT_CONFIGS into drone prompts

P2 API Consistency:
- Standardize error response format in server.ts (MCP & gateway endpoints)
- WebSocket param validation already in place (verified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-11 18:07:42 +01:00
parent 3936b15dbf
commit 5095bc0dff
7 changed files with 495 additions and 24 deletions
+6 -6
View File
@@ -240,10 +240,10 @@ export namespace Server {
async (c) => {
const gateway = WhatsAppGateway.getInstance()
if (!gateway) {
return c.json({ success: false, error: "WhatsApp gateway not running" }, 400)
return c.json({ data: null, errors: [{ message: "WhatsApp gateway not running" }], success: false }, 400)
}
if (!gateway.isReady()) {
return c.json({ success: false, error: "WhatsApp not connected" }, 400)
return c.json({ data: null, errors: [{ message: "WhatsApp not connected" }], success: false }, 400)
}
const { phone, message } = c.req.valid("json")
const chatId = `${phone}@c.us`
@@ -272,10 +272,10 @@ export namespace Server {
async (c) => {
const gateway = WhatsAppGateway.getInstance()
if (!gateway) {
return c.json({ success: false, error: "WhatsApp gateway not running" }, 400)
return c.json({ data: null, errors: [{ message: "WhatsApp gateway not running" }], success: false }, 400)
}
if (!gateway.isReady()) {
return c.json({ success: false, error: "WhatsApp not connected" }, 400)
return c.json({ data: null, errors: [{ message: "WhatsApp not connected" }], success: false }, 400)
}
const { chatJid, messageId, emoji } = c.req.valid("json")
const success = await gateway.sendReaction(chatJid, messageId, emoji)
@@ -3197,7 +3197,7 @@ export namespace Server {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
return c.json({ data: null, errors: [{ message: `MCP server ${name} does not support OAuth` }], success: false }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
@@ -3257,7 +3257,7 @@ export namespace Server {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
return c.json({ data: null, errors: [{ message: `MCP server ${name} does not support OAuth` }], success: false }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
+2 -2
View File
@@ -318,9 +318,9 @@ export class ContinuityManager {
// Save to memory
await this.memoryBridge.saveConversationState(this.currentState);
// Store individual facts as memories for semantic search
// Store individual facts as memories for semantic search (persona-isolated)
if (newFacts.length > 0) {
await this.memoryBridge.storeKeyFacts(newFacts, this.currentState.sessionId);
await this.memoryBridge.storeKeyFacts(newFacts, this.currentState.sessionId, this.currentState.leadPersona);
}
return this.currentState;
+77 -11
View File
@@ -68,9 +68,13 @@ export class QdrantMemoryBridge implements MemoryBridge {
private embedder: EmbeddingProvider;
private config: PersonasConfig["qdrant"];
private initialized = false;
private instanceId: string;
constructor(config: PersonasConfig["qdrant"], options?: { useMockEmbeddings?: boolean }) {
constructor(config: PersonasConfig["qdrant"], options?: { useMockEmbeddings?: boolean; instanceId?: string }) {
this.config = config;
// Generate a stable instance ID based on machine identity or use provided one
// This prevents state collision when multiple orchestrators run
this.instanceId = options?.instanceId ?? this.generateInstanceId();
// Initialize embedding provider
// Use mock if no API key or explicitly requested
@@ -116,6 +120,24 @@ export class QdrantMemoryBridge implements MemoryBridge {
this.initialized = true;
}
/**
* Generate a stable instance ID for this machine/user
*/
private generateInstanceId(): string {
// Use hostname + username for a stable per-machine ID
const os = require("os");
const hostname = os.hostname() || "unknown";
const username = os.userInfo().username || "user";
return stringToUUID(`personas-${hostname}-${username}`);
}
/**
* Get the state ID for this instance
*/
private getStateId(): string {
return stringToUUID(`state-${this.instanceId}`);
}
/**
* Save Personas state to Qdrant
*/
@@ -123,8 +145,8 @@ export class QdrantMemoryBridge implements MemoryBridge {
await this.init();
const stateJson = JSON.stringify(state);
// Use a fixed UUID for the current state (deterministic)
const stateId = "00000000-0000-0000-0000-000000000001";
// Use instance-specific UUID for state (prevents multi-instance collision)
const stateId = this.getStateId();
// Generate embedding for the state summary
const stateSummary = this.generateStateSummary(state);
@@ -157,7 +179,7 @@ export class QdrantMemoryBridge implements MemoryBridge {
async loadState(): Promise<PersonasState | null> {
await this.init();
const stateId = "00000000-0000-0000-0000-000000000001";
const stateId = this.getStateId();
const results = await this.storage.get([stateId]);
const result = results[0];
@@ -295,6 +317,7 @@ export class QdrantMemoryBridge implements MemoryBridge {
/**
* Store a memory entry for semantic retrieval
* Memories are isolated by persona namespace for privacy
*/
async storeMemory(
content: string,
@@ -302,12 +325,21 @@ export class QdrantMemoryBridge implements MemoryBridge {
): Promise<string> {
await this.init();
// Require persona for proper isolation
const persona = metadata.persona as string;
if (!persona) {
throw new Error("Memory storage requires persona in metadata for isolation");
}
// Use persona-specific namespace for isolation
const namespace = `personas:${persona}`;
const memory = await this.memoryStore.save({
content,
category: "context",
source: "agent",
senderId: (metadata.persona as string) ?? "personas",
namespace: "personas",
senderId: persona,
namespace,
metadata,
});
@@ -316,16 +348,24 @@ export class QdrantMemoryBridge implements MemoryBridge {
/**
* Search memories by semantic similarity
* Can search within a specific persona's namespace or across all personas
*/
async searchMemories(
query: string,
limit = 10
limit = 10,
options?: { persona?: string; includeShared?: boolean }
): Promise<Array<{ id: string; content: string; score: number }>> {
await this.init();
// If persona specified, search only that persona's namespace
// Otherwise search the shared namespace
const namespace = options?.persona
? `personas:${options.persona}`
: "personas:shared";
const results = await this.memoryStore.search(query, {
limit,
namespace: "personas",
namespace,
});
return results.map((r) => ({
@@ -335,6 +375,28 @@ export class QdrantMemoryBridge implements MemoryBridge {
}));
}
/**
* Search memories across all personas (for cross-persona context)
*/
async searchAllPersonaMemories(
query: string,
limit = 10
): Promise<Array<{ id: string; content: string; score: number; persona?: string }>> {
await this.init();
// Search without namespace filter to get all memories
const results = await this.memoryStore.search(query, {
limit,
});
return results.map((r) => ({
id: r.id,
content: r.content,
score: r.score,
persona: r.senderId,
}));
}
/**
* Get memories by IDs
*/
@@ -355,13 +417,14 @@ export class QdrantMemoryBridge implements MemoryBridge {
/**
* Store key facts extracted from conversation
*/
async storeKeyFacts(facts: string[], sessionId: string): Promise<void> {
async storeKeyFacts(facts: string[], sessionId: string, persona: string): Promise<void> {
await this.init();
for (const fact of facts) {
await this.storeMemory(fact, {
type: "key_fact",
sessionId,
persona,
extractedAt: Date.now(),
});
}
@@ -376,6 +439,7 @@ export class QdrantMemoryBridge implements MemoryBridge {
limit?: number;
includeKeyFacts?: boolean;
sessionId?: string;
persona?: string;
}
): Promise<{
relevantMemories: Array<{ content: string; score: number }>;
@@ -385,8 +449,10 @@ export class QdrantMemoryBridge implements MemoryBridge {
const limit = options?.limit ?? 5;
// Search for relevant memories
const memories = await this.searchMemories(taskDescription, limit);
// Search for relevant memories (persona-specific if provided)
const memories = await this.searchMemories(taskDescription, limit, {
persona: options?.persona,
});
// Load conversation state if session ID provided
let conversationState: ConversationState | undefined;
+263
View File
@@ -7,6 +7,207 @@
import type { PersonaId, OrchestrationPersona } from "./types";
import { ORCHESTRATION_PERSONAS } from "./types";
import { existsSync, readFileSync } from "fs";
import { join, dirname } from "path";
// Import AGENT_CONFIGS for tool injection
import { AGENT_CONFIGS } from "../agent/personas";
// =============================================================================
// Skill Loading
// =============================================================================
interface SkillFrontmatter {
name: string;
description?: string;
includes?: string[];
}
interface LoadedSkill {
frontmatter: SkillFrontmatter;
content: string;
}
// Cache for loaded skills
const skillCache = new Map<string, LoadedSkill>();
/**
* Find the skills directory (supports both dev and installed paths)
*/
function findSkillsDir(): string {
// Try relative paths from common locations
const candidates = [
join(process.cwd(), ".claude", "skills"),
join(dirname(process.execPath), "..", "..", ".claude", "skills"),
join(process.env.HOME || "", ".local", "src", "agent-core", ".claude", "skills"),
];
for (const dir of candidates) {
if (existsSync(dir)) {
return dir;
}
}
// Fallback to cwd
return join(process.cwd(), ".claude", "skills");
}
/**
* Parse YAML frontmatter from markdown content
*/
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) {
return { frontmatter: { name: "unknown" }, body: content };
}
const yamlContent = match[1];
const body = match[2];
// Simple YAML parsing for our use case
const frontmatter: SkillFrontmatter = { name: "unknown" };
const lines = yamlContent.split("\n");
let currentKey = "";
let inArray = false;
const arrayValues: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Check for array item
if (trimmed.startsWith("- ") && inArray) {
arrayValues.push(trimmed.slice(2).trim());
continue;
}
// If we were collecting an array, save it
if (inArray && currentKey === "includes") {
frontmatter.includes = [...arrayValues];
arrayValues.length = 0;
inArray = false;
}
// Check for key: value
const colonIndex = trimmed.indexOf(":");
if (colonIndex > 0) {
currentKey = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (value) {
// Simple value
if (currentKey === "name") frontmatter.name = value;
else if (currentKey === "description") frontmatter.description = value;
} else {
// Start of array or empty
if (currentKey === "includes") {
inArray = true;
}
}
}
}
// Handle case where array is at the end
if (inArray && currentKey === "includes" && arrayValues.length > 0) {
frontmatter.includes = [...arrayValues];
}
return { frontmatter, body };
}
/**
* Load a skill by name (handles includes recursively)
*/
function loadSkill(skillName: string, skillsDir: string, loaded = new Set<string>()): LoadedSkill | null {
// Prevent infinite loops
if (loaded.has(skillName)) {
return null;
}
loaded.add(skillName);
// Check cache
if (skillCache.has(skillName)) {
return skillCache.get(skillName)!;
}
// Try to load the skill file
const skillPath = join(skillsDir, skillName, "SKILL.md");
if (!existsSync(skillPath)) {
return null;
}
try {
const content = readFileSync(skillPath, "utf-8");
const { frontmatter, body } = parseFrontmatter(content);
// Load included skills
let combinedContent = body;
if (frontmatter.includes?.length) {
const includedParts: string[] = [];
for (const includeName of frontmatter.includes) {
const included = loadSkill(includeName, skillsDir, loaded);
if (included) {
includedParts.push(`\n<!-- Included from ${includeName} -->\n${included.content}`);
}
}
if (includedParts.length > 0) {
combinedContent = body + "\n" + includedParts.join("\n");
}
}
const skill: LoadedSkill = {
frontmatter,
content: combinedContent,
};
skillCache.set(skillName, skill);
return skill;
} catch {
return null;
}
}
/**
* Get loaded skill content for a persona
*/
export function getPersonaSkillContent(persona: PersonaId): string | null {
const skillsDir = findSkillsDir();
const skill = loadSkill(persona, skillsDir);
return skill?.content ?? null;
}
/**
* Clear the skill cache (useful for reloading)
*/
export function clearSkillCache(): void {
skillCache.clear();
}
/**
* Get persona-specific tools from AGENT_CONFIGS
*/
export function getPersonaTools(persona: PersonaId): string[] {
const config = AGENT_CONFIGS[persona];
if (!config?.tools) return [];
// Extract domain-specific tools (those with persona prefix)
const domainTools: string[] = [];
for (const [tool, enabled] of Object.entries(config.tools)) {
if (enabled && tool.includes(":")) {
domainTools.push(tool);
}
}
return domainTools;
}
/**
* Get full tool configuration for a persona
*/
export function getPersonaToolConfig(persona: PersonaId): Record<string, boolean> {
const config = AGENT_CONFIGS[persona];
return config?.tools ?? {};
}
// Import tiara types
// Note: These are from the vendor submodule
@@ -154,6 +355,7 @@ export function selectDroneType(
/**
* Generate a system prompt for a persona drone.
* Includes skill content from SKILL.md files.
*/
export function generateDronePrompt(
persona: PersonaId,
@@ -162,6 +364,7 @@ export function generateDronePrompt(
plan?: string;
objectives?: string[];
keyFacts?: string[];
includeSkills?: boolean;
}
): string {
const config = getPersonaConfig(persona);
@@ -180,6 +383,30 @@ export function generateDronePrompt(
});
parts.push(``);
// Load and include skill content (if enabled, default true)
const includeSkills = context?.includeSkills !== false;
if (includeSkills) {
parts.push(`# Capabilities and Tools\n`);
// Include domain-specific tools from AGENT_CONFIGS
const domainTools = getPersonaTools(persona);
if (domainTools.length > 0) {
parts.push(`## Available Domain Tools`);
for (const tool of domainTools) {
parts.push(`- \`${tool}\``);
}
parts.push(``);
}
// Include condensed skill content from SKILL.md
const skillContent = getPersonaSkillContent(persona);
if (skillContent) {
const condensed = condenseSkillContent(skillContent, persona);
parts.push(condensed);
parts.push(``);
}
}
// Context from queen
if (context?.plan) {
parts.push(`# Current Plan`);
@@ -218,6 +445,42 @@ export function generateDronePrompt(
return parts.join("\n");
}
/**
* Condense skill content for drone prompts (extract key sections)
*/
function condenseSkillContent(content: string, persona: PersonaId): string {
const parts: string[] = [];
// Extract Domain Tools section if present
const toolsMatch = content.match(/## Domain Tools[\s\S]*?\n\n(?=##|$)/);
if (toolsMatch) {
parts.push(toolsMatch[0].trim());
}
// Extract Core Capabilities section if present
const capsMatch = content.match(/## Core Capabilities[\s\S]*?\n\n(?=##|$)/);
if (capsMatch) {
// Just get the headers, not all the code blocks
const capsContent = capsMatch[0];
const headers = capsContent.match(/### [^\n]+/g);
if (headers) {
parts.push(`Available capabilities: ${headers.map(h => h.replace("### ", "")).join(", ")}`);
}
}
// If no structured content found, provide a summary based on persona
if (parts.length === 0) {
const summaries: Record<PersonaId, string> = {
zee: "Memory storage/search, messaging (WhatsApp/Telegram), calendar, contacts, notifications",
stanley: "Market data, portfolio management, SEC filings, research, NautilusTrader backtesting",
johny: "Knowledge graph, spaced repetition, concept mapping, practice problems, learning progress",
};
parts.push(`Tools: ${summaries[persona]}`);
}
return parts.join("\n\n");
}
/**
* Get persona config by ID
*/
+16 -3
View File
@@ -476,12 +476,25 @@ export interface MemoryBridge {
/** Load state from Qdrant */
loadState(): Promise<PersonasState | null>;
/** Store a memory for continuity */
/** Store a memory for continuity (requires persona in metadata for isolation) */
storeMemory(content: string, metadata: Record<string, unknown>): Promise<string>;
/** Search memories by query */
searchMemories(query: string, limit?: number): Promise<Array<{ id: string; content: string; score: number }>>;
/** Search memories by query (persona-isolated unless searching shared namespace) */
searchMemories(
query: string,
limit?: number,
options?: { persona?: string; includeShared?: boolean }
): Promise<Array<{ id: string; content: string; score: number }>>;
/** Search memories across all personas (for cross-persona context) */
searchAllPersonaMemories?(
query: string,
limit?: number
): Promise<Array<{ id: string; content: string; score: number; persona?: string }>>;
/** Get memories by IDs */
getMemories(ids: string[]): Promise<Array<{ id: string; content: string }>>;
/** Store key facts (requires persona for isolation) */
storeKeyFacts?(facts: string[], sessionId: string, persona: string): Promise<void>;
}
+2 -2
View File
@@ -4,7 +4,7 @@
* Exports all built-in plugins for easy registration.
*/
export { ClaudeFlowPlugin } from './tiara';
export { ClaudeFlowPlugin } from './claude-flow';
export { AnthropicAuthPlugin } from './anthropic-auth';
export { CopilotAuthPlugin } from './copilot-auth';
export { MemoryPersistencePlugin } from './memory-persistence';
@@ -13,7 +13,7 @@ export { MemoryPersistencePlugin } from './memory-persistence';
export { StanleyFinancePlugin } from './domains/stanley-finance';
export { ZeeMessagingPlugin } from './domains/zee-messaging';
import { ClaudeFlowPlugin } from './tiara';
import { ClaudeFlowPlugin } from './claude-flow';
import { AnthropicAuthPlugin } from './anthropic-auth';
import { CopilotAuthPlugin } from './copilot-auth';
import { MemoryPersistencePlugin } from './memory-persistence';
+129
View File
@@ -0,0 +1,129 @@
/**
* Tiara - Agent Orchestration Interface
*
* This module bridges the Council deliberation system with the Personas orchestrator.
* It exports the AgentOrchestrator interface expected by the council stages.
*/
import type { PersonaId } from "./personas/types";
/**
* Result from spawning an agent
*/
export interface AgentSpawnResult {
result?: unknown;
error?: string;
durationMs?: number;
}
/**
* Options for spawning an agent
*/
export interface AgentSpawnOptions {
agentType: string;
action: string;
params: Record<string, unknown>;
context?: string;
}
/**
* AgentOrchestrator interface expected by the Council deliberation system.
* Provides agent spawning capabilities for multi-agent coordination.
*/
export interface AgentOrchestrator {
/**
* Spawn an agent to perform a specific action
*/
spawnAgent(options: AgentSpawnOptions): Promise<AgentSpawnResult>;
}
/**
* Map council agent types to personas
*/
function mapAgentTypeToPersona(agentType: string): PersonaId {
switch (agentType.toLowerCase()) {
case "inbox_manager":
case "scheduler":
case "task_coordinator":
return "zee";
case "research_assistant":
return "johny";
case "market_analyst":
case "portfolio_manager":
return "stanley";
default:
return "zee"; // Default to Zee for unknown types
}
}
/**
* Create an AgentOrchestrator adapter from the Personas Orchestrator
*/
export function createAgentOrchestrator(
orchestrator: import("./personas/tiara").Orchestrator
): AgentOrchestrator {
return {
async spawnAgent(options: AgentSpawnOptions): Promise<AgentSpawnResult> {
const startTime = Date.now();
const persona = mapAgentTypeToPersona(options.agentType);
try {
// Build prompt from action and params
const prompt = buildAgentPrompt(options);
// Use spawnDroneWithWait to get the result
const result = await orchestrator.spawnDroneWithWait({
persona,
task: `${options.agentType}: ${options.action}`,
prompt,
});
return {
result: result.result,
error: result.error,
durationMs: result.durationMs,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startTime,
};
}
},
};
}
/**
* Build a prompt for the agent from spawn options
*/
function buildAgentPrompt(options: AgentSpawnOptions): string {
const parts: string[] = [];
parts.push(`## Agent Role: ${options.agentType}`);
parts.push(`## Action: ${options.action}`);
if (options.context) {
parts.push(`\n## Context\n${options.context}`);
}
if (options.params.query) {
parts.push(`\n## Query\n${options.params.query}`);
}
// Add any other params
const otherParams = Object.entries(options.params).filter(
([key]) => key !== "query"
);
if (otherParams.length > 0) {
parts.push(`\n## Parameters`);
for (const [key, value] of otherParams) {
parts.push(`- ${key}: ${JSON.stringify(value)}`);
}
}
return parts.join("\n");
}
// Re-export Orchestrator for convenience
export { Orchestrator } from "./personas/tiara";
export type { PersonaId } from "./personas/types";