Luigit
repositories / will

will

owned by admin

src/agent/tools.ts

Raw
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]
}