import { randomUUID } from 'node:crypto' import { defineTool } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' import type { RecallQuery } from '../shared/api.ts' import { MAINTENANCE_OPS } from '../shared/api.ts' import type { DeployPlatformClient } from './deploy-client.ts' import type { Outbox } from './outbox.ts' import type { ServiceClient } from './service-client.ts' /** * will-owned custom tools (spec AC11..AC20): chat_send, recall, maintenance, * and the scoped deployment_rollback. ZAI tools are defined in zai/tools.ts. */ export interface ToolDeps { outbox: Outbox service: ServiceClient deploy?: DeployPlatformClient | undefined chatId?: number | undefined /** Kick outbound delivery soon (spool drain + outbox flush), debounced by main. */ onChatQueued?: (() => void) | undefined } function ok(text: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(text) }], details: {} } } function fail(message: string) { return { content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: message }) }], isError: true, details: {}, } } export function createWillTools(deps: ToolDeps) { const chatSend = defineTool({ name: 'chat_send', label: 'Send chat message', description: 'Send a text message (and optionally a stored media digest) to your Telegram group. ' + 'This is your voice in the friendship channel; write freely, no report format.', parameters: Type.Object({ text: Type.Optional(Type.String({ description: 'message text' })), mediaDigest: Type.Optional(Type.String({ description: 'media digest to attach' })), mediaType: Type.Optional(Type.String({ description: 'mime type of the media' })), replyToMessageId: Type.Optional( Type.Number({ description: 'telegram message id to reply to' }), ), }), async execute(_id, params) { if (deps.chatId === undefined) return fail('telegram chat not configured') if (params.text === undefined && params.mediaDigest === undefined) { return fail('nothing to send: text or mediaDigest required') } await deps.outbox.enqueue({ id: `out-${randomUUID()}`, chatId: deps.chatId, ...(params.text !== undefined ? { text: params.text } : {}), ...(params.mediaDigest !== undefined ? { mediaDigest: params.mediaDigest } : {}), ...(params.mediaType !== undefined ? { mediaType: params.mediaType } : {}), ...(params.replyToMessageId !== undefined ? { replyToMessageId: params.replyToMessageId } : {}), }) return ok({ queued: true }) }, }) const recall = defineTool({ name: 'recall', label: 'Recall history', description: 'Query your own memory: chat, audit, deployment, or media-analysis rows, ' + 'hot and archived alike. Read-only; use it before assuming anything about the past.', parameters: Type.Object({ kind: Type.Union([ Type.Literal('chat'), Type.Literal('audit'), Type.Literal('deploy'), Type.Literal('analysis'), ]), since: Type.Optional(Type.String({ description: 'iso timestamp lower bound' })), until: Type.Optional(Type.String({ description: 'iso timestamp upper bound' })), q: Type.Optional(Type.String({ description: 'substring filter (text or payload)' })), digest: Type.Optional(Type.String({ description: 'media digest for kind=analysis' })), limit: Type.Optional(Type.Number({ description: 'max rows, default 50' })), }), async execute(_id, params) { const query: RecallQuery = { kind: params.kind, ...(params.since !== undefined ? { since: params.since } : {}), ...(params.until !== undefined ? { until: params.until } : {}), ...(params.q !== undefined ? { q: params.q } : {}), ...(params.digest !== undefined ? { digest: params.digest } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}), } try { const res = await deps.service.recall(query) return ok({ ok: true, cold: res.cold, rows: res.rows }) } catch (err) { return fail(err instanceof Error ? err.message : String(err)) } }, }) const maintenanceStatus = defineTool({ name: 'maintenance_status', label: 'Maintenance status', description: 'Read service health: storage state and free space, table counts, db size, ' + 'schema version, and the last sqlite quick_check.', parameters: Type.Object({}), async execute() { try { const status = await deps.service.status() return ok({ ok: true, status }) } catch (err) { return fail(err instanceof Error ? err.message : String(err)) } }, }) const maintenanceRun = defineTool({ name: 'maintenance_run', label: 'Run maintenance', description: 'Run one explicit maintenance operation on the service: quick_check, ' + 'archive_cycle, vacuum, or reconcile. Structural repairs are not a tool; ' + 'they ship as tested migrations.', parameters: Type.Object({ op: Type.Union([ Type.Literal('quick_check'), Type.Literal('archive_cycle'), Type.Literal('vacuum'), Type.Literal('reconcile'), ]), }), async execute(_id, params) { if (!(MAINTENANCE_OPS as readonly string[]).includes(params.op)) { return fail(`unknown op ${params.op}`) } try { const result = await deps.service.maintenance(params.op) return ok({ ok: true, result }) } catch (err) { return fail(err instanceof Error ? err.message : String(err)) } }, }) const deploymentRollback = defineTool({ name: 'deployment_rollback', label: 'Roll back deployment', description: 'Restore the immediately previous promoted deployment revision after a semantic ' + 'failure you diagnosed. One shot; a reconciliation barrier then requires your ' + 'revert commit naming the failed revision.', parameters: Type.Object({ reason: Type.String({ description: 'why this revision is bad' }) }), async execute(_id, _params) { if (!deps.deploy) { return fail('deployment platform not configured (WILL_DEPLOY_API_URL); cannot roll back') } const res = await deps.deploy.rollbackPrevious() if (!res.ok) return fail(res.error ?? 'rollback failed') return ok({ ok: true, ...(res.revision !== undefined ? { restored: res.revision } : {}) }) }, }) return [chatSend, recall, maintenanceStatus, maintenanceRun, deploymentRollback] }