// Zendy tools — LLM-callable wrappers over direct API clients. // Loaded by jiti as part of the zendy extension. import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import * as zendesk from "../dist/clients/zendesk.js"; import * as helm from "../dist/clients/helm-watchdog.js"; import * as kg from "../dist/clients/zendesk-kg.js"; import { getConfig } from "../dist/config/store.js"; // IMPORTANT: only `content` is sent to the model; `details` is UI/extension // metadata the model never sees (verified empirically against pi 0.79). Any // data the model must reason about has to be serialized into the text. function textResult(text: string, details: Record = {}) { return { content: [{ type: "text" as const, text }], details }; } function fmtComment(c: zendesk.SlimComment, users: Map): string { const who = users.get(c.author_id) ?? `user:${c.author_id}`; const vis = c.public ? "public" : "internal"; return `--- comment by ${who} (${vis}, ${c.created_at}) ---\n${c.plain_body ?? c.body ?? ""}`; } function fmtTicket(r: zendesk.TicketResult): string { const t = r.ticket; const users = new Map(); if (r.requester) users.set(r.requester.id, `${r.requester.name} <${r.requester.email}> (requester)`); if (r.assignee) users.set(r.assignee.id, `${r.assignee.name} <${r.assignee.email}> (assignee)`); const customFields = (t.custom_fields ?? []).filter((f) => f.value !== null && f.value !== ""); const lines = [ `# Ticket #${t.id}: ${t.subject}`, `status: ${t.status} | priority: ${t.priority ?? "-"} | created: ${t.created_at} | updated: ${t.updated_at}`, `requester: ${r.requester ? `${r.requester.name} <${r.requester.email}>` : t.requester_id}`, `assignee: ${r.assignee ? `${r.assignee.name} <${r.assignee.email}>` : t.assignee_id ?? "-"}`, `tags: ${(t.tags ?? []).join(", ") || "-"}`, customFields.length ? `custom_fields: ${JSON.stringify(customFields)}` : "", "", `## Description`, t.description ?? "", ].filter(Boolean); if (r.comments?.length) { lines.push("", `## Comments (${r.comments.length})`); for (const c of r.comments) lines.push(fmtComment(c, users)); } return lines.join("\n"); } function registerZendeskTools(pi: ExtensionAPI): void { pi.registerTool({ name: "zendy_ticket_get", label: "Zendy Ticket", description: "Fetch a Zendesk ticket with comments and user info via direct API. Prefer this over any CLI command for ticket analysis.", promptSnippet: "Fetch Zendesk ticket, comments, and user details with zendy_ticket_get.", promptGuidelines: [ "Use zendy_ticket_get when the user provides a Zendesk ticket ID. It returns ticket metadata, comments, requester, and assignee.", "Base conclusions on the returned ticket and comments; do not rely on external commands.", ], parameters: Type.Object({ ticketId: Type.Number({ description: "Zendesk ticket ID" }), }), async execute(_toolCallId: string, params: { ticketId: number }, signal?: AbortSignal) { const result = await zendesk.getTicketFull(params.ticketId, signal); return textResult(fmtTicket(result), { ticket: result.ticket, comments: result.comments, requester: result.requester, assignee: result.assignee, }); }, }); pi.registerTool({ name: "zendy_ticket_search", label: "Zendy Search", description: "Search Zendesk tickets via direct API. For fresh/live Zendesk lookups, not historical semantic retrieval.", promptSnippet: "Search live Zendesk tickets with zendy_ticket_search.", parameters: Type.Object({ query: Type.String({ description: "Zendesk search query string" }), }), async execute(_toolCallId: string, params: { query: string }, signal?: AbortSignal) { const result = await zendesk.searchTickets(params.query, signal); const lines = [`Zendesk search returned ${result.count} results for "${params.query}":`]; for (const r of result.results) { const id = r.id ?? '?'; const subject = r.subject ?? '(no subject)'; const status = r.status ?? '?'; const created = r.created_at ? String(r.created_at).slice(0, 10) : ''; lines.push(` #${id} [${status}] ${subject}${created ? ` (${created})` : ''}`); } return textResult(lines.join('\n'), { results: result.results, count: result.count }); }, }); pi.registerTool({ name: "zendy_whoami", label: "Zendy Whoami", description: "Check the currently authenticated Zendesk user identity. Use to verify credentials and confirm which account the tool is acting as.", promptSnippet: "Check current Zendesk identity with zendy_whoami.", parameters: Type.Object({}), async execute(_toolCallId: string, _params: Record, signal?: AbortSignal) { const { user } = await zendesk.getMe(signal); return textResult(`Authenticated as ${user.name} (${user.email}), role: ${user.role}.`, { user }); }, }); } function registerHelmTools(pi: ExtensionAPI): void { pi.registerTool({ name: "zendy_helm_get", label: "Zendy Helm", description: "Query Dify Helm Watchdog API for chart metadata, values.yaml, images, and validation. Always provide the exact chart version.", promptSnippet: "Query Helm chart data with zendy_helm_get.", promptGuidelines: [ "Use zendy_helm_get to query Dify Helm chart metadata. Always pass the customer's exact version.", "Defaults change between versions — never assume a config value without checking the right version.", ], parameters: Type.Object({ resource: Type.Union([ Type.Literal("version"), Type.Literal("values"), Type.Literal("images"), Type.Literal("validation"), Type.Literal("latest"), Type.Literal("versions"), Type.Literal("cache"), ], { description: "Resource to fetch" }), version: Type.Optional(Type.String({ description: "Chart version. Required for: version, values, images, validation" })), validateImages: Type.Optional(Type.Boolean({ description: "For images: include pull-status validation", default: false })), status: Type.Optional(Type.String({ description: "For validation: filter by MISSING etc." })), versionOnly: Type.Optional(Type.Boolean({ description: "For latest: return plain version string only", default: false })), }), async execute(_toolCallId: string, params: { resource: string; version?: string; validateImages?: boolean; status?: string; versionOnly?: boolean; }, signal?: AbortSignal) { const needsVersion = ["version", "values", "images", "validation"].includes(params.resource); if (needsVersion && !params.version) { throw new Error(`version is required when resource=${params.resource}`); } switch (params.resource) { case "version": { const data = await helm.getVersion(params.version!, signal); return textResult( `Helm Watchdog version metadata for ${params.version}:\n${JSON.stringify(data, null, 2)}`, { version: params.version, data }, ); } case "values": { const data = await helm.getValues(params.version!, signal); return textResult( `values.yaml for chart ${params.version}:\n\n${data}`, { version: params.version, data }, ); } case "images": { const data = await helm.getImages(params.version!, params.validateImages, signal); return textResult( `Images for chart ${params.version} (${data.length}):\n${JSON.stringify(data, null, 2)}`, { version: params.version, images: data }, ); } case "validation": { const data = await helm.getValidation(params.version!, params.status, signal); return textResult( `Validation for chart ${params.version}:\n${JSON.stringify(data, null, 2)}`, { version: params.version, results: data }, ); } case "latest": { const data = await helm.getLatest(params.versionOnly, signal); return textResult( `Latest chart version: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}`, { data }, ); } case "versions": { const data = await helm.listVersions(signal); return textResult( `Cached chart versions (${data.length}):\n${JSON.stringify(data, null, 2)}`, { versions: data }, ); } case "cache": { const data = await helm.getCache(signal); return textResult( `Cache metadata:\n${JSON.stringify(data, null, 2)}`, { data }, ); } } }, }); } function registerKnowledgeGraphTools(pi: ExtensionAPI): void { pi.registerTool({ name: "zendy_kg_search", label: "Zendy KG", description: "Search the Zendesk Knowledge Graph for historically similar tickets via direct API. Always cite ticketId values from results.", promptSnippet: "Search historical similar tickets with zendy_kg_search.", promptGuidelines: [ "Use zendy_kg_search for historical similar-ticket retrieval. Cite ticketId values when summarizing.", "Empty results do not prove no similar issue exists — the KG is a snapshot, not live Zendesk.", ], parameters: Type.Object({ query: Type.String({ description: "Natural-language description of the issue" }), limit: Type.Optional(Type.Number({ description: "Max results 1-20", default: 5 })), version: Type.Optional(Type.String({ description: "Filter by Dify version mentioned in tickets" })), priority: Type.Optional(Type.String({ description: "Filter: low, normal, high, urgent" })), status: Type.Optional(Type.String({ description: "Filter by ticket status: open, closed, pending, etc." })), }), async execute(_toolCallId: string, params: { query: string; limit?: number; version?: string; priority?: string; status?: string; }, signal?: AbortSignal) { const limit = Math.min(Math.max(Math.trunc(params.limit ?? 5), 1), 20); const result = await kg.search({ query: params.query, topK: limit, filter: { ...(params.version ? { versions: [params.version] } : {}), ...(params.priority ? { priority: params.priority } : {}), ...(params.status ? { status: params.status } : {}), }, }, signal); const blocks = result.results.map((r, i) => [ `[${i + 1}] ticketId: ${r.ticketId} — ${r.subject}`, ` status: ${r.status} | priority: ${r.priority} | created: ${r.createdAt} | versions: ${(r.versions ?? []).join(", ") || "-"} | rrfScore: ${r.rrfScore}`, r.quickSummary ? ` quick: ${r.quickSummary}` : "", r.issueSummary ? ` issue: ${r.issueSummary}` : "", r.solutionSummary ? ` solution: ${r.solutionSummary}` : "", ].filter(Boolean).join("\n")); return textResult( `KG search returned ${result.results.length} results for "${result.queryText}". Cite ticketId values when summarizing.\n\n${blocks.join("\n\n")}`, { results: result.results, queryText: result.queryText }, ); }, }); } function registerSourceTools(pi: ExtensionAPI): void { pi.registerTool({ name: "zendy_source_status", label: "Zendy Source Status", description: "Report the zendy source-analysis workspace path, bundled source repositories, and source-cloning authorization rules.", promptSnippet: "Check source workspace and repo registry with zendy_source_status.", parameters: Type.Object({}), async execute() { const workspace = process.env["ZENDY_SRC_DIR"] ?? null; const repos = getConfig().sourceRepos ?? {}; const repoLines = Object.entries(repos).map(([name, repo]) => { const visibility = repo.visibility ?? "public"; const access = visibility === "private" ? "SSH access required" : "public"; return `- ${name}: ${repo.url} (${access})${repo.description ? ` — ${repo.description}` : ""}`; }); const note = "Source cloning/search requires explicit user permission per zendy workflow rules. Private Enterprise repos are SSH-gated; clone failures usually mean the user lacks GitHub access."; return textResult( [ `Source workspace: ${workspace ?? "(not active — created on session start)"}`, "Configured source repositories:", repoLines.length ? repoLines.join("\n") : "- (none configured)", note, ].join("\n"), { workspace, repos, note }, ); }, }); } export function registerAllTools(pi: ExtensionAPI): void { registerZendeskTools(pi); registerHelmTools(pi); registerKnowledgeGraphTools(pi); registerSourceTools(pi); }