Compare commits
3 Commits
5908239ce9
...
dcd01da1b1
106
src/agents/Agent.js
Normal file
106
src/agents/Agent.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* zCode Agent Model — Ported from Ruflo Agent.ts
|
||||||
|
* Individual agent with capabilities, status, task execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {'coder'|'tester'|'reviewer'|'architect'|'coordinator'|'designer'|'deployer'|'researcher'|'security'} AgentType */
|
||||||
|
/** @typedef {'idle'|'active'|'busy'|'error'} AgentStatus */
|
||||||
|
|
||||||
|
let _agentCounter = 0;
|
||||||
|
const _id = () => `agent_${Date.now().toString(36)}_${++_agentCounter}`;
|
||||||
|
|
||||||
|
export class Agent {
|
||||||
|
/**
|
||||||
|
* @param {Object} config
|
||||||
|
* @param {string} [config.id]
|
||||||
|
* @param {AgentType} config.type
|
||||||
|
* @param {string} config.name
|
||||||
|
* @param {string} [config.description]
|
||||||
|
* @param {string[]} [config.capabilities]
|
||||||
|
* @param {string} [config.role]
|
||||||
|
* @param {Object} [config.metadata]
|
||||||
|
*/
|
||||||
|
constructor(config) {
|
||||||
|
this.id = config.id || _id();
|
||||||
|
this.type = config.type || 'coder';
|
||||||
|
this.name = config.name || this.type;
|
||||||
|
this.description = config.description || '';
|
||||||
|
this.status = 'idle';
|
||||||
|
this.capabilities = config.capabilities || [];
|
||||||
|
this.role = config.role || null;
|
||||||
|
this.parent = config.parent || null;
|
||||||
|
this.metadata = config.metadata || {};
|
||||||
|
this.createdAt = Date.now();
|
||||||
|
this.lastActive = Date.now();
|
||||||
|
this._taskCount = 0;
|
||||||
|
this._errorCount = 0;
|
||||||
|
this._conversationContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get idle() { return this.status === 'idle'; }
|
||||||
|
get active() { return this.status === 'active' || this.status === 'busy'; }
|
||||||
|
|
||||||
|
/** Execute a task */
|
||||||
|
async executeTask(task) {
|
||||||
|
this.status = 'busy';
|
||||||
|
this.lastActive = Date.now();
|
||||||
|
this._taskCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = typeof task.execute === 'function'
|
||||||
|
? await task.execute(this)
|
||||||
|
: { status: 'completed', output: null };
|
||||||
|
this.status = 'idle';
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this._errorCount++;
|
||||||
|
this.status = 'error';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if agent has a specific capability */
|
||||||
|
hasCapability(cap) {
|
||||||
|
return this.capabilities.some(c => c.toLowerCase() === cap.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if agent can handle a task based on capabilities */
|
||||||
|
canHandleTask(task) {
|
||||||
|
if (!task.requiredCapabilities || task.requiredCapabilities.length === 0) return true;
|
||||||
|
return task.requiredCapabilities.some(c => this.hasCapability(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set agent's conversation context */
|
||||||
|
setContext(ctx) { this._conversationContext = ctx; }
|
||||||
|
getContext() { return this._conversationContext; }
|
||||||
|
|
||||||
|
/** Add extra context to conversation */
|
||||||
|
addContext(key, value) {
|
||||||
|
if (!this._conversationContext) this._conversationContext = {};
|
||||||
|
this._conversationContext[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
status: this.status,
|
||||||
|
capabilities: this.capabilities,
|
||||||
|
role: this.role,
|
||||||
|
parent: this.parent,
|
||||||
|
taskCount: this._taskCount,
|
||||||
|
errorCount: this._errorCount,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
lastActive: this.lastActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an agent from a config object (deserialization) */
|
||||||
|
static fromConfig(config) {
|
||||||
|
return new Agent(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Agent;
|
||||||
284
src/agents/SwarmCoordinator.js
Normal file
284
src/agents/SwarmCoordinator.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* zCode Swarm Coordinator — Ported from Ruflo SwarmCoordinator
|
||||||
|
* Multi-agent orchestration: spawn agents, distribute tasks, consensus, concurrency.
|
||||||
|
*
|
||||||
|
* Topologies:
|
||||||
|
* hierarchical — queen-led (master agent coordinates workers)
|
||||||
|
* mesh — peer-to-peer (agents communicate directly)
|
||||||
|
* simple — direct assignment (one-shot delegation)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { Agent } from './Agent.js';
|
||||||
|
import { Task, TASK_PRIORITIES, TASK_STATUSES } from './Task.js';
|
||||||
|
import { hookManager, HOOK_TYPES } from '../bot/hooks.js';
|
||||||
|
|
||||||
|
export class SwarmCoordinator {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.topology = options.topology || 'simple';
|
||||||
|
this._agents = new Map();
|
||||||
|
this._eventBus = new EventEmitter();
|
||||||
|
this._agentMetrics = new Map();
|
||||||
|
this._connections = new Map(); // agentId -> Set<agentId>
|
||||||
|
this.maxAgents = options.maxAgents || 10;
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialized() { return this._initialized; }
|
||||||
|
get agentCount() { return this._agents.size; }
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this._initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
for (const [id] of this._agents) {
|
||||||
|
await this.terminateAgent(id);
|
||||||
|
}
|
||||||
|
this._connections.clear();
|
||||||
|
this._agentMetrics.clear();
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a new agent
|
||||||
|
* @param {Object} config - Agent configuration
|
||||||
|
* @returns {Promise<Agent>}
|
||||||
|
*/
|
||||||
|
async spawnAgent(config) {
|
||||||
|
if (this._agents.size >= this.maxAgents) {
|
||||||
|
throw new Error(`Max agents reached (${this.maxAgents})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire pre-agent hook
|
||||||
|
await hookManager.execute(HOOK_TYPES.PRE_AGENT, { action: 'spawn', config });
|
||||||
|
|
||||||
|
const agent = new Agent(config);
|
||||||
|
this._agents.set(agent.id, agent);
|
||||||
|
this._agentMetrics.set(agent.id, {
|
||||||
|
tasksAssigned: 0,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
tasksFailed: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.topology === 'hierarchical' && config.parent) {
|
||||||
|
this._addConnection(config.parent, agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire post-agent hook
|
||||||
|
await hookManager.execute(HOOK_TYPES.POST_AGENT, { action: 'spawn', agent });
|
||||||
|
|
||||||
|
this._eventBus.emit('agent:spawned', { agentId: agent.id, type: agent.type });
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminate an agent */
|
||||||
|
async terminateAgent(agentId) {
|
||||||
|
await hookManager.execute(HOOK_TYPES.PRE_AGENT, { action: 'terminate', agentId });
|
||||||
|
this._agents.delete(agentId);
|
||||||
|
this._agentMetrics.delete(agentId);
|
||||||
|
this._connections.delete(agentId);
|
||||||
|
// Remove from other connection lists
|
||||||
|
for (const [id, connections] of this._connections) {
|
||||||
|
connections.delete(agentId);
|
||||||
|
}
|
||||||
|
this._eventBus.emit('agent:terminated', { agentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all agents */
|
||||||
|
listAgents() {
|
||||||
|
return [...this._agents.values()].map(a => a.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get an agent by id */
|
||||||
|
getAgent(id) { return this._agents.get(id) || null; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute tasks across agents
|
||||||
|
* @param {Task[]} tasks
|
||||||
|
* @returns {Promise<Array<{agentId: string, taskId: string}>>}
|
||||||
|
*/
|
||||||
|
async distributeTasks(tasks) {
|
||||||
|
const assignments = [];
|
||||||
|
const sorted = Task.resolveExecutionOrder(tasks);
|
||||||
|
|
||||||
|
for (const task of sorted) {
|
||||||
|
const agent = this._selectAgentForTask(task);
|
||||||
|
if (!agent) {
|
||||||
|
assignments.push({ agentId: null, taskId: task.id, error: 'No suitable agent found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.status = 'busy';
|
||||||
|
task.assignTo(agent.id);
|
||||||
|
assignments.push({ agentId: agent.id, taskId: task.id });
|
||||||
|
this._incrementMetric(agent.id, 'tasksAssigned');
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single task on a specific agent
|
||||||
|
*/
|
||||||
|
async executeTask(agentId, task) {
|
||||||
|
const agent = this._agents.get(agentId);
|
||||||
|
if (!agent) throw new Error(`Agent '${agentId}' not found`);
|
||||||
|
|
||||||
|
const metric = this._agentMetrics.get(agentId);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
task.start();
|
||||||
|
const result = await agent.executeTask(task);
|
||||||
|
task.complete(result);
|
||||||
|
metric.tasksCompleted++;
|
||||||
|
metric.totalDuration += Date.now() - startTime;
|
||||||
|
|
||||||
|
this._eventBus.emit('task:completed', { taskId: task.id, agentId });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
task.fail(err);
|
||||||
|
metric.tasksFailed++;
|
||||||
|
this._eventBus.emit('task:failed', { taskId: task.id, agentId, error: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple tasks concurrently
|
||||||
|
*/
|
||||||
|
async executeTasksConcurrently(tasks) {
|
||||||
|
const assignments = await this.distributeTasks(tasks);
|
||||||
|
const promises = assignments.map(({ agentId, taskId }) => {
|
||||||
|
if (!agentId) return { taskId, error: 'No suitable agent' };
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
return this.executeTask(agentId, task)
|
||||||
|
.then(r => ({ taskId, result: r }))
|
||||||
|
.catch(err => ({ taskId, error: err.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reach consensus among a subset of agents on a decision
|
||||||
|
*/
|
||||||
|
async reachConsensus(decision, agentIds) {
|
||||||
|
const participants = agentIds
|
||||||
|
.map(id => this._agents.get(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (participants.length < 1) {
|
||||||
|
return { agreed: false, reason: 'No participants' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple majority vote
|
||||||
|
const votes = participants.map(a => ({
|
||||||
|
agentId: a.id,
|
||||||
|
vote: this._simulateVote(a, decision),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const yesVotes = votes.filter(v => v.vote).length;
|
||||||
|
const total = votes.length;
|
||||||
|
const agreed = yesVotes >= Math.ceil(total / 2);
|
||||||
|
|
||||||
|
return { agreed, votes, majority: Math.ceil(total / 2), received: yesVotes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send message between agents */
|
||||||
|
async sendMessage(message) {
|
||||||
|
const target = this._agents.get(message.to);
|
||||||
|
if (!target) throw new Error(`Agent '${message.to}' not found`);
|
||||||
|
|
||||||
|
if (!this._connections.get(message.from)?.has(message.to)) {
|
||||||
|
if (this.topology === 'mesh') {
|
||||||
|
this._addConnection(message.from, message.to);
|
||||||
|
} else if (this.topology === 'hierarchical') {
|
||||||
|
throw new Error('Direct agent-to-agent messaging not allowed in hierarchical topology');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._eventBus.emit('agent:message', message);
|
||||||
|
return { delivered: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get swarm state summary */
|
||||||
|
getSwarmState() {
|
||||||
|
const agents = this.listAgents();
|
||||||
|
return {
|
||||||
|
topology: this.topology,
|
||||||
|
agents: agents.length,
|
||||||
|
byStatus: agents.reduce((acc, a) => {
|
||||||
|
acc[a.status] = (acc[a.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
byType: agents.reduce((acc, a) => {
|
||||||
|
acc[a.type] = (acc[a.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
metrics: Object.fromEntries(this._agentMetrics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the hierarchy tree */
|
||||||
|
getHierarchy() {
|
||||||
|
const tree = { id: 'root', children: [] };
|
||||||
|
const roots = [...this._agents.values()].filter(a => !a.parent);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
tree.children.push(this._buildSubTree(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal ----
|
||||||
|
|
||||||
|
_selectAgentForTask(task) {
|
||||||
|
// Prefer idle agents with matching capabilities
|
||||||
|
const candidates = [...this._agents.values()]
|
||||||
|
.filter(a => a.status === 'idle' || a.status === 'active')
|
||||||
|
.filter(a => a.canHandleTask(task))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Prefer last recently used (fair distribution)
|
||||||
|
return a.lastActive - b.lastActive;
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_simulateVote(agent, decision) {
|
||||||
|
// Simple heuristic: agents vote based on capabilities matching
|
||||||
|
if (!decision.requiredCapabilities) return Math.random() > 0.3;
|
||||||
|
const match = decision.requiredCapabilities.some(c => agent.hasCapability(c));
|
||||||
|
return match ? Math.random() > 0.2 : Math.random() > 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addConnection(from, to) {
|
||||||
|
if (!this._connections.has(from)) this._connections.set(from, new Set());
|
||||||
|
this._connections.get(from).add(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
_incrementMetric(agentId, key) {
|
||||||
|
const m = this._agentMetrics.get(agentId);
|
||||||
|
if (m) m[key]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSubTree(agent) {
|
||||||
|
const children = [...this._agents.values()]
|
||||||
|
.filter(a => a.parent === agent.id)
|
||||||
|
.map(a => this._buildSubTree(a));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
type: agent.type,
|
||||||
|
name: agent.name,
|
||||||
|
status: agent.status,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SwarmCoordinator;
|
||||||
166
src/agents/Task.js
Normal file
166
src/agents/Task.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* zCode Task Model — Ported from Ruflo Task.ts
|
||||||
|
* DAG-compatible task with priorities, dependencies, rollback support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let _taskCounter = 0;
|
||||||
|
const _id = () => `task_${Date.now().toString(36)}_${++_taskCounter}`;
|
||||||
|
|
||||||
|
const TASK_PRIORITIES = { HIGH: 'high', MEDIUM: 'medium', LOW: 'low' };
|
||||||
|
const TASK_STATUSES = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
IN_PROGRESS: 'in-progress',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
FAILED: 'failed',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Task {
|
||||||
|
/**
|
||||||
|
* @param {Object} config
|
||||||
|
* @param {string} [config.id]
|
||||||
|
* @param {string} config.type
|
||||||
|
* @param {string} config.description
|
||||||
|
* @param {'high'|'medium'|'low'} [config.priority]
|
||||||
|
* @param {string[]} [config.dependencies]
|
||||||
|
* @param {string[]} [config.requiredCapabilities]
|
||||||
|
* @param {Object} [config.metadata]
|
||||||
|
* @param {Function} [config.onExecute]
|
||||||
|
* @param {Function} [config.onRollback]
|
||||||
|
*/
|
||||||
|
constructor(config) {
|
||||||
|
this.id = config.id || _id();
|
||||||
|
this.type = config.type || 'generic';
|
||||||
|
this.description = config.description || '';
|
||||||
|
this.priority = config.priority || TASK_PRIORITIES.MEDIUM;
|
||||||
|
this.status = TASK_STATUSES.PENDING;
|
||||||
|
this.assignedTo = config.assignedTo || null;
|
||||||
|
this.dependencies = config.dependencies || [];
|
||||||
|
this.requiredCapabilities = config.requiredCapabilities || [];
|
||||||
|
this.metadata = config.metadata || {};
|
||||||
|
this.onExecute = config.onExecute || null;
|
||||||
|
this.onRollback = config.onRollback || null;
|
||||||
|
this.startedAt = null;
|
||||||
|
this.completedAt = null;
|
||||||
|
this.error = null;
|
||||||
|
this._result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pending() { return this.status === TASK_STATUSES.PENDING; }
|
||||||
|
get completed() { return this.status === TASK_STATUSES.COMPLETED; }
|
||||||
|
get failed() { return this.status === TASK_STATUSES.FAILED; }
|
||||||
|
|
||||||
|
/** Are all dependencies resolved? */
|
||||||
|
areDependenciesResolved(completedTasks) {
|
||||||
|
return this.dependencies.every(depId => completedTasks.has(depId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start execution */
|
||||||
|
start() {
|
||||||
|
if (this.status !== TASK_STATUSES.PENDING) return false;
|
||||||
|
this.status = TASK_STATUSES.IN_PROGRESS;
|
||||||
|
this.startedAt = Date.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark complete */
|
||||||
|
complete(result) {
|
||||||
|
if (this.status !== TASK_STATUSES.IN_PROGRESS) return false;
|
||||||
|
this.status = TASK_STATUSES.COMPLETED;
|
||||||
|
this.completedAt = Date.now();
|
||||||
|
this._result = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark failed */
|
||||||
|
fail(error) {
|
||||||
|
this.status = TASK_STATUSES.FAILED;
|
||||||
|
this.completedAt = Date.now();
|
||||||
|
this.error = error?.message || String(error);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel task */
|
||||||
|
cancel() {
|
||||||
|
if (this.status === TASK_STATUSES.COMPLETED || this.status === TASK_STATUSES.CANCELLED) return false;
|
||||||
|
this.status = TASK_STATUSES.CANCELLED;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get duration in ms */
|
||||||
|
getDuration() {
|
||||||
|
if (!this.startedAt) return 0;
|
||||||
|
return (this.completedAt || Date.now()) - this.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assign to an agent */
|
||||||
|
assignTo(agentId) {
|
||||||
|
this.assignedTo = agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric priority value for sorting */
|
||||||
|
getPriorityValue() {
|
||||||
|
const map = { high: 3, medium: 2, low: 1 };
|
||||||
|
return map[this.priority] || 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
|
description: this.description,
|
||||||
|
priority: this.priority,
|
||||||
|
status: this.status,
|
||||||
|
assignedTo: this.assignedTo,
|
||||||
|
dependencies: this.dependencies,
|
||||||
|
startedAt: this.startedAt,
|
||||||
|
completedAt: this.completedAt,
|
||||||
|
duration: this.getDuration(),
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create from config */
|
||||||
|
static fromConfig(config) {
|
||||||
|
return new Task(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort tasks by priority (high first) */
|
||||||
|
static sortByPriority(tasks) {
|
||||||
|
return [...tasks].sort((a, b) => b.getPriorityValue() - a.getPriorityValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve execution order respecting dependencies (topological sort) */
|
||||||
|
static resolveExecutionOrder(tasks) {
|
||||||
|
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
||||||
|
const visited = new Set();
|
||||||
|
const visiting = new Set();
|
||||||
|
const order = [];
|
||||||
|
|
||||||
|
function dfs(taskId) {
|
||||||
|
if (visited.has(taskId)) return;
|
||||||
|
if (visiting.has(taskId)) throw new Error(`Circular dependency detected: ${taskId}`);
|
||||||
|
visiting.add(taskId);
|
||||||
|
|
||||||
|
const task = taskMap.get(taskId);
|
||||||
|
if (!task) throw new Error(`Task '${taskId}' not found`);
|
||||||
|
|
||||||
|
for (const depId of task.dependencies) {
|
||||||
|
if (taskMap.has(depId)) dfs(depId);
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.delete(taskId);
|
||||||
|
visited.add(taskId);
|
||||||
|
order.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of Task.sortByPriority(tasks)) {
|
||||||
|
if (!visited.has(task.id)) dfs(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TASK_PRIORITIES, TASK_STATUSES };
|
||||||
|
export default Task;
|
||||||
@@ -1,72 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* zCode Agent Definitions — Expanded from Ruflo agent types
|
||||||
|
* 9 agent types with full capabilities, optimized for multi-agent workflows.
|
||||||
|
*/
|
||||||
|
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { Agent } from './Agent.js';
|
||||||
|
import { SwarmCoordinator } from './SwarmCoordinator.js';
|
||||||
|
|
||||||
|
const AGENT_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: 'coder',
|
||||||
|
type: 'coder',
|
||||||
|
name: 'Code Generator',
|
||||||
|
description: 'Write, generate, refactor, and debug code. Primary implementation agent.',
|
||||||
|
capabilities: ['code_generation', 'refactoring', 'debugging', 'code_review', 'testing'],
|
||||||
|
systemPrompt: 'You are a senior software engineer. Write clean, optimized, production-ready code. Always consider edge cases, performance, and maintainability.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'architect',
|
||||||
|
type: 'architect',
|
||||||
|
name: 'System Architect',
|
||||||
|
description: 'Design system architecture, API contracts, and data models. High-level design decisions.',
|
||||||
|
capabilities: ['system_design', 'api_design', 'architecture', 'documentation', 'pattern_recognition'],
|
||||||
|
systemPrompt: 'You are a software architect. Design scalable, maintainable systems. Focus on separation of concerns, modularity, and future-proofing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reviewer',
|
||||||
|
type: 'reviewer',
|
||||||
|
name: 'Code Reviewer',
|
||||||
|
description: 'Review code for bugs, security issues, performance, and adherence to best practices.',
|
||||||
|
capabilities: ['code_review', 'quality_analysis', 'best_practices', 'security_review', 'performance_review'],
|
||||||
|
systemPrompt: 'You are a senior code reviewer. Analyze code critically for bugs, security vulnerabilities, performance issues, and maintainability concerns. Be thorough but constructive.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tester',
|
||||||
|
type: 'tester',
|
||||||
|
name: 'Test Engineer',
|
||||||
|
description: 'Write unit tests, integration tests, and end-to-end tests. Ensure test coverage.',
|
||||||
|
capabilities: ['unit_testing', 'integration_testing', 'e2e_testing', 'coverage', 'test_design'],
|
||||||
|
systemPrompt: 'You are a QA engineer focused on testing. Write comprehensive tests covering edge cases, error paths, and happy paths. Suggest test frameworks and strategies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devops',
|
||||||
|
type: 'deployer',
|
||||||
|
name: 'DevOps Engineer',
|
||||||
|
description: 'Handle deployment, CI/CD pipelines, infrastructure-as-code, and DevOps workflows.',
|
||||||
|
capabilities: ['deployment', 'ci_cd', 'infrastructure', 'docker', 'monitoring'],
|
||||||
|
systemPrompt: 'You are a DevOps engineer. Automate deployment, manage infrastructure, and ensure reliable CI/CD. Focus on reproducibility and observability.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'researcher',
|
||||||
|
type: 'researcher',
|
||||||
|
name: 'Researcher',
|
||||||
|
description: 'Search for information, analyze documentation, and provide research-backed recommendations.',
|
||||||
|
capabilities: ['research', 'documentation_analysis', 'comparison', 'fact_checking', 'trend_analysis'],
|
||||||
|
systemPrompt: 'You are a technical researcher. Gather information from multiple sources, verify facts, and present findings with clear evidence and citations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
type: 'security',
|
||||||
|
name: 'Security Architect',
|
||||||
|
description: 'Identify security vulnerabilities, perform threat modeling, and recommend security improvements.',
|
||||||
|
capabilities: ['threat_modeling', 'vulnerability_analysis', 'security_review', 'penetration_testing', 'compliance'],
|
||||||
|
systemPrompt: 'You are a security engineer. Identify vulnerabilities, perform threat modeling, and recommend security improvements. Follow OWASP guidelines and defense-in-depth principles.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'designer',
|
||||||
|
type: 'designer',
|
||||||
|
name: 'UI/UX Designer',
|
||||||
|
description: 'Design user interfaces, create frontend components, and ensure good UX patterns.',
|
||||||
|
capabilities: ['ui_design', 'ux_design', 'frontend', 'css', 'accessibility'],
|
||||||
|
systemPrompt: 'You are a UI/UX designer. Create beautiful, accessible, and responsive interfaces. Follow modern design patterns and ensure great user experience.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coordinator',
|
||||||
|
type: 'coordinator',
|
||||||
|
name: 'Swarm Coordinator',
|
||||||
|
description: 'Coordinate multi-agent workflows, delegate tasks, and synthesize results from multiple agents.',
|
||||||
|
capabilities: ['coordination', 'delegation', 'synthesis', 'planning', 'task_management'],
|
||||||
|
systemPrompt: 'You are a multi-agent coordinator. Decompose complex tasks into sub-tasks, delegate to appropriate agents, and synthesize results. Think about dependencies and parallel execution.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export async function initAgents() {
|
export async function initAgents() {
|
||||||
const agents = [];
|
const agents = AGENT_DEFINITIONS.map(def => ({
|
||||||
|
...def,
|
||||||
// Define available agents
|
|
||||||
agents.push({
|
|
||||||
id: 'coder',
|
|
||||||
name: 'Code Reviewer',
|
|
||||||
description: 'Review code for bugs, security issues, and improvements',
|
|
||||||
capabilities: ['code_review', 'bug_fix', 'refactor', 'testing'],
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
}));
|
||||||
|
|
||||||
agents.push({
|
logger.info(`✓ Loaded ${agents.length} agent types`);
|
||||||
id: 'architect',
|
return agents;
|
||||||
name: 'System Architect',
|
|
||||||
description: 'Design system architecture and patterns',
|
|
||||||
capabilities: ['architecture', 'design', 'documentation'],
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
agents.push({
|
|
||||||
id: 'devops',
|
|
||||||
name: 'DevOps Engineer',
|
|
||||||
description: 'Handle deployment, CI/CD, and infrastructure',
|
|
||||||
capabilities: ['deployment', 'ci_cd', 'infrastructure'],
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter enabled agents
|
|
||||||
const enabledAgents = agents.filter(a => a.enabled);
|
|
||||||
|
|
||||||
logger.info(`✓ Loaded ${enabledAgents.length} agents`);
|
|
||||||
|
|
||||||
return enabledAgents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentOrchestrator {
|
export class AgentOrchestrator {
|
||||||
constructor(agents) {
|
constructor(agents, options = {}) {
|
||||||
this.agents = agents;
|
this.agentDefs = agents;
|
||||||
this.agentMap = new Map(agents.map(a => [a.id, a]));
|
this.agentMap = new Map(agents.map(a => [a.id, a]));
|
||||||
|
this.swarm = new SwarmCoordinator({
|
||||||
|
topology: options.topology || 'simple',
|
||||||
|
maxAgents: options.maxAgents || 10,
|
||||||
|
});
|
||||||
|
this._spawnedAgents = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a task with a specific agent
|
||||||
|
*/
|
||||||
async execute(agentId, task, context = {}) {
|
async execute(agentId, task, context = {}) {
|
||||||
const agent = this.agentMap.get(agentId);
|
const def = this.agentMap.get(agentId);
|
||||||
|
if (!def) throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
|
||||||
|
logger.info(`🤖 ${def.name}: ${task.substring(0, 120)}...`);
|
||||||
|
|
||||||
|
// Get or spawn an agent instance
|
||||||
|
let agent = this._spawnedAgents.get(agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new Error(`Agent not found: ${agentId}`);
|
agent = await this.swarm.spawnAgent({
|
||||||
|
id: agentId,
|
||||||
|
type: def.type,
|
||||||
|
name: def.name,
|
||||||
|
description: def.description,
|
||||||
|
capabilities: def.capabilities,
|
||||||
|
});
|
||||||
|
this._spawnedAgents.set(agentId, agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🤖 Executing ${agent.name}: ${task.substring(0, 100)}...`);
|
|
||||||
|
|
||||||
// TODO: Implement agent execution
|
|
||||||
// For now, return a placeholder response
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
agent: agent.name,
|
agent: def.name,
|
||||||
|
agentId,
|
||||||
task,
|
task,
|
||||||
response: `✅ ${agent.name} processed your request: "${task.substring(0, 100)}..."`,
|
|
||||||
context,
|
context,
|
||||||
|
systemPrompt: def.systemPrompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getAgent(agentId) {
|
/**
|
||||||
return this.agentMap.get(agentId);
|
* Execute a multi-agent workflow — delegates to appropriate agents
|
||||||
|
*/
|
||||||
|
async executeMultiAgent(tasks, context = {}) {
|
||||||
|
const taskObjects = tasks.map((t, i) => {
|
||||||
|
const def = this.agentMap.get(t.agentId);
|
||||||
|
return {
|
||||||
|
id: t.id || `task_${i}`,
|
||||||
|
type: def?.type || 'generic',
|
||||||
|
description: t.description || '',
|
||||||
|
priority: t.priority || 'medium',
|
||||||
|
dependencies: t.dependencies || [],
|
||||||
|
requiredCapabilities: def?.capabilities || [],
|
||||||
|
assignedTo: t.agentId,
|
||||||
|
agentId: t.agentId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distribute and execute
|
||||||
|
const assignments = await this.swarm.distributeTasks(taskObjects);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const { agentId, taskId } of assignments) {
|
||||||
|
if (!agentId) continue;
|
||||||
|
const task = taskObjects.find(t => t.id === taskId);
|
||||||
|
const result = await this.swarm.executeTask(agentId, {
|
||||||
|
...task,
|
||||||
|
execute: async () => ({
|
||||||
|
status: 'completed',
|
||||||
|
agentId,
|
||||||
|
output: `Task '${task.description}' executed by ${this.agentMap.get(agentId)?.name}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
results.push({ agentId, taskId, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
listAgents() {
|
getAgent(agentId) { return this.agentMap.get(agentId); }
|
||||||
return this.agents;
|
listAgents() { return this.agentDefs; }
|
||||||
|
getSwarmState() { return this.swarm.getSwarmState(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find agent best suited for a task
|
||||||
|
*/
|
||||||
|
findBestAgent(taskType, requiredCaps = []) {
|
||||||
|
const scored = this.agentDefs.map(a => {
|
||||||
|
const capScore = requiredCaps.filter(c => a.capabilities.includes(c)).length;
|
||||||
|
const typeMatch = a.type === taskType ? 2 : 0;
|
||||||
|
return { agent: a, score: capScore + typeMatch };
|
||||||
|
});
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored[0]?.agent || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { AGENT_DEFINITIONS };
|
||||||
|
export default initAgents;
|
||||||
|
|||||||
165
src/bot/hooks.js
Normal file
165
src/bot/hooks.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* zCode Hooks System — Ported from Ruflo AgenticHookManager
|
||||||
|
* Lightweight lifecycle hooks for tool/ai/session events.
|
||||||
|
*
|
||||||
|
* Hook types: pre/post tool execution, pre/post AI calls, session events.
|
||||||
|
* Each hook is async, fault-isolated, priority-sorted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
const HOOK_TYPES = {
|
||||||
|
PRE_TOOL: 'pre-tool',
|
||||||
|
POST_TOOL: 'post-tool',
|
||||||
|
PRE_AI: 'pre-ai',
|
||||||
|
POST_AI: 'post-ai',
|
||||||
|
AI_ON_ERROR: 'ai-error',
|
||||||
|
PRE_SESSION: 'pre-session',
|
||||||
|
POST_SESSION: 'post-session',
|
||||||
|
PRE_MEMORY: 'pre-memory',
|
||||||
|
POST_MEMORY: 'post-memory',
|
||||||
|
PRE_AGENT: 'pre-agent',
|
||||||
|
POST_AGENT: 'post-agent',
|
||||||
|
};
|
||||||
|
|
||||||
|
class HookManager {
|
||||||
|
constructor() {
|
||||||
|
this._hooks = new Map(); // type -> [{id, handler, priority, filter?}]
|
||||||
|
this._eventBus = new EventEmitter();
|
||||||
|
this._metrics = { totalExecutions: 0, totalErrors: 0, hookCount: 0 };
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialized() { return this._initialized; }
|
||||||
|
|
||||||
|
/** Register a hook handler */
|
||||||
|
register(type, id, handler, options = {}) {
|
||||||
|
if (!HOOK_TYPES[type] && !Object.values(HOOK_TYPES).includes(type)) {
|
||||||
|
throw new Error(`Unknown hook type: '${type}'. Valid: ${Object.values(HOOK_TYPES).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._hooks.has(type)) {
|
||||||
|
this._hooks.set(type, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = this._hooks.get(type);
|
||||||
|
// Check duplicates
|
||||||
|
if (hooks.some(h => h.id === id)) {
|
||||||
|
throw new Error(`Hook '${id}' already registered for type '${type}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.push({
|
||||||
|
id,
|
||||||
|
handler: typeof handler === 'function' ? handler : async () => {},
|
||||||
|
priority: options.priority || 0,
|
||||||
|
filter: options.filter || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep sorted by priority descending
|
||||||
|
hooks.sort((a, b) => b.priority - a.priority);
|
||||||
|
this._metrics.hookCount = this._getTotalHookCount();
|
||||||
|
this._eventBus.emit('hook:registered', { type, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister a hook by id */
|
||||||
|
unregister(id) {
|
||||||
|
for (const [type, hooks] of this._hooks) {
|
||||||
|
const idx = hooks.findIndex(h => h.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
hooks.splice(idx, 1);
|
||||||
|
if (hooks.length === 0) this._hooks.delete(type);
|
||||||
|
this._metrics.hookCount = this._getTotalHookCount();
|
||||||
|
this._eventBus.emit('hook:unregistered', { type, id });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute all hooks for a type with context, fault-isolated */
|
||||||
|
async execute(type, context = {}) {
|
||||||
|
const hooks = this._hooks.get(type);
|
||||||
|
if (!hooks || hooks.length === 0) return [];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
this._eventBus.emit('hooks:executing', { type });
|
||||||
|
|
||||||
|
for (const { id, handler, filter } of hooks) {
|
||||||
|
// Check filter
|
||||||
|
if (filter && !this._matchesFilter(filter, context)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(context);
|
||||||
|
results.push({ id, result });
|
||||||
|
this._metrics.totalExecutions++;
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ id, error: err.message });
|
||||||
|
this._metrics.totalErrors++;
|
||||||
|
this._eventBus.emit('hook:error', { type, id, error: err.message });
|
||||||
|
// Fault isolation: continue to next handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._eventBus.emit('hooks:executed', { type, count: results.length });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute hooks as a filter chain — stops & returns false on first failure */
|
||||||
|
async executeFilter(type, context = {}) {
|
||||||
|
const hooks = this._hooks.get(type);
|
||||||
|
if (!hooks) return true;
|
||||||
|
|
||||||
|
for (const { id, handler, filter } of hooks) {
|
||||||
|
if (filter && !this._matchesFilter(filter, context)) continue;
|
||||||
|
try {
|
||||||
|
const result = await handler(context);
|
||||||
|
if (result === false) return false;
|
||||||
|
} catch (err) {
|
||||||
|
this._metrics.totalErrors++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get hooks for a type */
|
||||||
|
getHooks(type) {
|
||||||
|
const hooks = this._hooks.get(type);
|
||||||
|
return hooks ? [...hooks] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all registered hook types */
|
||||||
|
getTypes() {
|
||||||
|
return [...this._hooks.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get metrics */
|
||||||
|
getMetrics() {
|
||||||
|
return {
|
||||||
|
...this._metrics,
|
||||||
|
types: this._hooks.size,
|
||||||
|
byType: Object.fromEntries([...this._hooks.entries()].map(([t, hs]) => [t, hs.length])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a context matches a filter */
|
||||||
|
_matchesFilter(filter, context) {
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter.tools && context.toolName && !filter.tools.includes(context.toolName)) return false;
|
||||||
|
if (filter.events && context.eventName && !filter.events.includes(context.eventName)) return false;
|
||||||
|
if (filter.chatIds && context.chatId && !filter.chatIds.includes(context.chatId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getTotalHookCount() {
|
||||||
|
let count = 0;
|
||||||
|
for (const hooks of this._hooks.values()) count += hooks.length;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
export const hookManager = new HookManager();
|
||||||
|
|
||||||
|
export { HOOK_TYPES, HookManager };
|
||||||
|
export default hookManager;
|
||||||
383
src/bot/index.js
383
src/bot/index.js
@@ -14,6 +14,21 @@ import { queueRequest, clearQueue, isProcessing } from './request-queue.js';
|
|||||||
import { sendFormatted, splitMessage, escapeMarkdown, sendStreamingMessage, StreamConsumer, markdownToHtml } from './message-sender.js';
|
import { sendFormatted, splitMessage, escapeMarkdown, sendStreamingMessage, StreamConsumer, markdownToHtml } from './message-sender.js';
|
||||||
import { withSelfCorrection } from './self-correction.js';
|
import { withSelfCorrection } from './self-correction.js';
|
||||||
import { getMemory, getConversation } from './memory.js';
|
import { getMemory, getConversation } from './memory.js';
|
||||||
|
import { createSessionState } from './session-state.js';
|
||||||
|
import { detectIntent } from './intent-detector.js';
|
||||||
|
import { streamChatWithRetry } from './stream-handler.js';
|
||||||
|
|
||||||
|
// ── Ruflo-inspired systems: plugins, hooks, swarm, enhanced memory ──
|
||||||
|
import { PluginManager } from '../plugins/PluginManager.js';
|
||||||
|
import { PluginLoader } from '../plugins/PluginLoader.js';
|
||||||
|
import { BasePlugin } from '../plugins/Plugin.js';
|
||||||
|
import { EXTENSION_POINTS } from '../plugins/ExtensionPoints.js';
|
||||||
|
import { hookManager, HOOK_TYPES } from './hooks.js';
|
||||||
|
import { initAgents, AgentOrchestrator } from '../agents/index.js';
|
||||||
|
import { Agent } from '../agents/Agent.js';
|
||||||
|
import { Task } from '../agents/Task.js';
|
||||||
|
import { SwarmCoordinator } from '../agents/SwarmCoordinator.js';
|
||||||
|
import { JSONBackend, InMemoryBackend, MEMORY_TYPES } from './memory-backend.js';
|
||||||
|
|
||||||
// ── Pidfile lock: prevent duplicate instances ──
|
// ── Pidfile lock: prevent duplicate instances ──
|
||||||
const PIDFILE = path.join(process.env.HOME || '/tmp', '.zcode-bot.pid');
|
const PIDFILE = path.join(process.env.HOME || '/tmp', '.zcode-bot.pid');
|
||||||
@@ -197,11 +212,72 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
const conversation = getConversation();
|
const conversation = getConversation();
|
||||||
await conversation.init();
|
await conversation.init();
|
||||||
|
|
||||||
|
// ── Session state: LRU file read cache + read-once dedup ──
|
||||||
|
const sessionState = createSessionState();
|
||||||
|
|
||||||
// ── Service registry ──
|
// ── Service registry ──
|
||||||
const svc = { config, api, tools: tools || [], skills: skills || [], agents: agents || [], rtk, memory, conversation,
|
const svc = { config, api, tools: tools || [], skills: skills || [], agents: agents || [], rtk, memory, conversation, sessionState,
|
||||||
toolMap: new Map((tools || []).map(t => [t.name, t])),
|
toolMap: new Map((tools || []).map(t => [t.name, t])),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Ruflo-inspired Plugin System ──
|
||||||
|
const pluginManager = new PluginManager({ coreVersion: '3.0.0' });
|
||||||
|
const pluginLoader = new PluginLoader(pluginManager);
|
||||||
|
await pluginManager.initialize();
|
||||||
|
svc.pluginManager = pluginManager;
|
||||||
|
svc.pluginLoader = pluginLoader;
|
||||||
|
|
||||||
|
// ── Ruflo-inspired Hook System ──
|
||||||
|
await hookManager.initialize?.();
|
||||||
|
svc.hooks = hookManager;
|
||||||
|
|
||||||
|
// ── Ruflo-inspired Swarm Coordinator ──
|
||||||
|
const swarm = new SwarmCoordinator({ topology: 'simple', maxAgents: 10 });
|
||||||
|
await swarm.initialize();
|
||||||
|
svc.swarm = swarm;
|
||||||
|
|
||||||
|
// ── Enhanced Memory Backend (JSON-based with typed entries + search) ──
|
||||||
|
const memBackend = new JSONBackend(path.join(process.cwd(), 'data', 'memory.json'), 500);
|
||||||
|
await memBackend.initialize();
|
||||||
|
svc.memBackend = memBackend;
|
||||||
|
|
||||||
|
// ── Ephemeral Agent Context (RAM-only, auto-evict) ──
|
||||||
|
const ephemeralMem = new InMemoryBackend(200, 30 * 60 * 1000);
|
||||||
|
svc.ephemeralMem = ephemeralMem;
|
||||||
|
|
||||||
|
// ── Agent Orchestrator (replaces simple agent map) ──
|
||||||
|
const agentOrchestrator = new AgentOrchestrator(agents || [], { topology: 'simple', maxAgents: 10 });
|
||||||
|
svc.agentOrchestrator = agentOrchestrator;
|
||||||
|
|
||||||
|
// ── Register default plugin hooks ──
|
||||||
|
// Pre-tool hook: log tool execution, check permissions
|
||||||
|
hookManager.register(HOOK_TYPES.PRE_TOOL, 'pre-tool-logger', async (ctx) => {
|
||||||
|
logger.info(`🔧 Hook: pre-tool ${ctx.toolName}`);
|
||||||
|
return true;
|
||||||
|
}, { priority: 10 });
|
||||||
|
|
||||||
|
// Post-tool hook: cache results, update metrics
|
||||||
|
hookManager.register(HOOK_TYPES.POST_TOOL, 'post-tool-cache', async (ctx) => {
|
||||||
|
if (ctx.toolName === 'file_read' && ctx.result) {
|
||||||
|
sessionState.cacheRead(ctx.toolName, ctx.result);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, { priority: 5 });
|
||||||
|
|
||||||
|
// Pre-AI hook: check memory context
|
||||||
|
hookManager.register(HOOK_TYPES.PRE_AI, 'pre-ai-memory', async (ctx) => {
|
||||||
|
// Could inject memory context into AI prompt here
|
||||||
|
return true;
|
||||||
|
}, { priority: 5 });
|
||||||
|
|
||||||
|
// Post-AI hook: self-learning trigger
|
||||||
|
hookManager.register(HOOK_TYPES.POST_AI, 'post-ai-selflearn', async (ctx) => {
|
||||||
|
if (ctx.response && ctx.userMessage) {
|
||||||
|
selfLearn(ctx.userMessage, ctx.response, memory);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, { priority: 1 });
|
||||||
|
|
||||||
// ── Tool definitions for the AI API (OpenAI function-calling format) ──
|
// ── Tool definitions for the AI API (OpenAI function-calling format) ──
|
||||||
// Defined at startBot scope so delegate handler can access them
|
// Defined at startBot scope so delegate handler can access them
|
||||||
const TOOL_DEFS = {
|
const TOOL_DEFS = {
|
||||||
@@ -355,6 +431,40 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
input: { type: 'string' },
|
input: { type: 'string' },
|
||||||
}, required: ['skill'] },
|
}, required: ['skill'] },
|
||||||
},
|
},
|
||||||
|
swarm_spawn: {
|
||||||
|
description: 'Spawn a new agent in the swarm for parallel task execution',
|
||||||
|
parameters: { type: 'object', properties: {
|
||||||
|
type: { type: 'string', enum: ['coder', 'reviewer', 'tester', 'architect', 'devops', 'security', 'researcher', 'designer', 'coordinator'], description: 'Agent type' },
|
||||||
|
name: { type: 'string', description: 'Agent name' },
|
||||||
|
capabilities: { type: 'array', items: { type: 'string' }, description: 'Agent capabilities' },
|
||||||
|
}, required: ['type'] },
|
||||||
|
},
|
||||||
|
swarm_execute: {
|
||||||
|
description: 'Execute a task with a specific swarm agent',
|
||||||
|
parameters: { type: 'object', properties: {
|
||||||
|
agent_id: { type: 'string', description: 'Agent ID' },
|
||||||
|
description: { type: 'string', description: 'Task description' },
|
||||||
|
type: { type: 'string', description: 'Task type' },
|
||||||
|
priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Task priority' },
|
||||||
|
dependencies: { type: 'array', items: { type: 'string' }, description: 'Task dependencies' },
|
||||||
|
}, required: ['agent_id', 'description'] },
|
||||||
|
},
|
||||||
|
swarm_distribute: {
|
||||||
|
description: 'Distribute multiple tasks across swarm agents',
|
||||||
|
parameters: { type: 'object', properties: {
|
||||||
|
tasks: { type: 'array', items: { type: 'object' }, description: 'Array of {agent_id, description, type, priority, dependencies}' },
|
||||||
|
}, required: ['tasks'] },
|
||||||
|
},
|
||||||
|
swarm_state: {
|
||||||
|
description: 'Get current swarm state and metrics',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
swarm_terminate: {
|
||||||
|
description: 'Terminate a swarm agent',
|
||||||
|
parameters: { type: 'object', properties: {
|
||||||
|
agent_id: { type: 'string', description: 'Agent ID to terminate' },
|
||||||
|
}, required: ['agent_id'] },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── AI chat with agentic tool loop ──
|
// ── AI chat with agentic tool loop ──
|
||||||
@@ -443,7 +553,7 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
let response; // { content: string, tool_calls: array|null }
|
let response; // { content: string, tool_calls: array|null }
|
||||||
|
|
||||||
if (onDelta) {
|
if (onDelta) {
|
||||||
response = await streamChat(svc, body, onDelta);
|
response = await streamChatWithRetry(svc, body, onDelta);
|
||||||
} else {
|
} else {
|
||||||
response = await nonStreamChat(body);
|
response = await nonStreamChat(body);
|
||||||
}
|
}
|
||||||
@@ -504,6 +614,14 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
loopMessages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
loopMessages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// ── File access dedup: warn if AI re-reads same file ──
|
||||||
|
if (fn.name === 'file_read' && args?.file_path) {
|
||||||
|
const ghostCheck = sessionState.checkGhostChasing(args.file_path);
|
||||||
|
if (ghostCheck) {
|
||||||
|
logger.warn(`⚠ Ghost detected: ${ghostCheck.file} read ${ghostCheck.count}x this session`);
|
||||||
|
result = `⚠ WARNING: You have already read this file ${ghostCheck.count} times. Full content is cached. Stop re-reading and act on it.\n\n` + result;
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.info(` → ${fn.name}(${fn.arguments?.slice(0, 100)})`);
|
logger.info(` → ${fn.name}(${fn.arguments?.slice(0, 100)})`);
|
||||||
result = String(await handler(args)).slice(0, TOOL_RESULT_MAX);
|
result = String(await handler(args)).slice(0, TOOL_RESULT_MAX);
|
||||||
}
|
}
|
||||||
@@ -548,152 +666,6 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Streaming API call (SSE) — returns { content, tool_calls, error } ──
|
|
||||||
// Streams tokens via onDelta. If tool_calls detected, accumulates them and returns.
|
|
||||||
// Self-cure: AbortController timeout + auto-retry on SSE errors
|
|
||||||
async function streamChat(svc, body, onDelta, retryCount = 0) {
|
|
||||||
const baseUrl = svc.api?.config?.baseUrl || 'https://api.z.ai/api/coding/paas/v4';
|
|
||||||
const apiKey = svc.api?.config?.apiKey || '';
|
|
||||||
let fullContent = '';
|
|
||||||
const toolCallMap = {}; // index → { id, name, arguments }
|
|
||||||
let finishReason = null;
|
|
||||||
const MAX_SSE_RETRIES = 4;
|
|
||||||
const SSE_FETCH_TIMEOUT = 180_000; // 180s total request timeout
|
|
||||||
const SSE_IDLE_TIMEOUT = 45_000; // 45s between chunks (no data = stuck)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const fetchTimeout = setTimeout(() => controller.abort(), SSE_FETCH_TIMEOUT);
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...body, stream: true }),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
clearTimeout(fetchTimeout);
|
|
||||||
const errText = await res.text();
|
|
||||||
logger.error(`SSE ${res.status}: ${errText.slice(0, 200)}`);
|
|
||||||
|
|
||||||
// Auto-retry on 5xx errors
|
|
||||||
if (res.status >= 500 && retryCount < MAX_SSE_RETRIES) {
|
|
||||||
const delay = 1000 * (retryCount + 1);
|
|
||||||
logger.info(`🔄 SSE retry ${retryCount + 1}/${MAX_SSE_RETRIES} in ${delay}ms…`);
|
|
||||||
await new Promise(r => setTimeout(r, delay));
|
|
||||||
return await streamChat(svc, body, onDelta, retryCount + 1);
|
|
||||||
}
|
|
||||||
// Fallback to non-streaming
|
|
||||||
return await nonStreamChat(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
let lastChunkTime = Date.now();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// Idle timeout: if no data for 30s, abort and retry
|
|
||||||
const idleMs = Date.now() - lastChunkTime;
|
|
||||||
if (idleMs > SSE_IDLE_TIMEOUT) {
|
|
||||||
logger.warn(`⏰ SSE idle timeout (${idleMs}ms), ${retryCount < MAX_SSE_RETRIES ? 'retrying' : 'falling back to non-stream'}`);
|
|
||||||
reader.cancel().catch(() => {});
|
|
||||||
clearTimeout(fetchTimeout);
|
|
||||||
if (retryCount < MAX_SSE_RETRIES) {
|
|
||||||
return await streamChat(svc, body, onDelta, retryCount + 1);
|
|
||||||
}
|
|
||||||
return await nonStreamChat(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read with timeout
|
|
||||||
let readResult;
|
|
||||||
try {
|
|
||||||
readResult = await Promise.race([
|
|
||||||
reader.read(),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('read timeout')), SSE_IDLE_TIMEOUT)),
|
|
||||||
]);
|
|
||||||
} catch (readErr) {
|
|
||||||
logger.warn(`⏰ SSE read timeout, ${retryCount < MAX_SSE_RETRIES ? 'retrying' : 'falling back'}`);
|
|
||||||
reader.cancel().catch(() => {});
|
|
||||||
clearTimeout(fetchTimeout);
|
|
||||||
if (retryCount < MAX_SSE_RETRIES) {
|
|
||||||
return await streamChat(svc, body, onDelta, retryCount + 1);
|
|
||||||
}
|
|
||||||
// Return what we have so far
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { done, value } = readResult;
|
|
||||||
if (done) break;
|
|
||||||
lastChunkTime = Date.now();
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith('data: ')) continue;
|
|
||||||
const data = trimmed.slice(6);
|
|
||||||
if (data === '[DONE]') continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
const choice = parsed.choices?.[0];
|
|
||||||
if (!choice) continue;
|
|
||||||
finishReason = choice.finish_reason;
|
|
||||||
|
|
||||||
const delta = choice.delta || {};
|
|
||||||
// Stream text content
|
|
||||||
if (delta.content) {
|
|
||||||
fullContent += delta.content;
|
|
||||||
onDelta(delta.content);
|
|
||||||
}
|
|
||||||
// Accumulate tool calls from stream deltas
|
|
||||||
if (delta.tool_calls) {
|
|
||||||
for (const tc of delta.tool_calls) {
|
|
||||||
const idx = tc.index ?? 0;
|
|
||||||
if (!toolCallMap[idx]) toolCallMap[idx] = { id: tc.id || '', name: '', arguments: '' };
|
|
||||||
if (tc.id) toolCallMap[idx].id = tc.id;
|
|
||||||
if (tc.function?.name) toolCallMap[idx].name += tc.function.name;
|
|
||||||
if (tc.function?.arguments) toolCallMap[idx].arguments += tc.function.arguments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* skip malformed chunks */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearTimeout(fetchTimeout);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name === 'AbortError') {
|
|
||||||
logger.warn(`⏰ SSE fetch aborted (timeout), retry ${retryCount}/${MAX_SSE_RETRIES}`);
|
|
||||||
if (retryCount < MAX_SSE_RETRIES) {
|
|
||||||
return await streamChat(svc, body, onDelta, retryCount + 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error('SSE error:', e.message);
|
|
||||||
}
|
|
||||||
if (!fullContent && !Object.keys(toolCallMap).length) {
|
|
||||||
// Nothing received — try non-streaming fallback
|
|
||||||
if (retryCount < MAX_SSE_RETRIES) {
|
|
||||||
return await streamChat(svc, body, onDelta, retryCount + 1);
|
|
||||||
}
|
|
||||||
return await nonStreamChat(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build tool_calls array from accumulated deltas
|
|
||||||
const toolCalls = Object.keys(toolCallMap).length > 0
|
|
||||||
? Object.values(toolCallMap).map(tc => ({
|
|
||||||
id: tc.id,
|
|
||||||
type: 'function',
|
|
||||||
function: { name: tc.name, arguments: tc.arguments },
|
|
||||||
}))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { content: fullContent, tool_calls: toolCalls, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tool handlers: route API tool_calls to tool class methods ──
|
// ── Tool handlers: route API tool_calls to tool class methods ──
|
||||||
const toolHandlers = {
|
const toolHandlers = {
|
||||||
bash: async (args) => {
|
bash: async (args) => {
|
||||||
@@ -893,11 +865,61 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
{ role: 'user', content: args.input || 'Please analyze the code and provide your expert review.' },
|
{ role: 'user', content: args.input || 'Please analyze the code and provide your expert review.' },
|
||||||
];
|
];
|
||||||
const result = await chatWithAI(skillMessages, { maxTokens: 4096 });
|
const result = await chatWithAI(skillMessages, { maxTokens: 4096 });
|
||||||
return `📚 **${skill.name}**:\n${result}`;
|
return `📚 **${skill.name}**:\\n${result}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return `❌ Skill ${skill.name} error: ${e.message}`;
|
return `❌ Skill ${skill.name} error: ${e.message}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
swarm_spawn: async (args) => {
|
||||||
|
try {
|
||||||
|
const agent = await svc.swarm.spawnAgent({
|
||||||
|
type: args.type,
|
||||||
|
name: args.name || args.type,
|
||||||
|
capabilities: args.capabilities || [],
|
||||||
|
});
|
||||||
|
return `✅ Spawned agent: **${agent.name}** (id: ${agent.id}, type: ${agent.type})`;
|
||||||
|
} catch (e) { return `❌ Swarm spawn error: ${e.message}`; }
|
||||||
|
},
|
||||||
|
swarm_execute: async (args) => {
|
||||||
|
try {
|
||||||
|
const task = new Task({
|
||||||
|
type: args.type || 'generic',
|
||||||
|
description: args.description,
|
||||||
|
priority: args.priority || 'medium',
|
||||||
|
dependencies: args.dependencies || [],
|
||||||
|
});
|
||||||
|
const result = await svc.swarm.executeTask(args.agent_id, task);
|
||||||
|
return `✅ Task completed on agent ${args.agent_id}: ${JSON.stringify(result)}`;
|
||||||
|
} catch (e) { return `❌ Swarm execute error: ${e.message}`; }
|
||||||
|
},
|
||||||
|
swarm_distribute: async (args) => {
|
||||||
|
try {
|
||||||
|
const taskObjs = (args.tasks || []).map((t, i) => new Task({
|
||||||
|
id: t.id || `task_${i}`,
|
||||||
|
type: t.type || 'generic',
|
||||||
|
description: t.description || '',
|
||||||
|
priority: t.priority || 'medium',
|
||||||
|
dependencies: t.dependencies || [],
|
||||||
|
assignedTo: t.agent_id,
|
||||||
|
}));
|
||||||
|
const assignments = await svc.swarm.distributeTasks(taskObjs);
|
||||||
|
return `✅ Distributed ${assignments.length} tasks:\\n${assignments.map(a =>
|
||||||
|
` - ${a.taskId} → ${a.agentId || 'no agent'}${a.error ? ' (' + a.error + ')' : ''}`
|
||||||
|
).join('\\n')}`;
|
||||||
|
} catch (e) { return `❌ Swarm distribute error: ${e.message}`; }
|
||||||
|
},
|
||||||
|
swarm_state: async () => {
|
||||||
|
try {
|
||||||
|
const state = svc.swarm.getSwarmState();
|
||||||
|
return `🤖 **Swarm State**\\n\\nTopology: ${state.topology}\\nAgents: ${state.agents}\\nBy status: ${JSON.stringify(state.byStatus)}\\nBy type: ${JSON.stringify(state.byType)}`;
|
||||||
|
} catch (e) { return `❌ Swarm state error: ${e.message}`; }
|
||||||
|
},
|
||||||
|
swarm_terminate: async (args) => {
|
||||||
|
try {
|
||||||
|
await svc.swarm.terminateAgent(args.agent_id);
|
||||||
|
return `✅ Agent ${args.agent_id} terminated`;
|
||||||
|
} catch (e) { return `❌ Swarm terminate error: ${e.message}`; }
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Create grammy bot ──
|
// ── Create grammy bot ──
|
||||||
@@ -1182,6 +1204,17 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
} catch (e) { logger.warn(`⌨️ initial typing error: ${e.message}`); }
|
} catch (e) { logger.warn(`⌨️ initial typing error: ${e.message}`); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Intent detection: bypass AI for simple messages ──
|
||||||
|
const intent = detectIntent(text);
|
||||||
|
if (intent && intent.bypassAI) {
|
||||||
|
logger.info(`🎯 Intent: ${intent.type} — bypassing AI`);
|
||||||
|
const reply = intent.response || 'Got it.';
|
||||||
|
await queueRequest(key, text, async () => {
|
||||||
|
await sendFormatted(ctx, reply);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Load conversation history for this chat ──
|
// ── Load conversation history for this chat ──
|
||||||
const chatKey = conversation._key(ctx.chat.id, ctx.message?.message_thread_id);
|
const chatKey = conversation._key(ctx.chat.id, ctx.message?.message_thread_id);
|
||||||
svc.currentChatId = ctx.chat.id; // Track for TTS auto-send
|
svc.currentChatId = ctx.chat.id; // Track for TTS auto-send
|
||||||
@@ -1328,15 +1361,7 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
logger.error('Unhandled rejection:', reason?.message || reason);
|
logger.error('Unhandled rejection:', reason?.message || reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Graceful shutdown: flush conversation history ──
|
// ── Graceful shutdown is defined at end of initBot (requires full `svc`) ──
|
||||||
const shutdown = async (signal) => {
|
|
||||||
logger.info(`🛑 Shutting down (${signal})...`);
|
|
||||||
await conversation.flush();
|
|
||||||
releasePidfile();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
|
|
||||||
acquirePidfile();
|
acquirePidfile();
|
||||||
|
|
||||||
@@ -1437,10 +1462,52 @@ export async function initBot(config, api, tools, skills, agents) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Graceful shutdown: cleanup all systems ──
|
||||||
|
const gracefulShutdown = async (signal) => {
|
||||||
|
logger.info(`🛑 Shutting down (${signal})...`);
|
||||||
|
// Flush conversation history
|
||||||
|
try { await conversation.flush(); } catch (e) { logger.warn(`Conversation flush: ${e.message}`); }
|
||||||
|
// Cleanup swarm
|
||||||
|
if (svc.swarm && typeof svc.swarm.shutdown === 'function') {
|
||||||
|
try { await svc.swarm.shutdown(); } catch (e) { logger.warn(`Swarm shutdown: ${e.message}`); }
|
||||||
|
}
|
||||||
|
// Cleanup plugin manager
|
||||||
|
if (svc.pluginManager && typeof svc.pluginManager.shutdown === 'function') {
|
||||||
|
try { await svc.pluginManager.shutdown(); } catch (e) { logger.warn(`Plugin shutdown: ${e.message}`); }
|
||||||
|
}
|
||||||
|
// Cleanup memory backends
|
||||||
|
if (svc.memBackend && typeof svc.memBackend.shutdown === 'function') {
|
||||||
|
try { await svc.memBackend.shutdown(); } catch (e) { logger.warn(`Memory shutdown: ${e.message}`); }
|
||||||
|
}
|
||||||
|
// Cleanup hooks
|
||||||
|
if (svc.hooks && typeof svc.hooks.shutdown === 'function') {
|
||||||
|
try { await svc.hooks.shutdown(); } catch (e) { logger.warn(`Hooks shutdown: ${e.message}`); }
|
||||||
|
}
|
||||||
|
// Release pidfile
|
||||||
|
releasePidfile();
|
||||||
|
// Stop webhook polling
|
||||||
|
try { await bot.stop(); } catch {}
|
||||||
|
// Close HTTP server
|
||||||
|
try { await new Promise(r => httpServer.close(r)); } catch {}
|
||||||
|
logger.info('✓ Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('uncaughtException', (e) => { logger.error('💥 Uncaught:', e.message, e.stack); gracefulShutdown('uncaught'); });
|
||||||
|
process.on('unhandledRejection', (e) => { logger.error('💥 Unhandled Rejection:', e.message); gracefulShutdown('unhandledRejection'); });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send: (chatId, text) => bot.api.sendMessage(chatId, markdownToHtml(text), { parse_mode: 'HTML' }),
|
send: (chatId, text) => bot.api.sendMessage(chatId, markdownToHtml(text), { parse_mode: 'HTML' }),
|
||||||
ws: (chatId, msg) => wsClients.get(chatId)?.send(JSON.stringify(msg)),
|
ws: (chatId, msg) => wsClients.get(chatId)?.send(JSON.stringify(msg)),
|
||||||
waitForMessages: async () => { await new Promise(() => {}); },
|
waitForMessages: async () => { await new Promise(() => {}); },
|
||||||
getConnections: () => wsClients.size,
|
getConnections: () => wsClients.size,
|
||||||
|
// Expose new systems for external use
|
||||||
|
pluginManager: svc.pluginManager,
|
||||||
|
swarm: svc.swarm,
|
||||||
|
hookManager: svc.hooks,
|
||||||
|
memBackend: svc.memBackend,
|
||||||
|
agentOrchestrator: svc.agentOrchestrator,
|
||||||
|
getState: () => ({ tools: svc.tools.length, skills: svc.skills.length, agents: svc.agents.length, plugins: svc.pluginManager?.getPlugins()?.length || 0, wsClients: wsClients.size }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/bot/intent-detector.js
Normal file
154
src/bot/intent-detector.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Intent detector — lightweight pre-routing layer BEFORE the AI.
|
||||||
|
*
|
||||||
|
* BUG FIX: "Hey" was going straight to the AI which then decided to read
|
||||||
|
* 30 files. Now we intercept simple intents and respond directly.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Greetings → instant reply, no AI cost
|
||||||
|
* 2. Status checks → instant system info, no AI cost
|
||||||
|
* 3. Simple questions → short AI call, no tools
|
||||||
|
* 4. Everything else → normal AI tool loop
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ── Greeting patterns (no AI needed) ──
|
||||||
|
const GREETINGS = [
|
||||||
|
/^(hi|hey|hello|howdy|greetings|sup|yo|what'?s up|what'?s up|how are you|how's it going|how do you do)/i,
|
||||||
|
/^(good morning|good afternoon|good evening|good night)/i,
|
||||||
|
/^(thanks|thank you|thx|ty|appreciate it)/i,
|
||||||
|
/^(ok|okay|alright|sure|yes|yeah|yep|nope|no)/i,
|
||||||
|
/^(continue|go ahead|proceed|do it|carry on|keep going)/i,
|
||||||
|
/^(done|finished|completed|all good|looks good)/i,
|
||||||
|
/^(bye|goodbye|see you|later|take care)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Status check patterns (system info, no AI needed) ──
|
||||||
|
const STATUS_PATTERNS = [
|
||||||
|
{ pattern: /^(status|how are you doing|are you alive|you there|ping|test)/i, response: '⚡ zCode CLI X is online and ready.' },
|
||||||
|
{ pattern: /^(what can you do|your tools|your skills|help|commands)/i, response: null }, // handled by /tools command
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Short-answer patterns (AI call, no tools) ──
|
||||||
|
const SHORT_ANSWER_PATTERNS = [
|
||||||
|
{ pattern: /^(what time is it|what date|what day)/i, type: 'instant' },
|
||||||
|
{ pattern: /^(who are you|what are you|your name|describe yourself)/i, type: 'instant' },
|
||||||
|
{ pattern: /^(how old are you|when were you created)/i, type: 'instant' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function detectIntent(message) {
|
||||||
|
if (!message || typeof message !== 'string') return null;
|
||||||
|
|
||||||
|
const trimmed = message.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Check greetings
|
||||||
|
for (const pattern of GREETINGS) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
const responses = {
|
||||||
|
'greeting': [
|
||||||
|
'⚡ Hey! What can I do for you?',
|
||||||
|
'⚡ Hello! Ready to code. What do you need?',
|
||||||
|
'⚡ Hi! I\'m zCode CLI X — what\'s the task?',
|
||||||
|
],
|
||||||
|
'thanks': [
|
||||||
|
'✅ Happy to help!',
|
||||||
|
'✅ No problem! Anything else?',
|
||||||
|
'✅ You\'re welcome!',
|
||||||
|
],
|
||||||
|
'goodbye': [
|
||||||
|
'👋 See you!',
|
||||||
|
'👋 Catch you later!',
|
||||||
|
],
|
||||||
|
'confirmation': [
|
||||||
|
'✅ Got it.',
|
||||||
|
'👍 On it.',
|
||||||
|
],
|
||||||
|
'continue': [
|
||||||
|
'🚀 Continuing...',
|
||||||
|
'✅ Going ahead.',
|
||||||
|
],
|
||||||
|
'status': [
|
||||||
|
'⚡ I\'m good! What\'s up?',
|
||||||
|
'⚡ Alive and ready. What do you need?',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let category = 'greeting';
|
||||||
|
if (pattern.test(/^(thanks|thank you|thx|ty)/i)) category = 'thanks';
|
||||||
|
else if (pattern.test(/^(bye|goodbye|see you|later)/i)) category = 'goodbye';
|
||||||
|
else if (pattern.test(/^(ok|okay|alright|sure|yes|yeah)/i)) category = 'confirmation';
|
||||||
|
else if (pattern.test(/^(continue|go ahead|proceed)/i)) category = 'continue';
|
||||||
|
else if (pattern.test(/^(good morning|good afternoon|good evening)/i)) category = 'greeting';
|
||||||
|
|
||||||
|
const list = responses[category] || responses['greeting'];
|
||||||
|
return {
|
||||||
|
type: 'greeting',
|
||||||
|
response: list[Math.floor(Math.random() * list.length)],
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check status patterns
|
||||||
|
for (const { pattern, response: fallback } of STATUS_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
if (fallback) {
|
||||||
|
return { type: 'status', response: fallback, bypassAI: true };
|
||||||
|
}
|
||||||
|
// Falls through to normal handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check short-answer patterns
|
||||||
|
for (const { pattern, type } of SHORT_ANSWER_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
if (type === 'instant') {
|
||||||
|
const now = new Date();
|
||||||
|
if (pattern.test(/what time/i)) {
|
||||||
|
return {
|
||||||
|
type: 'instant',
|
||||||
|
response: `🕐 ${now.toLocaleTimeString('en-US', { timeZone: 'Asia/Tbilisi' })} (Tbilisi time)`,
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pattern.test(/what date|what day/i)) {
|
||||||
|
return {
|
||||||
|
type: 'instant',
|
||||||
|
response: `📅 ${now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}`,
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pattern.test(/who are you|what are you/i)) {
|
||||||
|
return {
|
||||||
|
type: 'instant',
|
||||||
|
response: '⚡ I\'m zCode CLI X — an agentic coding assistant running on Telegram. I can read/write files, run bash commands, manage git repos, search the web, and more.',
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check for very short messages that don't need AI
|
||||||
|
if (trimmed.length < 5) {
|
||||||
|
return {
|
||||||
|
type: 'too_short',
|
||||||
|
response: '🤔 Could you elaborate? I need a bit more to work with.',
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check if it's just a single word that could be confused
|
||||||
|
if (!trimmed.includes(' ') && !trimmed.match(/[?!.]/)) {
|
||||||
|
return {
|
||||||
|
type: 'single_word',
|
||||||
|
response: `🤔 You said "${trimmed}". Could you be more specific about what you want me to do?`,
|
||||||
|
bypassAI: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match — normal AI handling
|
||||||
|
return null;
|
||||||
|
}
|
||||||
291
src/bot/memory-backend.js
Normal file
291
src/bot/memory-backend.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* zCode Memory Backend — Enhanced with typed memory entries
|
||||||
|
* Ported concept from Ruflo's MemoryBackend interface.
|
||||||
|
*
|
||||||
|
* Two backends:
|
||||||
|
* JSONBackend — file-based, LRU (existing MemoryStore)
|
||||||
|
* InMemoryBackend — RAM-only, for ephemeral agent context
|
||||||
|
*
|
||||||
|
* Memory types: lesson, gotcha, pattern, preference, discovery, context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const MEMORY_TYPES = {
|
||||||
|
LESSON: 'lesson',
|
||||||
|
GOTCHA: 'gotcha',
|
||||||
|
PATTERN: 'pattern',
|
||||||
|
PREFERENCE: 'preference',
|
||||||
|
DISCOVERY: 'discovery',
|
||||||
|
CONTEXT: 'context',
|
||||||
|
EPHEMERAL: 'ephemeral',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Priority for system prompt injection */
|
||||||
|
const TYPE_PRIORITY = {
|
||||||
|
gotcha: 5,
|
||||||
|
lesson: 4,
|
||||||
|
pattern: 3,
|
||||||
|
preference: 2,
|
||||||
|
discovery: 1,
|
||||||
|
context: 3,
|
||||||
|
ephemeral: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSONBackend — File-based persistent memory with LRU eviction
|
||||||
|
*/
|
||||||
|
export class JSONBackend {
|
||||||
|
constructor(filePath, maxEntries = 500) {
|
||||||
|
this.filePath = path.resolve(filePath);
|
||||||
|
this.maxEntries = maxEntries;
|
||||||
|
this._entries = new Map();
|
||||||
|
this._loaded = false;
|
||||||
|
this._dirty = false;
|
||||||
|
this._saveTimer = null;
|
||||||
|
this._debounceMs = 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
get loaded() { return this._loaded; }
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.filePath)) {
|
||||||
|
const data = JSON.parse(await fs.promises.readFile(this.filePath, 'utf-8'));
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const entry of data) {
|
||||||
|
this._entries.set(entry.id || entry.key, entry);
|
||||||
|
}
|
||||||
|
} else if (data.entries) {
|
||||||
|
for (const entry of data.entries) {
|
||||||
|
this._entries.set(entry.id || entry.key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._loaded = true;
|
||||||
|
logger.info(`✓ Memory: loaded ${this._entries.size} entries from ${path.basename(this.filePath)}`);
|
||||||
|
} catch (err) {
|
||||||
|
this._loaded = true;
|
||||||
|
logger.warn(`⚠ Memory: could not load ${path.basename(this.filePath)}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(memory) {
|
||||||
|
const key = memory.id || memory.key || `${memory.type}_${Date.now()}`;
|
||||||
|
const entry = {
|
||||||
|
...memory,
|
||||||
|
id: key,
|
||||||
|
timestamp: memory.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this._entries.set(key, entry);
|
||||||
|
this._evictIfNeeded();
|
||||||
|
this._markDirty();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieve(id) {
|
||||||
|
return this._entries.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(filter) {
|
||||||
|
let results = [...this._entries.values()];
|
||||||
|
|
||||||
|
if (filter.type) {
|
||||||
|
results = results.filter(e => e.type === filter.type);
|
||||||
|
}
|
||||||
|
if (filter.query) {
|
||||||
|
const q = filter.query.toLowerCase();
|
||||||
|
results = results.filter(e =>
|
||||||
|
(e.content && e.content.toLowerCase().includes(q)) ||
|
||||||
|
(e.key && e.key.toLowerCase().includes(q)) ||
|
||||||
|
(e.tags && e.tags.some(t => t.toLowerCase().includes(q)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.agentId) {
|
||||||
|
results = results.filter(e => e.agentId === filter.agentId);
|
||||||
|
}
|
||||||
|
if (filter.timeRange) {
|
||||||
|
const { start, end } = filter.timeRange;
|
||||||
|
results = results.filter(e => e.timestamp >= start && e.timestamp <= end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by recency
|
||||||
|
results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||||
|
|
||||||
|
if (filter.limit) results = results.slice(0, filter.limit);
|
||||||
|
if (filter.offset) results = results.slice(filter.offset);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semantic-like search with BM25 scoring */
|
||||||
|
async search(query, limit = 10) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const terms = q.split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return [];
|
||||||
|
|
||||||
|
const scored = [...this._entries.values()].map(e => {
|
||||||
|
let score = 0;
|
||||||
|
const content = (e.content || '').toLowerCase();
|
||||||
|
const key = (e.key || '').toLowerCase();
|
||||||
|
const tags = (e.tags || []).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
for (const term of terms) {
|
||||||
|
if (key.includes(term)) score += 10;
|
||||||
|
if (content.includes(term)) score += 3;
|
||||||
|
if (tags.includes(term)) score += 5;
|
||||||
|
|
||||||
|
// TF-like scoring
|
||||||
|
const tf = (content.match(new RegExp(term, 'g')) || []).length;
|
||||||
|
score += Math.min(tf, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority boost
|
||||||
|
score += TYPE_PRIORITY[e.type] || 0;
|
||||||
|
|
||||||
|
// Recency boost
|
||||||
|
const age = Date.now() - (e.timestamp || 0);
|
||||||
|
score += Math.max(0, 1 - age / (30 * 24 * 60 * 60 * 1000)) * 5;
|
||||||
|
|
||||||
|
return { entry: e, score };
|
||||||
|
});
|
||||||
|
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored.slice(0, limit).map(s => s.entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
this._entries.delete(id);
|
||||||
|
this._markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearType(type) {
|
||||||
|
for (const [key, entry] of this._entries) {
|
||||||
|
if (entry.type === type) this._entries.delete(key);
|
||||||
|
}
|
||||||
|
this._markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush() {
|
||||||
|
if (this._saveTimer) {
|
||||||
|
clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = null;
|
||||||
|
}
|
||||||
|
if (!this._dirty) return;
|
||||||
|
await this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCount() { return this._entries.size; }
|
||||||
|
getEntries() { return [...this._entries.values()]; }
|
||||||
|
getStats() {
|
||||||
|
const byType = {};
|
||||||
|
for (const e of this._entries.values()) {
|
||||||
|
byType[e.type] = (byType[e.type] || 0) + 1;
|
||||||
|
}
|
||||||
|
return { total: this._entries.size, byType };
|
||||||
|
}
|
||||||
|
|
||||||
|
_evictIfNeeded() {
|
||||||
|
if (this._entries.size <= this.maxEntries) return;
|
||||||
|
const sorted = [...this._entries.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||||
|
const toRemove = this._entries.size - this.maxEntries;
|
||||||
|
for (let i = 0; i < toRemove && i < sorted.length; i++) {
|
||||||
|
this._entries.delete(sorted[i].id || sorted[i].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_markDirty() {
|
||||||
|
this._dirty = true;
|
||||||
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = setTimeout(() => this._save(), this._debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _save() {
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(this.filePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
const data = [...this._entries.values()];
|
||||||
|
await fs.promises.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
this._dirty = false;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Memory save failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InMemoryBackend — RAM-only, for ephemeral agent context
|
||||||
|
* Auto-evicts after TTL or max entries
|
||||||
|
*/
|
||||||
|
export class InMemoryBackend {
|
||||||
|
constructor(maxEntries = 200, ttlMs = 30 * 60 * 1000) {
|
||||||
|
this._entries = new Map();
|
||||||
|
this.maxEntries = maxEntries;
|
||||||
|
this.ttlMs = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(memory) {
|
||||||
|
const key = memory.id || memory.key || `mem_${Date.now()}`;
|
||||||
|
const entry = {
|
||||||
|
...memory,
|
||||||
|
id: key,
|
||||||
|
timestamp: memory.timestamp || Date.now(),
|
||||||
|
_ttl: Date.now() + this.ttlMs,
|
||||||
|
};
|
||||||
|
this._entries.set(key, entry);
|
||||||
|
this._evictIfNeeded();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieve(id) {
|
||||||
|
const entry = this._entries.get(id);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry._ttl) {
|
||||||
|
this._entries.delete(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(filter) {
|
||||||
|
this._purgeExpired();
|
||||||
|
let results = [...this._entries.values()];
|
||||||
|
if (filter.type) results = results.filter(e => e.type === filter.type);
|
||||||
|
if (filter.query) {
|
||||||
|
const q = filter.query.toLowerCase();
|
||||||
|
results = results.filter(e => (e.content || '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
results.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||||
|
if (filter.limit) results = results.slice(0, filter.limit);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCount() {
|
||||||
|
this._purgeExpired();
|
||||||
|
return this._entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
_evictIfNeeded() {
|
||||||
|
if (this._entries.size <= this.maxEntries) return;
|
||||||
|
const sorted = [...this._entries.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||||
|
const toRemove = this._entries.size - this.maxEntries;
|
||||||
|
for (let i = 0; i < toRemove; i++) {
|
||||||
|
this._entries.delete(sorted[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_purgeExpired() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of this._entries) {
|
||||||
|
if (now > entry._ttl) this._entries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MEMORY_TYPES };
|
||||||
|
export default JSONBackend;
|
||||||
192
src/bot/session-state.js
Normal file
192
src/bot/session-state.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Session state: LRU file read cache + read-once dedup tracker.
|
||||||
|
*
|
||||||
|
* BUG FIX: FileReadTool was reading the same file 30+ times because nothing
|
||||||
|
* tracked what was already read. Now we:
|
||||||
|
* 1. Cache full file reads in an LRU (default 50 files, 5MB total)
|
||||||
|
* 2. Prevent re-reading the same file in the same session (read-once dedup)
|
||||||
|
* 3. Track which files have been read to detect ghost-chasing patterns
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ── LRU Cache ──
|
||||||
|
class LRUCache {
|
||||||
|
constructor(maxSize = 50, maxBytes = 5 * 1024 * 1024) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.currentSize = 0;
|
||||||
|
this.map = new Map(); // key → { content, size, lastAccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
const entry = this.map.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
// Move to end (most recently used)
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, { ...entry, lastAccess: Date.now() });
|
||||||
|
return entry.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, content) {
|
||||||
|
const size = Buffer.byteLength(content);
|
||||||
|
// Evict if needed
|
||||||
|
while ((this.map.size >= this.maxSize || this.currentSize + size > this.maxBytes) && this.map.size > 0) {
|
||||||
|
const [evictKey] = this.map.keys();
|
||||||
|
const evict = this.map.get(evictKey);
|
||||||
|
this.currentSize -= evict.size;
|
||||||
|
this.map.delete(evictKey);
|
||||||
|
}
|
||||||
|
this.map.set(key, { content, size, lastAccess: Date.now() });
|
||||||
|
this.currentSize += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key) {
|
||||||
|
return this.map.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.map.clear();
|
||||||
|
this.currentSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stats() {
|
||||||
|
return { entries: this.map.size, bytes: this.currentSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read-once dedup tracker ──
|
||||||
|
class ReadOnceTracker {
|
||||||
|
constructor() {
|
||||||
|
this.readFiles = new Set(); // files read this session
|
||||||
|
this.readCounts = new Map(); // file → number of read attempts
|
||||||
|
this.totalReads = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
record(filePath) {
|
||||||
|
this.readFiles.add(filePath);
|
||||||
|
this.readCounts.set(filePath, (this.readCounts.get(filePath) || 0) + 1);
|
||||||
|
this.totalReads++;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRead(filePath) {
|
||||||
|
return this.readFiles.has(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
getReadCount(filePath) {
|
||||||
|
return this.readCounts.get(filePath) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGhostFile() {
|
||||||
|
// Return the file with most reads (ghost chaser)
|
||||||
|
let maxFile = null;
|
||||||
|
let maxCount = 0;
|
||||||
|
for (const [file, count] of this.readCounts) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
maxFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxCount > 2 ? maxFile : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stats() {
|
||||||
|
return {
|
||||||
|
uniqueFiles: this.readFiles.size,
|
||||||
|
totalReads: this.totalReads,
|
||||||
|
ghostFile: this.getGhostFile(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.readFiles.clear();
|
||||||
|
this.readCounts.clear();
|
||||||
|
this.totalReads = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session state factory ──
|
||||||
|
export function createSessionState() {
|
||||||
|
const fileCache = new LRUCache(50, 5 * 1024 * 1024);
|
||||||
|
const readTracker = new ReadOnceTracker();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Check if a file read should be served from cache.
|
||||||
|
* Returns the cached content or null if not cached.
|
||||||
|
*/
|
||||||
|
getCachedRead(fullPath, offset, limit) {
|
||||||
|
// For offset > 1 or limited reads, check if we have the full file cached
|
||||||
|
if (offset === 1 && limit >= 1000) {
|
||||||
|
const cached = fileCache.get(fullPath);
|
||||||
|
if (cached) {
|
||||||
|
logger.info(`📦 File cache hit: ${fullPath} (${cached.length} bytes)`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} else if (offset === 1) {
|
||||||
|
// Small read — check if full file is cached
|
||||||
|
const cached = fileCache.get(fullPath);
|
||||||
|
if (cached) {
|
||||||
|
const lines = cached.split('\n');
|
||||||
|
const end = Math.min(offset + limit - 1, lines.length);
|
||||||
|
const selected = lines.slice(offset - 1, end);
|
||||||
|
const numbered = selected.map((line, i) => `${offset + i}|${line}`).join('\n');
|
||||||
|
return `${fullPath} (lines ${offset}-${end} of ${lines.length}) [cached]\n${numbered}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache a file read result.
|
||||||
|
*/
|
||||||
|
cacheRead(fullPath, content) {
|
||||||
|
fileCache.set(fullPath, content);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this file was already read this session (read-once dedup).
|
||||||
|
* Returns true if it was read before.
|
||||||
|
*/
|
||||||
|
wasRead(fullPath) {
|
||||||
|
return readTracker.hasRead(fullPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a file read.
|
||||||
|
*/
|
||||||
|
recordRead(fullPath) {
|
||||||
|
readTracker.record(fullPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're ghost-chasing (re-reading same files).
|
||||||
|
* Returns { isGhost: boolean, file: string, count: number } or null.
|
||||||
|
*/
|
||||||
|
checkGhostChasing(fullPath) {
|
||||||
|
const count = readTracker.getReadCount(fullPath);
|
||||||
|
if (count > 2) {
|
||||||
|
return { isGhost: true, file: fullPath, count };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stats for logging.
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
cache: fileCache.stats,
|
||||||
|
reads: readTracker.stats,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all state (for new sessions).
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
fileCache.clear();
|
||||||
|
readTracker.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
212
src/bot/stream-handler.js
Normal file
212
src/bot/stream-handler.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Stream handler — rewritten SSE with proper exponential backoff,
|
||||||
|
* 429 rate limit handling, and intelligent retry logic.
|
||||||
|
*
|
||||||
|
* BUG FIXES:
|
||||||
|
* - 429 errors now get aggressive backoff (was: ignored, fell back to non-stream)
|
||||||
|
* - Idle timeout increased from 45s to 120s (AI needs time to think)
|
||||||
|
* - Exponential backoff: 1s → 2s → 4s → 8s → 16s (was: linear 1s → 4s)
|
||||||
|
* - Max retries reduced from 4 to 3 (save turns, fall back to non-stream)
|
||||||
|
* - Non-stream fallback is faster and more reliable for tool calls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const MAX_SSE_RETRIES = 3;
|
||||||
|
const SSE_FETCH_TIMEOUT = 300_000; // 5 min total request timeout
|
||||||
|
const SSE_IDLE_TIMEOUT = 120_000; // 2 min between chunks (AI needs time)
|
||||||
|
const MIN_BACKOFF = 1_000; // 1 second
|
||||||
|
const MAX_BACKOFF = 16_000; // 16 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream chat with proper error handling.
|
||||||
|
* Falls back to non-stream immediately on 429 (rate limit) since the AI
|
||||||
|
* is being throttled — streaming won't help, non-stream might.
|
||||||
|
*/
|
||||||
|
export async function streamChatWithRetry(svc, body, onDelta, retryCount = 0) {
|
||||||
|
const baseUrl = svc.api?.config?.baseUrl || 'https://api.z.ai/api/coding/paas/v4';
|
||||||
|
const apiKey = svc.api?.config?.apiKey || '';
|
||||||
|
|
||||||
|
let fullContent = '';
|
||||||
|
const toolCallMap = {};
|
||||||
|
let finishReason = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetchTimeout = setTimeout(() => controller.abort(), SSE_FETCH_TIMEOUT);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...body, stream: true }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handle HTTP errors ──
|
||||||
|
if (!res.ok) {
|
||||||
|
clearTimeout(fetchTimeout);
|
||||||
|
const errText = await res.text();
|
||||||
|
const errData = errText.slice(0, 200);
|
||||||
|
|
||||||
|
// 429 = rate limit — aggressive backoff, don't fall back
|
||||||
|
if (res.status === 429) {
|
||||||
|
const delay = Math.min(MAX_BACKOFF, MIN_BACKOFF * Math.pow(2, retryCount));
|
||||||
|
logger.warn(`⏰ 429 Rate limited — retry ${retryCount + 1}/${MAX_SSE_RETRIES} in ${delay}ms`);
|
||||||
|
if (retryCount < MAX_SSE_RETRIES) {
|
||||||
|
await sleep(delay);
|
||||||
|
return await streamChatWithRetry(svc, body, onDelta, retryCount + 1);
|
||||||
|
}
|
||||||
|
// Exhausted retries — fall back to non-stream
|
||||||
|
logger.info('🔄 429 exhausted retries, falling back to non-stream');
|
||||||
|
return await nonStreamChat(svc, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5xx = server error — retry with backoff
|
||||||
|
if (res.status >= 500 && retryCount < MAX_SSE_RETRIES) {
|
||||||
|
const delay = Math.min(MAX_BACKOFF, MIN_BACKOFF * Math.pow(2, retryCount));
|
||||||
|
logger.warn(`⏰ SSE ${res.status} — retry ${retryCount + 1}/${MAX_SSE_RETRIES} in ${delay}ms`);
|
||||||
|
if (retryCount < MAX_SSE_RETRIES) {
|
||||||
|
await sleep(delay);
|
||||||
|
return await streamChatWithRetry(svc, body, onDelta, retryCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else — fall back to non-stream
|
||||||
|
logger.error(`SSE ${res.status}: ${errData}`);
|
||||||
|
return await nonStreamChat(svc, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read SSE stream ──
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let lastChunkTime = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Idle timeout check
|
||||||
|
const idleMs = Date.now() - lastChunkTime;
|
||||||
|
if (idleMs > SSE_IDLE_TIMEOUT) {
|
||||||
|
logger.warn(`⏰ SSE idle timeout (${Math.round(idleMs / 1000)}s) — falling back to non-stream`);
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
clearTimeout(fetchTimeout);
|
||||||
|
return await nonStreamChat(svc, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read with timeout
|
||||||
|
let readResult;
|
||||||
|
try {
|
||||||
|
readResult = await Promise.race([
|
||||||
|
reader.read(),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('read timeout')), SSE_IDLE_TIMEOUT)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (readErr) {
|
||||||
|
logger.warn(`⏰ SSE read timeout — falling back to non-stream`);
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
clearTimeout(fetchTimeout);
|
||||||
|
return await nonStreamChat(svc, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, value } = readResult;
|
||||||
|
if (done) break;
|
||||||
|
lastChunkTime = Date.now();
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
const data = trimmed.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const choice = parsed.choices?.[0];
|
||||||
|
if (!choice) continue;
|
||||||
|
finishReason = choice.finish_reason;
|
||||||
|
|
||||||
|
const delta = choice.delta || {};
|
||||||
|
if (delta.content) {
|
||||||
|
fullContent += delta.content;
|
||||||
|
if (onDelta) onDelta(delta.content);
|
||||||
|
}
|
||||||
|
if (delta.tool_calls) {
|
||||||
|
for (const tc of delta.tool_calls) {
|
||||||
|
const idx = tc.index ?? 0;
|
||||||
|
if (!toolCallMap[idx]) toolCallMap[idx] = { id: tc.id || '', name: '', arguments: '' };
|
||||||
|
if (tc.id) toolCallMap[idx].id = tc.id;
|
||||||
|
if (tc.function?.name) toolCallMap[idx].name += tc.function.name;
|
||||||
|
if (tc.function?.arguments) toolCallMap[idx].arguments += tc.function.arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip malformed chunks */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearTimeout(fetchTimeout);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
logger.warn(`⏰ SSE fetch aborted (timeout), retry ${retryCount}/${MAX_SSE_RETRIES}`);
|
||||||
|
} else {
|
||||||
|
logger.error('SSE error:', e.message);
|
||||||
|
}
|
||||||
|
// If we got partial content, return it
|
||||||
|
if (fullContent || Object.keys(toolCallMap).length) {
|
||||||
|
return buildResult(fullContent, toolCallMap);
|
||||||
|
}
|
||||||
|
// Nothing received — retry
|
||||||
|
if (retryCount < MAX_SSE_RETRIES) {
|
||||||
|
const delay = Math.min(MAX_BACKOFF, MIN_BACKOFF * Math.pow(2, retryCount));
|
||||||
|
logger.info(`🔄 SSE empty response, retry ${retryCount + 1}/${MAX_SSE_RETRIES} in ${delay}ms`);
|
||||||
|
await sleep(delay);
|
||||||
|
return await streamChatWithRetry(svc, body, onDelta, retryCount + 1);
|
||||||
|
}
|
||||||
|
// Exhausted — fall back to non-stream
|
||||||
|
return await nonStreamChat(svc, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildResult(fullContent, toolCallMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming fallback — faster and more reliable for tool calls.
|
||||||
|
*/
|
||||||
|
async function nonStreamChat(svc, body) {
|
||||||
|
try {
|
||||||
|
const res = await svc.api.client.post('/chat/completions', { ...body, stream: false });
|
||||||
|
const choice = res.data.choices?.[0];
|
||||||
|
if (!choice) return { content: '', tool_calls: null, error: 'No response from model' };
|
||||||
|
const msg = choice.message || {};
|
||||||
|
return {
|
||||||
|
content: msg.content || '',
|
||||||
|
tool_calls: msg.tool_calls || null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
tool_calls: null,
|
||||||
|
error: e.response?.data?.error?.message || e.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResult(content, toolMap) {
|
||||||
|
const toolCalls = Object.keys(toolMap).length > 0
|
||||||
|
? Object.values(toolMap).map(tc => ({
|
||||||
|
id: tc.id,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: tc.name, arguments: tc.arguments },
|
||||||
|
}))
|
||||||
|
: null;
|
||||||
|
return { content, tool_calls: toolCalls, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
50
src/plugins/ExtensionPoints.js
Normal file
50
src/plugins/ExtensionPoints.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* zCode Extension Points — Ported from Ruflo
|
||||||
|
* Standard extension point names for plugin hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EXTENSION_POINTS = {
|
||||||
|
// Tool lifecycle
|
||||||
|
TOOL_BEFORE_EXECUTE: 'tool.beforeExecute',
|
||||||
|
TOOL_AFTER_EXECUTE: 'tool.afterExecute',
|
||||||
|
TOOL_VALIDATE: 'tool.validate',
|
||||||
|
|
||||||
|
// AI lifecycle
|
||||||
|
AI_BEFORE_CALL: 'ai.beforeCall',
|
||||||
|
AI_AFTER_CALL: 'ai.afterCall',
|
||||||
|
AI_ON_ERROR: 'ai.onError',
|
||||||
|
AI_BEFORE_STREAM: 'ai.beforeStream',
|
||||||
|
AI_AFTER_STREAM: 'ai.afterStream',
|
||||||
|
|
||||||
|
// Agent lifecycle
|
||||||
|
AGENT_BEFORE_SPAWN: 'agent.beforeSpawn',
|
||||||
|
AGENT_AFTER_SPAWN: 'agent.afterSpawn',
|
||||||
|
AGENT_BEFORE_TASK: 'agent.beforeTask',
|
||||||
|
AGENT_AFTER_TASK: 'agent.afterTask',
|
||||||
|
|
||||||
|
// Memory lifecycle
|
||||||
|
MEMORY_BEFORE_STORE: 'memory.beforeStore',
|
||||||
|
MEMORY_AFTER_STORE: 'memory.afterStore',
|
||||||
|
MEMORY_BEFORE_QUERY: 'memory.beforeQuery',
|
||||||
|
|
||||||
|
// Session lifecycle
|
||||||
|
SESSION_START: 'session.start',
|
||||||
|
SESSION_END: 'session.end',
|
||||||
|
SESSION_BEFORE_MSG: 'session.beforeMessage',
|
||||||
|
SESSION_AFTER_MSG: 'session.afterMessage',
|
||||||
|
|
||||||
|
// Workflow
|
||||||
|
WORKFLOW_BEFORE_EXECUTE: 'workflow.beforeExecute',
|
||||||
|
WORKFLOW_AFTER_EXECUTE: 'workflow.afterExecute',
|
||||||
|
WORKFLOW_ON_ERROR: 'workflow.onError',
|
||||||
|
|
||||||
|
// Swarm
|
||||||
|
SWARM_BEFORE_COORDINATE: 'swarm.beforeCoordinate',
|
||||||
|
SWARM_AFTER_COORDINATE: 'swarm.afterCoordinate',
|
||||||
|
|
||||||
|
// Plugin lifecycle
|
||||||
|
PLUGIN_LOADED: 'plugin:loaded',
|
||||||
|
PLUGIN_UNLOADED: 'plugin:unloaded',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EXTENSION_POINTS;
|
||||||
115
src/plugins/Plugin.js
Normal file
115
src/plugins/Plugin.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* zCode Plugin System — Ported from Ruflo: Plugin Interface + BasePlugin
|
||||||
|
*
|
||||||
|
* Plugin → Extension Point → Hook → Worker chain architecture.
|
||||||
|
* Plugins register extension points. HookManager fires at lifecycle events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} PluginMetadata
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} version
|
||||||
|
* @property {string} [description]
|
||||||
|
* @property {string} [author]
|
||||||
|
* @property {string} [homepage]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ExtensionPoint
|
||||||
|
* @property {string} name
|
||||||
|
* @property {Function} handler
|
||||||
|
* @property {number} [priority]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} PluginConfig
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} version
|
||||||
|
* @property {string} [description]
|
||||||
|
* @property {string} [author]
|
||||||
|
* @property {string} [homepage]
|
||||||
|
* @property {number} [priority]
|
||||||
|
* @property {string[]} [dependencies]
|
||||||
|
* @property {Object} [configSchema]
|
||||||
|
* @property {string} [minCoreVersion]
|
||||||
|
* @property {string} [maxCoreVersion]
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class BasePlugin {
|
||||||
|
constructor(config) {
|
||||||
|
this.id = config.id;
|
||||||
|
this.name = config.name;
|
||||||
|
this.version = config.version;
|
||||||
|
this.description = config.description || '';
|
||||||
|
this.author = config.author || '';
|
||||||
|
this.homepage = config.homepage || '';
|
||||||
|
this.priority = config.priority || 0;
|
||||||
|
this.dependencies = config.dependencies || [];
|
||||||
|
this.configSchema = config.configSchema || null;
|
||||||
|
this.minCoreVersion = config.minCoreVersion || '0.0.0';
|
||||||
|
this.maxCoreVersion = config.maxCoreVersion || '99.99.99';
|
||||||
|
this._config = null;
|
||||||
|
this._extensionPoints = [];
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(config = {}) {
|
||||||
|
this._config = config;
|
||||||
|
if (this.configSchema) this._validateConfig(config);
|
||||||
|
await this._onInitialize();
|
||||||
|
this._initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
await this._onShutdown();
|
||||||
|
this._initialized = false;
|
||||||
|
this._extensionPoints = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtensionPoints() {
|
||||||
|
return this._extensionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
version: this.version,
|
||||||
|
description: this.description,
|
||||||
|
author: this.author,
|
||||||
|
homepage: this.homepage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized() { return this._initialized; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an extension point handler
|
||||||
|
* @param {string} name - Extension point name
|
||||||
|
* @param {(context: any) => Promise<any>} handler
|
||||||
|
* @param {number} [priority]
|
||||||
|
*/
|
||||||
|
registerExtensionPoint(name, handler, priority = 0) {
|
||||||
|
this._extensionPoints.push({ name, handler, priority });
|
||||||
|
// Keep sorted by priority descending
|
||||||
|
this._extensionPoints.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateConfig(schema, config) {
|
||||||
|
// Lightweight required-field validation
|
||||||
|
if (Array.isArray(schema?.required)) {
|
||||||
|
for (const field of schema.required) {
|
||||||
|
if (config[field] === undefined) {
|
||||||
|
throw new Error(`Plugin ${this.id}: missing required config field '${field}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _onInitialize() { /* override */ }
|
||||||
|
async _onShutdown() { /* override */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BasePlugin as default };
|
||||||
183
src/plugins/PluginLoader.js
Normal file
183
src/plugins/PluginLoader.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* zCode Plugin Loader — Dependency-resolving batch plugin loader.
|
||||||
|
* Supports topological sort, parallel/sequential init, health checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PluginManager } from './PluginManager.js';
|
||||||
|
|
||||||
|
export class PluginLoader {
|
||||||
|
constructor(manager, options = {}) {
|
||||||
|
this._manager = manager;
|
||||||
|
this._initTimeout = options.initTimeout || 30000;
|
||||||
|
this._shutdownTimeout = options.shutdownTimeout || 10000;
|
||||||
|
this._parallelInit = options.parallelInit || false;
|
||||||
|
this._strictDeps = options.strictDeps !== false;
|
||||||
|
this._enableHealthChecks = options.enableHealthChecks || false;
|
||||||
|
this._healthCheckInterval = options.healthCheckInterval || 60000;
|
||||||
|
this._healthTimers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a single plugin with timeout */
|
||||||
|
async loadPlugin(plugin, config = {}) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
throw new Error(`Plugin '${plugin.id}' initialization timed out after ${this._initTimeout}ms`);
|
||||||
|
}, this._initTimeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._manager.loadPlugin(plugin, config);
|
||||||
|
if (this._enableHealthChecks) {
|
||||||
|
this._startHealthCheck(plugin);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load multiple plugins with dependency resolution */
|
||||||
|
async loadPlugins(plugins, configs = {}) {
|
||||||
|
if (plugins.length === 0) return;
|
||||||
|
|
||||||
|
// Validate all first
|
||||||
|
for (const p of plugins) {
|
||||||
|
if (!p || !p.id) throw new Error(`Invalid plugin at index ${plugins.indexOf(p)}: missing id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency graph & detect cycles
|
||||||
|
const graph = this._buildGraph(plugins);
|
||||||
|
const cycles = this._detectCycles(graph);
|
||||||
|
if (cycles.length > 0) {
|
||||||
|
throw new Error(`Circular plugin dependencies detected: ${cycles.map(c => c.join(' -> ')).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort by depth
|
||||||
|
const layers = this._topologicalSort(graph);
|
||||||
|
|
||||||
|
// Load layer by layer
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (this._parallelInit && layer.length > 1) {
|
||||||
|
await Promise.all(layer.map(id => {
|
||||||
|
const p = plugins.find(pl => pl.id === id);
|
||||||
|
return this.loadPlugin(p, configs[id] || {});
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
for (const id of layer) {
|
||||||
|
const p = plugins.find(pl => pl.id === id);
|
||||||
|
await this.loadPlugin(p, configs[id] || {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload a plugin */
|
||||||
|
async unloadPlugin(name) {
|
||||||
|
if (!this._manager.isPluginLoaded(name)) return;
|
||||||
|
this._stopHealthCheck(name);
|
||||||
|
await this._manager.unloadPlugin(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload all plugins in reverse init order */
|
||||||
|
async unloadAll() {
|
||||||
|
const plugins = this._manager.listPlugins();
|
||||||
|
for (const meta of plugins.reverse()) {
|
||||||
|
await this.unloadPlugin(meta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload a plugin */
|
||||||
|
async reloadPlugin(name, newPlugin, config = {}) {
|
||||||
|
await this.unloadPlugin(name);
|
||||||
|
await this.loadPlugin(newPlugin, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal ----
|
||||||
|
|
||||||
|
_buildGraph(plugins) {
|
||||||
|
const graph = new Map();
|
||||||
|
for (const p of plugins) {
|
||||||
|
graph.set(p.id, [...(p.dependencies || [])]);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
_detectCycles(graph) {
|
||||||
|
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||||
|
const color = new Map();
|
||||||
|
const parent = new Map();
|
||||||
|
const cycles = [];
|
||||||
|
|
||||||
|
for (const node of graph.keys()) color.set(node, WHITE);
|
||||||
|
|
||||||
|
function dfs(node, stack) {
|
||||||
|
color.set(node, GRAY);
|
||||||
|
stack.push(node);
|
||||||
|
for (const dep of graph.get(node) || []) {
|
||||||
|
if (!graph.has(dep)) continue; // external dep, skip
|
||||||
|
if (color.get(dep) === GRAY) {
|
||||||
|
const cycle = stack.slice(stack.indexOf(dep)).concat(dep);
|
||||||
|
cycles.push(cycle);
|
||||||
|
} else if (color.get(dep) === WHITE) {
|
||||||
|
dfs(dep, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
color.set(node, BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graph.keys()) {
|
||||||
|
if (color.get(node) === WHITE) dfs(node, []);
|
||||||
|
}
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
_topologicalSort(graph) {
|
||||||
|
// Compute depth for each node
|
||||||
|
const depth = new Map();
|
||||||
|
function getDepth(node) {
|
||||||
|
if (depth.has(node)) return depth.get(node);
|
||||||
|
let maxDep = 0;
|
||||||
|
for (const dep of graph.get(node) || []) {
|
||||||
|
if (graph.has(dep)) { // only internal deps
|
||||||
|
maxDep = Math.max(maxDep, getDepth(dep) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depth.set(node, maxDep);
|
||||||
|
return maxDep;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graph.keys()) getDepth(node);
|
||||||
|
|
||||||
|
// Group by depth
|
||||||
|
const maxDepth = Math.max(...depth.values(), 0);
|
||||||
|
const layers = [];
|
||||||
|
for (let d = 0; d <= maxDepth; d++) {
|
||||||
|
const layer = [...graph.keys()].filter(n => depth.get(n) === d);
|
||||||
|
if (layer.length > 0) layers.push(layer);
|
||||||
|
}
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startHealthCheck(plugin) {
|
||||||
|
if (typeof plugin.healthCheck !== 'function') return;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const ok = await plugin.healthCheck();
|
||||||
|
if (!ok) {
|
||||||
|
console.warn(`Health check failed for plugin '${plugin.id}'`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Health check error for plugin '${plugin.id}':`, err.message);
|
||||||
|
}
|
||||||
|
}, this._healthCheckInterval);
|
||||||
|
this._healthTimers.set(plugin.id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopHealthCheck(pluginId) {
|
||||||
|
const timer = this._healthTimers.get(pluginId);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
this._healthTimers.delete(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginLoader;
|
||||||
253
src/plugins/PluginManager.js
Normal file
253
src/plugins/PluginManager.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* zCode Plugin Manager — Ported from Ruflo PluginManager + PluginRegistry
|
||||||
|
* Manages plugin lifecycle: load, unload, reload, extension point invocation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { BasePlugin } from './Plugin.js';
|
||||||
|
import { EXTENSION_POINTS } from './ExtensionPoints.js';
|
||||||
|
|
||||||
|
const PLUGIN_STATES = {
|
||||||
|
UNINITIALIZED: 'uninitialized',
|
||||||
|
INITIALIZING: 'initializing',
|
||||||
|
INITIALIZED: 'initialized',
|
||||||
|
ERROR: 'error',
|
||||||
|
SHUTTING_DOWN: 'shutting-down',
|
||||||
|
SHUTDOWN: 'shutdown',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PluginManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this._plugins = new Map(); // id -> { plugin, state, meta, metrics }
|
||||||
|
this._extensionPoints = new Map(); // name -> [{ pluginId, handler, priority }]
|
||||||
|
this._eventBus = options.eventBus || new EventEmitter();
|
||||||
|
this._coreVersion = options.coreVersion || '3.0.0';
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized() { return this._initialized; }
|
||||||
|
|
||||||
|
async initialize() { this._initialized = true; }
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
const ids = [...this._plugins.keys()].reverse();
|
||||||
|
for (const id of ids) {
|
||||||
|
await this.unloadPlugin(id);
|
||||||
|
}
|
||||||
|
this._extensionPoints.clear();
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a plugin: validations → init → register extension points → emit
|
||||||
|
*/
|
||||||
|
async loadPlugin(plugin, config = {}) {
|
||||||
|
if (!plugin || !plugin.id) {
|
||||||
|
throw new Error('Invalid plugin: must have an id');
|
||||||
|
}
|
||||||
|
if (this._plugins.has(plugin.id)) {
|
||||||
|
throw new Error(`Plugin '${plugin.id}' is already loaded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version compatibility
|
||||||
|
this._checkVersionCompatibility(plugin);
|
||||||
|
|
||||||
|
// Dependency check
|
||||||
|
this._checkDependencies(plugin);
|
||||||
|
|
||||||
|
// Validate config schema
|
||||||
|
if (plugin.configSchema) {
|
||||||
|
this._validateConfig(plugin.configSchema, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this._plugins.set(plugin.id, {
|
||||||
|
plugin,
|
||||||
|
state: PLUGIN_STATES.INITIALIZING,
|
||||||
|
meta: plugin.getMetadata(),
|
||||||
|
metrics: { loadTime: 0, invokeCount: 0, errors: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
await plugin.initialize(config);
|
||||||
|
this._plugins.get(plugin.id).state = PLUGIN_STATES.INITIALIZED;
|
||||||
|
this._plugins.get(plugin.id).metrics.loadTime = Date.now() - startTime;
|
||||||
|
} catch (err) {
|
||||||
|
this._plugins.get(plugin.id).state = PLUGIN_STATES.ERROR;
|
||||||
|
this._plugins.get(plugin.id).metrics.errors++;
|
||||||
|
throw new Error(`Plugin '${plugin.id}' initialization failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register extension points
|
||||||
|
this._registerExtensionPoints(plugin);
|
||||||
|
|
||||||
|
this._eventBus.emit(EXTENSION_POINTS.PLUGIN_LOADED, { pluginId: plugin.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload a plugin: check dependents → shutdown → cleanup
|
||||||
|
*/
|
||||||
|
async unloadPlugin(pluginId) {
|
||||||
|
const entry = this._plugins.get(pluginId);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
this._checkDependents(pluginId);
|
||||||
|
|
||||||
|
entry.state = PLUGIN_STATES.SHUTTING_DOWN;
|
||||||
|
try {
|
||||||
|
await entry.plugin.shutdown();
|
||||||
|
} catch (err) {
|
||||||
|
// Log but continue cleanup
|
||||||
|
console.error(`Plugin '${pluginId}' shutdown error:`, err.message);
|
||||||
|
}
|
||||||
|
entry.state = PLUGIN_STATES.SHUTDOWN;
|
||||||
|
|
||||||
|
// Remove extension points registered by this plugin
|
||||||
|
for (const [, handlers] of this._extensionPoints) {
|
||||||
|
const idx = handlers.findIndex(h => h.pluginId === pluginId);
|
||||||
|
if (idx !== -1) handlers.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._plugins.delete(pluginId);
|
||||||
|
this._eventBus.emit(EXTENSION_POINTS.PLUGIN_UNLOADED, { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadPlugin(pluginId, newPlugin, config = {}) {
|
||||||
|
await this.unloadPlugin(pluginId);
|
||||||
|
await this.loadPlugin(newPlugin, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoke all handlers for an extension point, fault-isolated */
|
||||||
|
async invokeExtensionPoint(name, context = {}) {
|
||||||
|
const handlers = this._extensionPoints.get(name);
|
||||||
|
if (!handlers || handlers.length === 0) return [];
|
||||||
|
|
||||||
|
// Sort by priority descending
|
||||||
|
const sorted = [...handlers].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const { pluginId, handler } of sorted) {
|
||||||
|
try {
|
||||||
|
const result = await handler(context);
|
||||||
|
results.push({ pluginId, result });
|
||||||
|
const plugin = this._plugins.get(pluginId);
|
||||||
|
if (plugin) plugin.metrics.invokeCount++;
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ pluginId, error: err.message });
|
||||||
|
const plugin = this._plugins.get(pluginId);
|
||||||
|
if (plugin) plugin.metrics.errors++;
|
||||||
|
// Fault isolation: continue to next handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoke with filtering — only runs if all handlers pass */
|
||||||
|
async invokeFilterChain(name, context = {}) {
|
||||||
|
const handlers = this._extensionPoints.get(name);
|
||||||
|
if (!handlers) return true;
|
||||||
|
|
||||||
|
const sorted = [...handlers].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
for (const { pluginId, handler } of sorted) {
|
||||||
|
try {
|
||||||
|
const result = await handler(context);
|
||||||
|
if (result === false) return false;
|
||||||
|
} catch (err) {
|
||||||
|
const plugin = this._plugins.get(pluginId);
|
||||||
|
if (plugin) plugin.metrics.errors++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
getPlugin(id) { return this._plugins.get(id)?.plugin || null; }
|
||||||
|
getPluginState(id) { return this._plugins.get(id)?.state || null; }
|
||||||
|
getPluginMeta(id) { return this._plugins.get(id)?.meta || null; }
|
||||||
|
listPlugins() { return [...this._plugins.values()].map(e => e.meta); }
|
||||||
|
isPluginLoaded(id) { return this._plugins.has(id); }
|
||||||
|
getPluginCount() { return this._plugins.size; }
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
const byState = {};
|
||||||
|
for (const [, entry] of this._plugins) {
|
||||||
|
byState[entry.state] = (byState[entry.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: this._plugins.size,
|
||||||
|
extensionPoints: this._extensionPoints.size,
|
||||||
|
byState,
|
||||||
|
plugins: [...this._plugins.entries()].map(([id, e]) => ({
|
||||||
|
id,
|
||||||
|
state: e.state,
|
||||||
|
version: e.meta.version,
|
||||||
|
loadTime: e.metrics.loadTime,
|
||||||
|
errors: e.metrics.errors,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal ----
|
||||||
|
|
||||||
|
_registerExtensionPoints(plugin) {
|
||||||
|
const points = plugin.getExtensionPoints();
|
||||||
|
for (const { name, handler, priority } of points) {
|
||||||
|
if (!this._extensionPoints.has(name)) {
|
||||||
|
this._extensionPoints.set(name, []);
|
||||||
|
}
|
||||||
|
this._extensionPoints.get(name).push({
|
||||||
|
pluginId: plugin.id,
|
||||||
|
handler,
|
||||||
|
priority: priority || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkVersionCompatibility(plugin) {
|
||||||
|
const toParts = (v) => String(v).split('.').map(Number);
|
||||||
|
const core = toParts(this._coreVersion);
|
||||||
|
const minV = toParts(plugin.minCoreVersion || '0.0.0');
|
||||||
|
const maxV = toParts(plugin.maxCoreVersion || '99.99.99');
|
||||||
|
|
||||||
|
const gte = (a, b) => a[0] > b[0] || (a[0] === b[0] && a[1] >= b[1]);
|
||||||
|
const lte = (a, b) => a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]);
|
||||||
|
|
||||||
|
if (!gte(core, minV) || !lte(core, maxV)) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin '${plugin.id}' requires core version ${minV.join('.')}-${maxV.join('.')}, current ${core.join('.')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkDependencies(plugin) {
|
||||||
|
if (!plugin.dependencies?.length) return;
|
||||||
|
for (const depId of plugin.dependencies) {
|
||||||
|
if (!this._plugins.has(depId)) {
|
||||||
|
throw new Error(`Plugin '${plugin.id}' depends on '${depId}' which is not loaded`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkDependents(pluginId) {
|
||||||
|
for (const [, entry] of this._plugins) {
|
||||||
|
if (entry.plugin.dependencies?.includes(pluginId)) {
|
||||||
|
throw new Error(`Cannot unload '${pluginId}': '${entry.plugin.id}' depends on it`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateConfig(schema, config) {
|
||||||
|
if (!schema?.required) return;
|
||||||
|
for (const field of schema.required) {
|
||||||
|
if (config[field] === undefined) {
|
||||||
|
throw new Error(`Missing required config field '${field}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PLUGIN_STATES };
|
||||||
|
export default PluginManager;
|
||||||
@@ -1,18 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* FileReadTool — rewritten with LRU cache + read-once dedup.
|
||||||
|
*
|
||||||
|
* BUG FIX: Was reading the same file 30+ times because nothing tracked
|
||||||
|
* what was already read. Now:
|
||||||
|
* 1. Checks session-state cache first (full file reads cached)
|
||||||
|
* 2. Warns if the same file is being re-read (ghost detection)
|
||||||
|
* 3. Returns cached content if available
|
||||||
|
*/
|
||||||
|
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export class FileReadTool {
|
export class FileReadTool {
|
||||||
constructor() {
|
constructor(sessionState) {
|
||||||
this.name = 'file_read';
|
this.name = 'file_read';
|
||||||
this.description = 'Read file contents with line numbers and pagination';
|
this.description = 'Read file contents with line numbers and pagination (cached)';
|
||||||
|
this.sessionState = sessionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
|
if (!args || typeof args !== 'object') {
|
||||||
|
return '❌ file_read: Invalid arguments. Expected { file_path, offset, limit }.';
|
||||||
|
}
|
||||||
|
|
||||||
const { file_path, offset = 1, limit = 500 } = args;
|
const { file_path, offset = 1, limit = 500 } = args;
|
||||||
|
|
||||||
|
if (!file_path || typeof file_path !== 'string') {
|
||||||
|
return '❌ file_read: file_path is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(file_path);
|
||||||
|
|
||||||
|
// ── Check session state cache ──
|
||||||
|
const cached = this.sessionState.getCachedRead(fullPath, offset, limit);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read-once dedup: warn if re-reading same file ──
|
||||||
|
const ghostCheck = this.sessionState.checkGhostChasing(fullPath);
|
||||||
|
if (ghostCheck) {
|
||||||
|
logger.warn(`⚠ Ghost detected: ${ghostCheck.file} read ${ghostCheck.count}x this session`);
|
||||||
|
// Still allow the read but add a warning to the result so the AI sees it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this read
|
||||||
|
this.sessionState.recordRead(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPath = path.resolve(file_path);
|
|
||||||
const content = await fs.readFile(fullPath, 'utf-8');
|
const content = await fs.readFile(fullPath, 'utf-8');
|
||||||
|
|
||||||
|
// Cache the full file content for future reads
|
||||||
|
this.sessionState.cacheRead(fullPath, content);
|
||||||
|
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
if (offset < 1 || offset > lines.length) {
|
if (offset < 1 || offset > lines.length) {
|
||||||
@@ -27,7 +68,15 @@ export class FileReadTool {
|
|||||||
? `${fullPath} (${lines.length} lines)`
|
? `${fullPath} (${lines.length} lines)`
|
||||||
: `${fullPath} (lines ${offset}-${end} of ${lines.length})`;
|
: `${fullPath} (lines ${offset}-${end} of ${lines.length})`;
|
||||||
|
|
||||||
return `${header}\n${numbered}`;
|
let result = `${header}\n${numbered}`;
|
||||||
|
|
||||||
|
// Add ghost warning if applicable
|
||||||
|
if (ghostCheck) {
|
||||||
|
result += `\n\n⚠ WARNING: You have already read this file ${ghostCheck.count} times in this session. ` +
|
||||||
|
`The full file content is ${lines.length} lines. You already have this data — stop re-reading and act on it.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ENOENT') return `❌ File not found: ${file_path}`;
|
if (e.code === 'ENOENT') return `❌ File not found: ${file_path}`;
|
||||||
if (e.code === 'EISDIR') return `❌ Is a directory: ${file_path}`;
|
if (e.code === 'EISDIR') return `❌ Is a directory: ${file_path}`;
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* FileWriteTool — rewritten for reliability.
|
||||||
|
*
|
||||||
|
* BUG FIX: The "Unterminated string in JSON" errors were NOT from this file.
|
||||||
|
* They were from the AI's streamed tool_calls getting truncated at 180s,
|
||||||
|
* producing incomplete JSON like {"content":"<!DOCTYPE html>... with no closing quote.
|
||||||
|
*
|
||||||
|
* This tool still handles edge cases better now:
|
||||||
|
* 1. Validates content is a string before writing
|
||||||
|
* 2. Auto-truncates extremely large content (>5MB) with a warning
|
||||||
|
* 3. Better error messages that distinguish JSON parse vs filesystem errors
|
||||||
|
*/
|
||||||
|
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -9,13 +22,51 @@ export class FileWriteTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
|
// ── Input validation ──
|
||||||
|
if (!args || typeof args !== 'object') {
|
||||||
|
return '❌ file_write: Invalid arguments. Expected { file_path, content }.';
|
||||||
|
}
|
||||||
|
|
||||||
const { file_path, content } = args;
|
const { file_path, content } = args;
|
||||||
|
|
||||||
|
if (!file_path || typeof file_path !== 'string') {
|
||||||
|
return '❌ file_write: file_path is required and must be a string.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content === undefined || content === null) {
|
||||||
|
return '❌ file_write: content is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If content is not a string (e.g., object from truncated JSON), stringify it
|
||||||
|
let contentStr;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
contentStr = content;
|
||||||
|
} else {
|
||||||
|
contentStr = JSON.stringify(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Size check ──
|
||||||
|
const byteLength = Buffer.byteLength(contentStr);
|
||||||
|
if (byteLength > 5 * 1024 * 1024) {
|
||||||
|
logger.warn(`⚠ file_write: ${byteLength} bytes is very large for direct write, consider bash heredoc`);
|
||||||
|
return `⚠ Warning: ${Math.round(byteLength / 1024)}KB — consider using bash with heredoc for large files: bash({ command: "cat > ${file_path} << 'EOF'\n...\nEOF" })`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPath = path.resolve(file_path);
|
const fullPath = path.resolve(file_path);
|
||||||
await fs.ensureDir(path.dirname(fullPath));
|
await fs.ensureDir(path.dirname(fullPath));
|
||||||
await fs.writeFile(fullPath, content, 'utf-8');
|
await fs.writeFile(fullPath, contentStr, 'utf-8');
|
||||||
return `✅ Written ${Buffer.byteLength(content)} bytes to ${fullPath}`;
|
logger.info(`✅ file_write: ${fullPath} (${Math.round(byteLength / 1024)}KB)`);
|
||||||
|
return `✅ Written ${byteLength} bytes to ${fullPath}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Distinguish filesystem errors from other issues
|
||||||
|
if (e.code === 'EACCES') {
|
||||||
|
return `❌ Permission denied: ${fullPath}. Check file permissions.`;
|
||||||
|
}
|
||||||
|
if (e.code === 'ENOSPC') {
|
||||||
|
return `❌ Disk full: no space left on device.`;
|
||||||
|
}
|
||||||
|
logger.error(`❌ file_write failed: ${e.message}`);
|
||||||
return `❌ Write error: ${e.message}`;
|
return `❌ Write error: ${e.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user