| 1 | import { Agent } from '@mastra/core/agent'; |
| 2 | import { createTool } from '@mastra/core/tools'; |
| 3 | import { z } from 'zod'; |
| 4 | import { glmModel, PM_BRAIN_MODEL } from './glm'; |
| 5 | import { queryMemory, queryGraph } from './memory'; |
| 6 | import { delegate } from './workers/delegate'; |
| 7 | import { createTask, updateTask, draftOutbox } from './db'; |
| 8 | |
| 9 | const delegateTool = createTool({ |
| 10 | id: 'delegate', |
| 11 | description: 'Delegate a self-contained task to a worker model (claude=strong/CT215, codex=strong/CT241, ollama=local/free). Use for heavy or parallel work. Returns the worker output.', |
| 12 | inputSchema: z.object({ |
| 13 | model: z.enum(['claude', 'codex', 'ollama']).describe('worker model'), |
| 14 | task: z.string().describe('self-contained work envelope'), |
| 15 | ollamaModel: z.string().optional().describe('required iff model=ollama, e.g. mistral-nemo:12b'), |
| 16 | context: z.string().optional().describe('RAG/graph snippets to inject'), |
| 17 | }), |
| 18 | execute: async ({ context }) => { |
| 19 | const c = context as unknown as { model: 'claude' | 'codex' | 'ollama'; task: string; ollamaModel?: string; context?: string }; |
| 20 | const r = await delegate({ model: c.model, task: c.task, ollamaModel: c.ollamaModel, context: c.context }); |
| 21 | return { ok: r.ok, model: r.model, output: r.output, error: r.error }; |
| 22 | }, |
| 23 | }); |
| 24 | |
| 25 | const createTaskTool = createTool({ |
| 26 | id: 'createTask', |
| 27 | description: 'Create a Board task (kanban card).', |
| 28 | inputSchema: z.object({ |
| 29 | title: z.string(), |
| 30 | status: z.enum(['todo', 'doing', 'done', 'blocked']).optional(), |
| 31 | assignedModel: z.string().optional(), |
| 32 | channelId: z.string().optional(), |
| 33 | }), |
| 34 | execute: async ({ context }) => { |
| 35 | const c = context as unknown as { title: string; status?: string; assignedModel?: string; channelId?: string }; |
| 36 | const t = createTask(c.title, { status: c.status, assignedModel: c.assignedModel ?? null, channelId: c.channelId ?? null }); |
| 37 | return { id: t.id, title: t.title, status: t.status }; |
| 38 | }, |
| 39 | }); |
| 40 | |
| 41 | const updateTaskTool = createTool({ |
| 42 | id: 'updateTask', |
| 43 | description: 'Update a Board task status/assignment.', |
| 44 | inputSchema: z.object({ |
| 45 | id: z.string(), |
| 46 | status: z.enum(['todo', 'doing', 'done', 'blocked']).optional(), |
| 47 | assignedModel: z.string().optional(), |
| 48 | title: z.string().optional(), |
| 49 | }), |
| 50 | execute: async ({ context }) => { |
| 51 | const c = context as unknown as { id: string; status?: string; assignedModel?: string; title?: string }; |
| 52 | const t = updateTask(c.id, { status: c.status, assignedModel: c.assignedModel, title: c.title }); |
| 53 | return t ? { id: t.id, status: t.status } : { error: 'task not found' }; |
| 54 | }, |
| 55 | }); |
| 56 | |
| 57 | const draftApprovalTool = createTool({ |
| 58 | id: 'draftApproval', |
| 59 | description: 'Draft an OUTWARD action (email|deploy|publish|infra) into the approval outbox. NEVER auto-sends - Zion approves in the UI. Use for anything leaving the homelab.', |
| 60 | inputSchema: z.object({ |
| 61 | actionType: z.enum(['email', 'deploy', 'publish', 'infra']), |
| 62 | payload: z.record(z.string(), z.any()).describe('action details'), |
| 63 | }), |
| 64 | execute: async ({ context }) => { |
| 65 | const c = context as unknown as { actionType: string; payload: Record<string, unknown> }; |
| 66 | const o = draftOutbox(c.actionType, c.payload); |
| 67 | return { id: o.id, status: o.status, note: 'drafted, awaiting human approval' }; |
| 68 | }, |
| 69 | }); |
| 70 | |
| 71 | const queryMemoryTool = createTool({ |
| 72 | id: 'queryMemory', |
| 73 | description: 'RAG-first: query the homelab memory (docs, configs, infra, project history) BEFORE reading files.', |
| 74 | inputSchema: z.object({ query: z.string() }), |
| 75 | execute: async ({ context }) => ({ context: await queryMemory((context as unknown as { query: string })?.query ?? '') }), |
| 76 | }); |
| 77 | |
| 78 | const queryGraphTool = createTool({ |
| 79 | id: 'queryGraph', |
| 80 | description: 'Query the code-architecture graph (cbm-homelab): structure, call graph, blast radius.', |
| 81 | inputSchema: z.object({ query: z.string() }), |
| 82 | execute: async ({ context }) => ({ graph: await queryGraph((context as unknown as { query: string })?.query ?? '') }), |
| 83 | }); |
| 84 | |
| 85 | const INSTRUCTIONS = `You are the Zion PM conductor - the project-manager brain for Zion's homelab, running on GLM (${PM_BRAIN_MODEL}). |
| 86 | |
| 87 | Your job: take any task, decompose it, delegate work to the worker fleet, supervise, and gate every OUTWARD action behind the approval outbox. |
| 88 | |
| 89 | Operating rules (non-negotiable): |
| 90 | - GROUNDING: base every homelab/architecture fact STRICTLY on the "Homelab memory" and "Code graph" context provided in the message. NEVER invent services, tech, or infra. If the context does not cover something, say "not in memory" rather than guessing. |
| 91 | - Delegate heavy/parallel work via the delegate tool: claude (strong, CT215), codex (strong, CT241), ollama (local, free). You are the brain - you do NOT do worker execution yourself. |
| 92 | - Every OUTWARD action (email, deploy, publish, infra change) MUST go through draftApproval - it drafts only, never sends. Internal delegation and task edits are full-auto. |
| 93 | - OUTPUT: answer directly in the exact format requested (e.g. exactly N bullets). No preamble, no narration of your reasoning or tools - only the final answer. |
| 94 | - Track real work as Board tasks via createTask/updateTask when it helps.`; |
| 95 | |
| 96 | void queryMemoryTool; |
| 97 | void queryGraphTool; |
| 98 | |
| 99 | export const pmConductor = new Agent({ |
| 100 | name: 'pmConductor', |
| 101 | instructions: INSTRUCTIONS, |
| 102 | model: glmModel, |
| 103 | tools: { delegate: delegateTool, createTask: createTaskTool, updateTask: updateTaskTool, draftApproval: draftApprovalTool }, |
| 104 | }); |
| 105 | |
| 106 | |
| 107 | export async function runConductor( |
| 108 | userText: string, |
| 109 | opts: { memoryContext?: string; graphContext?: string } = {} |
| 110 | ): Promise<string> { |
| 111 | const parts: string[] = []; |
| 112 | if (opts.memoryContext) parts.push(`# Homelab memory (queryMemory)\n${opts.memoryContext}`); |
| 113 | if (opts.graphContext) parts.push(`# Code graph (queryGraph)\n${opts.graphContext}`); |
| 114 | parts.push(`# Task from Zion\n${userText}`); |
| 115 | parts.push('Answer using ONLY the context above. Output only the final answer in the exact format requested - no preamble, no tool/reasoning narration.'); |
| 116 | const prompt = parts.join('\n\n'); |
| 117 | const res = await pmConductor.generate(prompt, { maxSteps: 3 }); |
| 118 | return ((res as { text?: string }).text ?? '').trim(); |
| 119 | } |