Files
Void-Homelab/lib/api/routes/companion.js
root 8ce97bbacc feat(companion): Dross persona (Cradle) + migration 008 rename; remove dead API-key path
- system prompt = Dross (Ozriel's construct fragment, per Void 1.0), with tool guidance
- migration 008 renames the seeded agent 'companion' → display name 'Dross'
- removed lib/ai/anthropic.js + lib/ai/agent/runtime.js + tests + @anthropic-ai/sdk dep (companion now runs via the claude CLI; kept lib/ai/secret.js for the Vaultwarden roadmap)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:58:43 +10:00

210 lines
8.6 KiB
JavaScript

import { Router } from 'express';
import { z } from 'zod';
import { fileURLToPath } from 'url';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { validate } from '../validate.js';
import { asyncWrap } from '../errors.js';
import * as conversations from '../../db/repos/conversations.js';
import * as messages from '../../db/repos/messages.js';
import * as agents from '../../db/repos/agents.js';
import { runClaudeTurn } from '../../ai/claude_cli.js';
const COMPANION_SLUG = 'companion';
const SYSTEM = `You are Dross — a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, "The Void."
You are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness — while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information — don't over-explain basics, don't hedge. Be concise.
You have tools, and you use them rather than guessing:
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`;
/** Absolute path to the companion MCP stdio server. */
const COMPANION_STDIO_PATH = fileURLToPath(
new URL('../../mcp/companion-stdio.js', import.meta.url)
);
async function resolveConversation(space_id) {
const agent = await agents.getBySlug(COMPANION_SLUG);
const convo = await conversations.findOrCreateForSpace(space_id, agent.id, { kind: 'user', id: null });
return { agent, convo };
}
export const spacesScopedRouter = Router({ mergeParams: true });
spacesScopedRouter.get('/', asyncWrap(async (req, res) => {
const { agent, convo } = await resolveConversation(req.params.space_id);
const rows = await messages.listByConversation(convo.id);
res.json({
conversation_id: convo.id,
agent: { id: agent.id, slug: agent.slug, name: agent.name },
messages: rows
});
}));
const turnSchema = z.object({
text: z.string().min(1),
// nullish: the rail sends `view: null` when not on a specific entity.
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
});
spacesScopedRouter.post('/turn',
validate({ body: turnSchema }),
asyncWrap(async (req, res) => {
const { agent, convo } = await resolveConversation(req.params.space_id);
const { text, view } = req.body;
// Resume the claude session if this conversation already had turns (the CLI
// keys session history by --session-id; first turn creates it, rest --resume).
const priorTurns = (await messages.listByConversation(convo.id)).length;
const resume = priorTurns > 0;
await messages.append(convo.id, { role: 'user', body: text });
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
// Write a per-turn MCP config temp file declaring the companion stdio server.
// The stdio child is spawned by `claude`; pass the DB/Ollama connection env
// explicitly so the tools work regardless of how claude propagates env to
// MCP children (don't rely on inheritance or cwd-based dotenv).
const mcpConfigPath = join(tmpdir(), `void-mcp-${randomUUID()}.json`);
const agentActor = {
kind: 'agent',
id: agent.id,
capabilities: agent.capabilities,
scopes: agent.scopes
};
const mcpConfig = {
mcpServers: {
void: {
// Absolute node path: claude resolves `command` against the MCP child's
// env (which has no PATH), so a bare 'node' fails to spawn ("status:failed").
command: process.execPath,
args: [COMPANION_STDIO_PATH],
env: {
VOID_SPACE_ID: req.params.space_id,
VOID_AGENT_JSON: JSON.stringify(agentActor),
VOID_VIEW_JSON: view ? JSON.stringify(view) : '',
DATABASE_URL: process.env.DATABASE_URL || '',
OLLAMA_URL: process.env.OLLAMA_URL || ''
}
}
}
};
await writeFile(mcpConfigPath, JSON.stringify(mcpConfig));
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
const draftIds = [];
const companionTools = [
'mcp__void__search',
'mcp__void__read',
'mcp__void__context',
'mcp__void__propose_change'
];
let result;
try {
result = await runClaudeTurn({
sessionId: convo.id,
resume,
systemPrompt: SYSTEM,
userText: text,
mcpConfigPath,
// `tools` restricts the session to ONLY our tools (no built-in Bash/Read/…);
// `allowedTools` auto-approves them in non-interactive (--print) mode.
tools: companionTools,
allowedTools: companionTools,
claudeExe,
home: process.env.VOID_CLAUDE_HOME || undefined,
onEvent: (e) => {
if (e.type === 'delta') {
send('delta', { type: 'delta', text: e.text });
} else if (e.type === 'tool') {
send('tool', { type: 'tool', tool: e.tool, status: e.status });
} else if (e.type === 'tool_result') {
// Extract pending_change_id from the MCP tool result.
//
// companion-stdio.js returns:
// { content: [{ type:'text', text: JSON.stringify(result) }], structuredContent: result }
//
// claude_cli.js surfaces this as:
// { type:'tool_result', name, result: raw.content }
// where result = the content array: [{ type:'text', text:'...' }]
//
// Defensive parsing: try structuredContent first (future-proof), then
// scan content array text blocks and JSON.parse them.
// The CLI delivers an MCP tool_result `content` as a JSON STRING,
// e.g. '{"pending_change_id":"...","applied":false,"summary":"..."}'.
// Be defensive: also accept a content-block array or a structuredContent object.
let parsed = null;
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
try {
if (typeof e.result === 'string') {
parsed = tryParse(e.result);
} else if (e.result?.structuredContent?.pending_change_id) {
parsed = e.result.structuredContent;
} else if (Array.isArray(e.result)) {
for (const block of e.result) {
const candidate = block?.type === 'text' && block.text ? tryParse(block.text) : null;
if (candidate?.pending_change_id) { parsed = candidate; break; }
}
}
} catch {
// parsing failed — no draft to surface
}
if (parsed?.pending_change_id) {
draftIds.push(parsed.pending_change_id);
send('draft', {
type: 'draft',
pending_change_id: parsed.pending_change_id,
summary: parsed.summary || 'a change'
});
}
} else if (e.type === 'error') {
send('error', { type: 'error', message: e.message });
}
// 'result' events are captured via the resolved return value; no SSE needed mid-stream.
}
});
} catch (e) {
send('error', { message: String(e?.message || e) });
res.end();
// Clean up temp file even on error
unlink(mcpConfigPath).catch(() => {});
return;
}
// Clean up the temp MCP config file
unlink(mcpConfigPath).catch(() => {});
const assistant = await messages.append(convo.id, {
role: 'assistant',
body: result.text,
agent_id: agent.id,
metadata: {
tool_trace: result.toolTrace,
draft_ids: draftIds,
usage: result.usage
}
});
send('done', {
assistant_message_id: assistant.id,
draft_ids: draftIds,
usage: result.usage
});
res.end();
})
);