initial implementation

This commit is contained in:
Shivering Ocelot committed 2026-10-02 07:07:19 -05:00
1 parent 536b308096
commit a08c41a426
7 files changed
+949 -20

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket).
// Modeled on the browser client in reference/backend.js: nonce-tracked actions,
// action listeners, orphan handlers for the auth response, auto-reconnect.
export default class Horde {
constructor({ host, token = null }) {
this.host = host;
this.token = token;
this.socket = null;
this.nextNonce = 0;
this.pending = new Map(); // nonce -> { resolve, reject }
this.actionListeners = new Map(); // action -> [callback(packet)]
this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies)
this.connectListeners = [];
this.reconnectTimeout = null;
this.stopped = false;
this.pingTimeout = null; // Tracks the heartbeat interval
}
connect() {
if (this.stopped) return;
if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return;
const socket = new WebSocket(this.host + "/ws");
this.socket = socket;
let promiseInner = {};
const connectPromise = new Promise((resolve, reject) => {
promiseInner = {resolve, reject};
});
socket.onopen = async () => {
console.log(`Connected to ${this.host}`);
try {
if (this.token) {
socket.send(JSON.stringify({ token: this.token }));
const response = await this.waitOrphan();
if (response && response.error) {
console.error('Hub authentication failed:', response.error);
socket.close();
return;
}
} else {
console.warn('No auth token configured; the hub may reject this session');
}
for (const cb of this.connectListeners) {
try { cb(); } catch (err) { console.error('Connect listener failed:', err); }
}
} catch (err) {
console.error('Error during hub login:', err);
socket.close();
}
// Start heartbeat
this.schedulePing();
if (promiseInner) {
promiseInner.resolve();
promiseInner = null;
}
};
socket.onmessage = (event) => {
if (typeof event.data === 'string') this.handleMessage(event.data);
};
socket.onclose = () => {
this.socket = null;
this.clearPending();
if (!this.stopped) {
console.log('Disconnected from hub. Reconnecting in 1s...');
this.reconnectTimeout = setTimeout(() => this.connect(), 1000);
}
this.pingTimeout = null;
};
socket.onerror = (event) => {
console.error('WebSocket error:', event.message || event.error || event);
if (promiseInner) {
promiseInner.reject(event.message || event.error || event);
promiseInner = null;
}
};
return connectPromise;
}
close() {
this.stopped = true;
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
if (this.pingTimeout) clearTimeout(this.pingTimeout);
if (this.socket) {
this.socket.onclose = null;
this.socket.close();
this.socket = null;
}
this.clearPending();
}
clearPending() {
for (const { reject } of this.pending.values()) {
reject(new Error('Connection lost'));
}
this.pending.clear();
}
onConnect(cb) {
this.connectListeners.push(cb);
}
onAction(action, cb) {
let list = this.actionListeners.get(action);
if (!list) {
list = [];
this.actionListeners.set(action, list);
}
list.push(cb);
}
waitOrphan() {
return new Promise((resolve) => this.orphanHandlers.push(resolve));
}
send(value) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this.socket.send(JSON.stringify(value));
}
sendAction(action, body = {}) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('WebSocket is not open'));
}
const nonce = this.nextNonce++;
const promise = new Promise((resolve, reject) => {
this.pending.set(nonce, { resolve, reject });
});
promise.catch(() => {}); // avoid unhandled rejections when nobody awaits
this.send({ action, nonce, body });
return promise;
}
handleMessage(raw) {
let packet;
try {
packet = JSON.parse(raw);
} catch (err) {
console.error('Failed to parse hub message:', raw, err);
return;
}
if (packet.nonce != null) {
const pending = this.pending.get(packet.nonce);
if (!pending) {
console.warn(`Orphan response for nonce ${packet.nonce}`);
return;
}
this.pending.delete(packet.nonce);
if (packet.error) pending.reject(new Error(packet.error));
else pending.resolve(packet);
return;
}
if (packet.action) {
const listeners = this.actionListeners.get(packet.action);
if (listeners && listeners.length) {
for (const cb of listeners) {
try { cb(packet); } catch (err) {
console.error(`Listener for '${packet.action}' failed:`, err);
}
}
return;
}
}
if (this.orphanHandlers.length) {
this.orphanHandlers.shift()(packet);
return;
}
if (packet.error) console.error('Hub error:', packet.error);
else {
//console.warn('Ignored hub message:', packet.action);
}
}
schedulePing() {
if (this.pingTimeout) clearTimeout(this.pingTimeout);
this.pingTimeout = setTimeout(() => {
if (this.stopped) {
this.pingTimeout = null;
return;
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendAction("ping");
this.schedulePing();
} else {
this.pingTimeout = null;
}
}, 30_000);
}
}
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
'use strict';
/*
* Gral — CLI automated task execution application.
*
* Sections:
* 1. Imports & constants
* 2. Context / API (global `Gral`)
* 3. Config loading
* 4. Function loading
* 5. Command line interface (parsing & help)
* 6. Entry point
*/
/* ------------------------------------------------------------------ *
* 1. Imports & constants
* ------------------------------------------------------------------ */
import * as fs from 'fs';
import * as path from 'path';
import Horde from './horde.js';
import run from './run.js';
const CONFIG_DIR = '.gral';
const CONFIG_FILE = 'config.json';
const FUNCTIONS_DIR = 'functions';
const DEFAULT_CONFIG = {
host: 'wss://hub.hordelab.com',
token: null,
};
// Optional environment overrides for host/token.
if (process.env.HORDE_HOST) DEFAULT_CONFIG.host = process.env.HORDE_HOST;
if (process.env.HORDE_TOKEN) DEFAULT_CONFIG.token = process.env.HORDE_TOKEN;
/* ------------------------------------------------------------------ *
* 2. Context / API
* ------------------------------------------------------------------ */
/**
* Global execution context exposed to function modules as `Gral`.
* For now it only carries the current working directory.
*/
const Gral = {
path: process.cwd(),
// The function itself is defined in run.js
run,
};
global.Gral = Gral;
/* ------------------------------------------------------------------ *
* 3. Config loading
* ------------------------------------------------------------------ */
/**
* Load the config from '.gral/config.json', falling back to defaults.
* Missing fields are filled from DEFAULT_CONFIG; the file may be absent.
*
* @param {string} root Directory the command was launched in.
* @returns {object} The effective config object.
*/
function loadConfig(root) {
const configPath = path.join(root, CONFIG_DIR, CONFIG_FILE);
let stored = {};
try {
stored = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
// Absent or unreadable config: rely on defaults.
stored = {};
}
return { ...DEFAULT_CONFIG, ...stored };
}
/* ------------------------------------------------------------------ *
* 4. Function loading
* ------------------------------------------------------------------ */
/**
* Load every '.js' module in '.gral/functions/', keyed by basename.
* Missing directory yields an empty set.
*
* @param {string} root Directory the command was launched in.
* @returns {Map<string, function>} function name -> module default export.
*/
async function loadFunctions(root) {
const dir = path.join(root, CONFIG_DIR, FUNCTIONS_DIR);
const functions = new Map();
let entries = [];
try {
entries = fs.readdirSync(dir);
} catch (err) {
return functions;
}
for (const entry of entries) {
if (!entry.endsWith('.js')) continue;
const name = entry.slice(0, -'.js'.length);
const mod = await import(path.join(dir, entry));
const fn = mod && mod.default ? mod.default : mod;
if (typeof fn === 'function') {
functions.set(name, fn);
}
}
return functions;
}
/* ------------------------------------------------------------------ *
* 5. Command line interface
* ------------------------------------------------------------------ */
const HELP = [
'Gral — automated task execution',
'',
'Usage:',
' gral <function> [-p PROMPT | -f PROMPT_FILE]',
'',
'Options:',
' -p, --prompt <PROMPT> Prompt text passed to the function',
' -f, --prompt-file <FILE> Read the prompt from a file',
' -l, --list List available functions',
' -h, --help Show this help',
].join('\n');
/**
* Parse argv (excluding node + script) into a command object.
*
* @param {string[]} argv Process arguments after the script path.
* @returns {{functionName: ?string, prompt: ?string, list: boolean, help: boolean}}
*/
function parseArgs(argv) {
const command = {
functionName: null,
prompt: null,
list: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case '-p':
case '--prompt':
command.prompt = argv[++i] ?? null;
break;
case '-f':
case '--prompt-file':
command.prompt = readPromptFile(argv[++i]);
break;
case '-l':
case '--list':
command.list = true;
break;
case '-h':
case '--help':
command.help = true;
break;
default:
if (arg.startsWith('-')) {
// Unknown option: ignore per light error handling.
break;
}
if (command.functionName === null) {
command.functionName = arg;
}
}
}
return command;
}
/**
* Read the prompt from a file, returning null if it cannot be read.
*
* @param {?string} filePath Path to the prompt file.
* @returns {?string}
*/
function readPromptFile(filePath) {
if (!filePath) return null;
try {
return fs.readFileSync(filePath, 'utf8').trim();
} catch (err) {
return null;
}
}
/* ------------------------------------------------------------------ *
* 6. Entry point
* ------------------------------------------------------------------ */
/**
* Execute the selected function with the gathered input.
*
* @param {string[]} argv Process arguments after the script path.
* @returns {Promise<number>} Exit code.
*/
async function main(argv) {
const command = parseArgs(argv);
const root = process.cwd();
// Context loaded up-front so functions see an accurate `Gral.path`.
let config = loadConfig(root);
Gral.path = root;
const functions = await loadFunctions(root);
let horde = new Horde({host: config.host, token: config.token});
await horde.connect();
Gral.horde = horde;
if (command.help) {
console.log(HELP);
return 0;
}
if (command.list) {
const names = [...functions.keys()].sort();
if (names.length === 0) {
console.log('No functions available.');
} else {
console.log('Available functions:');
for (const name of names) console.log(` ${name}`);
}
return 0;
}
if (!command.functionName) {
console.error('Error: a function name is required.');
console.error(HELP);
return 1;
}
const fn = functions.get(command.functionName);
if (!fn) {
console.error(`Error: unknown function '${command.functionName}'.`);
return 1;
}
if (command.prompt === null) {
console.error("Error: a prompt is required (-p PROMPT or -f PROMPT_FILE).");
return 1;
}
const input = { prompt: command.prompt };
try {
await fn(input);
} catch (err) {
console.error(`Error executing '${command.functionName}':`, err && err.message);
return 1;
}
return 0;
}
main(process.argv.slice(2)).then(
(code) => process.exit(code),
(err) => {
console.error(err);
process.exit(1);
}
);
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env node
'use strict';
/*
* run.js — the harness behind `Gral.run`.
*
* Given a prompt for a target agent it:
* 1. Resolves the `read` / `write` permission scopes.
* 2. Collects the `open` files and directories as prompt context.
* 3. Assembles the tool specs from `tools` + `defaultTools`.
* 4. Runs the prompt/tool-call loop: send the prompt, execute any tool
* calls locally, feed the results back, repeat until the agent
* returns a plain response.
*/
import * as fs from 'fs';
import * as path from 'path';
const MAX_READ_BYTES = 1024;
const MAX_ITERATIONS = 20;
/* ------------------------------------------------------------------ *
* Scopes / permissions
* ------------------------------------------------------------------ */
/**
* Normalize a scope descriptor into an absolute allow-list.
*
* null | true -> null (allow everything)
* false | [] -> [] (allow nothing)
* [entry, ...] -> resolved absolute paths
*
* @param {?boolean|string[]} scope
* @param {string} root
* @returns {?string[]}
*/
function resolveScope(scope, root) {
if (scope === true || scope === null || scope === undefined) return null;
if (!Array.isArray(scope)) return [];
return scope.map((entry) => path.resolve(root, entry));
}
/**
* True when `target` is equal to or nested beneath an allowed path.
* A `null` allow-list grants unrestricted access.
*/
function isAllowed(target, allowed) {
if (allowed === null || allowed === undefined) return true;
return allowed.some(
(base) => target === base || target.startsWith(base + path.sep)
);
}
/* ------------------------------------------------------------------ *
* Default tool handlers
*
* Every handler receives the parsed call `args` and a context of
* `{root, scope}`; the scope is the resolved allow-list for the tool.
* ------------------------------------------------------------------ */
function readFile(args, { root, scope }) {
const file = args.path;
const abs = path.resolve(root, file);
if (!isAllowed(abs, scope)) throw new Error(`read denied: ${file}`);
let lines = fs.readFileSync(abs, 'utf8').split('\n');
const from = args.lineStart != null ? args.lineStart - 1 : 0;
const to = args.lineEnd != null ? args.lineEnd : lines.length;
lines = lines.slice(from, to);
let content = lines.join('\n');
const bytes = Buffer.from(content, 'utf8');
if (bytes.length > MAX_READ_BYTES) {
content = bytes.slice(0, MAX_READ_BYTES).toString('utf8');
}
return content;
}
function writeFile(args, { root, scope }) {
const file = args.path;
const abs = path.resolve(root, file);
if (!isAllowed(abs, scope)) throw new Error(`write denied: ${file}`);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, args.content ?? '');
return `wrote ${file}`;
}
function updateFile(args, { root, scope }) {
const file = args.path;
const abs = path.resolve(root, file);
if (!isAllowed(abs, scope)) throw new Error(`write denied: ${file}`);
const content = fs.readFileSync(abs, 'utf8');
const search = args.search;
const replace = args.replace ?? '';
let updated;
if (args.global) {
updated = content.split(search).join(replace);
} else {
const at = content.indexOf(search);
updated =
at < 0
? content
: content.slice(0, at) + replace + content.slice(at + search.length);
}
fs.writeFileSync(abs, updated);
return `updated ${file}`;
}
function listDir(args, { root, scope }) {
const dir = args.path || '.';
const abs = path.resolve(root, dir);
if (!isAllowed(abs, scope)) throw new Error(`read denied: ${dir}`);
return fs
.readdirSync(abs, { withFileTypes: true })
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
}
/* ------------------------------------------------------------------ *
* Default tools, keyed by name
* ------------------------------------------------------------------ */
const DEFAULT_TOOLS = {
'read-file': {
scope: 'read',
handler: readFile,
description: 'Read a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
lineStart: { type: 'number' },
lineEnd: { type: 'number' },
},
required: ['path'],
},
},
'write-file': {
scope: 'write',
handler: writeFile,
description: 'Write the entire content of a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string' },
},
required: ['path', 'content'],
},
},
'update-file': {
scope: 'write',
handler: updateFile,
description: 'Replace `search` with `replace` in a file.',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
search: { type: 'string' },
replace: { type: 'string' },
global: { type: 'boolean' },
},
required: ['path', 'search'],
},
},
'list-dir': {
scope: 'read',
handler: listDir,
description: 'List a directory. Directories are suffixed with "/".',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: [],
},
},
};
/**
* Resolve the `defaultTools` selection to a list of default tool names.
* `null` / `true` select every default; `false` / `[]` select none.
*/
function selectDefaults(selection) {
if (selection === true || selection === null || selection === undefined) {
return Object.keys(DEFAULT_TOOLS);
}
if (!Array.isArray(selection)) return [];
return selection.filter((name) => name in DEFAULT_TOOLS);
}
/**
* Assemble the tool bundle: selected defaults plus any custom `tools`.
* A custom tool whose name matches a default overrides it.
*/
function buildTools({ read, write, tools, defaultTools }) {
const bundle = [];
for (const name of selectDefaults(defaultTools)) {
const def = DEFAULT_TOOLS[name];
bundle.push({
name,
description: def.description,
parameters: def.parameters,
scope: def.scope === 'read' ? read : write,
handler: def.handler,
});
}
if (Array.isArray(tools)) {
for (const tool of tools) {
if (!tool || !tool.name) continue;
const idx = bundle.findIndex((t) => t.name === tool.name);
if (idx >= 0) bundle[idx] = tool;
else bundle.push(tool);
}
}
return bundle;
}
/**
* Build a name -> execution function for the bundle. Unknown tools and
* handler failures are reported as tool-result strings rather than thrown,
* so the loop can hand them back to the agent.
*/
function makeExecutor(bundle, root) {
const byName = new Map(bundle.map((tool) => [tool.name, tool]));
return function execute(name, args) {
const tool = byName.get(name);
if (!tool) return `Error: unknown tool '${name}'`;
if (typeof tool.handler !== 'function') {
return `Error: tool '${name}' has no handler`;
}
try {
return tool.handler(args || {}, { root, scope: tool.scope });
} catch (err) {
return `Error: ${err.message}`;
}
};
}
/* ------------------------------------------------------------------ *
* Open context
* ------------------------------------------------------------------ */
/**
* Read the `open` entries as prompt context. Files carry their content,
* directories carry a listing. Entries outside the read scope are skipped.
*/
function collectContext(open, root, read) {
if (open == null || open === false) return [];
const entries = open === true ? ['.'] : Array.isArray(open) ? open : [open];
const context = [];
for (const entry of entries) {
const abs = path.resolve(root, entry);
if (!isAllowed(abs, read)) continue;
let stat;
try {
stat = fs.statSync(abs);
} catch (err) {
continue;
}
if (stat.isDirectory()) {
const listing = fs
.readdirSync(abs, { withFileTypes: true })
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
context.push({ path: entry, type: 'dir', content: listing.join('\n') });
} else {
context.push({
path: entry,
type: 'file',
content: fs.readFileSync(abs, 'utf8'),
});
}
}
return context;
}
function formatContext(context) {
return context
.map((entry) =>
entry.type === 'dir'
? `Directory: ${entry.path}\n${entry.content}`
: `File: ${entry.path}\n${entry.content}`
)
.join('\n\n---\n\n');
}
/* ------------------------------------------------------------------ *
* Tool-call extraction
* ------------------------------------------------------------------ */
/**
* Normalize the tool calls returned by the prompt action. Accepts either
* a flat `{id, name, arguments}` shape or a nested `{id, function:{...}}`
* shape, and parses string `arguments` as JSON.
*/
function extractToolCalls(res) {
const raw = res && (res.toolCalls || res.tool_calls);
if (!Array.isArray(raw)) return [];
return raw.map((call) => {
const fn = call.function || call;
let args = call.arguments !== undefined ? call.arguments : fn.arguments;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (err) {
args = {};
}
}
return { id: call.id, name: fn.name, args: args || {} };
});
}
/* ------------------------------------------------------------------ *
* Run
* ------------------------------------------------------------------ */
/**
* The harness behind `Gral.run`. Sends a prompt to a target agent with a
* tool bundle restricted by the supplied arguments, then loops: while the
* agent returns tool calls, execute them locally and send the results
* back until a plain response is produced.
*
* @param {object} options
* @param {string} options.agentId Target agent for the prompt.
* @param {string} [options.instruction] Instruction for the agent.
* @param {string} [options.input] Main input for the agent.
* @param {object[]} [options.messages] Prior messages for the agent.
* @param {?boolean|string[]} [options.read] Readable files/dirs (null = all).
* @param {?boolean|string[]} [options.write] Writable files/dirs (null = all).
* @param {?boolean|string[]} [options.open] Paths opened as context.
* @param {object[]} [options.tools] Extra tools to pass the agent.
* @param {?boolean|string[]} [options.defaultTools] Default tool selection.
* @returns {Promise<any>} The agent's final response.
*/
async function run({
agentId,
instruction,
input,
messages,
read = null,
write = null,
open = null,
tools = null,
defaultTools = true,
} = {}) {
const Gral = global.Gral;
const root = Gral && Gral.path ? Gral.path : process.cwd();
if (!agentId) throw new Error('run: agentId is required');
if (!Gral || !Gral.horde) throw new Error('run: horde connection unavailable');
const readScope = resolveScope(read, root);
const writeScope = resolveScope(write, root);
const bundle = buildTools({
read: readScope,
write: writeScope,
tools,
defaultTools,
});
const specs = bundle.map(({ name, description, parameters }) => ({
type: 'function',
function: {
name,
description,
parameters,
}
}));
const execute = makeExecutor(bundle, root);
const context = collectContext(open, root, readScope);
const history = Array.isArray(messages) ? messages.slice() : [];
if (context.length) {
history.unshift({ role: 'system', content: formatContext(context) });
}
for (let step = 0; step < MAX_ITERATIONS; step++) {
const res = await Gral.horde.sendAction('send-agent-prompt', {
agentId,
instruction,
input,
options: {
messages: history,
tools: specs,
},
});
const calls = res.toolCalls || [];
if (calls.length === 0) return res.response;
if (res.response) {
console.log(res.response);
history.push({
role: 'assistant',
content: res.response,
});
}
for (const call of calls) {
call.result = execute(call.tool_name, call.arguments);
history.push({
role: 'tool',
content: call,
});
}
if (calls.length) {
console.log('called: ' + calls.map(c => c.tool_name));
}
}
throw new Error(`run: exceeded ${MAX_ITERATIONS} tool-call iterations`);
}
export default run;