This commit is contained in:
16
sidecar/package.json
Normal file
16
sidecar/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "waggle-sidecar",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/core": "*",
|
||||
"@waggle/agent": "*",
|
||||
"@waggle/weaver": "*"
|
||||
}
|
||||
}
|
||||
124
sidecar/src/agent-session.ts
Normal file
124
sidecar/src/agent-session.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Orchestrator, type OrchestratorConfig } from '@waggle/agent';
|
||||
import type { MindDB } from '@waggle/core';
|
||||
import { createEmbeddingProvider } from '@waggle/core';
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
|
||||
export interface StreamEvent {
|
||||
type: 'token' | 'tool_use' | 'tool_result' | 'done' | 'error';
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export class AgentSession {
|
||||
private orchestrator!: Orchestrator;
|
||||
private initPromise: Promise<void>;
|
||||
|
||||
constructor(db: MindDB) {
|
||||
this.initPromise = this.init(db);
|
||||
}
|
||||
|
||||
private async init(db: MindDB): Promise<void> {
|
||||
const embeddingProvider = await createEmbeddingProvider({
|
||||
targetDimensions: 1024,
|
||||
});
|
||||
this.orchestrator = new Orchestrator({
|
||||
db,
|
||||
embedder: embeddingProvider,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
message: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
onStream?: StreamCallback,
|
||||
): Promise<string> {
|
||||
await this.initPromise;
|
||||
const systemPrompt = this.orchestrator.buildSystemPrompt();
|
||||
const tools = this.orchestrator.getTools();
|
||||
|
||||
try {
|
||||
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
||||
const client = new Anthropic({ apiKey });
|
||||
|
||||
const anthropicTools = tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
...t.parameters,
|
||||
},
|
||||
}));
|
||||
|
||||
let messages: Array<{ role: 'user' | 'assistant'; content: string | Array<unknown> }> = [
|
||||
{ role: 'user', content: message },
|
||||
];
|
||||
|
||||
let maxTurns = 10;
|
||||
while (maxTurns-- > 0) {
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
tools: anthropicTools as never,
|
||||
messages: messages as never,
|
||||
});
|
||||
|
||||
const toolBlocks = response.content.filter((b: { type: string }) => b.type === 'tool_use');
|
||||
const textBlocks = response.content.filter((b: { type: string }) => b.type === 'text');
|
||||
|
||||
if (toolBlocks.length === 0) {
|
||||
const text = textBlocks.map((b) => (b as { text: string }).text).join('');
|
||||
onStream?.({ type: 'done', data: text });
|
||||
return text;
|
||||
}
|
||||
|
||||
const toolResults: Array<unknown> = [];
|
||||
for (const block of toolBlocks) {
|
||||
const tb = block as { id: string; name: string; input: Record<string, unknown> };
|
||||
onStream?.({ type: 'tool_use', data: { name: tb.name, id: tb.id } });
|
||||
|
||||
try {
|
||||
const result = await this.orchestrator.executeTool(tb.name, tb.input);
|
||||
onStream?.({ type: 'tool_result', data: { name: tb.name, result } });
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tb.id,
|
||||
content: result,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
onStream?.({ type: 'tool_result', data: { name: tb.name, error: errMsg } });
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tb.id,
|
||||
content: `Error: ${errMsg}`,
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages = [
|
||||
...messages,
|
||||
{ role: 'assistant', content: response.content as Array<unknown> },
|
||||
{ role: 'user', content: toolResults as Array<unknown> },
|
||||
];
|
||||
}
|
||||
|
||||
return 'Max tool turns reached.';
|
||||
} catch (err) {
|
||||
const errMsg = (err as Error).message;
|
||||
onStream?.({ type: 'error', data: errMsg });
|
||||
|
||||
if (errMsg.includes('API key') || errMsg.includes('authentication')) {
|
||||
return 'Please set your API key in Settings to start chatting.';
|
||||
}
|
||||
return `Error: ${errMsg}`;
|
||||
}
|
||||
}
|
||||
|
||||
getOrchestrator(): Orchestrator {
|
||||
return this.orchestrator;
|
||||
}
|
||||
}
|
||||
51
sidecar/src/main.ts
Normal file
51
sidecar/src/main.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createInterface } from 'readline';
|
||||
import { MindDB } from '@waggle/core';
|
||||
import { RpcHandler, type JsonRpcRequest } from './rpc-handler.js';
|
||||
import { WeaverScheduler } from './weaver-scheduler.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
const mindPath = process.env.WAGGLE_MIND_PATH ??
|
||||
path.join(os.homedir(), '.waggle', 'default.mind');
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(mindPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const db = new MindDB(mindPath);
|
||||
const handler = new RpcHandler(db);
|
||||
const weaver = new WeaverScheduler(db);
|
||||
weaver.start();
|
||||
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
try {
|
||||
const request = JSON.parse(line) as JsonRpcRequest;
|
||||
const response = await handler.handle(request);
|
||||
process.stdout.write(JSON.stringify(response) + '\n');
|
||||
} catch (err) {
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32700, message: 'Parse error' },
|
||||
id: null,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
weaver.stop();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
weaver.stop();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Signal ready
|
||||
process.stderr.write('waggle-sidecar:ready\n');
|
||||
48
sidecar/src/mcp-manager.ts
Normal file
48
sidecar/src/mcp-manager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface McpServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class McpManager {
|
||||
private servers: Map<string, McpServerConfig>;
|
||||
|
||||
constructor() {
|
||||
this.servers = new Map();
|
||||
}
|
||||
|
||||
addServer(config: McpServerConfig): void {
|
||||
if (this.servers.has(config.id)) {
|
||||
throw new Error(`Server "${config.id}" already exists`);
|
||||
}
|
||||
this.servers.set(config.id, { enabled: true, ...config });
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
this.servers.delete(id);
|
||||
}
|
||||
|
||||
getServer(id: string): McpServerConfig | undefined {
|
||||
return this.servers.get(id);
|
||||
}
|
||||
|
||||
listServers(): McpServerConfig[] {
|
||||
return Array.from(this.servers.values());
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return JSON.stringify(Array.from(this.servers.values()));
|
||||
}
|
||||
|
||||
static fromJSON(json: string): McpManager {
|
||||
const manager = new McpManager();
|
||||
const configs = JSON.parse(json) as McpServerConfig[];
|
||||
for (const config of configs) {
|
||||
manager.servers.set(config.id, config);
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
139
sidecar/src/rpc-handler.ts
Normal file
139
sidecar/src/rpc-handler.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
type MindDB,
|
||||
IdentityLayer,
|
||||
AwarenessLayer,
|
||||
FrameStore,
|
||||
SessionStore,
|
||||
KnowledgeGraph,
|
||||
} from '@waggle/core';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import { McpManager, type McpServerConfig } from './mcp-manager.js';
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
[key: string]: unknown;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export class RpcHandler {
|
||||
private db: MindDB;
|
||||
private identity: IdentityLayer;
|
||||
private awareness: AwarenessLayer;
|
||||
private frames: FrameStore;
|
||||
private sessions: SessionStore;
|
||||
private knowledge: KnowledgeGraph;
|
||||
private settings: Settings;
|
||||
private agentSession: AgentSession;
|
||||
private mcpManager: McpManager;
|
||||
|
||||
constructor(db: MindDB) {
|
||||
this.db = db;
|
||||
this.identity = new IdentityLayer(db);
|
||||
this.awareness = new AwarenessLayer(db);
|
||||
this.frames = new FrameStore(db);
|
||||
this.sessions = new SessionStore(db);
|
||||
this.knowledge = new KnowledgeGraph(db);
|
||||
this.settings = { model: 'claude-sonnet-4-6', apiKey: '' };
|
||||
this.agentSession = new AgentSession(db);
|
||||
this.mcpManager = new McpManager();
|
||||
}
|
||||
|
||||
async handle(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||
try {
|
||||
const result = await this.dispatch(request.method, request.params ?? {});
|
||||
return { jsonrpc: '2.0', result, id: request.id };
|
||||
} catch (err) {
|
||||
if (err instanceof MethodNotFoundError) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32601, message: `Method not found: ${request.method}` },
|
||||
id: request.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: (err as Error).message },
|
||||
id: request.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'ping':
|
||||
return { status: 'ok' };
|
||||
|
||||
case 'mind.getIdentity':
|
||||
return this.identity.exists() ? this.identity.toContext() : 'No identity set.';
|
||||
|
||||
case 'mind.getAwareness':
|
||||
return this.awareness.toContext();
|
||||
|
||||
case 'chat.send': {
|
||||
const message = params.message as string;
|
||||
if (!message) throw new Error('message is required');
|
||||
|
||||
const streamEvents: unknown[] = [];
|
||||
const response = await this.agentSession.sendMessage(
|
||||
message,
|
||||
this.settings.apiKey,
|
||||
this.settings.model,
|
||||
(event) => {
|
||||
streamEvents.push(event);
|
||||
},
|
||||
);
|
||||
return { response, events: streamEvents };
|
||||
}
|
||||
|
||||
case 'settings.get':
|
||||
return { ...this.settings };
|
||||
|
||||
case 'settings.set': {
|
||||
const key = params.key as string;
|
||||
const value = params.value;
|
||||
if (!(key in this.settings)) {
|
||||
throw new Error(`Unknown setting: ${key}`);
|
||||
}
|
||||
(this.settings as Record<string, unknown>)[key] = value;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case 'mcp.list':
|
||||
return this.mcpManager.listServers();
|
||||
|
||||
case 'mcp.add': {
|
||||
const config = params as unknown as McpServerConfig;
|
||||
this.mcpManager.addServer(config);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case 'mcp.remove':
|
||||
this.mcpManager.removeServer(params.id as string);
|
||||
return { success: true };
|
||||
|
||||
default:
|
||||
throw new MethodNotFoundError(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MethodNotFoundError extends Error {
|
||||
constructor(method: string) {
|
||||
super(`Method not found: ${method}`);
|
||||
this.name = 'MethodNotFoundError';
|
||||
}
|
||||
}
|
||||
71
sidecar/src/skill-loader.ts
Normal file
71
sidecar/src/skill-loader.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
||||
|
||||
function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } | null {
|
||||
const match = content.match(FRONTMATTER_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const meta: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
const value = line.slice(colonIdx + 1).trim();
|
||||
if (key && value) meta[key] = value;
|
||||
}
|
||||
return { meta, body: match[2].trim() };
|
||||
}
|
||||
|
||||
export class SkillLoader {
|
||||
private directories: string[];
|
||||
|
||||
constructor(directories: string[]) {
|
||||
this.directories = directories;
|
||||
}
|
||||
|
||||
discover(): Skill[] {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
|
||||
for (const dir of this.directories) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillMdPath = path.join(dir, entry.name, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
if (!parsed || !parsed.meta.name || !parsed.meta.description) continue;
|
||||
|
||||
const skill: Skill = {
|
||||
name: parsed.meta.name,
|
||||
description: parsed.meta.description,
|
||||
prompt: parsed.body,
|
||||
model: parsed.meta.model,
|
||||
tools: parsed.meta.tools
|
||||
? parsed.meta.tools.split(',').map(t => t.trim())
|
||||
: undefined,
|
||||
path: skillMdPath,
|
||||
};
|
||||
|
||||
skillMap.set(skill.name, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values());
|
||||
}
|
||||
}
|
||||
64
sidecar/src/weaver-scheduler.ts
Normal file
64
sidecar/src/weaver-scheduler.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { type MindDB, FrameStore, SessionStore } from '@waggle/core';
|
||||
import { MemoryWeaver } from '@waggle/weaver';
|
||||
|
||||
export interface WeaverConfig {
|
||||
consolidationIntervalMs: number;
|
||||
decayIntervalMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: WeaverConfig = {
|
||||
consolidationIntervalMs: 60 * 60 * 1000,
|
||||
decayIntervalMs: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
export class WeaverScheduler {
|
||||
private weaver: MemoryWeaver;
|
||||
private sessions: SessionStore;
|
||||
private timers: NodeJS.Timeout[] = [];
|
||||
private config: WeaverConfig;
|
||||
|
||||
constructor(db: MindDB, config: Partial<WeaverConfig> = {}) {
|
||||
const frames = new FrameStore(db);
|
||||
const sessions = new SessionStore(db);
|
||||
this.sessions = sessions;
|
||||
this.weaver = new MemoryWeaver(db, frames, sessions);
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.timers.push(
|
||||
setInterval(() => this.runConsolidation(), this.config.consolidationIntervalMs)
|
||||
);
|
||||
this.timers.push(
|
||||
setInterval(() => this.runDecay(), this.config.decayIntervalMs)
|
||||
);
|
||||
process.stderr.write('waggle-weaver:started\n');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const timer of this.timers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
runConsolidation(): void {
|
||||
try {
|
||||
const activeSessions = this.sessions.getActive();
|
||||
for (const session of activeSessions) {
|
||||
this.weaver.consolidateGop(session.gop_id);
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`waggle-weaver:consolidation-error:${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
runDecay(): void {
|
||||
try {
|
||||
this.weaver.decayFrames();
|
||||
this.weaver.strengthenFrames();
|
||||
} catch (err) {
|
||||
process.stderr.write(`waggle-weaver:decay-error:${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
sidecar/tsconfig.json
Normal file
16
sidecar/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user