Files
memind_adm/server/skills/executors/goosed.mjs
T

198 lines
6.2 KiB
JavaScript

/**
* GoosedExecutor — runs a skill via a goosed session.
*
* Session lifecycle:
* 1. POST /agent/start { working_dir }
* 2. POST /sessions/:id/reply { request_id: UUID, user_message: {...} }
* 3. GET /sessions/:id/events (SSE) → collect until Finish event or timeout
* 4. DELETE /sessions/:id (best-effort cleanup)
*
* Dual-node: pickGoosedUrl() round-robins across configured nodes.
* Protocol: HTTPS with self-signed cert (rejectUnauthorized: false).
*/
import https from 'node:https';
import crypto from 'node:crypto';
import { goosedFetch, pickGoosedUrl } from '../goosed-client.mjs';
const SESSION_TIMEOUT_MS = Number(process.env.SKILL_GOOSED_TIMEOUT_MS ?? 120_000);
const tlsAgent = new https.Agent({ rejectUnauthorized: false });
export async function invokeGoosedSkill(skill, input) {
const node = pickGoosedUrl();
const prompt = buildPrompt(skill, input);
// 1. Start session (no messages yet)
const startRes = await goosedFetch('/agent/start', {
method: 'POST',
node,
body: {
working_dir: process.env.GOOSE_SKILL_WORKING_DIR ?? '/tmp',
},
});
if (!startRes.ok) {
throw Object.assign(
new Error(`goosed /agent/start failed (${startRes.status})`),
{ status: startRes.status, upstream: startRes.body },
);
}
const sessionId = startRes.body?.id ?? startRes.body?.session_id;
if (!sessionId) {
throw new Error(`goosed did not return a session id: ${JSON.stringify(startRes.body)}`);
}
// 1b. Set the LLM provider on the session
const goosedProvider = process.env.GOOSE_PROVIDER ?? process.env.GOOSED_PROVIDER ?? 'custom_deepseek';
const goosedModel = process.env.GOOSE_MODEL ?? process.env.GOOSED_MODEL;
const providerBody = { session_id: sessionId, provider: goosedProvider };
if (goosedModel) providerBody.model = goosedModel;
await goosedFetch('/agent/update_provider', { method: 'POST', node, body: providerBody });
// 2. Send the prompt via reply
const requestId = crypto.randomUUID();
const replyRes = await goosedFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
node,
body: {
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
metadata: { userVisible: true, agentVisible: true },
content: [{ type: 'text', text: prompt }],
},
},
});
if (!replyRes.ok) {
throw Object.assign(
new Error(`goosed /sessions/${sessionId}/reply failed (${replyRes.status})`),
{ status: replyRes.status, upstream: replyRes.body },
);
}
// 3. Stream SSE events and collect result
try {
return await collectSessionResult(node, sessionId);
} finally {
goosedFetch(`/sessions/${sessionId}`, { method: 'DELETE', node }).catch(() => {});
}
}
function buildPrompt(skill, input) {
if (input.design_md_content) {
return `You are a UI generation assistant. Your output must be a complete, self-contained HTML page — not a component, not a snippet, not a code block with backticks. Output raw HTML only, starting with <!DOCTYPE html>.
Apply the following design system exactly. Match the colors, typography, spacing, border styles, shadows, and interaction patterns described:
${input.design_md_content}
Task: ${input.task ?? input.prompt ?? ''}
Requirements:
- Output a single complete HTML file with <html>, <head>, <body> and all inline <style> — no external stylesheets or JS frameworks
- The page should feel like a real product page from this brand, not a generic template
- Use the exact color values, font sizes, border-radius, shadow formulas from the design system above
- Include realistic placeholder content relevant to the task
- The page must be visually complete and ready to render in a browser`;
}
return input.task ?? input.prompt ?? JSON.stringify(input);
}
function collectSessionResult(node, sessionId) {
const secret =
process.env.GOOSE_SERVER__SECRET_KEY ??
process.env.TKMIND_SERVER__SECRET_KEY ??
'local-dev-secret';
return new Promise((resolve, reject) => {
const url = new URL(`${node}/sessions/${sessionId}/events`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: { 'X-Secret-Key': secret, Accept: 'text/event-stream' },
agent: tlsAgent,
};
const req = https.request(options, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`SSE stream returned ${res.statusCode}`));
}
let buffer = '';
let collectedText = '';
const timer = setTimeout(() => {
req.destroy();
resolve({ content: collectedText, truncated: true });
}, SESSION_TIMEOUT_MS);
res.setEncoding('utf8');
res.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') continue;
let event;
try { event = JSON.parse(raw); } catch { continue; }
const text = extractText(event);
if (text) collectedText += text;
if (isFinishEvent(event)) {
clearTimeout(timer);
req.destroy();
resolve({ content: collectedText });
return;
}
}
});
res.on('end', () => {
clearTimeout(timer);
resolve({ content: collectedText });
});
res.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
req.on('error', reject);
req.end();
});
}
function extractText(event) {
if (event?.type === 'Message' && event?.message?.role === 'assistant') {
return (event.message.content ?? [])
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('');
}
if (event?.delta?.text) return event.delta.text;
if (typeof event?.content === 'string') return event.content;
return '';
}
function isFinishEvent(event) {
return (
event?.type === 'Finish' ||
event?.type === 'finish' ||
event?.status === 'done' ||
event?.done === true
);
}