diff --git a/.env.example b/.env.example index 12fac543c..e05ec0b3b 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,41 @@ GOOGLE_CLIENT_SECRET="" # schedule. # AGENT_BRIDGE_SECRET="" +# Optional XMPP agent gateway. Set XMPP_COMPONENT_ENABLED to 1 and provide the +# component identity, component secret, and owning organization together. The +# gateway exposes only operations from apps/agent/src/exports. Task state is +# retained in PostgreSQL for 24 hours after admission. +# XMPP_COMPONENT_ENABLED="1" +# XMPP_COMPONENT_JID="gateway.agents.example.com" +# XMPP_COMPONENT_SECRET="" +# XMPP_ORGANIZATION_ID="" +# XMPP_COMPONENT_SERVICE="xmpp://127.0.0.1:5275" +# XMPP_DEFAULT_AGENT_JID="assistant@agents.example.com" +# XMPP_AGENT_DOMAIN="agents.example.com" +# XMPP_SERVER_DOMAIN="example.com" +# XMPP_GATEWAY_ID="gw-1" +# XMPP_AGENT_VERSION="1.0.0" + +# Comma-separated domains that can discover and invoke the endpoint. The +# server and agent domains are used when this value is absent. +# XMPP_ALLOWED_CALLER_DOMAINS="example.com,agents.example.com" + +# Comma-separated bare JIDs that can invoke destructive exports. Destructive +# exports remain visible but return forbidden when this value is absent. +# XMPP_ALLOW_DESTRUCTIVE_CALLERS="trusted-agent@agents.example.com" + +# Optional transport tuning. The copied gateway supplies safe defaults. +# XMPP_XML_LANG="en" +# XMPP_RECEIPT_TIMEOUT_MS="30000" +# XMPP_RECEIPT_MAX_RESENDS="0" +# XMPP_RECEIPT_SWEEP_MS="10000" +# XMPP_RECONNECT_INITIAL_MS="1000" +# XMPP_RECONNECT_MAX_MS="60000" +# XMPP_PING_INTERVAL_MS="60000" +# XMPP_PING_TIMEOUT_MS="10000" +# XMPP_PING_FAILURE_THRESHOLD="2" +# XMPP_MAX_PENDING_IQ_REQUESTS="256" + # PORT="3001" diff --git a/.gitignore b/.gitignore index 853852992..404b72c60 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ yarn-error.log* # Agent scratch files .scratch/ +.serena/ +.codegraph/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 8a779c4eb..138887c13 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,6 +14,7 @@ ".agents/**", ".claude/**", "apps/api/src/generated/**", + "packages/agent-xmpp/**", "packages/db/src/generated/**", "packages/ui/src/components/**", "tools/oxlint/anti-slop/**" diff --git a/apps/agent/agent/channels/xmpp.ts b/apps/agent/agent/channels/xmpp.ts new file mode 100644 index 000000000..637b09692 --- /dev/null +++ b/apps/agent/agent/channels/xmpp.ts @@ -0,0 +1,101 @@ +import { defineChannel, GET, POST } from "eve/channels"; +import { + ExportToolValidationError, + normalizeExportToolError, +} from "../../src/export-tools/errors"; +import { createEveExportSend } from "../../src/export-tools/eve-adapter"; +import { executeExportTool } from "../../src/export-tools/executor"; +import { exportToolManifest } from "../../src/export-tools/manifest"; +import { schemaIssues } from "../../src/export-tools/schema"; +import { + type ExportStreamEvent, + exportInvocationRequestSchema, + exportStreamEventSchema, +} from "../../src/export-tools/wire"; + +function authorized(request: Request): boolean { + const secret = process.env.AGENT_BRIDGE_SECRET; + return ( + Boolean(secret) && + request.headers.get("authorization") === `Bearer ${secret}` + ); +} + +function denied(): Response { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +export default defineChannel({ + routes: [ + GET("/internal/xmpp/export-tools/manifest", async (request) => { + if (!authorized(request)) return denied(); + return Response.json({ tools: exportToolManifest() }); + }), + POST("/internal/xmpp/export-tools/invoke", async (request, { send }) => { + if (!authorized(request)) return denied(); + const parsed = exportInvocationRequestSchema.safeParse( + await request.json(), + ); + if (!parsed.success) { + return Response.json( + { error: "Invalid invocation", issues: parsed.error.issues }, + { status: 400 }, + ); + } + const invocation = { + requestId: parsed.data.requestId, + operation: parsed.data.operation, + caller: parsed.data.caller, + }; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let sessionId: string | undefined; + const write = (value: ExportStreamEvent) => { + controller.enqueue( + encoder.encode( + `${JSON.stringify(exportStreamEventSchema.parse(value))}\n`, + ), + ); + }; + const eveSend = createEveExportSend(send, invocation, request.signal); + void executeExportTool(parsed.data.operation, parsed.data.arguments, { + abortSignal: request.signal, + invocation, + progress: async (update) => write({ type: "progress", update }), + send: async (agentRequest) => { + const result = await eveSend(agentRequest); + sessionId = result.sessionId; + return result; + }, + }) + .then((value) => write({ type: "result", value, sessionId })) + .catch((cause) => { + const error = normalizeExportToolError( + cause instanceof Error + ? cause + : new Error("Export tool threw a non-error value", { + cause, + }), + ); + write({ + type: "error", + error: { + code: error.code, + message: error.message, + issues: + error instanceof ExportToolValidationError + ? [...schemaIssues(error.issues)] + : undefined, + }, + }); + }) + .finally(() => controller.close()); + }, + }); + return new Response(stream, { + headers: { "content-type": "application/x-ndjson; charset=utf-8" }, + }); + }), + ], +}); diff --git a/apps/agent/package.json b/apps/agent/package.json index 2360a75ad..fdf8b0ccb 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -15,21 +15,29 @@ "check-types": "tsc --noEmit", "test": "CRM_TELEMETRY_DISABLED=1 bun test", "eval": "CRM_TELEMETRY_DISABLED=1 eve eval", + "e2e:xmpp": "bun test/e2e/xmpp-export.e2e.ts", "lint": "biome check .", "clean": "rm -rf .turbo .eve node_modules" }, "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/gateway": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", "context.dev": "2.10.0", "eve": "^0.29.4", + "ai": "7.0.47", + "ulid": "3.0.2", "zod": "^4.4.3" }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "@xmpp/client": "0.14.0", "just-bash": "^3.2.0", "microsandbox": "^0.6.8", "typescript": "^5.9.2" diff --git a/apps/agent/scripts/start.ts b/apps/agent/scripts/start.ts index af93f7e5c..016e4a20d 100644 --- a/apps/agent/scripts/start.ts +++ b/apps/agent/scripts/start.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { constants } from "node:os"; +import { startXmppGatewayHost } from "../src/xmpp/gateway-host"; const rawPort = process.env.AGENT_PORT ?? process.env.PORT ?? "2000"; const port = Number(rawPort); @@ -10,6 +11,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) { ); } +const gateway = + process.env.XMPP_COMPONENT_ENABLED === "1" + ? await startXmppGatewayHost() + : null; const cli = process.platform === "win32" ? "eve.cmd" : "eve"; const child = spawn(cli, ["start", "--port", String(port)], { stdio: "inherit", @@ -18,9 +23,10 @@ const child = spawn(cli, ["start", "--port", String(port)], { let settled = false; -const finish = (code: number) => { +const finish = async (code: number) => { if (settled) return; settled = true; + await gateway?.close(); process.exitCode = code; }; @@ -33,10 +39,10 @@ process.once("SIGTERM", forward); child.once("exit", (code, signal) => { const signalNumber = signal ? constants.signals[signal] : null; - finish(code ?? (signalNumber ? 128 + signalNumber : 1)); + void finish(code ?? (signalNumber ? 128 + signalNumber : 1)); }); child.once("error", (error) => { console.error(`[agent] could not start eve: ${error.message}`); - finish(1); + void finish(1); }); diff --git a/apps/agent/src/export-tools/define-export-tool.ts b/apps/agent/src/export-tools/define-export-tool.ts new file mode 100644 index 000000000..22c953ab3 --- /dev/null +++ b/apps/agent/src/export-tools/define-export-tool.ts @@ -0,0 +1,9 @@ +import type { ExportToolDefinition } from "./types"; + +export type DefinedExportTool = ExportToolDefinition; + +export function defineExportTool( + definition: ExportToolDefinition, +): DefinedExportTool { + return Object.freeze(definition); +} diff --git a/apps/agent/src/export-tools/errors.ts b/apps/agent/src/export-tools/errors.ts new file mode 100644 index 000000000..ae48f1781 --- /dev/null +++ b/apps/agent/src/export-tools/errors.ts @@ -0,0 +1,70 @@ +import type { StandardSchemaIssue } from "./types"; + +export type ExportToolErrorCode = + | "EXPORT_TOOL_NOT_FOUND" + | "INVALID_ARGUMENTS" + | "EXECUTION_FAILED" + | "AGENT_RUN_FAILED" + | "CANCELLED" + | "OUTPUT_VALIDATION_FAILED"; + +export class ExportToolError extends Error { + constructor( + message: string, + readonly code: ExportToolErrorCode, + options?: ErrorOptions, + ) { + super(message, options); + this.name = new.target.name; + } +} + +export class ExportToolNotFoundError extends ExportToolError { + constructor(readonly operation: string) { + super(`Export tool not found: ${operation}`, "EXPORT_TOOL_NOT_FOUND"); + } +} + +export class ExportToolValidationError extends ExportToolError { + constructor( + message: string, + readonly issues: readonly StandardSchemaIssue[], + code: Extract< + ExportToolErrorCode, + "INVALID_ARGUMENTS" | "OUTPUT_VALIDATION_FAILED" + >, + ) { + super(message, code); + } +} + +export class ExportToolExecutionError extends ExportToolError { + constructor(cause: unknown) { + super("Export tool execution failed", "EXECUTION_FAILED", { + cause, + }); + } +} + +export class ExportAgentRunError extends ExportToolError { + constructor(message: string, cause?: unknown) { + super(message, "AGENT_RUN_FAILED", { cause }); + } +} + +export class ExportCancelledError extends ExportToolError { + constructor() { + super("Export invocation was cancelled", "CANCELLED"); + } +} + +export function normalizeExportToolError(error: Error): ExportToolError { + if (error instanceof ExportToolError) return error; + if ( + error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + return new ExportCancelledError(); + } + return new ExportToolExecutionError(error); +} diff --git a/apps/agent/src/export-tools/eve-adapter.ts b/apps/agent/src/export-tools/eve-adapter.ts new file mode 100644 index 000000000..5654c8e63 --- /dev/null +++ b/apps/agent/src/export-tools/eve-adapter.ts @@ -0,0 +1,93 @@ +import type { SendFn, SendPayload, Session } from "eve/channels"; + +import { ExportAgentRunError, ExportCancelledError } from "./errors"; +import { toJsonSchema, validateSchema } from "./schema"; +import type { + ExportAgentRequest, + ExportAgentResult, + StandardSchemaV1, +} from "./types"; +import { type ExportInvocation, exportJsonValueSchema } from "./wire"; + +type ExportSession = Pick; + +export function createEveExportSend( + send: SendFn, + invocation: ExportInvocation, + abortSignal: AbortSignal, +): (request: ExportAgentRequest) => Promise> { + return async (request: ExportAgentRequest) => { + if (abortSignal.aborted) throw new ExportCancelledError(); + const payload: SendPayload = { + message: request.message, + context: + request.clientContext === undefined + ? undefined + : [JSON.stringify(request.clientContext)], + outputSchema: request.outputSchema + ? toJsonSchema(request.outputSchema) + : undefined, + }; + const options = { + auth: { + authenticator: "xmpp-agent-gateway", + principalType: "agent", + principalId: invocation.caller ?? "xmpp-agent-gateway", + attributes: { + requestId: invocation.requestId, + operation: invocation.operation, + }, + }, + continuationToken: invocation.requestId, + mode: request.taskMode === false ? "conversation" : "task", + title: request.title, + } as const; + const session = await send(payload, options); + const cancel = () => void session.cancel(); + abortSignal.addEventListener("abort", cancel, { once: true }); + try { + return await collectAgentResult(session, request.outputSchema); + } finally { + abortSignal.removeEventListener("abort", cancel); + } + }; +} + +export async function collectAgentResult( + session: ExportSession, + schema?: StandardSchemaV1, +): Promise> { + const stream = await session.getEventStream(); + const reader = stream.getReader(); + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + const event = item.value; + if (event.type === "result.completed") { + const raw = exportJsonValueSchema.parse(event.data?.result); + const value = schema + ? await validateSchema( + schema, + raw, + "Invalid agent result", + "OUTPUT_VALIDATION_FAILED", + ) + : (raw as T); + return { sessionId: session.id, value }; + } + if (event.type === "turn.cancelled") { + throw new ExportCancelledError(); + } + if (event.type === "turn.failed" || event.type === "session.failed") { + throw new ExportAgentRunError( + String(event.data?.message ?? "Eve agent run failed"), + event.data, + ); + } + } + } finally { + reader.releaseLock(); + } + throw new ExportAgentRunError("Eve session ended without a result"); +} diff --git a/apps/agent/src/export-tools/executor.ts b/apps/agent/src/export-tools/executor.ts new file mode 100644 index 000000000..67617247a --- /dev/null +++ b/apps/agent/src/export-tools/executor.ts @@ -0,0 +1,38 @@ +import { ExportCancelledError, normalizeExportToolError } from "./errors"; +import { exportTool } from "./registry"; +import { validateSchema } from "./schema"; +import type { ExportToolContext } from "./types"; +import { type ExportJsonValue, exportJsonValueSchema } from "./wire"; + +export async function executeExportTool( + name: string, + rawInput: ExportJsonValue, + ctx: ExportToolContext, +): Promise { + const definition = exportTool(name); + if (ctx.abortSignal.aborted) throw new ExportCancelledError(); + const input = await validateSchema( + definition.inputSchema, + rawInput, + "Invalid export tool input", + "INVALID_ARGUMENTS", + ); + try { + const output = await definition.execute(input, ctx); + const jsonOutput = exportJsonValueSchema.parse(output); + if (!definition.outputSchema) return jsonOutput; + const validated = await validateSchema( + definition.outputSchema, + jsonOutput, + "Invalid export tool output", + "OUTPUT_VALIDATION_FAILED", + ); + return exportJsonValueSchema.parse(validated); + } catch (error) { + throw normalizeExportToolError( + error instanceof Error + ? error + : new Error("Export tool threw a non-error value", { cause: error }), + ); + } +} diff --git a/apps/agent/src/export-tools/export-tools.test.ts b/apps/agent/src/export-tools/export-tools.test.ts new file mode 100644 index 000000000..7182ebfa2 --- /dev/null +++ b/apps/agent/src/export-tools/export-tools.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "bun:test"; + +import handleCrmRequest from "../exports/handle_crm_request"; +import { ExportCancelledError, ExportToolValidationError } from "./errors"; +import { collectAgentResult } from "./eve-adapter"; +import { executeExportTool } from "./executor"; +import { exportToolManifest } from "./manifest"; +import type { ExportToolContext } from "./types"; +import { exportInvocationRequestSchema, exportStreamEventSchema } from "./wire"; + +function context( + overrides: Partial = {}, +): ExportToolContext { + return { + abortSignal: new AbortController().signal, + invocation: { requestId: "req_1", operation: "ping" }, + progress: async () => {}, + send: async () => { + throw new Error("Unexpected agent call"); + }, + ...overrides, + }; +} + +describe("export tools", () => { + it("runs a deterministic export without an agent call", async () => { + await expect(executeExportTool("ping", {}, context())).resolves.toEqual({ + status: "ok", + requestId: "req_1", + }); + }); + + it("rejects invalid input before progress or agent work", async () => { + const calls: string[] = []; + const run = executeExportTool( + "handle_crm_request", + { request: "" }, + context({ + progress: async () => { + calls.push("progress"); + }, + send: async () => { + calls.push("send"); + return { sessionId: "ses_1", value: {} as T }; + }, + }), + ); + await expect(run).rejects.toBeInstanceOf(ExportToolValidationError); + expect(calls).toEqual([]); + }); + + it("runs agent work between progress events", async () => { + const calls: string[] = []; + const result = await handleCrmRequest.execute( + { request: "Review the current relationship" }, + context({ + progress: async (update) => { + calls.push(update.stage ?? ""); + }, + send: async () => { + calls.push("send"); + return { + sessionId: "ses_1", + value: { summary: "Reviewed", actionsTaken: [] } as T, + }; + }, + }), + ); + expect(calls).toEqual(["reasoning", "send", "complete"]); + expect(result).toEqual({ summary: "Reviewed", actionsTaken: [] }); + }); + + it("publishes only the explicit export registry", () => { + expect(exportToolManifest().map((tool) => tool.name)).toEqual([ + "handle_crm_request", + "ping", + ]); + }); + + it("uses one strict wire contract for invocation and stream events", () => { + expect( + exportInvocationRequestSchema.safeParse({ + requestId: "req_1", + operation: "ping", + arguments: {}, + metadata: {}, + }).success, + ).toBe(false); + expect( + exportStreamEventSchema.safeParse({ + type: "result", + value: {}, + metadata: {}, + }).success, + ).toBe(false); + }); + + it("collects a native structured Eve result", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ + type: "result.completed", + data: { result: { summary: "Done", actionsTaken: [] } }, + }); + controller.close(); + }, + }); + await expect( + collectAgentResult( + { + id: "ses_1", + cancel: async () => ({ status: "accepted" }), + getEventStream: async () => stream, + }, + handleCrmRequest.outputSchema, + ), + ).resolves.toEqual({ + sessionId: "ses_1", + value: { summary: "Done", actionsTaken: [] }, + }); + }); + + it("maps Eve cancellation to the export cancellation error", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "turn.cancelled" }); + controller.close(); + }, + }); + await expect( + collectAgentResult({ + id: "ses_1", + cancel: async () => ({ status: "accepted" }), + getEventStream: async () => stream, + }), + ).rejects.toBeInstanceOf(ExportCancelledError); + }); +}); diff --git a/apps/agent/src/export-tools/manifest.ts b/apps/agent/src/export-tools/manifest.ts new file mode 100644 index 000000000..b98d9945c --- /dev/null +++ b/apps/agent/src/export-tools/manifest.ts @@ -0,0 +1,18 @@ +import { exportTools } from "./registry"; +import { toJsonSchema } from "./schema"; +import type { ExportToolManifestEntry } from "./types"; + +export function exportToolManifest(): readonly ExportToolManifestEntry[] { + return Object.entries(exportTools).map(([name, definition]) => { + const entry: ExportToolManifestEntry = { + name, + description: definition.description, + inputSchema: toJsonSchema(definition.inputSchema), + }; + if (definition.outputSchema) { + entry.outputSchema = toJsonSchema(definition.outputSchema); + } + if (definition.annotations) entry.annotations = definition.annotations; + return entry; + }); +} diff --git a/apps/agent/src/export-tools/registry.ts b/apps/agent/src/export-tools/registry.ts new file mode 100644 index 000000000..8575b6ed3 --- /dev/null +++ b/apps/agent/src/export-tools/registry.ts @@ -0,0 +1,20 @@ +import handleCrmRequest from "../exports/handle_crm_request"; +import ping from "../exports/ping"; +import { ExportToolNotFoundError } from "./errors"; +import type { AnyExportToolDefinition } from "./types"; + +export const exportTools = { + handle_crm_request: handleCrmRequest, + ping, +} as const; + +export function exportTool(name: string): AnyExportToolDefinition { + switch (name) { + case "handle_crm_request": + return handleCrmRequest as AnyExportToolDefinition; + case "ping": + return ping as AnyExportToolDefinition; + default: + throw new ExportToolNotFoundError(name); + } +} diff --git a/apps/agent/src/export-tools/schema.ts b/apps/agent/src/export-tools/schema.ts new file mode 100644 index 000000000..1c740a9e5 --- /dev/null +++ b/apps/agent/src/export-tools/schema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import { ExportToolValidationError } from "./errors"; +import type { + JsonSchema, + StandardSchemaIssue, + StandardSchemaV1, +} from "./types"; +import type { ExportJsonValue } from "./wire"; + +export async function validateSchema( + schema: StandardSchemaV1, + value: ExportJsonValue, + message: string, + code: "INVALID_ARGUMENTS" | "OUTPUT_VALIDATION_FAILED", +): Promise { + const result = await schema["~standard"].validate(value); + if (result.issues) { + throw new ExportToolValidationError(message, result.issues, code); + } + return result.value; +} + +export function schemaIssues( + issues: readonly StandardSchemaIssue[], +): ReadonlyArray<{ path: string; message: string }> { + return issues.map((issue) => ({ + path: + issue.path + ?.map((part) => String(part instanceof Object ? part.key : part)) + .join(".") ?? "", + message: issue.message, + })); +} + +export function toJsonSchema( + schema: StandardSchemaV1, +): JsonSchema { + if (schema instanceof z.ZodType) { + return z.toJSONSchema(schema, { target: "draft-2020-12" }) as JsonSchema; + } + throw new TypeError( + `JSON Schema export is unavailable for ${schema["~standard"].vendor}`, + ); +} diff --git a/apps/agent/src/export-tools/types.ts b/apps/agent/src/export-tools/types.ts new file mode 100644 index 000000000..b9e857445 --- /dev/null +++ b/apps/agent/src/export-tools/types.ts @@ -0,0 +1,76 @@ +import type { SendPayload } from "eve/channels"; + +import type { + ExportInvocation, + ExportJsonObject, + ExportProgress, +} from "./wire"; + +export interface StandardSchemaV1 { + readonly "~standard": { + readonly version: 1; + readonly vendor: string; + validate( + value: Input, + ): StandardSchemaResult | Promise>; + }; +} + +export type StandardSchemaResult = + | { readonly value: T; readonly issues?: undefined } + | { readonly issues: readonly StandardSchemaIssue[] }; + +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: ReadonlyArray; +} + +export interface ExportToolAnnotations { + readonly title?: string; + readonly idempotent?: boolean; + readonly readOnly?: boolean; + readonly destructive?: boolean; + readonly longRunning?: boolean; +} + +export interface ExportAgentRequest { + readonly message: NonNullable; + readonly outputSchema?: StandardSchemaV1; + readonly title?: string; + readonly taskMode?: boolean; + readonly clientContext?: ExportJsonObject; +} + +export interface ExportAgentResult { + readonly sessionId: string; + readonly value: T; +} + +export interface ExportToolContext { + readonly abortSignal: AbortSignal; + readonly invocation: ExportInvocation; + progress(update: ExportProgress): Promise; + send( + request: ExportAgentRequest, + ): Promise>; +} + +export interface ExportToolDefinition { + readonly description: string; + readonly inputSchema: StandardSchemaV1; + readonly outputSchema?: StandardSchemaV1; + readonly annotations?: ExportToolAnnotations; + execute(input: I, ctx: ExportToolContext): Promise | O; +} + +export type AnyExportToolDefinition = ExportToolDefinition; + +export type JsonSchema = NonNullable; + +export interface ExportToolManifestEntry { + readonly name: string; + readonly description: string; + readonly inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: ExportToolAnnotations; +} diff --git a/apps/agent/src/export-tools/wire.ts b/apps/agent/src/export-tools/wire.ts new file mode 100644 index 000000000..87aea9262 --- /dev/null +++ b/apps/agent/src/export-tools/wire.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const jsonObjectSchema = z.record(z.string(), z.json()); +export const exportJsonValueSchema = z.json(); + +export const exportInvocationSchema = z + .object({ + requestId: z.string().trim().min(1).max(160), + operation: z.string().trim().min(1).max(128), + caller: z.string().trim().min(1).max(3071).optional(), + }) + .strict(); + +export const exportInvocationRequestSchema = exportInvocationSchema.extend({ + arguments: exportJsonValueSchema, +}); + +export const exportProgressSchema = z + .object({ + stage: z.string().optional(), + percent: z.number().optional(), + message: z.string().optional(), + }) + .strict(); + +const exportIssueSchema = z + .object({ + path: z.string(), + message: z.string(), + }) + .strict(); + +export const exportStreamEventSchema = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("progress"), update: exportProgressSchema }) + .strict(), + z + .object({ + type: z.literal("result"), + value: exportJsonValueSchema, + sessionId: z.string().optional(), + }) + .strict(), + z + .object({ + type: z.literal("error"), + error: z + .object({ + code: z.string(), + message: z.string(), + issues: z.array(exportIssueSchema).optional(), + }) + .strict(), + }) + .strict(), +]); + +export type ExportInvocation = z.infer; +export type ExportProgress = z.infer; +export type ExportStreamEvent = z.infer; +export type ExportJsonObject = z.infer; +export type ExportJsonValue = z.infer; diff --git a/apps/agent/src/exports/handle_crm_request.ts b/apps/agent/src/exports/handle_crm_request.ts new file mode 100644 index 000000000..c065a9316 --- /dev/null +++ b/apps/agent/src/exports/handle_crm_request.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { defineExportTool } from "../export-tools/define-export-tool"; + +const resultSchema = z.object({ + summary: z.string().min(1), + actionsTaken: z.array( + z.object({ + type: z.string().min(1), + description: z.string().min(1), + }), + ), +}); + +export default defineExportTool({ + description: + "Process a bounded CRM request with the agent's normal evidence and action tools.", + inputSchema: z.object({ + request: z.string().trim().min(1).max(2_000), + record: z + .object({ + type: z.enum(["contact", "company", "deal"]), + id: z.string().trim().min(1).max(120), + }) + .optional(), + }), + outputSchema: resultSchema, + annotations: { + title: "Handle CRM request", + idempotent: false, + readOnly: false, + destructive: true, + longRunning: true, + }, + async execute(input, ctx) { + await ctx.progress({ + stage: "reasoning", + percent: 10, + message: "Processing CRM request", + }); + const target = input.record + ? `${input.record.type} ${input.record.id}` + : "the relevant CRM records"; + const result = await ctx.send({ + taskMode: true, + title: "Remote CRM request", + message: [ + `Process this external request about ${target}.`, + input.request, + "Use normal CRM evidence rules and available tools.", + "Report only actions that completed successfully.", + "Return a concise summary and the actions taken.", + ].join("\n\n"), + outputSchema: resultSchema, + clientContext: { + requestId: ctx.invocation.requestId, + caller: ctx.invocation.caller ?? null, + operation: ctx.invocation.operation, + }, + }); + await ctx.progress({ + stage: "complete", + percent: 100, + message: "CRM request complete", + }); + return result.value; + }, +}); diff --git a/apps/agent/src/exports/ping.ts b/apps/agent/src/exports/ping.ts new file mode 100644 index 000000000..3f1549d40 --- /dev/null +++ b/apps/agent/src/exports/ping.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { defineExportTool } from "../export-tools/define-export-tool"; + +export default defineExportTool({ + description: "Confirm that the CRM agent export endpoint is available.", + inputSchema: z.object({}), + outputSchema: z.object({ + status: z.literal("ok"), + requestId: z.string(), + }), + annotations: { + title: "Ping CRM agent", + idempotent: true, + readOnly: true, + destructive: false, + longRunning: false, + }, + execute(_input, ctx) { + return { status: "ok" as const, requestId: ctx.invocation.requestId }; + }, +}); diff --git a/apps/agent/src/xmpp/config.ts b/apps/agent/src/xmpp/config.ts new file mode 100644 index 000000000..8f2a4a14c --- /dev/null +++ b/apps/agent/src/xmpp/config.ts @@ -0,0 +1,60 @@ +import { + type GatewayConfig, + loadConfig as loadGatewayConfig, +} from "@agent-xmpp/gateway"; + +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; + +export const XMPP_EXPORT = { + task: { + retentionMs: 24 * HOUR_MS, + }, + gateway: { + sweepMs: 5 * SECOND_MS, + }, +} as const; + +export interface XmppHostConfig { + readonly gateway: GatewayConfig; + readonly organizationId: string; + readonly bridgeSecret: string; + readonly agentUrl: string; + readonly agentVersion?: string; + readonly allowedCallerDomains: ReadonlySet; + readonly destructiveCallers: ReadonlySet; +} + +export function loadXmppHostConfig(): XmppHostConfig { + const gateway = loadGatewayConfig(); + return { + gateway, + organizationId: requiredEnv("XMPP_ORGANIZATION_ID"), + bridgeSecret: requiredEnv("AGENT_BRIDGE_SECRET"), + agentUrl: process.env.AGENT_URL ?? "http://127.0.0.1:2000", + agentVersion: process.env.XMPP_AGENT_VERSION, + allowedCallerDomains: commaSeparatedSet( + process.env.XMPP_ALLOWED_CALLER_DOMAINS ?? + `${gateway.serverDomain},${gateway.agentDomain}`, + ), + destructiveCallers: commaSeparatedSet( + process.env.XMPP_ALLOW_DESTRUCTIVE_CALLERS, + ), + }; +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required env: ${name}`); + return value; +} + +function commaSeparatedSet(value: string | undefined): ReadonlySet { + return new Set( + (value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); +} diff --git a/apps/agent/src/xmpp/gateway-host.ts b/apps/agent/src/xmpp/gateway-host.ts new file mode 100644 index 000000000..a55ec789c --- /dev/null +++ b/apps/agent/src/xmpp/gateway-host.ts @@ -0,0 +1,226 @@ +import { + EmbeddedXmppGateway, + type GatewayRuntimeMailbox, + type TaskWireEvent, +} from "@agent-xmpp/gateway"; +import { + type AgentTaskError, + type AgentTaskEventType, + type AgentTaskRecord, + type McpToolResult, + terminalTaskStates, +} from "@agent-xmpp/protocol"; +import { ulid } from "ulid"; + +import { + type ExportJsonValue, + type ExportStreamEvent, + exportInvocationRequestSchema, + exportStreamEventSchema, + jsonObjectSchema, +} from "../export-tools/wire"; +import { loadXmppHostConfig, XMPP_EXPORT } from "./config"; +import { createXmppIqHandler } from "./iq-handler"; +import { createXmppManifest } from "./manifest"; +import { PostgresXmppTaskStore } from "./task-store"; + +export async function startXmppGatewayHost(): Promise<{ + close(): Promise; +}> { + const config = loadXmppHostConfig(); + const store = new PostgresXmppTaskStore(config.organizationId); + const agent = createXmppManifest({ + jid: config.gateway.defaultAgentJid, + organizationId: config.organizationId, + version: config.agentVersion, + }); + const controllers = new Map(); + let gateway: EmbeddedXmppGateway; + const emit = async ( + task: AgentTaskRecord, + type: AgentTaskEventType, + payload: TaskWireEvent["payload"], + ) => { + await gateway.deliverTaskEvent({ + taskId: task.taskId, + eventId: ulid(), + revision: task.revision, + type, + from: task.targetJid, + to: task.notificationJid || task.callerJid, + payload, + }); + }; + const run = async (initial: AgentTaskRecord) => { + const controller = new AbortController(); + controllers.set(initial.taskId, controller); + let task = initial; + try { + task = await store.transition(task.taskId, task.revision, { + state: "RUNNING", + }); + await emit(task, "status", { + state: "running", + updatedAt: task.updatedAt, + }); + const response = await fetch( + new URL("/internal/xmpp/export-tools/invoke", config.agentUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${config.bridgeSecret}`, + "content-type": "application/json", + }, + body: JSON.stringify( + exportInvocationRequestSchema.parse({ + requestId: task.taskId, + operation: task.tool, + arguments: task.arguments, + caller: task.callerJid, + }), + ), + signal: controller.signal, + }, + ); + if (!response.ok || !response.body) { + throw new Error(`Eve export endpoint returned ${response.status}`); + } + for await (const event of readNdjson(response.body)) { + if (event.type === "progress") { + task = await store.transition(task.taskId, task.revision, { + progress: jsonObjectSchema.parse(event.update), + }); + await emit(task, "progress", event.update); + continue; + } + if (event.type === "error") { + if (event.error.code === "CANCELLED") { + throw new DOMException(event.error.message, "AbortError"); + } + throw new ExportEndpointError(event.error.code, event.error.message); + } + const result = mcpResult(event.value); + const transition = { + state: "COMPLETED", + result: jsonObjectSchema.parse(result), + eveSessionId: event.sessionId, + } as const; + task = await store.transition(task.taskId, task.revision, transition); + await emit(task, "completed", { result }); + return; + } + throw new Error("Eve export endpoint ended without a result"); + } catch (error) { + const current = await store.get(initial.taskId); + if (!current || terminalTaskStates.has(current.state)) return; + if ( + (error instanceof DOMException && error.name === "AbortError") || + current.state === "cancelling" + ) { + task = await store.transition(current.taskId, current.revision, { + state: "CANCELLED", + }); + await emit(task, "cancelled", { reason: "Task cancelled" }); + return; + } + const failure: AgentTaskError = { + code: + error instanceof ExportEndpointError ? error.code : "gateway-error", + message: + error instanceof Error ? error.message : "Task execution failed", + retryable: !(error instanceof ExportEndpointError), + }; + task = await store.transition(current.taskId, current.revision, { + state: "FAILED", + error: jsonObjectSchema.parse(failure), + }); + await emit(task, "failed", { error: failure }); + } finally { + controllers.delete(initial.taskId); + } + }; + const iqHandler = createXmppIqHandler({ + componentJid: config.gateway.componentJid, + agent, + store, + allowedCallerDomains: config.allowedCallerDomains, + destructiveCallers: config.destructiveCallers, + onAccepted: (task) => void run(task), + onCancel: async (task) => { + controllers.get(task.taskId)?.abort(); + }, + }); + const mailbox: GatewayRuntimeMailbox = { + async deliverInbound() {}, + async deliverFormResponse() {}, + async deliverTaskEvent(_event: TaskWireEvent) {}, + }; + gateway = new EmbeddedXmppGateway(config.gateway, mailbox, { + onIqGet: iqHandler, + resolveVirtualAgent: (jid) => + jid === agent.manifest.agent.jid + ? { + jid, + name: agent.manifest.agent.title ?? agent.manifest.agent.name, + } + : null, + }); + await store.failInterrupted(); + await gateway.start(); + const sweep = setInterval( + () => void store.deleteExpired(), + XMPP_EXPORT.gateway.sweepMs, + ); + sweep.unref?.(); + return { + async close() { + clearInterval(sweep); + for (const controller of controllers.values()) controller.abort(); + await gateway.stop(); + }, + }; +} + +async function* readNdjson( + stream: ReadableStream, +): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + pending += decoder.decode(chunk.value, { stream: true }); + for (;;) { + const boundary = pending.indexOf("\n"); + if (boundary < 0) break; + const line = pending.slice(0, boundary); + pending = pending.slice(boundary + 1); + if (line) yield exportStreamEventSchema.parse(JSON.parse(line)); + } + } + pending += decoder.decode(); + if (pending.trim()) { + yield exportStreamEventSchema.parse(JSON.parse(pending)); + } + } finally { + reader.releaseLock(); + } +} + +function mcpResult(value: ExportJsonValue): McpToolResult { + return { + content: [{ type: "text", text: JSON.stringify(value) }], + structuredContent: jsonObjectSchema.parse(value), + }; +} + +class ExportEndpointError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} diff --git a/apps/agent/src/xmpp/iq-handler.ts b/apps/agent/src/xmpp/iq-handler.ts new file mode 100644 index 000000000..e9a40972a --- /dev/null +++ b/apps/agent/src/xmpp/iq-handler.ts @@ -0,0 +1,331 @@ +import { digestJson, validateJsonBounded } from "@agent-xmpp/core"; +import { + buildAcceptedResult, + buildAgentDirectory, + buildAgentInfo, + buildGatewayInfo, + buildManifestResult, + buildPingResponse, + buildSchemaResult, + buildTaskResultResponse, + buildTaskStateResponse, + buildToolCollectionInfo, + buildToolInfo, + buildToolItems, + DISCO_INFO_NS, + DISCO_ITEMS_NS, + type Element, + hiddenObjectError, + isPingRequest, + ProtocolError, + parseManifestRequest, + parseSchemaRequest, + parseTaskCancellation, + parseTaskInput, + parseTaskInvocation, + parseTaskRecoveryRequest, + protocolErrorIq, + toolFromNode, + toolsNode, + xml, +} from "@agent-xmpp/gateway"; +import { + AGENT_TASK_NS, + type AgentTaskRecord, + bareJid, + type RegisteredAgent, + terminalTaskStates, +} from "@agent-xmpp/protocol"; +import { ulid } from "ulid"; +import { z } from "zod"; + +import { XMPP_EXPORT } from "./config"; +import { + type PostgresXmppTaskStore, + XmppTaskConflictError, +} from "./task-store"; + +export interface XmppIqHandlerOptions { + readonly componentJid: string; + readonly agent: RegisteredAgent; + readonly store: PostgresXmppTaskStore; + readonly allowedCallerDomains: ReadonlySet; + readonly destructiveCallers: ReadonlySet; + onAccepted(task: AgentTaskRecord): void; + onCancel(task: AgentTaskRecord, reason?: string): Promise; +} + +export function createXmppIqHandler(options: XmppIqHandlerOptions) { + return async (stanza: Element): Promise => { + try { + return await routeIq(stanza, options); + } catch (error) { + if (error instanceof ProtocolError) { + return protocolErrorIq(stanza, error); + } + if (error instanceof XmppTaskConflictError) { + return protocolErrorIq( + stanza, + new ProtocolError("conflict", error.message), + ); + } + return protocolErrorIq( + stanza, + new ProtocolError("internal-server-error", "Request processing failed"), + ); + } + }; +} + +async function routeIq( + stanza: Element, + options: XmppIqHandlerOptions, +): Promise { + if ( + stanza.name !== "iq" || + !["get", "set"].includes(String(stanza.attrs.type)) + ) { + return null; + } + const from = String(stanza.attrs.from ?? ""); + const caller = bareJid(from); + const callerDomain = caller.split("@")[1] ?? caller; + if (!options.allowedCallerDomains.has(callerDomain)) { + throw hiddenObjectError(); + } + const to = bareJid(String(stanza.attrs.to ?? "")); + if ( + isPingRequest(stanza) && + (to === options.componentJid || to === options.agent.manifest.agent.jid) + ) { + return buildPingResponse(stanza); + } + if (to === options.componentJid) return routeGateway(stanza, options); + if (to !== options.agent.manifest.agent.jid) throw hiddenObjectError(); + return routeAgent(stanza, options, caller); +} + +function routeGateway( + stanza: Element, + options: XmppIqHandlerOptions, +): Element | null { + const info = stanza.getChild("query", DISCO_INFO_NS); + const items = stanza.getChild("query", DISCO_ITEMS_NS); + if (info && !info.attrs.node) + return buildGatewayInfo(stanza, options.componentJid); + if (items && !items.attrs.node) { + return buildAgentDirectory(stanza, options.componentJid, [options.agent]); + } + return null; +} + +async function routeAgent( + stanza: Element, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + const invocation = parseTaskInvocation(stanza); + if (invocation) return acceptInvocation(stanza, invocation, options, caller); + const cancellation = parseTaskCancellation(stanza); + if (cancellation) return cancelTask(stanza, cancellation, options, caller); + if (parseTaskInput(stanza)) { + throw new ProtocolError( + "unexpected-request", + "Task input is not supported", + ); + } + const recovery = parseTaskRecoveryRequest(stanza); + if (recovery) { + const task = await options.store.getForCaller( + recovery.taskId, + caller, + options.agent.manifest.agent.jid, + ); + if (!task) throw hiddenObjectError(); + if (recovery.kind === "result" && !terminalTaskStates.has(task.state)) { + throw new ProtocolError("unexpected-request", "Task is not terminal"); + } + return recovery.kind === "state" + ? buildTaskStateResponse(stanza, task) + : buildTaskResultResponse(stanza, task); + } + const info = stanza.getChild("query", DISCO_INFO_NS); + const items = stanza.getChild("query", DISCO_ITEMS_NS); + const manifestRequest = parseManifestRequest(stanza); + const schemaRequest = parseSchemaRequest(stanza); + const version = options.agent.manifest.agent.version; + if (info && !info.attrs.node) return buildAgentInfo(stanza, options.agent); + if (info?.attrs.node === toolsNode(version)) { + return buildToolCollectionInfo(stanza, options.agent); + } + if (items?.attrs.node === toolsNode(version)) { + return buildToolItems(stanza, options.agent); + } + if (info?.attrs.node) { + const selected = toolFromNode(String(info.attrs.node)); + if (!selected || selected.version !== version) throw hiddenObjectError(); + const tool = options.agent.tools.find( + (candidate) => candidate.name === selected.name, + ); + if (!tool) throw hiddenObjectError(); + return buildToolInfo(stanza, options.agent, tool); + } + if (manifestRequest) { + if (manifestRequest.version && manifestRequest.version !== version) + throw hiddenObjectError(); + return buildManifestResult(stanza, options.agent); + } + if (schemaRequest) { + if ( + schemaRequest.version !== version || + schemaRequest.manifestHash !== options.agent.manifestHash + ) { + throw new ProtocolError("conflict", "Manifest selection conflict"); + } + const tool = options.agent.tools.find( + (candidate) => candidate.name === schemaRequest.tool, + ); + if (!tool) throw hiddenObjectError(); + return buildSchemaResult( + stanza, + options.agent, + tool, + schemaRequest.direction, + ); + } + return null; +} + +async function acceptInvocation( + stanza: Element, + invocation: NonNullable>, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + if ( + invocation.apiVersion !== options.agent.manifest.agent.version || + invocation.manifestHash !== options.agent.manifestHash + ) { + throw new ProtocolError("conflict", "Manifest selection conflict"); + } + const tool = options.agent.tools.find( + (candidate) => candidate.name === invocation.tool, + ); + if (!tool) throw hiddenObjectError(); + if (tool.xmpp?.approvalRequired && !options.destructiveCallers.has(caller)) { + throw new ProtocolError("forbidden", "Tool approval is unavailable"); + } + const errors = await validateJsonBounded( + tool.inputSchema, + invocation.arguments, + ); + if (errors.length) { + throw new ProtocolError( + "bad-request", + `Argument validation failed: ${errors.join("; ")}`, + ); + } + const now = new Date(); + const deadline = invocation.deadline + ? new Date(invocation.deadline) + : undefined; + if (deadline && deadline.getTime() <= now.getTime()) { + throw new ProtocolError("not-acceptable", "Task deadline is expired"); + } + const maximumTimeoutSeconds = tool.xmpp?.maximumTimeoutSeconds; + if ( + deadline && + maximumTimeoutSeconds && + deadline.getTime() > now.getTime() + maximumTimeoutSeconds * 1_000 + ) { + throw new ProtocolError( + "not-acceptable", + "Task deadline exceeds the tool maximum", + ); + } + const retainUntil = new Date( + Math.max( + now.getTime() + XMPP_EXPORT.task.retentionMs, + deadline?.getTime() ?? 0, + ), + ); + const fingerprint = digestJson({ + caller, + target: invocation.toJid, + requestId: invocation.requestId, + tool: invocation.tool, + apiVersion: invocation.apiVersion, + manifestHash: invocation.manifestHash, + arguments: invocation.arguments, + deadline: invocation.deadline ?? null, + }); + const admission = { + id: ulid(), + requestId: invocation.requestId, + callerJid: caller, + notificationJid: invocation.notificationJid, + targetJid: invocation.toJid, + tool: invocation.tool, + apiVersion: invocation.apiVersion, + manifestHash: invocation.manifestHash, + fingerprint, + arguments: z.record(z.string(), z.json()).parse(invocation.arguments), + retainUntil, + }; + if (deadline) Object.assign(admission, { deadline }); + const admitted = await options.store.admit(admission); + if (!admitted.replay) options.onAccepted(admitted.task); + return buildAcceptedResult( + stanza, + { + requestId: admitted.task.requestId, + taskId: admitted.task.taskId, + revision: 0, + created: admitted.task.createdAt, + retainUntil: admitted.task.retainUntil, + }, + options.agent.manifest.agent.jid, + ); +} + +async function cancelTask( + stanza: Element, + cancellation: NonNullable>, + options: XmppIqHandlerOptions, + caller: string, +): Promise { + const task = await options.store.getForCaller( + cancellation.taskId, + caller, + options.agent.manifest.agent.jid, + ); + if (!task) throw hiddenObjectError(); + if (terminalTaskStates.has(task.state)) { + throw new ProtocolError("unexpected-request", "Task is terminal"); + } + if (task.revision !== cancellation.expectedRevision) { + throw new ProtocolError("conflict", "Task revision conflict"); + } + const cancelling = await options.store.transition( + task.taskId, + task.revision, + { + state: "CANCELLING", + }, + ); + await options.onCancel(cancelling, cancellation.reason); + return xml( + "iq", + { + type: "result", + id: stanza.attrs.id, + from: options.agent.manifest.agent.jid, + to: stanza.attrs.from, + }, + xml("cancel-accepted", { + xmlns: AGENT_TASK_NS, + "task-id": cancelling.taskId, + revision: String(cancelling.revision), + }), + ); +} diff --git a/apps/agent/src/xmpp/manifest.test.ts b/apps/agent/src/xmpp/manifest.test.ts new file mode 100644 index 000000000..4091687fe --- /dev/null +++ b/apps/agent/src/xmpp/manifest.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_API_NS } from "@agent-xmpp/protocol"; + +import { createXmppManifest } from "./manifest"; + +describe("XMPP export manifest", () => { + it("derives the public tool surface from export schemas", () => { + const agent = createXmppManifest({ + jid: "assistant@agents.example.com", + organizationId: "org_1", + }); + expect(agent.tools.map((tool) => tool.name)).toEqual([ + "handle_crm_request", + "ping", + ]); + expect(agent.tools[0]?.xmpp?.approvalRequired).toBe(true); + expect(agent.manifest.tools[0]?.[AGENT_API_NS]).toEqual( + expect.objectContaining({ + supportsProgress: true, + supportsCancellation: true, + }), + ); + }); +}); diff --git a/apps/agent/src/xmpp/manifest.ts b/apps/agent/src/xmpp/manifest.ts new file mode 100644 index 000000000..1b87dd775 --- /dev/null +++ b/apps/agent/src/xmpp/manifest.ts @@ -0,0 +1,76 @@ +import { + canonicalJson, + digestJson, + registeredTools, + validateManifest, +} from "@agent-xmpp/core"; +import { + AGENT_API_NS, + type AgentApiManifest, + type RegisteredAgent, +} from "@agent-xmpp/protocol"; + +import { exportToolManifest } from "../export-tools/manifest"; + +export interface XmppManifestOptions { + readonly jid: string; + readonly organizationId: string; + readonly version?: string; +} + +export function createXmppManifest( + options: XmppManifestOptions, +): RegisteredAgent { + const tools = exportToolManifest().map((tool) => { + const annotations: NonNullable< + AgentApiManifest["tools"][number]["annotations"] + > = {}; + const manifestTool: AgentApiManifest["tools"][number] = { + name: tool.name, + description: tool.description, + inputSchema: { ...tool.inputSchema }, + annotations, + [AGENT_API_NS]: { + supportsProgress: true, + supportsCancellation: true, + supportsInput: false, + approvalRequired: tool.annotations?.destructive === true, + }, + }; + if (tool.annotations?.title) manifestTool.title = tool.annotations.title; + if (tool.outputSchema) { + manifestTool.outputSchema = { ...tool.outputSchema }; + } + if (tool.annotations?.readOnly !== undefined) { + annotations.readOnlyHint = tool.annotations.readOnly; + } + if (tool.annotations?.destructive !== undefined) { + annotations.destructiveHint = tool.annotations.destructive; + } + if (tool.annotations?.idempotent !== undefined) { + annotations.idempotentHint = tool.annotations.idempotent; + } + return manifestTool; + }); + const manifest = validateManifest({ + manifestSpecVersion: "0", + agent: { + jid: options.jid, + name: "compcrm", + title: "CRM Agent", + description: "Researches CRM records and performs bounded CRM work.", + version: options.version ?? "1.0.0", + }, + implementation: { name: "compcrm-eve", version: "1.0.0" }, + tools, + } satisfies AgentApiManifest); + return { + manifest, + manifestHash: digestJson(manifest), + canonicalManifest: canonicalJson(manifest), + tools: registeredTools(manifest), + tenantId: options.organizationId, + active: true, + registeredAt: new Date().toISOString(), + }; +} diff --git a/apps/agent/src/xmpp/task-store.ts b/apps/agent/src/xmpp/task-store.ts new file mode 100644 index 000000000..f6c9a0675 --- /dev/null +++ b/apps/agent/src/xmpp/task-store.ts @@ -0,0 +1,212 @@ +import type { + AgentTaskError, + AgentTaskRecord, + McpToolResult, +} from "@agent-xmpp/protocol"; +import { db, Prisma, type XmppAgentTaskState } from "@crm/db"; +import { z } from "zod"; + +const storedTaskResult = z.object({ + content: z.array(z.object({ type: z.literal("text"), text: z.string() })), + structuredContent: z.record(z.string(), z.unknown()).optional(), +}); + +const storedTaskError = z.object({ + code: z.string(), + message: z.string(), + retryable: z.boolean(), +}); + +export interface AdmitXmppTask { + readonly id: string; + readonly requestId: string; + readonly callerJid: string; + readonly notificationJid: string; + readonly targetJid: string; + readonly tool: string; + readonly apiVersion: string; + readonly manifestHash: string; + readonly fingerprint: string; + readonly arguments: Prisma.InputJsonValue; + readonly deadline?: Date; + readonly retainUntil: Date; +} + +export interface XmppTaskTransition { + readonly state?: XmppAgentTaskState; + readonly progress?: Prisma.InputJsonValue; + readonly result?: Prisma.InputJsonValue; + readonly error?: Prisma.InputJsonValue; + readonly summary?: string; + readonly eveSessionId?: string; +} + +export class XmppTaskConflictError extends Error { + constructor(readonly kind: "replay" | "revision") { + super(`XMPP task ${kind} conflict`); + } +} + +export class PostgresXmppTaskStore { + constructor(readonly organizationId: string) {} + + async admit( + input: AdmitXmppTask, + ): Promise<{ task: AgentTaskRecord; replay: boolean }> { + const replayKey = { + organizationId: this.organizationId, + callerJid: input.callerJid, + targetJid: input.targetJid, + requestId: input.requestId, + }; + const where = { + organizationId_callerJid_targetJid_requestId: replayKey, + }; + const existing = await db.xmppAgentTask.findUnique({ + where: { + ...where, + }, + }); + if (existing) return this.replay(existing, input.fingerprint); + try { + const created = await db.xmppAgentTask.create({ + data: { ...input, organizationId: this.organizationId }, + }); + return { task: taskRecord(created), replay: false }; + } catch (error) { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + throw error; + } + const concurrent = await db.xmppAgentTask.findUniqueOrThrow({ + where, + }); + return this.replay(concurrent, input.fingerprint); + } + } + + async get(id: string): Promise { + const task = await db.xmppAgentTask.findFirst({ + where: { id, organizationId: this.organizationId }, + }); + return task ? taskRecord(task) : null; + } + + async getForCaller( + id: string, + callerJid: string, + targetJid: string, + ): Promise { + const task = await db.xmppAgentTask.findFirst({ + where: { + id, + organizationId: this.organizationId, + callerJid, + targetJid, + }, + }); + return task ? taskRecord(task) : null; + } + + async transition( + id: string, + expectedRevision: number, + transition: XmppTaskTransition, + ): Promise { + const updated = await db.xmppAgentTask.updateMany({ + where: { + id, + organizationId: this.organizationId, + revision: expectedRevision, + }, + data: { + ...transition, + revision: { increment: 1 }, + }, + }); + if (updated.count !== 1) throw new XmppTaskConflictError("revision"); + return taskRecord( + await db.xmppAgentTask.findFirstOrThrow({ + where: { id, organizationId: this.organizationId }, + }), + ); + } + + async failInterrupted(): Promise { + const result = await db.xmppAgentTask.updateMany({ + where: { + organizationId: this.organizationId, + state: { in: ["ACCEPTED", "RUNNING", "CANCELLING"] }, + }, + data: { + state: "FAILED", + revision: { increment: 1 }, + error: { + code: "gateway-restarted", + message: "The XMPP gateway restarted before the task completed", + retryable: true, + }, + }, + }); + return result.count; + } + + async deleteExpired(now = new Date()): Promise { + const result = await db.xmppAgentTask.deleteMany({ + where: { + organizationId: this.organizationId, + retainUntil: { lt: now }, + state: { in: ["COMPLETED", "FAILED", "CANCELLED"] }, + }, + }); + return result.count; + } + + private replay(task: Prisma.XmppAgentTaskModel, fingerprint: string) { + if (task.fingerprint !== fingerprint) { + throw new XmppTaskConflictError("replay"); + } + return { task: taskRecord(task), replay: true }; + } +} + +const taskStates = { + ACCEPTED: "accepted", + RUNNING: "running", + CANCELLING: "cancelling", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", +} satisfies Record; + +function taskRecord(task: Prisma.XmppAgentTaskModel): AgentTaskRecord { + const record: AgentTaskRecord = { + taskId: task.id, + requestId: task.requestId, + callerJid: task.callerJid, + notificationJid: task.notificationJid, + targetJid: task.targetJid, + tenantId: task.organizationId, + tool: task.tool, + apiVersion: task.apiVersion, + manifestHash: task.manifestHash, + arguments: task.arguments, + state: taskStates[task.state], + revision: task.revision, + fingerprint: task.fingerprint, + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + retainUntil: task.retainUntil.toISOString(), + }; + if (task.deadline) record.deadline = task.deadline.toISOString(); + if (task.result) { + record.result = storedTaskResult.parse(task.result) satisfies McpToolResult; + } + if (task.error) { + record.error = storedTaskError.parse(task.error) satisfies AgentTaskError; + } + if (task.summary) record.summary = task.summary; + return record; +} diff --git a/apps/agent/test/e2e/xmpp-export.e2e.ts b/apps/agent/test/e2e/xmpp-export.e2e.ts new file mode 100644 index 000000000..9c5db24d5 --- /dev/null +++ b/apps/agent/test/e2e/xmpp-export.e2e.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { + buildTaskInvocation, + type Element, + parseTaskEvent, + xml, +} from "@agent-xmpp/gateway"; +import { AGENT_TASK_NS, type AgentTaskRecord } from "@agent-xmpp/protocol"; +import { client } from "@xmpp/client"; +import { z } from "zod"; +import { createXmppManifest } from "../../src/xmpp/manifest"; + +const domain = process.env.XMPP_E2E_DOMAIN ?? "example.org"; +const service = process.env.XMPP_E2E_SERVICE ?? "xmpp://127.0.0.1:15222"; +const username = process.env.XMPP_E2E_USERNAME ?? "john"; +const password = process.env.XMPP_E2E_PASSWORD ?? "secret"; +const callerJid = `${username}@${domain}`; +const targetJid = + process.env.XMPP_DEFAULT_AGENT_JID ?? `assistant@gateway.${domain}`; +const organizationId = process.env.XMPP_ORGANIZATION_ID; +const bridgeSecret = process.env.AGENT_BRIDGE_SECRET; + +if (process.env.XMPP_E2E_ALLOW_SELF_SIGNED === "1") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +} + +assert.ok(organizationId, "XMPP_ORGANIZATION_ID is required"); +assert.ok(bridgeSecret, "AGENT_BRIDGE_SECRET is required"); + +const manifestResponse = await fetch( + `${process.env.AGENT_URL ?? "http://127.0.0.1:2000"}/internal/xmpp/export-tools/manifest`, + { headers: { authorization: `Bearer ${bridgeSecret}` } }, +); +assert.equal(manifestResponse.status, 200); +const exported = z + .object({ tools: z.array(z.object({ name: z.string() })) }) + .parse(await manifestResponse.json()); +assert.deepEqual( + exported.tools.map((tool) => tool.name), + ["handle_crm_request", "ping"], +); + +const agent = createXmppManifest({ + jid: targetJid, + organizationId, + version: process.env.XMPP_AGENT_VERSION, +}); +const xmpp = client({ + service, + domain, + username, + password, + resource: `compcrm-e2e-${randomUUID()}`, +}); +const stanzas: Element[] = []; +const waiters = new Set<() => void>(); +xmpp.on("stanza", (stanza) => { + stanzas.push(stanza); + for (const notify of waiters) notify(); +}); + +try { + await xmpp.start(); + await xmpp.send(xml("presence")); + const requestId = `request-${randomUUID()}`; + const task: AgentTaskRecord = { + taskId: `caller-${randomUUID()}`, + requestId, + callerJid, + notificationJid: callerJid, + targetJid, + tenantId: organizationId, + tool: "ping", + apiVersion: agent.manifest.agent.version, + manifestHash: agent.manifestHash, + arguments: {}, + state: "accepted", + revision: 0, + fingerprint: "caller-fingerprint", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + retainUntil: new Date(Date.now() + 60_000).toISOString(), + }; + const invocation = buildTaskInvocation(task); + await xmpp.send(invocation); + const acceptedIq = await waitFor( + (stanza) => + stanza.is("iq") && + stanza.attrs.id === invocation.attrs.id && + stanza.attrs.type === "result", + ); + const accepted = acceptedIq.getChild("accepted", AGENT_TASK_NS); + assert.ok(accepted); + const taskId = String(accepted.attrs["task-id"]); + const completedStanza = await waitFor((stanza) => { + const event = parseTaskEvent(stanza); + return event?.taskId === taskId && event.type === "completed"; + }); + const completed = parseTaskEvent(completedStanza); + assert.ok(completed); + const result = z + .object({ + structuredContent: z.object({ + status: z.literal("ok"), + requestId: z.string(), + }), + }) + .parse(completed.payload.result); + assert.deepEqual(result.structuredContent, { + status: "ok", + requestId: taskId, + }); + console.log("XMPP export E2E passed"); +} finally { + await xmpp.stop(); +} + +async function waitFor( + predicate: (stanza: Element) => boolean, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const match = stanzas.find(predicate); + if (match) return match; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Timed out waiting for XMPP stanza"); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiters.delete(notify); + reject(new Error("Timed out waiting for XMPP stanza")); + }, remaining); + const notify = () => { + clearTimeout(timer); + waiters.delete(notify); + resolve(); + }; + waiters.add(notify); + }); + } +} diff --git a/apps/agent/test/xmpp-task-store.integration.spec.ts b/apps/agent/test/xmpp-task-store.integration.spec.ts new file mode 100644 index 000000000..be83c8cf6 --- /dev/null +++ b/apps/agent/test/xmpp-task-store.integration.spec.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { PostgresXmppTaskStore } from "../src/xmpp/task-store"; + +const suffix = crypto.randomUUID(); +const organizationId = `xmpp-store-${suffix}`; +const otherOrganizationId = `xmpp-store-other-${suffix}`; +const store = new PostgresXmppTaskStore(organizationId); + +beforeAll(async () => { + await db.organization.createMany({ + data: [ + { + id: organizationId, + name: "XMPP Store Test", + slug: organizationId, + createdAt: new Date(), + }, + { + id: otherOrganizationId, + name: "XMPP Store Other Test", + slug: otherOrganizationId, + createdAt: new Date(), + }, + ], + }); +}); + +afterAll(async () => { + await db.organization.deleteMany({ + where: { id: { in: [organizationId, otherOrganizationId] } }, + }); + await db.$disconnect(); +}); + +describe("PostgreSQL XMPP task state", () => { + it("replays identical requests and rejects changed requests", async () => { + const first = await store.admit(admission("replay", "fingerprint-a")); + const replay = await store.admit(admission("replay", "fingerprint-a")); + + expect(first.replay).toBe(false); + expect(replay.replay).toBe(true); + expect(replay.task.taskId).toBe(first.task.taskId); + + await expect( + store.admit(admission("replay", "fingerprint-b")), + ).rejects.toThrow("replay conflict"); + }); + + it("enforces organization ownership and optimistic revisions", async () => { + const admitted = await store.admit(admission("transition", "transition")); + const running = await store.transition(admitted.task.taskId, 0, { + state: "RUNNING", + progress: { stage: "started" }, + }); + + expect(running.state).toBe("running"); + expect(running.revision).toBe(1); + expect( + await new PostgresXmppTaskStore(otherOrganizationId).get( + admitted.task.taskId, + ), + ).toBeNull(); + await expect( + store.transition(admitted.task.taskId, 0, { state: "COMPLETED" }), + ).rejects.toThrow("revision conflict"); + }); + + it("fails interrupted work and deletes expired terminal rows", async () => { + const interrupted = await store.admit( + admission("interrupted", "interrupted"), + ); + const expired = await store.admit( + admission("expired", "expired", new Date(Date.now() - 1_000)), + ); + await store.transition(expired.task.taskId, 0, { state: "COMPLETED" }); + + expect(await store.failInterrupted()).toBeGreaterThanOrEqual(1); + expect((await store.get(interrupted.task.taskId))?.state).toBe("failed"); + expect(await store.deleteExpired()).toBe(1); + expect(await store.get(expired.task.taskId)).toBeNull(); + }); +}); + +function admission(requestId: string, fingerprint: string, retainUntil?: Date) { + return { + id: crypto.randomUUID(), + requestId, + callerJid: "caller@example.test", + notificationJid: "caller@example.test/device", + targetJid: "assistant@agents.example.test", + tool: "ping", + apiVersion: "1.0.0", + manifestHash: "manifest", + fingerprint, + arguments: { message: requestId }, + retainUntil: retainUntil ?? new Date(Date.now() + 60_000), + }; +} diff --git a/apps/agent/tsconfig.json b/apps/agent/tsconfig.json index 14ff6177c..ef1b137fe 100644 --- a/apps/agent/tsconfig.json +++ b/apps/agent/tsconfig.json @@ -5,8 +5,8 @@ "module": "preserve", "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "types": ["node"] + "types": ["node", "bun"] }, - "include": ["agent/**/*.ts", "evals/**/*.ts"], + "include": ["agent/**/*.ts", "evals/**/*.ts", "src/**/*.ts"], "exclude": ["node_modules", ".eve"] } diff --git a/bun.lock b/bun.lock index 1c6df3e63..901e3f2a2 100644 --- a/bun.lock +++ b/bun.lock @@ -17,17 +17,24 @@ "name": "agent", "version": "0.0.1", "dependencies": { + "@agent-xmpp/core": "workspace:*", + "@agent-xmpp/gateway": "workspace:*", + "@agent-xmpp/protocol": "workspace:*", "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", "@crm/validation": "workspace:*", + "ai": "7.0.47", "context.dev": "2.10.0", "eve": "^0.29.4", + "ulid": "3.0.2", "zod": "^4.4.3", }, "devDependencies": { "@crm/typescript-config": "workspace:*", + "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "@xmpp/client": "0.14.0", "just-bash": "^3.2.0", "microsandbox": "^0.6.8", "typescript": "^5.9.2", @@ -117,6 +124,44 @@ "typescript": "^5", }, }, + "packages/agent-xmpp/core": { + "name": "@agent-xmpp/core", + "version": "0.1.0", + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "ajv": "8.17.1", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, + "packages/agent-xmpp/gateway": { + "name": "@agent-xmpp/gateway", + "version": "0.1.0", + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "@xmpp/component": "0.14.0", + "@xmpp/xml": "0.14.0", + "ulid": "3.0.2", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, + "packages/agent-xmpp/protocol": { + "name": "@agent-xmpp/protocol", + "version": "0.1.0", + "dependencies": { + "idn-hostname": "15.1.10", + "precis-wasm": "0.1.0", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, "packages/auth": { "name": "@crm/auth", "version": "0.0.0", @@ -252,6 +297,12 @@ "sharp", ], "packages": { + "@agent-xmpp/core": ["@agent-xmpp/core@workspace:packages/agent-xmpp/core"], + + "@agent-xmpp/gateway": ["@agent-xmpp/gateway@workspace:packages/agent-xmpp/gateway"], + + "@agent-xmpp/protocol": ["@agent-xmpp/protocol@workspace:packages/agent-xmpp/protocol"], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-N1P6bdW/aC5rxLeuGYgx3X4el3DoZy8UWlky+g+AeIZSmxaEi/AToHJL4cmZ6nCPHk1byqJWwC+PaOZG0hK0dw=="], "@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], @@ -436,6 +487,58 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -882,6 +985,8 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="], @@ -1028,6 +1133,8 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], @@ -1096,6 +1203,8 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1118,7 +1227,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], @@ -1178,19 +1287,93 @@ "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xmpp/base64": ["@xmpp/base64@0.14.0", "", {}, "sha512-tz2LuzLMtjGSVVeuXDnDw19H+uOrqhrRFcQKJ3THV0HVjerDewT8WKXdrtBVSQ7n4Ue0k+awYcIE7qDD9rwMMQ=="], + + "@xmpp/client": ["@xmpp/client@0.14.0", "", { "dependencies": { "@xmpp/client-core": "^0.14.0", "@xmpp/iq": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/reconnect": "^0.14.0", "@xmpp/resolve": "^0.14.0", "@xmpp/resource-binding": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/sasl-anonymous": "^0.14.0", "@xmpp/sasl-ht-sha-256-none": "^0.14.0", "@xmpp/sasl-plain": "^0.14.0", "@xmpp/sasl-scram-sha-1": "^0.14.0", "@xmpp/sasl2": "^0.14.0", "@xmpp/starttls": "^0.14.0", "@xmpp/stream-features": "^0.14.0", "@xmpp/stream-management": "^0.14.0", "@xmpp/tcp": "^0.14.0", "@xmpp/tls": "^0.14.0", "@xmpp/websocket": "^0.14.0", "saslmechanisms": "^0.1.1" } }, "sha512-Y6k77iifYOGuWncOLRr6dVmbsQvcsIFjYI9pXhkSzQDDNnd3hVJGgo/Xx+W/0/o1+kg/tPm/NwX5Yq77NTd9xA=="], + + "@xmpp/client-core": ["@xmpp/client-core@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0", "saslmechanisms": "^0.1.1" } }, "sha512-fW0C6vn4y8Jp1g8uBmCETuOmEDXpjFc/mfY8P0J88nhK2AdPOB5m0ntz0yyAXcPd16smnHVckiOsk9qBjF9dDA=="], + + "@xmpp/component": ["@xmpp/component@0.14.0", "", { "dependencies": { "@xmpp/component-core": "^0.14.0", "@xmpp/iq": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/reconnect": "^0.14.0" } }, "sha512-+o9FDP/eOn71NreR3wI+2jxm+csYM0eVX+WtvKy94SrPSmX59cDPpVe2IABIfAInx7bTggz6k/qgrUHuiMZzMQ=="], + + "@xmpp/component-core": ["@xmpp/component-core@0.14.0", "", { "dependencies": { "@xmpp/connection-tcp": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-rzrnLUxDu9vTxgWH0b8R8Mou67x5fZWy/0K6jWlB09jaltx4ovvewI+Mfis9LueMO9naNzjbq0PuxWII/HmccA=="], + + "@xmpp/connection": ["@xmpp/connection@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-VRnukXwiXWCsRVQVISUiAxHHgRWH37nxdUaz50o0+cUAYyqtlHIJ2zy+ZHMlxkEWtl+XciNLxwkJKbypJFQheQ=="], + + "@xmpp/connection-tcp": ["@xmpp/connection-tcp@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-gvYyUghuCHW0qalEYaZvbHeTnEoXc+hCCWP/qvoVGjjSQNwkYRUsy1/8RP/sA6RlQ2sR0nhpkJkJE6K/4rsFIQ=="], + + "@xmpp/error": ["@xmpp/error@0.14.0", "", {}, "sha512-b4W/MwAZl8basfmYhcemK3De92xBOiSrIz0VY2cnN2ss1VMsa4d32GuaJGBIpa4ulN/2DFb3vPCeTn5jXvHAJw=="], + + "@xmpp/events": ["@xmpp/events@0.14.0", "", { "dependencies": { "events": "^3.3.0" } }, "sha512-6mKRIEi69sYr8H9SrkJKaXobOgCq/sU6/pKTQS5cuQZCy0qLuyx9uttkOyAYmtUbVD+HSrXYS72KhMpdQQh88Q=="], + + "@xmpp/id": ["@xmpp/id@0.14.0", "", {}, "sha512-0n8OFYPWkBYDi5fHGiJT6SLg9ncOflTmINzjHzt5A3NxEMVrYmqxbRB44u3NLMk1gBNqGtEkc2wl0WHjS0tHWg=="], + + "@xmpp/iq": ["@xmpp/iq@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/id": "^0.14.0", "@xmpp/middleware": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-0r3QVKR4XAvkZ4shQwPBkSM21sSfj26Cg8+AYBkd0BKJTde7mRQCISBNidt1xiXA5VPH1+6Qx7fmtPzk3uel+Q=="], + + "@xmpp/jid": ["@xmpp/jid@0.14.0", "", {}, "sha512-ggiNgjblkeHPbHJ7JLOMIqxc1qQQt0gV+LhXI16afib2wLr120YgvRChY04gjkvQ+IF2sGz7mzPvcAukTmNGHA=="], + + "@xmpp/middleware": ["@xmpp/middleware@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/xml": "^0.14.0", "koa-compose": "^4.1.0" } }, "sha512-UGm7ed5NEMapE/z0jqY5daGSBqto/iciDjtyncoXHZdOnR4iFYFF9gSO/65Z1HsL2pbHQpcK0WS+Kn+FGOxPTw=="], + + "@xmpp/reconnect": ["@xmpp/reconnect@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0" } }, "sha512-XaaT3KrFLf1ZfYZCIx11zauT8bIVXRz9FMaGj4mofq1tC8SiKvIIqJXSgrouhkibb9vPeM9XSiGkH47B/1hc6g=="], + + "@xmpp/resolve": ["@xmpp/resolve@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-S4Rhupb2zS4EtT4EFUJLPl2pPSFPfrjp/1bxPil+zBt9sNcbrvIwSNaYeQAAfLD/yYb8/DhaxC/blvdHyM1Xzw=="], + + "@xmpp/resource-binding": ["@xmpp/resource-binding@0.14.0", "", { "dependencies": { "@xmpp/xml": "^0.14.0" } }, "sha512-b8EyHUOpkAS25b6cpOQ8fv7cCLfOpJq4dkgs6Td0MdODMmcfWEh38siZKaP0OoNmYWZCxBSANdsBmeidozJxqA=="], + + "@xmpp/sasl": ["@xmpp/sasl@0.14.0", "", { "dependencies": { "@xmpp/base64": "^0.14.0", "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-C6mWvtRhJCCjv0Q6uuc3FUuDdgUhnX7lCF9afmNT5umy1XB0l1iRmBJYTriSHNBwgErM3i3mydafeRjpuQfP5Q=="], + + "@xmpp/sasl-anonymous": ["@xmpp/sasl-anonymous@0.14.0", "", { "dependencies": { "sasl-anonymous": "^0.1.0" } }, "sha512-lZ8jaYuKBpfQ2T5Bv+7yxII9OduV1a+mlNkKtG94WchrVzR8fU6VpbTrez88KajA42ngcVCtiO3i6tPepYyK8Q=="], + + "@xmpp/sasl-ht-sha-256-none": ["@xmpp/sasl-ht-sha-256-none@0.14.0", "", {}, "sha512-LmMSzX35bI/V2lV26k/hYPm8Pn7InsBEX72b86+ugIBQpzD8/jmB1F/Mfm5vrbWkMLl2KWe61gE+L/p0mvLZdA=="], + + "@xmpp/sasl-plain": ["@xmpp/sasl-plain@0.14.0", "", { "dependencies": { "sasl-plain": "^0.1.0" } }, "sha512-TGQX6gsCi6LP2lPNkkd+HoZ1ynCesRMQrtJrWbDAa6oMyCwie9xuBcG/5lDJ5fn03G7xxRh1YSQp+x8yFK+q4w=="], + + "@xmpp/sasl-scram-sha-1": ["@xmpp/sasl-scram-sha-1@0.14.0", "", { "dependencies": { "sasl-scram-sha-1": "^1.3.0" } }, "sha512-0ZrsMDx9Haou2yTCzDCwvMsYPvHCxF2bkpyBuiPoYAOWRsIQH1TssZK3d88N7wSvvojter905DNwLPizdaF3og=="], + + "@xmpp/sasl2": ["@xmpp/sasl2@0.14.0", "", { "dependencies": { "@xmpp/base64": "^0.14.0", "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-OcDIDr9xwiOO58yqr6hj0eh9CfKOvwBbNDNhC0wByA07cwOsoojLb1dv3WR7pKk1cETv6wHWQ1o/leRoDB/Yzg=="], + + "@xmpp/starttls": ["@xmpp/starttls@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "@xmpp/tls": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-n21Oy5pyD6Cipo96SAVI1ASx/5qo/z3W7J1TWZSG3/gGgrTaCB+VSV0xI+FqRexVJysg3lw3cBidYQL+Lp/pHw=="], + + "@xmpp/stream-features": ["@xmpp/stream-features@0.14.0", "", {}, "sha512-XoogP53qv1lzq/TNnzydT0KmmDKsJ7vpbV1c33fcuBDY0LCmPWA2U5th+Atn3JjEMlyznpVDFco9BF1NgFTHzg=="], + + "@xmpp/stream-management": ["@xmpp/stream-management@0.14.0", "", { "dependencies": { "@xmpp/error": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/time": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-i7qLB8KXzLtm2TZXgtVlBprI4Vm8lykJ7Gth2TFavBTky+PQFNr2BH4lGxxvZ7/WkYbhx2jsmDT9JjI7sUCRdw=="], + + "@xmpp/tcp": ["@xmpp/tcp@0.14.0", "", { "dependencies": { "@xmpp/connection-tcp": "^0.14.0" } }, "sha512-vJb5Y60ub+YClK01l+653+qpIe6vedw/82szuB4IU50eTDTL8voer6eGK7AMJOJzoZzRofbLh8Ow5txAyZ8vXw=="], + + "@xmpp/time": ["@xmpp/time@0.14.0", "", {}, "sha512-KlOLwZXXYrRiIZ+Lg0Rg+iDxIBLfCgVQOkIaYG2nieLVtbrHItI5gv2eI2I6m0cPsX8LajGkCpvQTqUCNZclag=="], + + "@xmpp/tls": ["@xmpp/tls@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/connection-tcp": "^0.14.0", "@xmpp/events": "^0.14.0" } }, "sha512-nY/nHlHYgs3i2+xwt/5Ff3J4aCdZADdTOH+NwdfgqBIK9kWDJPW43nnIrbrZgmUL3YOjW5sb1lUI20bFUJACww=="], + + "@xmpp/websocket": ["@xmpp/websocket@0.14.0", "", { "dependencies": { "@xmpp/connection": "^0.14.0", "@xmpp/events": "^0.14.0", "@xmpp/xml": "^0.14.0" } }, "sha512-mRjpRHtOujrPIT7I/X2xvn8w6X/7J8gGPfGIKNGLJHBQu1OlT5noUd++mslCAcGdCOf5TGwtJvE7a8CPtSnTFg=="], + + "@xmpp/xml": ["@xmpp/xml@0.14.0", "", { "dependencies": { "@xmpp/events": "^0.14.0", "ltx": "^3.1.2" } }, "sha512-1rrj3SaIM51wtJUn1l2n4FK2e1QLfEmma9bsD1utaaosA8elpCyWi12QDPfaYoTyWSXNvQ7mGzvFevUJUs1mcQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent": ["agent@workspace:apps/agent"], "ai": ["ai@7.0.47", "", { "dependencies": { "@ai-sdk/gateway": "4.0.36", "@ai-sdk/provider": "4.0.4", "@ai-sdk/provider-utils": "5.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-e0MpNtufu6JmcmwMUTgM1smCRkP5z014iPaKgnCm7kmMsTSIZbOJN2vglhPq0WIj/6x7ewVi6W0FyIR0pjaE3Q=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1216,6 +1399,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], @@ -1280,6 +1465,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1546,12 +1733,16 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1562,12 +1753,16 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eve": ["eve@0.29.4", "", { "dependencies": { "nitro": "3.0.260610-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.38", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-EwOmL37l+Iuu7Umno7at3flFpMs4AtT9cg1J4dt7EZ8ZdxaCvGXwvKGwiulMkG8oXkMQ5CNhxMi2ruHJwrtpwQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], @@ -1576,6 +1771,8 @@ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.6.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA=="], @@ -1642,6 +1839,8 @@ "fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], @@ -1734,6 +1933,8 @@ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "idn-hostname": ["idn-hostname@15.1.10", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-/mSXWRhVasTJ7Z4z18523rTA6CmStYN29yDt+oXi9fe1/M0SO2Un1BgUr3v28aAZG5hWicyUtIamC/juXt3nZQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -1846,6 +2047,8 @@ "knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="], + "koa-compose": ["koa-compose@4.1.0", "", {}, "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="], + "kysely": ["kysely@0.29.4", "", {}, "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -1902,6 +2105,8 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "ltx": ["ltx@3.1.2", "", {}, "sha512-tFSKojN92FqNK6eRTmKK/ROUTUYVWKAxgohz523TPhF1G3nR3DXQS/I7/705rEPrDSloKDgMdRlh0qgMFQoVYw=="], + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2090,6 +2295,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], @@ -2214,6 +2421,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "precis-wasm": ["precis-wasm@0.1.0", "", {}, "sha512-0DIxaIaiZRX4TJ9tMDuXBbgGGcCx93SaunKfcvrM91kbMRGW1NVbIalO0Ku4hF6H1ltl2aLKl9wDodmelL+IkQ=="], + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -2232,6 +2441,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], @@ -2358,6 +2569,14 @@ "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "sasl-anonymous": ["sasl-anonymous@0.1.0", "", {}, "sha512-x+0sdsV0Gie2EexxAUsx6ZoB+X6OCthlNBvAQncQxreEWQJByAPntj0EAgTlJc2kZicoc+yFzeR6cl8VfsQGfA=="], + + "sasl-plain": ["sasl-plain@0.1.0", "", {}, "sha512-X8mCSfR8y0NryTu0tuVyr4IS2jBunBgyG+3a0gEEkd0nlHGiyqJhlc4EIkzmSwaa7F8S4yo+LS6Cu5qxRkJrmg=="], + + "sasl-scram-sha-1": ["sasl-scram-sha-1@1.4.0", "", {}, "sha512-kdP8uAFkak8flnmKTldWnQ98IuC15ASdKxNHWwbqexRj7szHs7y+JEtigLkPoq9gnp/SAxxU4O2huL8OLqSr9w=="], + + "saslmechanisms": ["saslmechanisms@0.1.1", "", {}, "sha512-pVlvK5ysevz8MzybRnDIa2YMxn0OJ7b9lDiWhMoaKPoJ7YkAg/7YtNjUgaYzElkwHxsw8dBMhaEn7UP6zxEwPg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], @@ -2394,6 +2613,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -2422,9 +2643,11 @@ "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -2484,10 +2707,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -2514,6 +2741,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "turbo": ["turbo@2.10.8", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.8", "@turbo/darwin-arm64": "2.10.8", "@turbo/linux-64": "2.10.8", "@turbo/linux-arm64": "2.10.8", "@turbo/windows-64": "2.10.8", "@turbo/windows-arm64": "2.10.8" }, "bin": { "turbo": "bin/turbo" } }, "sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg=="], @@ -2534,13 +2763,15 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="], + "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -2594,6 +2825,10 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2604,6 +2839,8 @@ "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2650,6 +2887,12 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@agent-xmpp/core/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@agent-xmpp/gateway/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@agent-xmpp/protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -2706,8 +2949,20 @@ "@chevrotain/gast/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "@crm/auth/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/db/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/env/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/telemetry/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@crm/ui/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@crm/ui/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@crm/validation/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2728,6 +2983,8 @@ "@mermaid-js/parser/@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@nestjs/config/dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2736,12 +2993,16 @@ "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.9.1", "", { "dependencies": { "@prisma/debug": "7.9.1" } }, "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw=="], "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@prisma/streams-local/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@prisma/streams-local/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "@prisma/studio-core/@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], @@ -2900,6 +3161,20 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/body-parser/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/connect/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/pg/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/send/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/serve-static/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "@types/superagent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2908,8 +3183,14 @@ "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + "agent/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "agent/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "api/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "api/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "app/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -2930,6 +3211,8 @@ "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "bun-types/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2942,6 +3225,8 @@ "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], @@ -3032,6 +3317,12 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "vite/rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3050,6 +3341,18 @@ "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "@crm/auth/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/db/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/env/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/telemetry/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/ui/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@crm/validation/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3078,6 +3381,20 @@ "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/connect/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/pg/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/send/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/serve-static/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@types/superagent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3090,7 +3407,9 @@ "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "app/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "agent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "api/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "app/next/@next/env": ["@next/env@16.3.0", "", {}, "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw=="], @@ -3114,6 +3433,8 @@ "app/next/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3146,6 +3467,60 @@ "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/docs/agent.md b/docs/agent.md index e1fc98353..28e2cd00b 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -219,6 +219,26 @@ missing key removes a place to look. **Never an error, never throws.** `capabilitiesFrom()`/`markdownFor()` are the pure halves. `contextDevKey()` is the only resolver, and `lib/context-dev.ts` memoises its client on the key string. +## XMPP export tools + +`src/exports` contains the explicit external operation allowlist. An export uses +`defineExportTool` and cannot expose a normal Eve tool accidentally. + +`src/export-tools` owns validation, manifests, execution, and the Eve adapter. The +adapter calls the current channel's `send` function and uses native task mode. + +`agent/channels/xmpp.ts` is the required Eve channel location. It provides authenticated +manifest and invocation routes for the XMPP gateway host. + +`src/xmpp` owns ProtoXEP routing and PostgreSQL task persistence. Every task belongs to +one organization. The replay key includes the organization, caller, target, and request. + +The copied protocol, validation, and gateway runtime sources derive from Clawdike commit +`d2386b42741410533cb302ffee1540d33192b34c`. + +The gateway starts only when `XMPP_COMPONENT_ENABLED=1`. Missing XMPP configuration +removes the capability and leaves the normal agent process available. + ## Budget and scheduling - `lib/focus.ts` — per-session budget in `defineState`; running out is a normal ending. diff --git a/docs/environment.md b/docs/environment.md index 22417c60e..e95fb55cd 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -116,6 +116,7 @@ single place that knows what is set. | `BLOB_READ_WRITE_TOKEN` | Mirrors logos and photos into Blob | | `AI_GATEWAY_API_KEY` | The model. Not needed on Vercel (OIDC) | | `AGENT_BRIDGE_SECRET` | The rep-facing Agent panel — see `agent.md` | +| `XMPP_COMPONENT_*`, `XMPP_*` | Optional XMPP agent gateway — see `setup.md` | `BLOB_READ_WRITE_TOKEN` is also in `env.validation.ts` and `apps/api/turbo.json` because the API and the seed write pictures too. The Next.js app is deliberately diff --git a/docs/eve-export-tool-subsystem-spec.md b/docs/eve-export-tool-subsystem-spec.md new file mode 100644 index 000000000..843c2c373 --- /dev/null +++ b/docs/eve-export-tool-subsystem-spec.md @@ -0,0 +1,1615 @@ +# `exportTool` Subsystem Specification for Eve Agents + +## Status + +**Proposed** + +This document specifies an `exportTool` subsystem for an Eve agent that exposes selected agent-level operations to external callers such as an XMPP agent gateway. + +The key design goal is to expose **agent operations**, not raw Eve tools. + +An exported operation may contain: + +1. deterministic application logic, +2. durable evidence/artifact creation, +3. optional agentic reasoning through Eve, +4. local Eve tool calls made by that agent, +5. subagent or remote-agent delegation, +6. a typed, machine-readable final result. + +The external caller sees one stable operation such as: + +```text +handle_recording(url) +``` + +while the local implementation may internally perform a deterministic preprocessing pipeline and only invoke the LLM when judgment is actually required. + +--- + +# 1. Motivation + +An Eve tool is primarily a capability **called by the local model**. + +For example: + +```text +model -> crm_create_task(...) +``` + +An exported operation has the opposite direction: + +```text +remote agent -> local Eve agent operation +``` + +Treating the exported operation itself as an Eve tool is awkward because the remote caller has already selected the operation, and the operation may need to invoke the local agent for reasoning. + +The subsystem therefore introduces a separate abstraction: + +```text +exportTool +``` + +Despite the name, an `exportTool` is not necessarily an Eve model tool. It is an externally callable operation implemented by the application hosting the Eve agent. + +Its internal execution may be fully deterministic, fully agentic, or a mixture of both. + +--- + +# 2. Design Goals + +The subsystem MUST: + +- expose a deliberate, allowlisted set of remotely callable operations; +- describe each operation with a typed input schema; +- optionally describe a typed output schema; +- allow arbitrary deterministic TypeScript before and after LLM execution; +- allow the operation to inject work into the already-running Eve runtime without HTTP loopback; +- avoid creating `new Client({ host })` when the invocation already runs inside the Eve process; +- allow the agent to use its normal instructions, tools, skills, sandbox, state, and subagents; +- preserve cancellation; +- support progress reporting; +- support deterministic validation before any LLM tokens are spent; +- allow manifest/tool metadata generation for the external gateway; +- keep externally visible operation schemas separate from the model-facing Eve tool set; +- make it impossible to accidentally export every Eve tool; +- make deterministic-only operations possible without invoking an LLM. + +The subsystem SHOULD: + +- use Standard Schema-compatible schemas; +- support Zod directly; +- support JSON Schema export; +- map one remote invocation to one Eve task/session by default; +- make the Eve invocation mechanism replaceable; +- permit future structured-output support without changing exported operation implementations. + +--- + +# 3. Non-goals + +The subsystem does not implement: + +- XMPP stanza parsing; +- XMPP discovery; +- XMPP task persistence; +- Deepgram itself; +- CRM APIs; +- Eve's runtime; +- generic MCP transport. + +Those are consumers or dependencies of this subsystem. + +The subsystem is specifically the Eve-side execution and exported-operation layer. + +--- + +# 4. Conceptual Model + +The primary abstraction is: + +```text +External caller + | + v +exportTool operation + | + +---- deterministic TypeScript + | + +---- services / database / evidence + | + +---- optional ctx.send(...) + | + v + Eve agent turn + | + +---- LLM reasoning + +---- local Eve tools + +---- skills + +---- subagents + +---- sandbox + | + v + result +``` + +Example: + +```text +handle_recording(url) + | + +-- fetch URL + +-- transcode to 16 kHz mono + +-- call Deepgram + +-- persist transcript as evidence + | + +-- ctx.send("Process evidence ev_123...") + | + +-- agent reads evidence + +-- reasons about transcript + +-- updates CRM + +-- delegates work + +-- returns summary/actions +``` + +The deterministic steps do not consume model tokens. + +--- + +# 5. Filesystem Layout + +Recommended layout: + +```text +agent/ +├── agent.ts +├── instructions.md +│ +├── tools/ +│ ├── read_evidence.ts +│ ├── crm_find_contact.ts +│ ├── crm_add_note.ts +│ └── crm_create_task.ts +│ +├── channels/ +│ └── xmpp.ts +│ +└── exports/ + ├── handle_recording.ts + └── ping.ts + +src/ +├── export-tools/ +│ ├── define-export-tool.ts +│ ├── registry.ts +│ ├── executor.ts +│ ├── schema.ts +│ ├── context.ts +│ └── errors.ts +│ +├── services/ +│ ├── recordings.ts +│ ├── audio.ts +│ ├── deepgram.ts +│ └── evidence.ts +│ +└── xmpp/ + ├── gateway-client.ts + └── manifest.ts +``` + +`agent/tools/` remains the set of capabilities callable by the local Eve model. + +`agent/exports/` contains the operations callable by remote agents. + +These two sets MUST NOT be conflated. + +--- + +# 6. Public API + +## 6.1 `defineExportTool` + +The basic API: + +```ts +export const handleRecording = defineExportTool({ + description: "Handle a recording and perform appropriate follow-up work", + + inputSchema: z.object({ + url: z.string().url(), + }), + + outputSchema: z.object({ + evidenceId: z.string(), + summary: z.string(), + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), + }), + + async execute(input, ctx) { + // arbitrary deterministic and/or agentic work + }, +}); +``` + +The definition MUST be a plain serializable-capability description plus an executable function. + +Proposed types: + +```ts +export interface ExportToolDefinition { + readonly description: string; + + readonly inputSchema: StandardSchemaV1; + + readonly outputSchema?: StandardSchemaV1; + + readonly annotations?: ExportToolAnnotations; + + execute( + input: I, + ctx: ExportToolContext, + ): Promise | O; +} + +export interface ExportToolAnnotations { + readonly title?: string; + readonly idempotent?: boolean; + readonly readOnly?: boolean; + readonly destructive?: boolean; + readonly longRunning?: boolean; +} +``` + +Helper: + +```ts +export function defineExportTool( + definition: ExportToolDefinition, +): ExportToolDefinition { + return definition; +} +``` + +This wrapper may later attach a symbol or metadata marker so the registry can reject arbitrary objects. + +--- + +# 7. Naming + +The operation name SHOULD come from the filename, matching Eve's filesystem-first style. + +Example: + +```text +agent/exports/handle_recording.ts +``` + +becomes: + +```text +handle_recording +``` + +Do not duplicate the name inside the definition unless a later requirement justifies aliases. + +This avoids: + +```ts +defineExportTool({ + name: "handle_recording", + ... +}) +``` + +and keeps naming consistent with Eve tools. + +--- + +# 8. ExportTool Context + +The core context type: + +```ts +export interface ExportToolContext { + /** + * Abort when the external task is cancelled or the surrounding + * Eve runtime is shutting down. + */ + readonly abortSignal: AbortSignal; + + /** + * Identity and metadata for the remote invocation. + */ + readonly invocation: ExportInvocation; + + /** + * Deterministic application services. + */ + readonly services: ApplicationServices; + + /** + * Report progress to the external task system. + */ + progress(update: ExportProgress): Promise; + + /** + * Start a turn in the already-running Eve runtime. + * + * This is intentionally an abstraction over Eve's current + * channel send primitive rather than an HTTP Client. + */ + send( + request: ExportAgentRequest, + ): Promise>; +} +``` + +Supporting types: + +```ts +export interface ExportInvocation { + readonly requestId: string; + readonly operation: string; + readonly caller?: string; + readonly metadata?: Record; +} + +export interface ExportProgress { + readonly stage?: string; + readonly percent?: number; + readonly message?: string; +} + +export interface ExportAgentRequest { + readonly message: string | UserContent; + + /** + * Optional structured result contract. + * + * The adapter is responsible for mapping this to the best + * Eve runtime mechanism available in the installed Eve version. + */ + readonly outputSchema?: StandardSchemaV1; + + readonly title?: string; + + /** + * For remote RPC-style invocation this should normally be true. + */ + readonly taskMode?: boolean; + + /** + * Non-durable metadata that may be injected as ephemeral + * context if the Eve entry path supports it. + */ + readonly clientContext?: unknown; +} + +export interface ExportAgentResult { + readonly sessionId: string; + readonly value: T; +} +``` + +The operation MUST NOT instantiate an Eve HTTP client. + +It receives `ctx.send()` from the integration boundary. + +--- + +# 9. Why `ctx.send()` Is an Adapter + +Eve's public APIs distinguish several contexts. + +Eve custom channels can call `send()` from an inbound channel handler to start or resume a session. This runs the normal Eve runtime in-process. + +However, the exact set of supported options on channel-based `send()` differs across Eve entry surfaces, and structured `outputSchema` propagation is not uniformly available in every channel API at the time of writing. + +Therefore the exported operation SHOULD NOT depend directly on a specific Eve internal signature. + +Bad: + +```ts +async execute(input, ctx) { + return internalEveRuntimePrivateFunction(...); +} +``` + +Also undesirable: + +```ts +const client = new Client({ + host: process.env.EVE_URL!, +}); +``` + +Recommended: + +```ts +async execute(input, ctx) { + return ctx.send({ + message: "...", + outputSchema: ResultSchema, + taskMode: true, + }); +} +``` + +The channel/integration adapter owns the Eve-version-specific implementation. + +This creates a small compatibility seam. + +--- + +# 10. Runtime Integration + +The preferred integration point is an Eve custom channel or another authored Eve runtime entrypoint that already has access to the in-process `send()` capability. + +Conceptually: + +```ts +export default defineChannel({ + routes: [ + // optional HTTP routes if needed by the integration + ], + + async receive(input, runtime) { + // runtime.send is Eve's in-process session dispatch primitive + }, +}); +``` + +For an XMPP bridge that already runs in the same Node process, the bridge should hand an invocation to the export-tool executor while providing an adapter around the live Eve `send` function. + +Pseudo-code: + +```ts +async function onXmppInvocation(invocation, eveRuntimeCtx) { + return executeExportTool( + invocation.tool, + invocation.arguments, + { + invocation, + abortSignal: invocation.abortSignal, + services, + progress: invocation.progress, + + send: async (request) => { + return sendThroughEveRuntime( + eveRuntimeCtx, + request, + ); + }, + }, + ); +} +``` + +The `exportTool` implementation itself remains unaware of XMPP and Eve transport details. + +--- + +# 11. Registry + +The registry explicitly defines the public surface. + +Example: + +```ts +import handleRecording from "../../agent/exports/handle_recording"; +import ping from "../../agent/exports/ping"; + +export const exportTools = { + handle_recording: handleRecording, + ping, +} as const; +``` + +The registry MUST be explicit. + +Do not recursively export everything in `agent/tools`. + +Automatic filesystem discovery of `agent/exports/*.ts` is acceptable if the directory itself is the allowlist. + +--- + +# 12. Invocation Executor + +The executor performs: + +1. tool lookup, +2. input validation, +3. context creation, +4. execution, +5. optional output validation, +6. normalized error conversion. + +Example: + +```ts +export async function executeExportTool( + name: string, + rawInput: unknown, + ctx: ExportToolContext, +): Promise { + const definition = exportTools[name]; + + if (!definition) { + throw new ExportToolNotFoundError(name); + } + + const inputResult = + await definition.inputSchema["~standard"].validate(rawInput); + + if (inputResult.issues) { + throw new ExportToolValidationError( + "Invalid export tool input", + inputResult.issues, + ); + } + + const output = await definition.execute( + inputResult.value, + ctx, + ); + + if (!definition.outputSchema) { + return output; + } + + const outputResult = + await definition.outputSchema["~standard"].validate(output); + + if (outputResult.issues) { + throw new ExportToolValidationError( + "Invalid export tool output", + outputResult.issues, + ); + } + + return outputResult.value; +} +``` + +Input validation MUST occur before deterministic processing and before any LLM call. + +Output validation SHOULD occur before sending the result back to the gateway. + +--- + +# 13. Full Example: `handle_recording` + +## 13.1 Schema + +```ts +// agent/exports/handle_recording.ts + +import { z } from "zod"; +import { defineExportTool } from "../../src/export-tools/define-export-tool"; + +export const HandleRecordingInput = z.object({ + url: z.string().url(), +}); + +export const HandleRecordingOutput = z.object({ + evidenceId: z.string(), + + transcript: z.object({ + durationSeconds: z.number().nonnegative(), + language: z.string().optional(), + }), + + summary: z.string(), + + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), +}); +``` + +--- + +## 13.2 Implementation + +```ts +export default defineExportTool({ + description: + "Fetch and transcribe a recording, preserve the transcript as evidence, " + + "then have the secretary agent interpret it and perform appropriate follow-up work.", + + inputSchema: HandleRecordingInput, + outputSchema: HandleRecordingOutput, + + annotations: { + title: "Handle recording", + idempotent: false, + readOnly: false, + longRunning: true, + }, + + async execute({ url }, ctx) { + // + // Deterministic phase + // + + await ctx.progress({ + stage: "fetch", + percent: 5, + message: "Fetching recording", + }); + + const recording = await ctx.services.recordings.fetch(url, { + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "transcode", + percent: 20, + message: "Transcoding recording", + }); + + const audio = await ctx.services.audio.transcode(recording, { + sampleRate: 16_000, + channels: 1, + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "transcribe", + percent: 40, + message: "Transcribing recording", + }); + + const transcription = + await ctx.services.deepgram.transcribe(audio, { + signal: ctx.abortSignal, + }); + + await ctx.progress({ + stage: "evidence", + percent: 60, + message: "Storing transcript as evidence", + }); + + const evidence = await ctx.services.evidence.create({ + type: "recording-transcript", + + source: { + kind: "url", + url, + }, + + content: transcription.text, + + metadata: { + durationSeconds: transcription.durationSeconds, + language: transcription.language, + provider: "deepgram", + }, + }); + + // + // Agentic phase + // + + await ctx.progress({ + stage: "reasoning", + percent: 70, + message: "Processing transcript", + }); + + const agentResult = await ctx.send({ + taskMode: true, + + title: "Process recording", + + message: ` +A new recording has been transcribed and stored as evidence. + +Evidence ID: ${evidence.id} + +Process this recording according to your secretary responsibilities. + +Inspect the evidence using the available evidence tools. Determine the +important facts, commitments, requests, deadlines, follow-ups, and CRM +implications. Perform appropriate actions using your available tools and +agents. + +Do not claim an action was performed unless the corresponding tool call +succeeded. + +Return: +- a concise summary; +- the actions actually taken. +`.trim(), + + outputSchema: z.object({ + summary: z.string(), + + actionsTaken: z.array( + z.object({ + type: z.string(), + description: z.string(), + }), + ), + }), + + clientContext: { + invocation: "handle_recording", + externalRequestId: ctx.invocation.requestId, + caller: ctx.invocation.caller, + }, + }); + + await ctx.progress({ + stage: "complete", + percent: 100, + message: "Recording processed", + }); + + return { + evidenceId: evidence.id, + + transcript: { + durationSeconds: transcription.durationSeconds, + language: transcription.language, + }, + + summary: agentResult.value.summary, + actionsTaken: agentResult.value.actionsTaken, + }; + }, +}); +``` + +The important property is that the model sees none of the fetch/transcode/Deepgram machinery. + +The LLM is invoked only after the transcript has been produced. + +--- + +# 14. Evidence Tool + +The agent needs a way to inspect the transcript. + +Recommended model-facing Eve tool: + +```ts +// agent/tools/read_evidence.ts + +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { evidence } from "../../src/services/evidence"; + +export default defineTool({ + description: + "Read stored evidence such as transcripts, messages, and documents.", + + inputSchema: z.object({ + id: z.string(), + start: z.number().int().nonnegative().optional(), + limit: z.number().int().positive().max(20000).optional(), + }), + + async execute({ id, start = 0, limit = 8000 }, ctx) { + const item = await evidence.get(id); + + if (!item) { + throw new Error(`Evidence not found: ${id}`); + } + + const chunk = item.content.slice(start, start + limit); + + return { + id: item.id, + type: item.type, + content: chunk, + start, + end: start + chunk.length, + totalLength: item.content.length, + hasMore: start + chunk.length < item.content.length, + }; + }, +}); +``` + +For long transcripts, also expose: + +```text +search_evidence(id, query) +``` + +so the agent does not need to inject a complete hour-long transcript into model context. + +--- + +# 15. In-Process `ctx.send()` Adapter + +The exported-operation context SHOULD expose a stable application-level `send()` method. + +The integration layer maps it to Eve's current runtime API. + +Conceptual implementation: + +```ts +function makeExportToolContext( + invocation: XmppInvocation, + eveChannelContext: EveChannelContext, +): ExportToolContext { + return { + abortSignal: invocation.abortSignal, + + invocation: { + requestId: invocation.requestId, + operation: invocation.tool, + caller: invocation.from, + }, + + services, + + progress: async (update) => { + await invocation.reportProgress(update); + }, + + send: async (request) => { + const result = await eveChannelContext.send( + request.message, + { + auth: invocationAuth(invocation), + mode: request.taskMode ? "task" : undefined, + title: request.title, + + // If/when the selected Eve send surface accepts these directly: + // outputSchema: request.outputSchema, + // clientContext: request.clientContext, + }, + ); + + return await collectAgentResult( + result, + request.outputSchema, + ); + }, + }; +} +``` + +This snippet is intentionally adapter-level pseudocode. + +The installed Eve version determines the exact channel `send()` result type and which per-turn fields are directly supported. + +The operation API remains stable. + +--- + +# 16. Structured Result Compatibility + +At the time of this design, Eve supports structured task results through `outputSchema` in task-mode/client-oriented surfaces, and emits `result.completed` for such turns. + +However, not all custom-channel/cross-channel entry surfaces currently propagate `outputSchema` uniformly. + +Therefore the subsystem MUST isolate this behavior behind: + +```ts +ctx.send(...) +``` + +and: + +```ts +collectAgentResult(...) +``` + +The preferred order of implementation is: + +1. use native Eve structured-output support when available on the in-process send path; +2. otherwise use a small compatibility adapter; +3. do not make the exported operation instantiate an HTTP `Client`; +4. do not import private Eve runtime internals. + +A future Eve upgrade should require changing only the adapter. + +--- + +# 17. Result Collection + +`ctx.send()` should resolve only when the task-mode turn reaches a terminal state. + +Conceptually: + +```ts +async function collectAgentResult( + session: EveSessionHandle, + schema?: StandardSchemaV1, +): Promise> { + for await (const event of session.stream()) { + switch (event.type) { + case "result.completed": + return { + sessionId: session.id, + value: event.data.result as T, + }; + + case "turn.failed": + case "session.failed": + throw new ExportAgentRunError(event); + + case "turn.cancelled": + throw new ExportCancelledError(); + } + } + + throw new ExportAgentRunError( + "Eve session ended without a result", + ); +} +``` + +Exact event names/types MUST follow the installed Eve version. + +--- + +# 18. Cancellation + +Cancellation MUST propagate end-to-end: + +```text +remote cancellation + | + v +export invocation AbortController + | + +-- fetch abort + +-- transcode abort/kill + +-- Deepgram abort + +-- evidence write abort where possible + | + +-- Eve turn cancellation +``` + +All deterministic services SHOULD accept an `AbortSignal`. + +Example: + +```ts +await recordings.fetch(url, { + signal: ctx.abortSignal, +}); +``` + +The Eve adapter SHOULD bind the same cancellation source to the Eve task/session cancellation API. + +The mapping should be retained: + +```text +external task id -> Eve session id / turn id +``` + +until completion. + +--- + +# 19. Progress + +Progress is application-level and independent of LLM text. + +Recommended stages for `handle_recording`: + +```text +5% fetch +20% transcode +40% transcribe +60% evidence +70% reasoning +100% complete +``` + +The percentages are advisory. + +The gateway should treat stage/message as more authoritative than exact percentage. + +Agent stream events MAY also be translated into richer progress messages, but the exported operation should not depend on model narration. + +--- + +# 20. Error Model + +Define normalized error types: + +```ts +export class ExportToolNotFoundError extends Error {} +export class ExportToolValidationError extends Error {} +export class ExportToolExecutionError extends Error {} +export class ExportAgentRunError extends Error {} +export class ExportCancelledError extends Error {} +``` + +Suggested externally visible error codes: + +```text +EXPORT_TOOL_NOT_FOUND +INVALID_ARGUMENTS +DETERMINISTIC_PROCESSING_FAILED +AGENT_RUN_FAILED +CANCELLED +OUTPUT_VALIDATION_FAILED +INTERNAL_ERROR +``` + +Do not leak arbitrary stack traces to remote callers. + +Log the full cause locally. + +--- + +# 21. Idempotency + +Some exported operations cause side effects. + +`handle_recording` may: + +- create evidence, +- update CRM state, +- create tasks, +- send messages. + +The invocation subsystem SHOULD pass a stable `requestId`. + +Deterministic side effects SHOULD use it as an idempotency key where practical. + +Example: + +```ts +const evidence = await evidence.create({ + idempotencyKey: + `handle_recording:${ctx.invocation.requestId}:transcript`, + ... +}); +``` + +For an external retry of the same invocation, the subsystem SHOULD avoid creating duplicate evidence or CRM work. + +If the XMPP gateway already guarantees exactly-once logical task identity, use that task/request identifier. + +--- + +# 22. Manifest Generation + +The externally visible tool manifest is generated from `agent/exports/`, not from `agent/tools/`. + +For each export: + +```ts +interface ExportToolManifestEntry { + name: string; + description: string; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: ExportToolAnnotations; +} +``` + +Conceptually: + +```ts +function manifestEntry( + name: string, + definition: ExportToolDefinition, +): ExportToolManifestEntry { + return { + name, + description: definition.description, + inputSchema: toJsonSchema(definition.inputSchema), + outputSchema: definition.outputSchema + ? toJsonSchema(definition.outputSchema) + : undefined, + annotations: definition.annotations, + }; +} +``` + +Do not maintain a second handwritten schema in the gateway. + +The authored schema is the source of truth. + +--- + +# 23. Security Boundary + +Only definitions under the export registry are remotely callable. + +For example: + +```text +agent/tools/bash.ts +agent/tools/write_file.ts +agent/tools/send_email.ts +``` + +do NOT automatically become: + +```text +remote.bash(...) +remote.write_file(...) +remote.send_email(...) +``` + +The public XMPP surface might expose only: + +```text +handle_recording +prepare_followup +process_invoice +``` + +The local model may internally call `send_email`, but a remote caller cannot invoke it directly unless it is intentionally exported. + +--- + +# 24. Deterministic-only Export + +Not every exported operation needs Eve. + +Example: + +```ts +export default defineExportTool({ + description: "Return the current agent build information", + + inputSchema: z.object({}), + + outputSchema: z.object({ + version: z.string(), + commit: z.string(), + }), + + async execute(_input, ctx) { + return { + version: ctx.services.build.version, + commit: ctx.services.build.commit, + }; + }, +}); +``` + +No model call occurs. + +This is a useful property of the abstraction. + +--- + +# 25. Agent-only Export + +At the other extreme: + +```ts +export default defineExportTool({ + description: "Review a customer situation and decide what to do", + + inputSchema: z.object({ + customerId: z.string(), + }), + + outputSchema: ReviewResult, + + async execute({ customerId }, ctx) { + const result = await ctx.send({ + taskMode: true, + message: + `Review customer ${customerId} according to your normal responsibilities.`, + outputSchema: ReviewResult, + }); + + return result.value; + }, +}); +``` + +The subsystem supports both extremes without changing the external contract. + +--- + +# 26. Recommended Session Semantics + +For RPC-style exported operations, the default SHOULD be: + +```text +one remote invocation -> one fresh Eve task/session +``` + +Reasons: + +- no accidental conversational contamination; +- deterministic ownership; +- straightforward cancellation; +- straightforward task/result mapping; +- simple retry semantics. + +Future exported operations MAY explicitly opt into a durable conversation/session key, but this should not be the default. + +--- + +# 27. Auth and Principal Mapping + +The integration layer should decide which Eve principal represents the remote invocation. + +Possible policies: + +```text +service principal: + xmpp-agent-gateway + +forwarded caller: + agent@example.com + +compound principal: + xmpp:agent@example.com +``` + +The exported operation should not construct Eve authentication manually. + +Provide it through the `ctx.send()` adapter. + +--- + +# 28. Testing + +## 28.1 Unit-test deterministic processing + +Mock `ctx.send()`. + +Example: + +```ts +it("transcribes before invoking the agent", async () => { + const calls: string[] = []; + + const ctx = makeTestContext({ + recordings: { + fetch: async () => { + calls.push("fetch"); + return recording; + }, + }, + + audio: { + transcode: async () => { + calls.push("transcode"); + return wav; + }, + }, + + deepgram: { + transcribe: async () => { + calls.push("transcribe"); + return { + text: "hello", + durationSeconds: 2, + }; + }, + }, + + evidence: { + create: async () => { + calls.push("evidence"); + return { id: "ev_1" }; + }, + }, + + send: async () => { + calls.push("send"); + return { + sessionId: "ses_1", + value: { + summary: "hello", + actionsTaken: [], + }, + }; + }, + }); + + await handleRecording.execute( + { url: "https://example.com/a.mp3" }, + ctx, + ); + + expect(calls).toEqual([ + "fetch", + "transcode", + "transcribe", + "evidence", + "send", + ]); +}); +``` + +This test spends zero model tokens. + +--- + +## 28.2 Validation test + +Verify invalid URLs fail before service calls: + +```ts +await expect( + executeExportTool( + "handle_recording", + { url: "not a URL" }, + ctx, + ), +).rejects.toThrow(ExportToolValidationError); +``` + +--- + +## 28.3 Deterministic-only test + +Verify an export can complete without calling `ctx.send()`. + +--- + +## 28.4 Eve integration test + +Use Eve's testing/eval facilities or a deterministic mock model to assert: + +- the exported operation creates an Eve task; +- the evidence ID is in the task message; +- the local agent calls `read_evidence`; +- CRM tools can be called; +- a structured result is eventually returned. + +--- + +## 28.5 Cancellation test + +Cancel while: + +1. fetching, +2. transcoding, +3. transcribing, +4. waiting on Eve. + +Each should settle as `CANCELLED`. + +--- + +# 29. Observability + +Every export invocation SHOULD log: + +```text +requestId +operation +caller +startTime +endTime +duration +deterministic stage timings +Eve sessionId +result status +error code +``` + +Recommended trace structure: + +```text +export.handle_recording +├── fetch +├── transcode +├── deepgram +├── evidence.create +└── eve.send + ├── session + ├── model steps + └── tool calls +``` + +Do not log full transcripts by default. + +--- + +# 30. Suggested Initial Implementation Order + +1. Implement `defineExportTool`. +2. Implement explicit registry. +3. Implement Standard Schema input/output validation. +4. Implement `ExportToolContext`. +5. Implement XMPP invocation -> executor wiring. +6. Implement progress and cancellation. +7. Implement in-process Eve `send()` adapter. +8. Implement `handle_recording`. +9. Implement `read_evidence`. +10. Generate gateway manifest from export schemas. +11. Add idempotency. +12. Add integration tests. +13. Add structured-result compatibility shim if the selected Eve channel entry path cannot pass `outputSchema` directly. + +--- + +# 31. Minimal End-to-End Example + +The smallest meaningful implementation is: + +```ts +// agent/exports/handle_recording.ts + +export default defineExportTool({ + description: "Transcribe and process a recording", + + inputSchema: z.object({ + url: z.string().url(), + }), + + outputSchema: z.object({ + evidenceId: z.string(), + summary: z.string(), + }), + + async execute({ url }, ctx) { + // deterministic + const input = await ctx.services.recordings.fetch(url, { + signal: ctx.abortSignal, + }); + + const wav = await ctx.services.audio.transcode(input, { + sampleRate: 16_000, + channels: 1, + signal: ctx.abortSignal, + }); + + const transcript = + await ctx.services.deepgram.transcribe(wav, { + signal: ctx.abortSignal, + }); + + const evidence = await ctx.services.evidence.create({ + type: "transcript", + content: transcript.text, + }); + + // agentic + const run = await ctx.send({ + taskMode: true, + + message: + `Process transcript evidence ${evidence.id}. ` + + `Perform appropriate follow-up actions.`, + + outputSchema: z.object({ + summary: z.string(), + }), + }); + + return { + evidenceId: evidence.id, + summary: run.value.summary, + }; + }, +}); +``` + +The corresponding Eve-side evidence tool: + +```ts +// agent/tools/read_evidence.ts + +export default defineTool({ + description: "Read stored evidence", + + inputSchema: z.object({ + id: z.string(), + }), + + async execute({ id }) { + return evidence.get(id); + }, +}); +``` + +And the conceptual in-process adapter: + +```ts +const ctx = { + ..., + + send: async (request) => { + // Adapt the currently active Eve channel/runtime `send` + // primitive here. Do not instantiate `eve/client`. + + const session = await eveSend( + request.message, + { + mode: request.taskMode ? "task" : undefined, + title: request.title, + }, + ); + + return collectAgentResult( + session, + request.outputSchema, + ); + }, +}; +``` + +This is the central pattern the subsystem should preserve. + +--- + +# 32. Architectural Rule of Thumb + +Use deterministic code when the answer is procedural and known: + +```text +fetch +decode +transcode +hash +parse +validate +call API +persist +``` + +Use Eve when the operation requires judgment: + +```text +interpret +prioritize +classify ambiguous information +decide whether an action is warranted +choose among tools +compose context-sensitive communication +delegate to another agent +``` + +An `exportTool` is the orchestrator that can combine both. + +--- + +# 33. Eve API Notes + +This design intentionally stays within Eve's public concepts. + +Relevant current Eve behavior: + +- Eve tools are typed actions called by the model and receive runtime `ctx`. +- Custom channels normalize inbound work and use an in-process `send()` path to start or resume Eve sessions. +- Eve task-mode runs are intended for work that runs to completion rather than interactive HITL conversation. +- Eve emits `result.completed` for turns that use structured output. +- Eve's public TypeScript API documentation explicitly warns that APIs not exported through the public package surface are framework internals. +- Current Eve channel/cross-channel APIs do not expose every structured-output option uniformly; therefore this specification deliberately hides Eve send mechanics behind the local `ctx.send()` adapter. + +Do not import private Eve workflow/session internals to implement `exportTool`. + +--- + +# 34. References + +Eve documentation and source: + +- https://github.com/vercel/eve/blob/main/docs/tools/overview.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/overview.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/slack.mdx +- https://github.com/vercel/eve/blob/main/docs/channels/eve.mdx +- https://github.com/vercel/eve/blob/main/docs/concepts/sessions-runs-and-streaming.md +- https://github.com/vercel/eve/blob/main/docs/reference/typescript-api.md +- https://github.com/vercel/eve/blob/main/docs/agent-config.md +- https://github.com/vercel/eve/blob/main/docs/schedules.mdx +- https://github.com/vercel/eve/issues/214 +- https://github.com/vercel/eve/issues/1270 + +XMPP gateway client reference: + +- https://raw.githubusercontent.com/romanbsd/xmpp-agent-gateway/refs/heads/master/docs/xmpp-client-guide.md diff --git a/docs/setup.md b/docs/setup.md index 03b8912f7..55c5a24af 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -142,6 +142,40 @@ DATABASE_URL="…" bunx prisma migrate diff \ strings, asserted by `packages/env/test/root.spec.ts`. **Generate your own secret**; never reuse one from an example, a tutorial, or another environment. +## XMPP agent gateway + +The gateway starts with the agent when `XMPP_COMPONENT_ENABLED=1`. +It uses the root `.env` and the same PostgreSQL database. + +Set these required values: + +```sh +XMPP_COMPONENT_ENABLED="1" +XMPP_COMPONENT_JID="gateway.agents.example.com" +XMPP_COMPONENT_SECRET="..." +XMPP_ORGANIZATION_ID="..." +AGENT_BRIDGE_SECRET="..." +``` + +The XMPP server must contain the component account. +The gateway connects through `XMPP_COMPONENT_SERVICE`. +The default address is `xmpp://127.0.0.1:5275`. + +`XMPP_ALLOWED_CALLER_DOMAINS` limits discovery and invocation. +`XMPP_ALLOW_DESTRUCTIVE_CALLERS` lists bare JIDs that can run destructive exports. +An empty destructive caller list denies every destructive export. + +The gateway exposes only `apps/agent/src/exports` registry entries. +It stores task state in PostgreSQL for recovery and replay protection. + +Run the live invocation check against a configured test server: + +```sh +XMPP_E2E_ALLOW_SELF_SIGNED=1 bun run --filter=agent e2e:xmpp +``` + +Use `XMPP_E2E_ALLOW_SELF_SIGNED` only with an isolated server certificate. + ## Tests ```sh diff --git a/package.json b/package.json index 534ec9d00..4c236c3ad 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "packages/agent-xmpp/*" ] } diff --git a/packages/agent-xmpp/core/package.json b/packages/agent-xmpp/core/package.json new file mode 100644 index 000000000..db19dcc25 --- /dev/null +++ b/packages/agent-xmpp/core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@agent-xmpp/core", + "version": "0.1.0", + "description": "ProtoXEP schema validation and canonicalization", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "ajv": "8.17.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/agent-xmpp/core/src/index.ts b/packages/agent-xmpp/core/src/index.ts new file mode 100644 index 000000000..8868041f9 --- /dev/null +++ b/packages/agent-xmpp/core/src/index.ts @@ -0,0 +1 @@ +export * from './schema.js'; diff --git a/packages/agent-xmpp/core/src/schema-worker.ts b/packages/agent-xmpp/core/src/schema-worker.ts new file mode 100644 index 000000000..60fdcc5fb --- /dev/null +++ b/packages/agent-xmpp/core/src/schema-worker.ts @@ -0,0 +1,57 @@ +import { parentPort } from 'node:worker_threads'; + +import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'; +import { isXep0082DateTime } from '@agent-xmpp/protocol'; + +interface ValidationRequest { + id: number; + schemaHash: string; + schema: Record; + value: unknown; +} + +interface ValidationResponse { + id: number; + errors?: string[]; + failure?: string; +} + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat('uri', { + type: 'string', + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat('date-time', isXep0082DateTime); + +const validators = new Map(); + +parentPort?.on('message', (request: ValidationRequest) => { + const response: ValidationResponse = { id: request.id }; + try { + let validate = validators.get(request.schemaHash); + if (!validate) { + validate = ajv.compile(request.schema); + validators.set(request.schemaHash, validate); + } + response.errors = validate(request.value) ? [] : (validate.errors ?? []).map(formatError); + } catch (error) { + response.failure = error instanceof Error ? error.message : String(error); + } + parentPort?.postMessage(response); +}); + +function formatError(error: ErrorObject): string { + return `${error.instancePath || '$'} ${error.message ?? error.keyword}`; +} diff --git a/packages/agent-xmpp/core/src/schema.ts b/packages/agent-xmpp/core/src/schema.ts new file mode 100644 index 000000000..95f243b5a --- /dev/null +++ b/packages/agent-xmpp/core/src/schema.ts @@ -0,0 +1,521 @@ +import { createHash } from "node:crypto"; +import { Worker } from "node:worker_threads"; +import { + type AgentApiManifest, + assertUnicodeScalarString, + DEFAULT_JSON_LIMITS, + isApiVersion, + isNormalizedEndpointJid, + isToolName, + isXep0082DateTime, + type JsonSchema, + parseStrictJson, + type RegisteredTool, + XMPP_TOOL_EXTENSION_KEY, +} from "@agent-xmpp/protocol"; +import EVENT_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/event.schema.json" with { + type: "json", +}; +import MANIFEST_SCHEMA_DOCUMENT from "@agent-xmpp/protocol/schema/manifest.schema.json" with { + type: "json", +}; +import { + Ajv2020, + type ErrorObject, + type ValidateFunction, +} from "ajv/dist/2020.js"; + +export const MANIFEST_MAX_BYTES = 1_048_576; +export const SCHEMA_MAX_BYTES = 262_144; +export const SCHEMA_MAX_DEPTH = 64; +export const SCHEMA_MAX_NODES = 10_000; +export const SCHEMA_MAX_PATTERN_BYTES = 4_096; +export const SCHEMA_MAX_PENDING_VALIDATIONS = 64; + +export class SchemaResourceLimitError extends Error {} + +const MANIFEST_SCHEMA = MANIFEST_SCHEMA_DOCUMENT as JsonSchema; +const EVENT_SCHEMA = EVENT_SCHEMA_DOCUMENT as JsonSchema; + +const ajv = new Ajv2020({ + strict: true, + allErrors: true, + validateSchema: true, + unicodeRegExp: true, + ownProperties: true, +}); +ajv.addFormat("uri", { + type: "string", + validate(value: string): boolean { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + }, +}); +ajv.addFormat("date-time", isXep0082DateTime); +const manifestValidator = ajv.compile(MANIFEST_SCHEMA); +const validatorCache = new Map(); +const SCHEMA_WORKER_COUNT = 2; +const DEFAULT_SCHEMA_TIMEOUT_MS = 500; + +interface WorkerRequest { + id: number; + schemaHash: string; + schema: JsonSchema; + value: unknown; +} + +interface WorkerResponse { + id: number; + errors?: string[]; + failure?: string; +} + +interface PendingValidation { + request: WorkerRequest; + timeoutMs: number; + resolve: (errors: string[]) => void; + reject: (error: Error) => void; +} + +interface SchemaWorkerSlot { + worker: Worker; + pending?: PendingValidation; + timer?: ReturnType; +} + +let nextValidationId = 1; +const validationQueue: PendingValidation[] = []; +const schemaWorkers: SchemaWorkerSlot[] = []; + +/** RFC 8785 JSON Canonicalization Scheme serialization. */ +export function canonicalJson(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") { + assertUnicodeScalarString(value); + return JSON.stringify(value); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("non-finite JSON number"); + return Object.is(value, -0) ? "0" : JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${canonicalJson(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + throw new Error(`unsupported JSON value: ${typeof value}`); +} + +/** XEP-0300 SHA-256 value: standard padded Base64, without an algorithm prefix. */ +export function digestJson(value: unknown): string { + return createHash("sha256") + .update(canonicalJson(value), "utf8") + .digest("base64"); +} + +export function assertJsonValueBounded( + value: unknown, + maxBytes: number, + label = "JSON value", +): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new Error("JSON byte limit must be a positive integer"); + const pending: Array<{ value: unknown; depth: number }> = [ + { value, depth: 0 }, + ]; + const visited = new WeakSet(); + let members = 0; + while (pending.length > 0) { + const current = pending.pop()!; + if (current.depth > DEFAULT_JSON_LIMITS.maxDepth) { + throw new SchemaResourceLimitError( + `${label} exceeds JSON depth ${DEFAULT_JSON_LIMITS.maxDepth}`, + ); + } + if (typeof current.value === "string") { + if ( + Buffer.byteLength(current.value, "utf8") > + DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON string`, + ); + } + continue; + } + if (current.value === null || typeof current.value !== "object") continue; + if (visited.has(current.value)) + throw new Error(`${label} contains a cyclic value`); + visited.add(current.value); + const entries = Array.isArray(current.value) + ? current.value.map((item) => [undefined, item] as const) + : Object.entries(current.value as Record); + members += entries.length; + if (members > DEFAULT_JSON_LIMITS.maxMembers) { + throw new SchemaResourceLimitError(`${label} exceeds JSON member limit`); + } + for (const [key, child] of entries) { + if ( + key !== undefined && + Buffer.byteLength(key, "utf8") > DEFAULT_JSON_LIMITS.maxStringBytes + ) { + throw new SchemaResourceLimitError( + `${label} contains an oversized JSON member name`, + ); + } + pending.push({ value: child, depth: current.depth + 1 }); + } + } + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} is not a JSON value`); + if (Buffer.byteLength(encoded, "utf8") > maxBytes) { + throw new SchemaResourceLimitError(`${label} exceeds ${maxBytes} bytes`); + } +} + +export function parseManifestJson(text: string): AgentApiManifest { + return validateManifest( + parseStrictJson(text, { maxBytes: MANIFEST_MAX_BYTES }), + ); +} + +export function validateManifest(value: unknown): AgentApiManifest { + const canonical = canonicalJson(value); + if (Buffer.byteLength(canonical, "utf8") > MANIFEST_MAX_BYTES) { + throw new SchemaResourceLimitError("manifest exceeds 1 MiB"); + } + if (!manifestValidator(value)) + throw new Error( + `invalid manifest: ${formatErrors(manifestValidator.errors)}`, + ); + const manifest = value as AgentApiManifest; + if (!isNormalizedEndpointJid(manifest.agent.jid)) { + throw new Error("agent.jid must be a normalized endpoint bare JID"); + } + if (!isApiVersion(manifest.agent.version)) + throw new Error("agent.version must be a valid API version"); + for (const [member, uri] of [ + ["agent.homepage", manifest.agent.homepage], + ["agent.avatarUrl", manifest.agent.avatarUrl], + ] as const) { + if (uri !== undefined && !isPublicProfileHttpsUri(uri)) { + throw new Error( + `${member} must be an absolute lowercase HTTPS URI with a non-empty host and no userinfo`, + ); + } + } + const names = new Set(); + for (const tool of manifest.tools) { + assertUnicodeScalarString(tool.name); + if (!isToolName(tool.name)) throw new Error("invalid XML tool name"); + if (names.has(tool.name)) throw new Error(`duplicate tool: ${tool.name}`); + names.add(tool.name); + preflightSchema(tool.inputSchema, `tool ${tool.name} inputSchema`); + if (tool.outputSchema) + preflightSchema(tool.outputSchema, `tool ${tool.name} outputSchema`); + const extension = tool[XMPP_TOOL_EXTENSION_KEY] as + | Record + | undefined; + const defaultTimeout = extension?.defaultTimeoutSeconds; + const maximumTimeout = extension?.maximumTimeoutSeconds; + if ( + typeof defaultTimeout === "number" && + typeof maximumTimeout === "number" && + defaultTimeout > maximumTimeout + ) { + throw new Error( + `tool ${tool.name} default timeout exceeds maximum timeout`, + ); + } + } + return manifest; +} + +function isPublicProfileHttpsUri(value: string): boolean { + if ( + !value.startsWith("https://") || + !/^[A-Za-z0-9\-._~:/?[\]@!$&'()*+,;=%]+$/.test(value) || + /%(?![0-9A-Fa-f]{2})/.test(value) || + !URL.canParse(value) + ) { + return false; + } + const authority = value.slice("https://".length).split(/[/?]/, 1)[0]!; + if (authority.endsWith(":")) return false; + const uri = new URL(value); + return ( + uri.protocol === "https:" && + uri.hostname.length > 0 && + uri.username === "" && + uri.password === "" && + uri.hash === "" + ); +} + +export function registeredTools(manifest: AgentApiManifest): RegisteredTool[] { + return manifest.tools.map((tool) => ({ + ...tool, + inputSchemaHash: digestJson(tool.inputSchema), + outputSchemaHash: tool.outputSchema + ? digestJson(tool.outputSchema) + : undefined, + xmpp: tool[XMPP_TOOL_EXTENSION_KEY] as RegisteredTool["xmpp"], + })); +} + +export function validateJson(schema: JsonSchema, value: unknown): string[] { + preflightSchema(schema, "schema"); + const hash = digestJson(schema); + let validate = validatorCache.get(hash); + if (!validate) { + const compiled = ajv.compile(schema); + validatorCache.set(hash, compiled); + validate = compiled; + } + return validate(value) ? [] : (validate.errors ?? []).map(formatError); +} + +/** + * Evaluate caller-controlled schemas away from the host event loop. Workers are + * bounded and replaced after a timeout; each worker caches validators by the + * canonical schema hash. + */ +export function validateJsonBounded( + schema: JsonSchema, + value: unknown, + timeoutMs = DEFAULT_SCHEMA_TIMEOUT_MS, +): Promise { + preflightSchema(schema, "schema"); + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject( + new Error("schema timeout must be a positive integer"), + ); + } + const pendingCount = + validationQueue.length + + schemaWorkers.filter((slot) => slot.pending).length; + if (pendingCount >= SCHEMA_MAX_PENDING_VALIDATIONS) { + return Promise.reject( + new SchemaResourceLimitError("schema validation queue is full"), + ); + } + return new Promise((resolve, reject) => { + validationQueue.push({ + request: { + id: nextValidationId++, + schemaHash: digestJson(schema), + schema, + value, + }, + timeoutMs, + resolve, + reject, + }); + ensureSchemaWorkers(); + dispatchValidationQueue(); + }); +} + +export function validateTaskEventPayload( + type: + | "status" + | "progress" + | "input_required" + | "completed" + | "failed" + | "cancelled", + payload: unknown, +): Promise { + const schema: JsonSchema = { ...EVENT_SCHEMA, $ref: `#/$defs/${type}` }; + delete schema.$id; + return validateJsonBounded(schema, payload); +} + +export async function closeSchemaWorkers(): Promise { + const workers = schemaWorkers.splice(0); + for (const slot of workers) { + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(new Error("schema validator worker closed")); + await slot.worker.terminate(); + } + while (validationQueue.length) + validationQueue + .shift()! + .reject(new Error("schema validator worker closed")); +} + +function ensureSchemaWorkers(): void { + while (schemaWorkers.length < SCHEMA_WORKER_COUNT) + schemaWorkers.push(createSchemaWorker()); +} + +function createSchemaWorker(): SchemaWorkerSlot { + const sourceMode = import.meta.url.endsWith(".ts"); + const worker = new Worker( + new URL( + sourceMode ? "./schema-worker.ts" : "./schema-worker.js", + import.meta.url, + ), + { + execArgv: sourceMode ? ["--import", "tsx"] : undefined, + }, + ); + worker.unref(); + const slot: SchemaWorkerSlot = { worker }; + worker.on("message", (response: WorkerResponse) => + settleWorker(slot, response), + ); + worker.on("error", (error) => replaceWorker(slot, error)); + worker.on("exit", (code) => { + if (schemaWorkers.includes(slot) && code !== 0) { + replaceWorker( + slot, + new Error(`schema validator worker exited with code ${code}`), + ); + } + }); + return slot; +} + +function dispatchValidationQueue(): void { + for (const slot of schemaWorkers) { + if (slot.pending) continue; + const pending = validationQueue.shift(); + if (!pending) return; + slot.pending = pending; + slot.timer = setTimeout(() => { + replaceWorker( + slot, + new SchemaResourceLimitError( + `schema validation timed out after ${pending.timeoutMs}ms`, + ), + ); + }, pending.timeoutMs); + slot.timer.unref?.(); + slot.worker.postMessage(pending.request); + } +} + +function settleWorker(slot: SchemaWorkerSlot, response: WorkerResponse): void { + const pending = slot.pending; + if (!pending || pending.request.id !== response.id) return; + if (slot.timer) clearTimeout(slot.timer); + slot.timer = undefined; + slot.pending = undefined; + if (response.failure) + pending.reject(new Error(`schema validation failed: ${response.failure}`)); + else pending.resolve(response.errors ?? []); + dispatchValidationQueue(); +} + +function replaceWorker(slot: SchemaWorkerSlot, error: Error): void { + const index = schemaWorkers.indexOf(slot); + if (index < 0) return; + if (slot.timer) clearTimeout(slot.timer); + slot.pending?.reject(error); + slot.pending = undefined; + void slot.worker.terminate(); + schemaWorkers[index] = createSchemaWorker(); + dispatchValidationQueue(); +} + +export function preflightSchema(schema: JsonSchema, label: string): void { + assertSchemaComplexity(schema, label); + const encoded = canonicalJson(schema); + if (Buffer.byteLength(encoded, "utf8") > SCHEMA_MAX_BYTES) { + throw new SchemaResourceLimitError(`${label} exceeds 256 KiB`); + } + if (!ajv.validateSchema(schema)) + throw new Error( + `${label} is not a valid JSON Schema: ${formatErrors(ajv.errors)}`, + ); +} + +function assertSchemaComplexity(schema: JsonSchema, label: string): void { + const pending: Array<{ value: unknown; depth: number }> = [ + { value: schema, depth: 0 }, + ]; + let nodes = 0; + while (pending.length > 0) { + const { value, depth } = pending.pop()!; + if (++nodes > SCHEMA_MAX_NODES) { + throw new SchemaResourceLimitError( + `${label} exceeds ${SCHEMA_MAX_NODES} nodes`, + ); + } + if (depth > SCHEMA_MAX_DEPTH) { + throw new SchemaResourceLimitError( + `${label} exceeds depth ${SCHEMA_MAX_DEPTH}`, + ); + } + if (!value || typeof value !== "object") continue; + const object = value as Record; + for (const keyword of ["$ref", "$dynamicRef"] as const) { + const reference = object[keyword]; + if (typeof reference === "string" && !reference.startsWith("#")) { + throw new Error(`${label} contains forbidden external ${keyword}`); + } + } + if (object.$vocabulary && typeof object.$vocabulary === "object") { + for (const [vocabulary, required] of Object.entries( + object.$vocabulary as Record, + )) { + if ( + required === true && + !vocabulary.startsWith("https://json-schema.org/draft/2020-12/vocab/") + ) { + throw new Error( + `${label} requires unsupported vocabulary ${vocabulary}`, + ); + } + } + } + if (typeof object.pattern === "string") + assertPattern(object.pattern, label); + if ( + object.patternProperties && + typeof object.patternProperties === "object" + ) { + for (const pattern of Object.keys( + object.patternProperties as Record, + )) { + assertPattern(pattern, label); + } + } + const children = Array.isArray(value) ? value : Object.values(object); + for (const child of children) + pending.push({ value: child, depth: depth + 1 }); + } +} + +function assertPattern(pattern: string, label: string): void { + if (Buffer.byteLength(pattern, "utf8") > SCHEMA_MAX_PATTERN_BYTES) { + throw new SchemaResourceLimitError( + `${label} contains a pattern exceeding ${SCHEMA_MAX_PATTERN_BYTES} bytes`, + ); + } + try { + new RegExp(pattern, "u"); + } catch (error) { + throw new Error(`${label} contains an invalid ECMA-262 pattern`, { + cause: error, + }); + } +} + +function formatError(error: ErrorObject): string { + return `${error.instancePath || "$"} ${error.message ?? error.keyword}`; +} + +function formatErrors(errors: ErrorObject[] | null | undefined): string { + return ( + (errors ?? []).map(formatError).join("; ") || "schema validation failed" + ); +} diff --git a/packages/agent-xmpp/core/tsconfig.json b/packages/agent-xmpp/core/tsconfig.json new file mode 100644 index 000000000..cb2ac4fc1 --- /dev/null +++ b/packages/agent-xmpp/core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/gateway/package.json b/packages/agent-xmpp/gateway/package.json new file mode 100644 index 000000000..9a9e1cd0d --- /dev/null +++ b/packages/agent-xmpp/gateway/package.json @@ -0,0 +1,28 @@ +{ + "name": "@agent-xmpp/gateway", + "version": "0.1.0", + "description": "XMPP component and ProtoXEP wire codecs for agent gateways", + "type": "module", + "main": "./dist/embedded-gateway.js", + "types": "./dist/embedded-gateway.d.ts", + "exports": { + ".": { + "types": "./dist/embedded-gateway.d.ts", + "import": "./dist/embedded-gateway.js" + } + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-xmpp/protocol": "workspace:*", + "@xmpp/component": "0.14.0", + "@xmpp/xml": "0.14.0", + "ulid": "3.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/agent-xmpp/gateway/src/agent-api-disco.ts b/packages/agent-xmpp/gateway/src/agent-api-disco.ts new file mode 100644 index 000000000..4fde89702 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-api-disco.ts @@ -0,0 +1,471 @@ +import { + AGENT_API_NS, + AGENT_DIRECTORY_NS, + AGENT_ENDPOINT_NS, + AGENT_TASK_NS, + AGENT_TOOL_NS, + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + JSON_SCHEMA_MEDIA_TYPE, + isApiVersion, + isToolName, + type AgentApiManifest, + type AgentXmppNamespaces, + type RegisteredAgent, + type RegisteredTool, + parseStrictJson, +} from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildHash, parseHash } from './hash-codec.js'; +import { buildRsm, pageRsm, parseRsm } from './rsm-codec.js'; +import { VCARD_TEMP_NS } from './xep-plugins/vcard.js'; + +export const DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info'; +export const DISCO_ITEMS_NS = 'http://jabber.org/protocol/disco#items'; +export const DATA_FORMS_NS = 'jabber:x:data'; +export const SEARCH_NS = 'jabber:iq:search'; +export { AGENT_DIRECTORY_NS, AGENT_API_NS, AGENT_TOOL_NS, AGENT_ENDPOINT_NS, AGENT_TASK_NS }; + +export interface ManifestRequest { + version?: string; +} + +export interface SchemaRequest { + tool: string; + version: string; + direction: 'input' | 'output'; + manifestHash: string; +} + +interface PayloadShape { + requiredAttributes?: readonly string[]; + optionalAttributes?: readonly string[]; + children?: readonly { name: string; xmlns?: string }[]; +} + +function assertPayloadShape(payload: Element, shape: PayloadShape): void { + const required = shape.requiredAttributes ?? []; + const allowed = new Set(['xmlns', ...required, ...(shape.optionalAttributes ?? [])]); + if ( + required.some((name) => payload.attrs[name] === undefined || payload.attrs[name] === '') || + Object.keys(payload.attrs).some((name) => !allowed.has(name)) + ) { + throw new Error(`${payload.name} has invalid attributes`); + } + + const expectedChildren = shape.children ?? []; + const actualChildren = payload.getChildElements(); + if ( + payload.children.some((child) => typeof child === 'string' && child.trim() !== '') || + actualChildren.length !== expectedChildren.length || + actualChildren.some( + (child, index) => + child.name !== expectedChildren[index]!.name || + (expectedChildren[index]!.xmlns !== undefined && child.attrs.xmlns !== expectedChildren[index]!.xmlns), + ) + ) { + throw new Error(`${payload.name} has invalid children`); + } +} + +function requestPayload( + request: Element, + name: string, + namespace: string, + expectedIqType: 'get' | 'set', +): Element | null { + if (request.name !== 'iq') return null; + const payload = request.getChild(name, namespace); + if (!payload) return null; + if (request.attrs.type !== expectedIqType) throw new Error(`${name} requires an IQ of type ${expectedIqType}`); + return payload; +} + +export function parseManifestRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ManifestRequest | null { + const payload = requestPayload(request, 'manifest-request', namespaces.api, 'get'); + if (!payload) return null; + assertPayloadShape(payload, { optionalAttributes: ['version'] }); + const version = payload.attrs.version === undefined ? undefined : String(payload.attrs.version); + if (version !== undefined && !isApiVersion(version)) throw new Error('manifest-request has an invalid version'); + return version === undefined ? {} : { version }; +} + +export function parseSchemaRequest( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): SchemaRequest | null { + const payload = requestPayload(request, 'schema-request', namespaces.api, 'get'); + if (!payload) return null; + assertPayloadShape(payload, { + requiredAttributes: ['tool', 'version', 'direction'], + children: [{ name: 'hash', xmlns: namespaces.hashes }], + }); + const tool = String(payload.attrs.tool); + const version = String(payload.attrs.version); + const direction = String(payload.attrs.direction); + if (!isToolName(tool) || !isApiVersion(version) || (direction !== 'input' && direction !== 'output')) { + throw new Error('schema-request has invalid attributes'); + } + return { + tool, + version, + direction, + manifestHash: parseHash(payload).value, + }; +} + +function resultIq(request: Element, from: string, child: Element): Element { + return xml('iq', { type: 'result', id: request.attrs.id, from, to: request.attrs.from }, child); +} + +function field(name: string, value: string, type?: string): Element { + return xml('field', { var: name, ...(type ? { type } : {}) }, xml('value', {}, value)); +} + +function resultForm(formType: string, fields: Element[]): Element { + return xml('x', { xmlns: DATA_FORMS_NS, type: 'result' }, field('FORM_TYPE', formType, 'hidden'), ...fields); +} + +function features(...values: string[]): Element[] { + return values.map((value) => xml('feature', { var: value })); +} + +const HUMAN_FEATURES = [ + 'urn:xmpp:ping', + 'urn:xmpp:receipts', + 'http://jabber.org/protocol/chatstates', + 'urn:xmpp:reply:0', + 'urn:xmpp:sid:0', + 'urn:xmpp:hints', +]; + +export function buildGatewayInfo( + request: Element, + componentJid: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_INFO_NS }, + xml('identity', { category: 'automation', type: 'agent-gateway', name: 'NanoClaw XMPP Agent Gateway' }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + SEARCH_NS, + DATA_FORMS_NS, + namespaces.directory, + namespaces.admin, + ...HUMAN_FEATURES, + ), + ), + ); +} + +export function buildDirectoryInfo(request: Element, componentJid: string): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_INFO_NS, node: AGENT_DIRECTORY_NS }, + xml('identity', { category: 'automation', type: 'agent-directory', name: 'NanoClaw Agent Directory' }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildAgentDirectory( + request: Element, + componentJid: string, + agents: RegisteredAgent[], + _namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild('query', DISCO_ITEMS_NS)!; + const page = pageRsm(agents, (agent) => agent.manifest.agent.jid, parseRsm(query)); + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: DISCO_ITEMS_NS, ...(query.attrs.node ? { node: query.attrs.node } : {}) }, + ...page.items.map((agent) => + xml('item', { + jid: agent.manifest.agent.jid, + name: agent.manifest.agent.title ?? agent.manifest.agent.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildAgentInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const identity = agent.manifest.agent; + const taskFeatures = new Set([namespaces.task]); + for (const tool of agent.tools) { + if (tool.xmpp?.supportsProgress) taskFeatures.add(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.add(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.add(namespaces.input); + } + return resultIq( + request, + identity.jid, + xml( + 'query', + { xmlns: DISCO_INFO_NS }, + xml('identity', { category: 'automation', type: 'agent-endpoint', name: identity.title ?? identity.name }), + ...features( + DISCO_INFO_NS, + DISCO_ITEMS_NS, + namespaces.endpoint, + namespaces.manifest, + namespaces.schema, + ...taskFeatures, + VCARD_TEMP_NS, + ...HUMAN_FEATURES, + ), + resultForm(namespaces.endpointInfo, [ + field('server_name', identity.name), + field('server_title', identity.title ?? identity.name), + ...(identity.description ? [field('description', identity.description)] : []), + field('version', identity.version), + field('manifest_hash_algo', 'sha-256'), + field('manifest_hash_value', agent.manifestHash), + field('cold_start_supported', '1'), + field('request_replay_seconds', '86400'), + ]), + ), + ); +} + +export function buildToolItems( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const query = request.getChild('query', DISCO_ITEMS_NS)!; + const page = pageRsm( + agent.tools, + (tool) => toolNode(agent.manifest.agent.version, tool.name, namespaces), + parseRsm(query), + (tool) => tool.name, + ); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { xmlns: DISCO_ITEMS_NS, node: toolsNode(agent.manifest.agent.version, namespaces) }, + ...page.items.map((tool) => + xml('item', { + jid: agent.manifest.agent.jid, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + name: tool.title ?? tool.name, + }), + ), + buildRsm(page), + ), + ); +} + +export function buildToolCollectionInfo( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { xmlns: DISCO_INFO_NS, node: toolsNode(agent.manifest.agent.version, namespaces) }, + xml('identity', { category: 'automation', type: 'agent-tool-collection' }), + ...features(DISCO_INFO_NS, DISCO_ITEMS_NS), + ), + ); +} + +export function buildToolInfo( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const taskFeatures: string[] = [namespaces.task]; + if (tool.xmpp?.supportsProgress) taskFeatures.push(namespaces.progress); + if (tool.xmpp?.supportsCancellation) taskFeatures.push(namespaces.cancel); + if (tool.xmpp?.supportsInput) taskFeatures.push(namespaces.input); + const optionalBoolean = (name: string, value: boolean | undefined): Element[] => + value === undefined ? [] : [field(name, value ? '1' : '0')]; + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'query', + { + xmlns: DISCO_INFO_NS, + node: toolNode(agent.manifest.agent.version, tool.name, namespaces), + }, + xml('identity', { category: 'automation', type: 'agent-tool', name: tool.title ?? tool.name }), + ...features(DISCO_INFO_NS, namespaces.tool, ...taskFeatures), + resultForm(namespaces.toolInfo, [ + field('name', tool.name), + ...(tool.title ? [field('title', tool.title)] : []), + ...(tool.description ? [field('description', tool.description)] : []), + field('api_version', agent.manifest.agent.version), + field('input_schema_hash_algo', 'sha-256'), + field('input_schema_hash_value', tool.inputSchemaHash), + ...(tool.outputSchemaHash + ? [field('output_schema_hash_algo', 'sha-256'), field('output_schema_hash_value', tool.outputSchemaHash)] + : []), + ...optionalBoolean('read_only', tool.annotations?.readOnlyHint), + ...optionalBoolean('destructive', tool.annotations?.destructiveHint), + ...optionalBoolean('idempotent', tool.annotations?.idempotentHint), + ...optionalBoolean('open_world', tool.annotations?.openWorldHint), + ]), + ), + ); +} + +export function buildSchemaResult( + request: Element, + agent: RegisteredAgent, + tool: RegisteredTool, + direction: 'input' | 'output', + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const schema = direction === 'input' ? tool.inputSchema : tool.outputSchema; + const schemaHash = direction === 'input' ? tool.inputSchemaHash : tool.outputSchemaHash; + if (!schema || !schemaHash) throw new Error('schema not found'); + const canonicalSchema = canonicalWireJson(schema); + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'schema', + { + xmlns: namespaces.api, + tool: tool.name, + version: agent.manifest.agent.version, + direction, + 'media-type': JSON_SCHEMA_MEDIA_TYPE, + }, + xml('manifest-hash', {}, buildHash(agent.manifestHash)), + xml('schema-hash', {}, buildHash(schemaHash)), + xml('json', {}, canonicalSchema), + ), + ); +} + +export function buildManifestResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + agent.manifest.agent.jid, + xml( + 'manifest', + { xmlns: namespaces.api, version: agent.manifest.agent.version, 'media-type': JSON_MEDIA_TYPE }, + buildHash(agent.manifestHash), + xml('json', {}, agent.canonicalManifest), + ), + ); +} + +export function toolsNode(version: string, namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES): string { + if (!isApiVersion(version)) throw new Error('invalid API version'); + return `${namespaces.tools}#${version}`; +} + +export function toolsVersionFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string | null { + const prefix = `${namespaces.tools}#`; + if (!node.startsWith(prefix)) return null; + const version = node.slice(prefix.length); + return isApiVersion(version) ? version : null; +} + +export function toolNode( + version: string, + name: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): string { + if (!isApiVersion(version)) throw new Error('invalid API version'); + if (!isToolName(name)) throw new Error('invalid tool name'); + return `${namespaces.tool}#${version}#${Buffer.from(name, 'utf8').toString('base64url')}`; +} + +export function toolFromNode( + node: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): { version: string; name: string } | null { + const prefix = `${namespaces.tool}#`; + if (!node.startsWith(prefix)) return null; + const separator = node.indexOf('#', prefix.length); + if (separator < 0) return null; + const version = node.slice(prefix.length, separator); + const encoded = node.slice(separator + 1); + if (!isApiVersion(version) || !encoded || encoded.includes('=') || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null; + try { + const bytes = Buffer.from(encoded, 'base64url'); + if (bytes.toString('base64url') !== encoded) return null; + const name = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (!isToolName(name)) return null; + return { version, name }; + } catch { + return null; + } +} + +export function parseManifestRegistration( + request: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AgentApiManifest | null { + if (request.name !== 'iq' || request.attrs.type !== 'set') return null; + const manifest = request.getChild('register', namespaces.api)?.getChild('manifest'); + if (!manifest || manifest.attrs['media-type'] !== JSON_MEDIA_TYPE) return null; + return parseStrictJson(manifest.getText(), { maxBytes: 1_048_576 }) as AgentApiManifest; +} + +export function buildManifestRegistrationResult( + request: Element, + agent: RegisteredAgent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return resultIq( + request, + request.attrs.to ? String(request.attrs.to) : agent.manifest.agent.jid, + xml( + 'registered', + { xmlns: namespaces.api, jid: agent.manifest.agent.jid, version: agent.manifest.agent.version }, + buildHash(agent.manifestHash), + ), + ); +} + +function canonicalWireJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalWireJson).join(',')}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalWireJson(object[key])}`) + .join(',')}}`; +} diff --git a/packages/agent-xmpp/gateway/src/agent-send.ts b/packages/agent-xmpp/gateway/src/agent-send.ts new file mode 100644 index 000000000..22f223e3d --- /dev/null +++ b/packages/agent-xmpp/gateway/src/agent-send.ts @@ -0,0 +1,53 @@ +/** + * Emits XEP-0085 Chat State Notifications on the agent's behalf (composing while + * the agent works, paused/inactive when it stops). States are directed to the same + * 1:1 resource or MUC room the inbound message came from. + * + * @see https://xmpp.org/extensions/xep-0085.html + */ +import type { Element } from '@xmpp/xml'; + +import type { InboundChatTargets } from './delivery.js'; +import { buildComposingStanza, buildInactiveStanza, buildPausedStanza } from './xep-plugins/chatstate.js'; + +async function sendChatStateForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, + state: 'composing' | 'paused' | 'inactive', +): Promise { + const build = + state === 'composing' ? buildComposingStanza : state === 'paused' ? buildPausedStanza : buildInactiveStanza; + await sendOutbound( + build({ + from: agentJid, + to: targets.to, + threadId: targets.threadId, + groupchat: targets.groupchat, + }), + ); +} + +export async function sendComposingForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'composing'); +} + +export async function sendPausedForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'paused'); +} + +export async function sendInactiveForAgent( + sendOutbound: (stanza: Element) => Promise, + agentJid: string, + targets: Pick, +): Promise { + await sendChatStateForAgent(sendOutbound, agentJid, targets, 'inactive'); +} diff --git a/packages/agent-xmpp/gateway/src/config.ts b/packages/agent-xmpp/gateway/src/config.ts new file mode 100644 index 000000000..46bd42e47 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/config.ts @@ -0,0 +1,99 @@ +import { DEFAULT_PROTOCOL_NAMESPACES, type AgentXmppNamespaces } from '@agent-xmpp/protocol'; + +export interface GatewayConfig { + gatewayId: string; + /** Component JID, e.g. gateway.agents.example */ + componentJid: string; + /** Delegated domain for virtual agent JIDs, e.g. agents.example */ + agentDomain: string; + /** XMPP server domain used as the XEP-0199 keepalive target. */ + serverDomain: string; + /** xmpp://host:5275 or xmpps://host:5347 */ + componentService: string; + componentSecret: string; + defaultAgentJid: string; + /** Default inherited language for human-readable XML text. */ + xmlLang?: string; + /** XEP-0184: how long to wait for a before a resend is due (ms). */ + receiptTimeoutMs: number; + /** + * XEP-0184: max resends of an un-acked message before giving up. + * Default 0 (observe-only): absence of a receipt is NOT evidence of failure — many + * clients/servers don't implement receipts and XMPP doesn't guarantee dedup of equal + * stanza/origin ids, so resending would duplicate ordinary messages. Only raise this + * for a deployment where every peer is known to support XEP-0184 and dedups. + */ + receiptMaxResends: number; + /** How often the resend sweep runs (ms). */ + receiptSweepMs: number; + /** Initial reconnect delay; subsequent failures back off exponentially. */ + reconnectInitialMs: number; + /** Maximum reconnect delay. */ + reconnectMaxMs: number; + /** Send XEP-0199 after this much connection inactivity. */ + pingIntervalMs: number; + /** Time allowed for an XEP-0199 response. */ + pingTimeoutMs: number; + /** Consecutive ping failures before forcing a reconnect. */ + pingFailureThreshold: number; + /** Maximum concurrent inbound or outbound IQ requests held by the component. */ + maxPendingIqRequests?: number; + protocolNamespaces?: AgentXmppNamespaces; +} + +/** Non-negative integer (0 is meaningful, e.g. observe-only resends). */ +function envNonNegInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : fallback; +} + +/** Strictly-positive integer — for interval/timeout values where 0 would busy-loop. */ +function envPosInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : fallback; +} + +function env(name: string, fallback?: string): string { + const v = process.env[name]; + if (v !== undefined && v !== '') return v; + if (fallback !== undefined) return fallback; + throw new Error(`Missing required env: ${name}`); +} + +function envLanguageTag(name: string): string | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + return value.length <= 64 && /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(value) ? value : undefined; +} + +export function loadConfig(): GatewayConfig { + const componentJid = env('XMPP_COMPONENT_JID'); + const agentDomain = process.env.XMPP_AGENT_DOMAIN || componentJid.split('.').slice(1).join('.') || componentJid; + const inferredServerDomain = componentJid.split('.').slice(1).join('.') || componentJid; + const reconnectInitialMs = envPosInt('XMPP_RECONNECT_INITIAL_MS', 1_000); + const reconnectMaxMs = Math.max(reconnectInitialMs, envPosInt('XMPP_RECONNECT_MAX_MS', 60_000)); + return { + gatewayId: process.env.XMPP_GATEWAY_ID || 'gw-1', + componentJid, + agentDomain, + serverDomain: process.env.XMPP_SERVER_DOMAIN || inferredServerDomain, + componentService: env('XMPP_COMPONENT_SERVICE', 'xmpp://127.0.0.1:5275'), + componentSecret: env('XMPP_COMPONENT_SECRET'), + defaultAgentJid: process.env.XMPP_DEFAULT_AGENT_JID || `assistant@${agentDomain}`, + xmlLang: envLanguageTag('XMPP_XML_LANG'), + receiptTimeoutMs: envPosInt('XMPP_RECEIPT_TIMEOUT_MS', 30_000), + receiptMaxResends: envNonNegInt('XMPP_RECEIPT_MAX_RESENDS', 0), + receiptSweepMs: envPosInt('XMPP_RECEIPT_SWEEP_MS', 10_000), + reconnectInitialMs, + reconnectMaxMs, + pingIntervalMs: envPosInt('XMPP_PING_INTERVAL_MS', 60_000), + pingTimeoutMs: envPosInt('XMPP_PING_TIMEOUT_MS', 10_000), + pingFailureThreshold: envPosInt('XMPP_PING_FAILURE_THRESHOLD', 2), + maxPendingIqRequests: envPosInt('XMPP_MAX_PENDING_IQ_REQUESTS', 256), + protocolNamespaces: DEFAULT_PROTOCOL_NAMESPACES, + }; +} diff --git a/packages/agent-xmpp/gateway/src/delivery.ts b/packages/agent-xmpp/gateway/src/delivery.ts new file mode 100644 index 000000000..f9c986f5f --- /dev/null +++ b/packages/agent-xmpp/gateway/src/delivery.ts @@ -0,0 +1,137 @@ +/** + * Inbound delivery gating and routing. 1:1 (XMPP `chat`) messages always pass; + * groupchat (XEP-0045) messages are delivered only when the agent is mentioned — + * via XEP-0513 explicit mentions or the plaintext `@nick` fallback in routing.ts. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ +import type { AgentMessage, BridgeFormResponsePayload, BridgeInboundPayload } from '@agent-xmpp/protocol'; +import { agentMessageText } from '@agent-xmpp/protocol'; + +import type { GatewayConfig } from './config.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { buildInboundEnvelope } from './xep-plugins/message.js'; +import { bareJid } from './xep-plugins/jid.js'; +import { mucRoomFromStanza } from './xep-plugins/muc.js'; +import { isMentionForAgent, shouldDeliverInbound } from './xep-plugins/routing.js'; + +export interface InboundDeliveryContext { + agentMsg: AgentMessage; + agentJid: string; + deliveryId: string; + stanzaType: string; + from: string; + redelivered?: boolean; +} + +export function shouldAcceptStanza(stanzaType: string, from: string, bodyText: string, agentNick: string): boolean { + const room = mucRoomFromStanza(from); + const isGroup = stanzaType === 'groupchat' || !!room; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + return shouldDeliverInbound(stanzaType, isGroup, isMention); +} + +export interface InboundChatTargets { + /** Reply/typing destination and host router session key: MUC room JID or bare sender JID. */ + to: string; + threadId: string | null; + /** True for MUC/groupchat traffic. */ + groupchat: boolean; +} + +/** Resolve where replies and typing notifications for an inbound stanza should go. */ +export function resolveInboundChatTargets( + from: string, + stanzaType: string, + agentMsg: Pick, +): InboundChatTargets { + const room = mucRoomFromStanza(from); + const groupchat = stanzaType === 'groupchat' || !!room; + const to = groupchat && room ? room : bareJid(agentMsg.from); + const threadId = agentMsg.threadId || (groupchat ? room || null : null); + return { to, threadId, groupchat }; +} + +export function buildBridgePayload( + config: GatewayConfig, + ctx: InboundDeliveryContext, +): BridgeInboundPayload { + const { agentMsg, agentJid, deliveryId, stanzaType, from, redelivered } = ctx; + const { to: platformId, threadId, groupchat: isGroup } = resolveInboundChatTargets(from, stanzaType, agentMsg); + const bodyText = agentMessageText(agentMsg); + const agentNick = agentJid.split('@')[0]; + const isMention = isMentionForAgent(stanzaType, bodyText, agentNick); + + const envelope = buildInboundEnvelope( + agentMsg, + config.gatewayId, + deliveryId, + { + stanzaId: agentMsg.id, + stableId: agentMsg.id, + stanzaType: stanzaType as 'chat' | 'groupchat', + }, + redelivered, + ); + + return { + platformId, + // RFC 6121 section 8.5.2.1: reply to the originating resource. Keeping + // routing on the bare JID avoids creating one NanoClaw session per client. + replyTo: isGroup ? undefined : from, + threadId, + agentJid, + isMention, + isGroup, + envelope, + }; +} + +export async function pushInboundToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: InboundDeliveryContext, +): Promise { + await mailbox.deliverInbound(buildBridgePayload(config, ctx)); +} + +export interface FormResponseContext { + agentJid: string; + from: string; + stanzaType: string; + questionId: string; + selectedIndex: number; +} + +export function buildFormResponsePayload( + _config: GatewayConfig, + ctx: FormResponseContext, +): BridgeFormResponsePayload { + const { to: platformId, threadId, groupchat: isGroup } = resolveInboundChatTargets( + ctx.from, + ctx.stanzaType, + { from: ctx.from, threadId: undefined }, + ); + + return { + type: 'form_response', + agentJid: ctx.agentJid, + platformId, + threadId, + questionId: ctx.questionId, + selectedIndex: ctx.selectedIndex, + // In a MUC the occupant identity is the resource (room@muc/nick); keep the full JID so + // the answer is attributed to the responder, not to the room. + userId: isGroup ? ctx.from : platformId, + timestamp: new Date().toISOString(), + }; +} + +export async function pushFormResponseToBridge( + config: GatewayConfig, + mailbox: GatewayRuntimeMailbox, + ctx: FormResponseContext, +): Promise { + await mailbox.deliverFormResponse(buildFormResponsePayload(config, ctx)); +} diff --git a/packages/agent-xmpp/gateway/src/embedded-gateway.ts b/packages/agent-xmpp/gateway/src/embedded-gateway.ts new file mode 100644 index 000000000..05efec428 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/embedded-gateway.ts @@ -0,0 +1,330 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + bareJid, + type AgentXmppNamespaces, + type OutboundDeliverRequest, +} from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildGatewayInfo, DISCO_INFO_NS } from './agent-api-disco.js'; +import type { GatewayConfig } from './config.js'; +export { loadConfig } from './config.js'; +export type { GatewayConfig } from './config.js'; +import { sendComposingForAgent, sendInactiveForAgent, sendPausedForAgent } from './agent-send.js'; +import { StanzaRouter, type ResolveVirtualAgentFn } from './stanza-router.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { applyStoreHints, buildOutboundStanza } from './xep-plugins/message.js'; +import { isMucJid } from './xep-plugins/muc.js'; +import { buildTaskEvent, type TaskWireEvent } from './task-stanza-codec.js'; +import { + createComponentSession, + type IqGetHandler, + type IqRequestOptions, + type XmppComponentSession, +} from './xmpp-component.js'; +import { RECEIPTS_NS } from './xep-plugins/receipts.js'; +import { ReceiptTracker } from './receipt-tracker.js'; +import { PING_NS } from './xep-plugins/ping.js'; +import { XmppKeepalive } from './xmpp-keepalive.js'; +import { buildAvailablePresence, buildUnavailablePresence, type VirtualAgentIdentity } from './xep-plugins/presence.js'; + +export interface EmbeddedIqHandlerOptions { + componentJid: string; + protocolNamespaces?: AgentXmppNamespaces; +} + +export interface PresenceSubscription { + agentJid: string; + subscriberJid: string; +} + +export interface PresenceSubscriptionStore { + listPresenceSubscriptions(): PresenceSubscription[]; + setPresenceSubscription(agentJid: string, subscriberJid: string, subscribed: boolean): void; +} + +export type XmppComponentSessionFactory = (config: GatewayConfig, onIqGet?: IqGetHandler) => XmppComponentSession; + +export interface EmbeddedXmppGatewayDependencies { + onIqGet?: IqGetHandler; + resolveVirtualAgent?: ResolveVirtualAgentFn; + presenceStore?: PresenceSubscriptionStore; + componentSessionFactory?: XmppComponentSessionFactory; +} + +function presenceRouteKey(route: PresenceSubscription): string { + return `${bareJid(route.agentJid).toLowerCase()}\u0000${bareJid(route.subscriberJid).toLowerCase()}`; +} + +class InMemoryPresenceSubscriptionStore implements PresenceSubscriptionStore { + private readonly subscriptions = new Map(); + + listPresenceSubscriptions(): PresenceSubscription[] { + return [...this.subscriptions.values()]; + } + + setPresenceSubscription(agentJid: string, subscriberJid: string, subscribed: boolean): void { + const route = { agentJid: bareJid(agentJid), subscriberJid: bareJid(subscriberJid) }; + const key = presenceRouteKey(route); + if (subscribed) this.subscriptions.set(key, route); + else this.subscriptions.delete(key); + } +} + +/** + * Root service identity belongs to the reusable gateway itself. Host handlers + * extend this surface with directory, endpoint, task, and administrative IQs. + */ +export function createEmbeddedIqHandler(options: EmbeddedIqHandlerOptions, downstream?: IqGetHandler): IqGetHandler { + return async (stanza) => { + const to = bareJid(String(stanza.attrs.to ?? '')); + const info = stanza.getChild('query', DISCO_INFO_NS); + if (stanza.attrs.type === 'get' && to === bareJid(options.componentJid) && info && !info.attrs.node) { + return buildGatewayInfo(stanza, options.componentJid, options.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES); + } + return (await downstream?.(stanza)) ?? null; + }; +} + +/** In-process XMPP channel runtime. All agent IO crosses GatewayRuntimeMailbox. */ +export class EmbeddedXmppGateway { + private session: XmppComponentSession | null = null; + private router: StanzaRouter | null = null; + private readonly receipts: ReceiptTracker; + private sweepTimer: ReturnType | null = null; + private keepalive: XmppKeepalive | null = null; + private connectionState: ReturnType = 'offline'; + private readonly presenceStore: PresenceSubscriptionStore; + private readonly publishedPresence = new Map(); + private presenceSync: Promise = Promise.resolve(); + + constructor( + private readonly config: GatewayConfig, + private readonly mailbox: GatewayRuntimeMailbox, + private readonly dependencies: EmbeddedXmppGatewayDependencies = {}, + ) { + this.presenceStore = dependencies.presenceStore ?? new InMemoryPresenceSubscriptionStore(); + this.receipts = new ReceiptTracker({ + timeoutMs: config.receiptTimeoutMs, + maxResends: config.receiptMaxResends, + }); + } + + async start(): Promise { + if (this.session) return; + const createSession = this.dependencies.componentSessionFactory ?? createComponentSession; + const session = createSession(this.config, createEmbeddedIqHandler(this.config, this.dependencies.onIqGet)); + const sendForAgent = async (_agentJid: string, stanza: Element) => session.send(stanza); + const router = new StanzaRouter( + this.config, + this.mailbox, + sendForAgent, + this.dependencies.resolveVirtualAgent, + (id) => this.receipts.ack(id), + (agent, subscriberJid, subscribed) => this.updatePresenceSubscription(agent, subscriberJid, subscribed), + ); + session.onStanza((stanza) => void router.handleIncoming(stanza)); + session.onStateChange((state) => { + const wasOnline = this.connectionState === 'online'; + this.connectionState = state; + if (state === 'online' && !wasOnline) { + this.publishedPresence.clear(); + void this.syncPresence(session); + } + }); + this.session = session; + this.router = router; + await session.start(); + this.connectionState = session.getState(); + this.keepalive = new XmppKeepalive( + { intervalMs: this.config.pingIntervalMs, failureThreshold: this.config.pingFailureThreshold }, + { + getState: () => session.getState(), + getLastActivityAt: () => session.getLastActivityAt(), + ping: async () => { + await session.requestIq( + xml( + 'iq', + { type: 'get', from: this.config.componentJid, to: this.config.serverDomain }, + xml('ping', { xmlns: PING_NS }), + ), + { timeoutMs: this.config.pingTimeoutMs }, + ); + }, + forceReconnect: (reason) => session.forceReconnect(reason), + }, + ); + this.keepalive.start(); + this.sweepTimer = setInterval(() => this.resendUnacked(), this.config.receiptSweepMs); + this.sweepTimer.unref?.(); + } + + async stop(): Promise { + const session = this.session; + this.connectionState = 'stopping'; + if (session) await this.publishUnavailablePresence(session); + this.session = null; + this.router = null; + this.keepalive?.stop(); + this.keepalive = null; + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + // Drop pending receipts so a restart's sweep can't resend this session's stanzas. + this.receipts.clear(); + if (session) await session.stop(); + this.publishedPresence.clear(); + this.connectionState = 'offline'; + } + + private updatePresenceSubscription(agent: VirtualAgentIdentity, subscriberJid: string, subscribed: boolean): void { + const route = { + agentJid: bareJid(agent.jid), + subscriberJid: bareJid(subscriberJid), + }; + this.presenceStore.setPresenceSubscription(route.agentJid, route.subscriberJid, subscribed); + void this.syncPresence(); + } + + syncPresence(session = this.session): Promise { + if (!session || session.getState() !== 'online') return Promise.resolve(); + const run = this.presenceSync + .catch(() => undefined) + .then(() => this.reconcilePresence(session)) + .catch((err) => { + console.error('[xmpp-gateway] presence synchronization failed:', err); + }); + this.presenceSync = run; + return run; + } + + private async reconcilePresence(session: XmppComponentSession): Promise { + const desired = new Map(); + for (const subscription of this.presenceStore.listPresenceSubscriptions()) { + const agent = this.dependencies.resolveVirtualAgent?.(bareJid(subscription.agentJid)); + if (!agent) continue; + const route = { agent, subscriberJid: bareJid(subscription.subscriberJid) }; + desired.set(presenceRouteKey(subscription), route); + } + + for (const [key, route] of this.publishedPresence) { + if (desired.has(key)) continue; + await session.send(buildUnavailablePresence(route.agent, route.subscriberJid)); + this.publishedPresence.delete(key); + } + for (const [key, route] of desired) { + if (this.publishedPresence.has(key)) continue; + await session.send(buildAvailablePresence(route.agent, route.subscriberJid)); + this.publishedPresence.set(key, route); + } + } + + private async publishUnavailablePresence(session: XmppComponentSession): Promise { + for (const route of this.publishedPresence.values()) { + await session.send(buildUnavailablePresence(route.agent, route.subscriberJid)).catch((err) => { + console.error('[xmpp-gateway] unavailable presence send failed:', err); + }); + } + } + + /** + * XEP-0184 sweep. Default is observe-only (receiptMaxResends=0): un-acked messages + * simply expire from tracking, since a missing receipt does not mean the message failed + * and blind resends would duplicate ordinary messages. When an operator opts into + * resends, we retry up to the cap and log the ones that still go unconfirmed. + */ + private resendUnacked(): void { + const session = this.session; + if (!session || !this.isConnected()) return; + const { resend, gaveUp } = this.receipts.due(Date.now()); + for (const stanza of resend) { + void session.send(stanza).catch((err) => { + console.error('[xmpp-gateway] receipt resend failed:', err); + }); + } + // Only noteworthy when resends were actually attempted; observe-only expiry is normal. + if (this.config.receiptMaxResends > 0) { + for (const id of gaveUp) { + console.error( + `[xmpp-gateway] no delivery receipt for ${id} after ${this.config.receiptMaxResends} resends; giving up`, + ); + } + } + } + + isConnected(): boolean { + return this.session !== null && this.connectionState === 'online'; + } + + /** Send an IQ get/set and await its correlated result or error response. */ + requestIq(stanza: Element, options?: IqRequestOptions): Promise { + return this.requiredSession().requestIq(stanza, options); + } + + /** + * The single outbound send path. Any stanza carrying an XEP-0184 is + * registered for receipt tracking *before* the send resolves — otherwise a fast peer's + * could arrive before registration and be dropped, leaving a delivered + * message pending (and, with resends enabled, later duplicated). If the send itself + * fails, the entry is removed. + */ + private async sendTracked(stanza: Element): Promise { + const session = this.requiredSession(); + const id = String(stanza.attrs.id ?? ''); + const track = id !== '' && stanza.getChild('request', RECEIPTS_NS) != null; + if (track) this.receipts.register(id, stanza); + try { + await session.send(stanza); + } catch (err) { + if (track) this.receipts.ack(id); + throw err; + } + return id; + } + + async deliver(input: OutboundDeliverRequest & { from: string }): Promise { + const built = buildOutboundStanza({ ...input, lang: input.lang ?? this.config.xmlLang }, input.from); + // XEP-0334 so an offline 1:1 peer still gets it; MUC messages aren't stored. + const stanza = applyStoreHints(built, built.attrs.type === 'chat' ? { store: true } : undefined); + return this.sendTracked(stanza); + } + + async deliverTaskEvent(event: TaskWireEvent): Promise { + return this.sendTracked(buildTaskEvent(event, this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES)); + } + + async setTyping( + from: string, + to: string, + threadId: string | null, + state: 'composing' | 'paused' | 'inactive', + ): Promise { + const session = this.requiredSession(); + const targets = { to, threadId, groupchat: isMucJid(to) }; + const send = (stanza: Element) => session.send(stanza); + if (state === 'inactive') await sendInactiveForAgent(send, from, targets); + else if (state === 'paused') await sendPausedForAgent(send, from, targets); + else await sendComposingForAgent(send, from, targets); + } + + private requiredSession(): XmppComponentSession { + if (!this.session) throw new Error('XMPP gateway is not connected'); + return this.session; + } +} + +export type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +export { xml, type Element } from '@xmpp/xml'; +export { IqResponseError } from './xmpp-component.js'; +export type { IqRequestOptions } from './xmpp-component.js'; +export * from './agent-api-disco.js'; +export * from './task-stanza-codec.js'; +export * from './hash-codec.js'; +export * from './json-codec.js'; +export * from './rsm-codec.js'; +export * from './protocol-error.js'; +export * from './xep-plugins/ping.js'; +export * from './xep-plugins/presence.js'; +export * from './xep-plugins/search.js'; +export * from './xep-plugins/vcard.js'; diff --git a/packages/agent-xmpp/gateway/src/hash-codec.ts b/packages/agent-xmpp/gateway/src/hash-codec.ts new file mode 100644 index 000000000..016e2598b --- /dev/null +++ b/packages/agent-xmpp/gateway/src/hash-codec.ts @@ -0,0 +1,22 @@ +import { HASHES_NS } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export interface Sha256Hash { + algorithm: 'sha-256'; + value: string; +} + +export function buildHash(value: string): Element { + return xml('hash', { xmlns: HASHES_NS, algo: 'sha-256' }, value); +} + +export function parseHash(parent: Element, wrapper?: string): Sha256Hash { + const container = wrapper ? parent.getChild(wrapper) : parent; + const hash = container?.getChild('hash', HASHES_NS); + if (!hash || hash.attrs.algo !== 'sha-256') throw new Error('a sha-256 XEP-0300 hash is required'); + const value = hash.getText(); + if (!/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/.test(value)) { + throw new Error('invalid SHA-256 Base64 hash'); + } + return { algorithm: 'sha-256', value }; +} diff --git a/packages/agent-xmpp/gateway/src/json-codec.ts b/packages/agent-xmpp/gateway/src/json-codec.ts new file mode 100644 index 000000000..4c6e62b92 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/json-codec.ts @@ -0,0 +1,12 @@ +import { JSON_MEDIA_TYPE, parseStrictJson } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export function parseJsonElement(element: Element, maxBytes = 1_048_576): unknown { + const mediaType = String(element.attrs['media-type'] ?? ''); + if (mediaType && mediaType !== JSON_MEDIA_TYPE) throw new Error(`unsupported JSON media type: ${mediaType}`); + return parseStrictJson(element.getText(), { maxBytes }); +} + +export function jsonElement(name: string, namespace: string, canonicalJson: string): Element { + return xml(name, { xmlns: namespace, 'media-type': JSON_MEDIA_TYPE }, canonicalJson); +} diff --git a/packages/agent-xmpp/gateway/src/protocol-error.ts b/packages/agent-xmpp/gateway/src/protocol-error.ts new file mode 100644 index 000000000..b8a52d342 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/protocol-error.ts @@ -0,0 +1,66 @@ +import { xml, type Element } from '@xmpp/xml'; + +export type StanzaErrorCondition = + | 'bad-request' + | 'forbidden' + | 'item-not-found' + | 'not-acceptable' + | 'conflict' + | 'resource-constraint' + | 'service-unavailable' + | 'unexpected-request' + | 'internal-server-error'; + +const STANZA_ERRORS_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas'; + +export class ProtocolError extends Error { + constructor( + readonly condition: StanzaErrorCondition, + message: string, + readonly type: 'cancel' | 'modify' | 'auth' | 'wait' = stanzaErrorType(condition), + ) { + super(message); + } +} + +export function protocolErrorIq(request: Element, error: unknown): Element { + const protocolError = + error instanceof ProtocolError ? error : new ProtocolError('internal-server-error', 'Request processing failed'); + const echoRequestPayload = + protocolError.condition !== 'bad-request' && + protocolError.condition !== 'resource-constraint' && + protocolError.condition !== 'internal-server-error'; + return xml( + 'iq', + { + type: 'error', + id: request.attrs.id, + from: request.attrs.to, + to: request.attrs.from, + }, + ...(echoRequestPayload ? request.children : []), + xml( + 'error', + { type: protocolError.type }, + xml(protocolError.condition, { xmlns: STANZA_ERRORS_NS }), + xml('text', { xmlns: STANZA_ERRORS_NS }, safeMessage(protocolError)), + ), + ); +} + +export function hiddenObjectError(): ProtocolError { + return new ProtocolError('item-not-found', 'The requested object was not found'); +} + +function safeMessage(error: ProtocolError): string { + if (error.condition === 'item-not-found') return 'The requested object was not found'; + if (error.condition === 'internal-server-error') return 'Request processing failed'; + return error.message.slice(0, 512); +} + +function stanzaErrorType(condition: StanzaErrorCondition): 'cancel' | 'modify' | 'auth' | 'wait' { + if (condition === 'bad-request' || condition === 'not-acceptable') return 'modify'; + if (condition === 'forbidden') return 'auth'; + if (condition === 'resource-constraint' || condition === 'service-unavailable') return 'wait'; + return 'cancel'; +} diff --git a/packages/agent-xmpp/gateway/src/receipt-tracker.ts b/packages/agent-xmpp/gateway/src/receipt-tracker.ts new file mode 100644 index 000000000..917e83abe --- /dev/null +++ b/packages/agent-xmpp/gateway/src/receipt-tracker.ts @@ -0,0 +1,79 @@ +/** + * XEP-0184 outbound delivery-receipt tracking. + * + * A component send only tells us the server accepted the stanza, not that the peer + * received it. For 1:1 messages that carry a , we register the stanza here; + * when the peer returns we `ack` it, and messages left un-acked past the + * timeout are handed back by `due` for a bounded number of resends. Resends reuse the + * same stanza (same id + origin-id), so conformant peers dedup per XEP-0184 §8. + * + * Pure and synchronous — no timers, no IO — so it unit-tests without a live connection. + * + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from '@xmpp/xml'; + +interface PendingReceipt { + stanza: Element; + sentAt: number; + attempts: number; +} + +export interface ReceiptTrackerOptions { + timeoutMs: number; + maxResends: number; +} + +/** What a sweep produced: stanzas to resend now, and ids we've given up on. */ +export interface ReceiptSweep { + resend: Element[]; + gaveUp: string[]; +} + +export class ReceiptTracker { + private readonly pending = new Map(); + + constructor(private readonly options: ReceiptTrackerOptions) {} + + /** Record a receipt-requested send keyed by its stanza id. */ + register(id: string, stanza: Element, now: number = Date.now()): void { + if (!id) return; + this.pending.set(id, { stanza, sentAt: now, attempts: 0 }); + } + + /** Peer confirmed delivery of `id`; stop tracking it. */ + ack(id: string): void { + this.pending.delete(id); + } + + /** Drop all pending state — called on gateway stop so a restart can't resend a prior session's stanzas. */ + clear(): void { + this.pending.clear(); + } + + /** + * Entries whose timeout has elapsed: each still under the resend cap is re-armed and + * returned in `resend`; each at the cap is dropped and returned in `gaveUp`. + */ + due(now: number = Date.now()): ReceiptSweep { + const resend: Element[] = []; + const gaveUp: string[] = []; + for (const [id, entry] of this.pending) { + if (now - entry.sentAt < this.options.timeoutMs) continue; + if (entry.attempts >= this.options.maxResends) { + this.pending.delete(id); + gaveUp.push(id); + continue; + } + entry.attempts += 1; + entry.sentAt = now; + resend.push(entry.stanza); + } + return { resend, gaveUp }; + } + + /** Number of messages still awaiting a receipt (for tests / diagnostics). */ + get size(): number { + return this.pending.size; + } +} diff --git a/packages/agent-xmpp/gateway/src/rsm-codec.ts b/packages/agent-xmpp/gateway/src/rsm-codec.ts new file mode 100644 index 000000000..5d2f0b89b --- /dev/null +++ b/packages/agent-xmpp/gateway/src/rsm-codec.ts @@ -0,0 +1,66 @@ +import { RSM_NS } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export interface RsmRequest { + max: number; + after?: string; + before?: string; +} + +export interface RsmPage { + items: T[]; + first?: string; + last?: string; + count: number; +} + +type RsmOrderKey = string | Uint8Array; + +export function parseRsm(parent: Element, defaultMax = 100, maximum = 100): RsmRequest { + const set = parent.getChild('set', RSM_NS); + const requested = Number(set?.getChildText('max') ?? defaultMax); + const max = Number.isInteger(requested) && requested >= 0 ? Math.min(requested, maximum) : defaultMax; + return { + max, + after: set?.getChildText('after') ?? undefined, + before: set?.getChildText('before') ?? undefined, + }; +} + +export function pageRsm( + items: T[], + id: (item: T) => string, + request: RsmRequest, + orderKey: (item: T) => RsmOrderKey = id, +): RsmPage { + const ordered = [...items].sort((left, right) => + Buffer.compare(Buffer.from(orderKey(left)), Buffer.from(orderKey(right))), + ); + let start = request.after ? ordered.findIndex((item) => id(item) === request.after) + 1 : 0; + if (request.after && start === 0) start = ordered.length; + let end = ordered.length; + if (request.before !== undefined) { + const before = request.before === '' ? ordered.length : ordered.findIndex((item) => id(item) === request.before); + end = before < 0 ? 0 : before; + start = Math.max(0, end - request.max); + } else { + end = Math.min(end, start + request.max); + } + const page = ordered.slice(start, end); + return { + items: page, + first: page[0] ? id(page[0]) : undefined, + last: page.at(-1) ? id(page.at(-1)!) : undefined, + count: ordered.length, + }; +} + +export function buildRsm(page: RsmPage): Element { + return xml( + 'set', + { xmlns: RSM_NS }, + ...(page.first ? [xml('first', {}, page.first)] : []), + ...(page.last ? [xml('last', {}, page.last)] : []), + xml('count', {}, String(page.count)), + ); +} diff --git a/packages/agent-xmpp/gateway/src/runtime-mailbox.ts b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts new file mode 100644 index 000000000..662e6c2de --- /dev/null +++ b/packages/agent-xmpp/gateway/src/runtime-mailbox.ts @@ -0,0 +1,15 @@ +import type { BridgeFormResponsePayload, BridgeInboundPayload } from '@agent-xmpp/protocol'; +import type { TaskWireEvent } from './task-stanza-codec.js'; + +/** + * Last-mile transport between the XMPP gateway and an agent runtime. + * + * NanoClaw implements this with per-session inbound.db writes. The interface + * intentionally contains no HTTP or provider concepts so another mailbox can + * replace it later without changing XMPP routing. + */ +export interface GatewayRuntimeMailbox { + deliverInbound(payload: BridgeInboundPayload): Promise; + deliverFormResponse(payload: BridgeFormResponsePayload): Promise; + deliverTaskEvent(event: TaskWireEvent): Promise; +} diff --git a/packages/agent-xmpp/gateway/src/stanza-router.ts b/packages/agent-xmpp/gateway/src/stanza-router.ts new file mode 100644 index 000000000..cb27b871c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/stanza-router.ts @@ -0,0 +1,158 @@ +/** + * Central inbound stanza dispatch for the component. Routes by stanza kind and spec: + * presence -> RFC 6121 §3 roster/probe handling (presence.ts) + * ask-question submit -> XEP-0004 Data Forms (data-form.ts) + * agent-task payloads -> configured gateway-private task namespace (task-stanza-codec.ts) + * XEP-0085/0184/0333 -> chat states & receipts are swallowed, not delivered (receipts.ts) + * message -> normalized to AgentMessage (message.ts), then delivery-gated + * + * On accepted 1:1 messages the router emits an XEP-0085 composing state and, when the + * sender opted in with an XEP-0184 , a delivery receipt. + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + * @see https://xmpp.org/extensions/xep-0085.html + * @see https://xmpp.org/extensions/xep-0184.html + */ +import type { Element } from '@xmpp/xml'; + +import { DEFAULT_PROTOCOL_NAMESPACES, type AgentMessage } from '@agent-xmpp/protocol'; + +import { sendComposingForAgent } from './agent-send.js'; +import { bareJid } from './xep-plugins/jid.js'; +import type { GatewayConfig } from './config.js'; +import { + pushFormResponseToBridge, + pushInboundToBridge, + resolveInboundChatTargets, + shouldAcceptStanza, + type InboundDeliveryContext, +} from './delivery.js'; +import type { GatewayRuntimeMailbox } from './runtime-mailbox.js'; +import { isAgentJid, resolveTargetAgentJid, stanzaToAgentMessage } from './xep-plugins/message.js'; +import { parseAskQuestionSubmit } from './xep-plugins/data-form.js'; +import { + buildReceivedReceipt, + isAckOrReceiptStanza, + receivedReceiptId, + requestsReceipt, +} from './xep-plugins/receipts.js'; +import { parseTaskEvent } from './task-stanza-codec.js'; +import { handleVirtualAgentPresence, type VirtualAgentIdentity } from './xep-plugins/presence.js'; + +export type SendStanzaFn = (stanza: Element) => Promise; +export type SendForAgentFn = (agentJid: string, stanza: Element) => Promise; +export type ResolveVirtualAgentFn = (jid: string) => VirtualAgentIdentity | null; +export type UpdatePresenceSubscriptionFn = ( + agent: VirtualAgentIdentity, + subscriberJid: string, + subscribed: boolean, +) => void; + +export class StanzaRouter { + constructor( + private config: GatewayConfig, + private mailbox: GatewayRuntimeMailbox, + private sendForAgent: SendForAgentFn, + private resolveVirtualAgent?: ResolveVirtualAgentFn, + private onReceipt?: (ackedId: string) => void, + private updatePresenceSubscription?: UpdatePresenceSubscriptionFn, + ) {} + + async handleIncoming(stanza: Element): Promise { + if (stanza.name === 'presence') { + const to = bareJid(String(stanza.attrs.to ?? '')); + const agent = this.resolveVirtualAgent?.(to); + if (agent) { + const result = handleVirtualAgentPresence(stanza, agent); + const change = result.subscriptionChange; + if (change) this.updatePresenceSubscription?.(agent, change.subscriberJid, change.subscribed); + for (const response of result.responses) { + await this.sendForAgent(agent.jid, response); + } + } + return; + } + if (stanza.name !== 'message') return; + + const toBare = bareJid(String(stanza.attrs.to ?? '')); + // Stanzas arrive on the component JID; resolve which registered agent they target. + const agentJid = resolveTargetAgentJid(toBare, this.config.agentDomain, this.config.defaultAgentJid); + + if (!isAgentJid(agentJid, this.config.agentDomain) && agentJid !== this.config.defaultAgentJid) { + return; + } + + const from = stanza.attrs.from as string; + const fromBare = bareJid(from); + const agentBare = bareJid(agentJid); + // C2S inbox receives agent self-sent stanzas (outbound loopback) — drop them. + if (fromBare && agentBare && fromBare === agentBare) return; + const namespaces = this.config.protocolNamespaces ?? DEFAULT_PROTOCOL_NAMESPACES; + if (stanza.getChildren('event', namespaces.task).length > 0) { + try { + const taskEvent = parseTaskEvent(stanza, namespaces); + if (taskEvent) await this.mailbox.deliverTaskEvent(taskEvent); + } catch (err) { + console.error('[xmpp-gateway] invalid task lifecycle event:', err instanceof Error ? err.message : err); + } + return; + } + + const formSubmit = parseAskQuestionSubmit(stanza); + if (formSubmit) { + const type = (stanza.attrs.type as string) || 'chat'; + await pushFormResponseToBridge(this.config, this.mailbox, { + agentJid, + from, + stanzaType: type, + questionId: formSubmit.questionId, + selectedIndex: formSubmit.selectedIndex, + }); + return; + } + + if (isAckOrReceiptStanza(stanza)) { + // XEP-0184: a peer's confirms one of our outbound messages. + const acked = receivedReceiptId(stanza); + if (acked) this.onReceipt?.(acked); + return; + } + const agentMsg = stanzaToAgentMessage(stanza, this.config.agentDomain); + if (!agentMsg) return; + + const type = (stanza.attrs.type as string) || 'chat'; + const agentNick = agentJid.split('@')[0]; + const bodyText = typeof agentMsg.body === 'string' ? agentMsg.body : JSON.stringify(agentMsg.body); + + if (!shouldAcceptStanza(type, from, bodyText, agentNick)) return; + + const stanzaId = agentMsg.id; + + const ctx: InboundDeliveryContext = { + agentMsg, + agentJid, + deliveryId: stanzaId, + stanzaType: type, + from, + redelivered: false, + }; + + void sendComposingForAgent( + (stanza) => this.sendForAgent(agentJid, stanza), + agentJid, + resolveInboundChatTargets(from, type, agentMsg), + ).catch((err) => { + console.error('[xmpp-gateway] composing notification send failed:', err); + }); + + await pushInboundToBridge(this.config, this.mailbox, ctx); + + // XEP-0184: ack only 1:1 messages that explicitly requested a receipt. + // Groupchat receipts are not used (§5.5) and unsolicited ones spam the sender. + if (from && type === 'chat' && requestsReceipt(stanza)) { + await this.sendForAgent(agentJid, buildReceivedReceipt(from, agentJid, stanzaId)).catch((err) => { + console.error('[xmpp-gateway] received receipt send failed:', err); + }); + } + } +} diff --git a/packages/agent-xmpp/gateway/src/task-stanza-codec.ts b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts new file mode 100644 index 000000000..2f5a12e36 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/task-stanza-codec.ts @@ -0,0 +1,624 @@ +import { + DEFAULT_PROTOCOL_NAMESPACES, + JSON_MEDIA_TYPE, + bareJid, + isApiVersion, + isNormalizedEndpointJid, + isOpaqueIdentifier, + isToolName, + isXep0082DateTime, + parseStrictJson, + taskEventTypes, + taskStates, + terminalTaskStates, + type AgentTaskEventType, + type AgentTaskRecord, + type AgentTaskState, + type AgentXmppNamespaces, + type McpToolResult, + type PendingTaskInput, +} from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { buildHash, parseHash } from './hash-codec.js'; + +export interface ParsedTaskInvocation { + requestId: string; + tool: string; + apiVersion: string; + manifestHash: string; + callerJid: string; + notificationJid: string; + toJid: string; + arguments: unknown; + deadline?: string; +} + +export interface ParsedTaskRecoveryRequest { + kind: 'state' | 'result'; + taskId: string; +} + +export interface ParsedTaskCancellation { + taskId: string; + expectedRevision: number; + reason?: string; +} + +export interface ParsedTaskInput { + taskId: string; + requestId: string; + expectedRevision: number; + input: unknown; +} + +export interface TaskWireEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + from: string; + to: string; + payload: Record; +} + +export interface AcceptedTask { + requestId: string; + taskId: string; + revision: 0; + created: string; + retainUntil: string; +} + +export interface TaskStateSnapshot { + taskId: string; + endpoint: string; + state: AgentTaskState; + revision: number; + apiVersion: string; + manifestHash: string; + created: string; + updated: string; + retainUntil: string; + deadline?: string; + resultAvailable: boolean; + pendingInput?: PendingTaskInput; +} + +export interface TaskResultSnapshot { + taskId: string; + state: Extract; + revision: number; + result?: McpToolResult; + error?: { code: string; message: string; retryable: boolean; details?: Record }; + summary?: string; +} + +export function parseTaskInvocation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInvocation | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'set') return null; + const payloads = stanza.getChildElements(); + const invoke = stanza.getChild('invoke', namespaces.task); + if (!invoke) return null; + if (payloads.length !== 1 || payloads[0] !== invoke) { + throw new Error('invoke must be the only IQ payload'); + } + assertOnlyAttributes(invoke, ['xmlns', 'request-id', 'tool', 'api-version']); + const children = invoke.getChildElements(); + const expectedNames = + children.length === 3 ? ['manifest-hash', 'arguments', 'deadline'] : ['manifest-hash', 'arguments']; + if ( + children.length < 2 || + children.length > 3 || + children.some((child, index) => child.name !== expectedNames[index] || child.getNS() !== namespaces.task) + ) { + throw new Error('invoke children must be manifest-hash, arguments, then optional deadline'); + } + const [manifestHashElement, argumentsElement, deadlineElement] = children; + assertOnlyAttributes(manifestHashElement!, ['xmlns']); + assertOnlyAttributes(argumentsElement!, ['xmlns', 'media-type']); + if (deadlineElement) assertOnlyAttributes(deadlineElement, ['xmlns']); + const hashChildren = manifestHashElement!.getChildElements(); + if (hashChildren.length !== 1 || hashChildren[0]!.name !== 'hash' || hashChildren[0]!.getNS() !== namespaces.hashes) { + throw new Error('manifest-hash must contain exactly one XEP-0300 hash'); + } + assertOnlyAttributes(hashChildren[0]!, ['xmlns', 'algo']); + if (argumentsElement!.getChildElements().length > 0 || (deadlineElement?.getChildElements().length ?? 0) > 0) { + throw new Error('arguments and deadline must contain character data only'); + } + const requestId = String(invoke.attrs['request-id'] ?? ''); + const tool = String(invoke.attrs.tool ?? ''); + const apiVersion = String(invoke.attrs['api-version'] ?? ''); + const targetJid = String(stanza.attrs.to ?? ''); + const deadline = deadlineElement?.getText(); + if ( + !isOpaqueIdentifier(requestId) || + !isToolName(tool) || + !isApiVersion(apiVersion) || + !isNormalizedEndpointJid(targetJid) || + (deadline !== undefined && !isXep0082DateTime(deadline)) || + argumentsElement!.attrs['media-type'] !== JSON_MEDIA_TYPE + ) { + throw new Error('invoke is missing required attributes or JSON arguments'); + } + return { + requestId, + tool, + apiVersion, + manifestHash: parseHash(invoke, 'manifest-hash').value, + callerJid: bareJid(String(stanza.attrs.from ?? '')), + notificationJid: String(stanza.attrs.from ?? ''), + toJid: targetJid, + arguments: parseStrictJson(argumentsElement!.getText()), + deadline, + }; +} + +export function parseTaskRecoveryRequest( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskRecoveryRequest | null { + const state = stanza.getChild('task-state-request', namespaces.task); + const result = stanza.getChild('task-result-request', namespaces.task); + const payload = state ?? result; + if (!payload) return null; + assertIqRequest(stanza, payload, 'get'); + assertOnlyAttributes(payload, ['xmlns', 'task-id']); + assertEmptyElement(payload); + const taskId = String(payload.attrs['task-id'] ?? ''); + if (!isOpaqueId(taskId)) throw new Error('invalid task recovery identifier'); + return { kind: state ? 'state' : 'result', taskId }; +} + +export function parseTaskCancellation( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskCancellation | null { + const cancel = stanza.getChild('cancel', namespaces.task); + if (!cancel) return null; + assertIqRequest(stanza, cancel, 'set'); + assertOnlyAttributes(cancel, ['xmlns', 'task-id', 'expected-revision']); + const children = cancel.getChildElements(); + if (children.length > 1 || children.some((child) => child.name !== 'reason' || child.getNS() !== namespaces.task)) { + throw new Error('cancel may contain only one reason'); + } + if (cancel.children.some((child) => typeof child === 'string' && child.trim() !== '')) { + throw new Error('cancel may not contain direct character data'); + } + const reason = children[0]; + if (reason) { + assertOnlyAttributes(reason, ['xmlns']); + if (reason.getChildElements().length > 0) throw new Error('reason must contain character data only'); + } + const taskId = String(cancel.attrs['task-id'] ?? ''); + const expectedRevision = parseNonNegativeInteger(cancel.attrs['expected-revision']); + if (!isOpaqueId(taskId)) throw new Error('invalid cancellation identifier'); + return { taskId, expectedRevision, ...(reason ? { reason: reason.getText() } : {}) }; +} + +export function parseTaskInput( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): ParsedTaskInput | null { + const provide = stanza.getChild('provide-input', namespaces.task); + if (!provide) return null; + assertIqRequest(stanza, provide, 'set'); + assertOnlyAttributes(provide, ['xmlns', 'task-id', 'request-id', 'expected-revision']); + const children = provide.getChildElements(); + if ( + children.length !== 1 || + children[0]!.name !== 'input' || + children[0]!.getNS() !== namespaces.task || + provide.children.some((child) => typeof child === 'string' && child.trim() !== '') + ) { + throw new Error('provide-input must contain exactly one input'); + } + const input = children[0]!; + assertOnlyAttributes(input, ['xmlns', 'media-type']); + if (input.attrs['media-type'] !== JSON_MEDIA_TYPE || input.getChildElements().length > 0) { + throw new Error('input must contain JSON character data'); + } + const taskId = String(provide.attrs['task-id'] ?? ''); + const requestId = String(provide.attrs['request-id'] ?? ''); + const expectedRevision = parseNonNegativeInteger(provide.attrs['expected-revision']); + if (!isOpaqueId(taskId) || !isOpaqueId(requestId)) throw new Error('invalid task input identifier'); + return { taskId, requestId, expectedRevision, input: parseStrictJson(input.getText()) }; +} + +export function buildTaskInvocation( + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `invoke-${task.requestId}`, + }, + xml( + 'invoke', + { + xmlns: namespaces.task, + 'request-id': task.requestId, + tool: task.tool, + 'api-version': task.apiVersion, + }, + xml('manifest-hash', {}, buildHash(task.manifestHash)), + xml('arguments', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(task.arguments)), + ...(task.deadline ? [xml('deadline', {}, task.deadline)] : []), + ), + ); +} + +export function buildAcceptedResult( + request: Element, + accepted: AcceptedTask, + from: string, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from, to: request.attrs.from }, + xml('accepted', { + xmlns: namespaces.task, + 'request-id': accepted.requestId, + 'task-id': accepted.taskId, + revision: '0', + created: accepted.created, + 'retain-until': accepted.retainUntil, + }), + ); +} + +export function parseAcceptedResult( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): AcceptedTask | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const accepted = stanza.getChild('accepted', namespaces.task); + if (!accepted) return null; + const revision = Number(accepted.attrs.revision); + if (revision !== 0) throw new Error('accepted task must start at revision 0'); + const parsed: AcceptedTask = { + requestId: String(accepted.attrs['request-id'] ?? ''), + taskId: String(accepted.attrs['task-id'] ?? ''), + revision: 0, + created: String(accepted.attrs.created ?? ''), + retainUntil: String(accepted.attrs['retain-until'] ?? ''), + }; + if ( + !parsed.requestId || + !parsed.taskId || + !isXep0082DateTime(parsed.created) || + !isXep0082DateTime(parsed.retainUntil) + ) { + throw new Error('accepted task is missing required attributes'); + } + if (!isOpaqueId(parsed.requestId) || !isOpaqueId(parsed.taskId)) { + throw new Error('accepted task contains an invalid opaque identifier'); + } + return parsed; +} + +export function buildTaskStateResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: task.targetJid, to: request.attrs.from }, + xml( + 'task-state', + { + xmlns: namespaces.task, + 'task-id': task.taskId, + endpoint: task.targetJid, + state: task.state, + revision: String(task.revision), + 'api-version': task.apiVersion, + created: task.createdAt, + updated: task.updatedAt, + 'retain-until': task.retainUntil, + 'result-available': terminalTaskStates.has(task.state) ? 'true' : 'false', + ...(task.deadline ? { deadline: task.deadline } : {}), + }, + xml('manifest-hash', {}, xml('hash', { xmlns: namespaces.hashes, algo: 'sha-256' }, task.manifestHash)), + ...(task.pendingInput + ? [xml('pending-input', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(task.pendingInput))] + : []), + ), + ); +} + +export function buildTaskResultResponse( + request: Element, + task: AgentTaskRecord, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + const payload = + task.state === 'completed' + ? { result: task.result, ...(task.summary ? { summary: task.summary } : {}) } + : task.state === 'failed' + ? { error: task.error } + : {}; + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: task.targetJid, to: request.attrs.from }, + xml( + 'task-result', + { + xmlns: namespaces.task, + 'task-id': task.taskId, + state: task.state, + revision: String(task.revision), + 'media-type': JSON_MEDIA_TYPE, + }, + JSON.stringify(payload), + ), + ); +} + +export function buildTaskStateRequest(task: AgentTaskRecord, remoteTaskId: string): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'get', + id: `state-${task.taskId}-${task.revision}`, + }, + xml('task-state-request', { xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, 'task-id': remoteTaskId }), + ); +} + +export function buildTaskResultRequest(task: AgentTaskRecord, remoteTaskId: string): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'get', + id: `result-${task.taskId}-${task.revision}`, + }, + xml('task-result-request', { xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, 'task-id': remoteTaskId }), + ); +} + +export function buildTaskCancellation( + task: AgentTaskRecord, + remoteTaskId: string, + expectedRevision: number, + reason?: string, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `cancel-${task.taskId}-${expectedRevision}`, + }, + xml( + 'cancel', + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + 'task-id': remoteTaskId, + 'expected-revision': String(expectedRevision), + }, + ...(reason !== undefined ? [xml('reason', {}, reason)] : []), + ), + ); +} + +export function buildTaskInput( + task: AgentTaskRecord, + remoteTaskId: string, + requestId: string, + expectedRevision: number, + input: unknown, +): Element { + return xml( + 'iq', + { + from: task.callerJid, + to: task.targetJid, + type: 'set', + id: `input-${task.taskId}-${expectedRevision}`, + }, + xml( + 'provide-input', + { + xmlns: DEFAULT_PROTOCOL_NAMESPACES.task, + 'task-id': remoteTaskId, + 'request-id': requestId, + 'expected-revision': String(expectedRevision), + }, + xml('input', { 'media-type': JSON_MEDIA_TYPE }, JSON.stringify(input)), + ), + ); +} + +export function parseTaskActionResult( + stanza: Element, + name: 'cancel-accepted' | 'input-accepted', +): { taskId: string; revision: number } | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const accepted = stanza.getChild(name, DEFAULT_PROTOCOL_NAMESPACES.task); + if (!accepted) return null; + const taskId = String(accepted.attrs['task-id'] ?? ''); + const revision = Number(accepted.attrs.revision); + if (!isOpaqueId(taskId) || !Number.isSafeInteger(revision) || revision < 1) { + throw new Error(`invalid ${name} result`); + } + return { taskId, revision }; +} + +export function parseTaskStateResult(stanza: Element): TaskStateSnapshot | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const state = stanza.getChild('task-state', DEFAULT_PROTOCOL_NAMESPACES.task); + if (!state) return null; + const taskState = String(state.attrs.state ?? '') as AgentTaskState; + const taskId = String(state.attrs['task-id'] ?? ''); + const revision = Number(state.attrs.revision); + const endpoint = String(state.attrs.endpoint ?? ''); + const apiVersion = String(state.attrs['api-version'] ?? ''); + const created = String(state.attrs.created ?? ''); + const updated = String(state.attrs.updated ?? ''); + const retainUntil = String(state.attrs['retain-until'] ?? ''); + if ( + !isOpaqueId(taskId) || + !taskStates.includes(taskState) || + !Number.isSafeInteger(revision) || + revision < 0 || + !isNormalizedEndpointJid(endpoint) || + !isApiVersion(apiVersion) || + !isXep0082DateTime(created) || + !isXep0082DateTime(updated) || + !isXep0082DateTime(retainUntil) || + (state.attrs.deadline !== undefined && !isXep0082DateTime(String(state.attrs.deadline))) + ) { + throw new Error('invalid task-state result'); + } + const pending = state.getChild('pending-input'); + if (pending && pending.attrs['media-type'] !== JSON_MEDIA_TYPE) { + throw new Error('pending task input must use application/json'); + } + return { + taskId, + endpoint, + state: taskState, + revision, + apiVersion, + manifestHash: parseHash(state, 'manifest-hash').value, + created, + updated, + retainUntil, + deadline: state.attrs.deadline ? String(state.attrs.deadline) : undefined, + resultAvailable: state.attrs['result-available'] === 'true', + pendingInput: pending ? (parseStrictJson(pending.getText()) as PendingTaskInput) : undefined, + }; +} + +export function parseTaskResult(stanza: Element): TaskResultSnapshot | null { + if (stanza.name !== 'iq' || stanza.attrs.type !== 'result') return null; + const result = stanza.getChild('task-result', DEFAULT_PROTOCOL_NAMESPACES.task); + if (!result) return null; + const taskId = String(result.attrs['task-id'] ?? ''); + const state = String(result.attrs.state ?? '') as TaskResultSnapshot['state']; + const revision = Number(result.attrs.revision); + if ( + !isOpaqueId(taskId) || + !terminalTaskStates.has(state) || + !Number.isSafeInteger(revision) || + revision < 1 || + result.attrs['media-type'] !== JSON_MEDIA_TYPE + ) { + throw new Error('invalid task-result'); + } + const payload = parseStrictJson(result.getText() || '{}') as Record; + return { + taskId, + state, + revision, + result: payload.result as McpToolResult | undefined, + error: payload.error as TaskResultSnapshot['error'], + summary: typeof payload.summary === 'string' ? payload.summary : undefined, + }; +} + +export function parseTaskEvent( + stanza: Element, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): TaskWireEvent | null { + if (stanza.name !== 'message') return null; + const events = stanza.getChildren('event', namespaces.task); + if (events.length === 0) return null; + if (events.length !== 1) throw new Error('task event message must contain exactly one event'); + const messageType = String(stanza.attrs.type ?? 'normal'); + if (messageType !== 'normal') throw new Error('invalid task event message type'); + const event = events[0]!; + assertOnlyAttributes(event, ['xmlns', 'task-id', 'event-id', 'revision', 'type']); + if (event.getChildElements().length > 0) throw new Error('task event payload must contain character data only'); + const type = String(event.attrs.type ?? '') as AgentTaskEventType; + if (!taskEventTypes.includes(type)) { + throw new Error('unknown task event type'); + } + const revision = Number(event.attrs.revision); + if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('invalid task event revision'); + const taskId = String(event.attrs['task-id'] ?? ''); + const eventId = String(event.attrs['event-id'] ?? ''); + if (!isOpaqueId(taskId) || !isOpaqueId(eventId)) throw new Error('invalid task event identifier'); + return { + taskId, + eventId, + revision, + type, + from: String(stanza.attrs.from ?? ''), + to: String(stanza.attrs.to ?? ''), + payload: parseStrictJson(event.getText() || '{}') as Record, + }; +} + +export function buildTaskEvent( + event: TaskWireEvent, + namespaces: AgentXmppNamespaces = DEFAULT_PROTOCOL_NAMESPACES, +): Element { + return xml( + 'message', + { from: event.from, to: event.to, type: 'normal', id: event.eventId }, + xml( + 'event', + { + xmlns: namespaces.task, + 'task-id': event.taskId, + 'event-id': event.eventId, + revision: String(event.revision), + type: event.type, + }, + JSON.stringify(event.payload), + ), + ); +} + +export function isOpaqueId(value: string): boolean { + return isOpaqueIdentifier(value); +} + +function assertIqRequest(stanza: Element, payload: Element, type: 'get' | 'set'): void { + if ( + stanza.name !== 'iq' || + stanza.attrs.type !== type || + stanza.getChildElements().length !== 1 || + stanza.getChildElements()[0] !== payload + ) { + throw new Error(`${payload.name} must be the only payload of an IQ ${type}`); + } +} + +function assertEmptyElement(element: Element): void { + if ( + element.getChildElements().length > 0 || + element.children.some((child) => typeof child === 'string' && child.trim() !== '') + ) { + throw new Error(`${element.name} must be empty`); + } +} + +function parseNonNegativeInteger(value: unknown): number { + if (typeof value !== 'string' || !/^\+?\d+$/.test(value)) throw new Error('missing non-negative integer'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error('invalid non-negative integer'); + return parsed; +} + +function assertOnlyAttributes(element: Element, allowed: readonly string[]): void { + const allowedNames = new Set(allowed); + if (Object.keys(element.attrs).some((name) => !allowedNames.has(name))) { + throw new Error(`${element.name} has unsupported attributes`); + } +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts new file mode 100644 index 000000000..b76e3c0e5 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/chatstate.ts @@ -0,0 +1,65 @@ +/** + * XEP-0085 Chat State Notifications. + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { bareJid } from './jid.js'; + +const CHATSTATES_NS = 'http://jabber.org/protocol/chatstates'; + +export function isChatStateStanza(stanza: Element): boolean { + if (stanza.name !== 'message') return false; + const body = stanza.getChildText('body'); + if (body?.trim()) return false; + for (const child of stanza.children) { + if (typeof child !== 'object' || child === null) continue; + if (child.attrs?.xmlns === CHATSTATES_NS) return true; + } + return false; +} + +export function buildComposingStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'composing' }); +} + +export function buildPausedStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'paused' }); +} + +export function buildInactiveStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; +}): Element { + return buildChatStateStanza({ ...opts, state: 'inactive' }); +} + +function buildChatStateStanza(opts: { + from: string; + to: string; + threadId?: string | null; + groupchat?: boolean; + state: 'composing' | 'paused' | 'inactive'; +}): Element { + // XEP-0085 states belong to the same 1:1 resource as the chat response. + const to = opts.groupchat ? bareJid(opts.to) : opts.to; + const type = opts.groupchat ? 'groupchat' : 'chat'; + const children: Element[] = [xml(opts.state, { xmlns: CHATSTATES_NS })]; + if (opts.threadId) { + children.unshift(xml('thread', {}, opts.threadId)); + } + return xml('message', { type, to, from: bareJid(opts.from) }, ...children); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts new file mode 100644 index 000000000..6d92e2358 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/data-form.ts @@ -0,0 +1,135 @@ +/** + * XEP-0004 Data Forms — ask_user_question multiple-choice via list-single fields. + * Outbound forms also carry XEP-0359 origin IDs. The optional reply marker uses + * the XEP-0461 namespace including `to`; it always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context (see message.ts header for detail). + * + * @see https://xmpp.org/extensions/xep-0004.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0461.html + */ + +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; + +import type { AskQuestionPayload, OutboundDeliverRequest } from '@agent-xmpp/protocol'; + +import { bareJid, isMucJid } from './jid.js'; +import { RECEIPTS_NS } from './receipts.js'; + +export const DATA_FORM_NS = 'jabber:x:data'; +export const ASK_QUESTION_FORM_TYPE = 'urn:xmpp:nanoclaw:ask-question:0'; + +const ORIGIN_ID_NS = 'urn:xmpp:sid:0'; +const REPLY_NS = 'urn:xmpp:reply:0'; + +export interface AskQuestionSubmit { + questionId: string; + selectedIndex: number; +} + +function optionLabel(raw: AskQuestionPayload['options'][number]): string { + return typeof raw === 'string' ? raw : raw.label; +} + +function buildBodyFallback(payload: AskQuestionPayload): string { + const labels = payload.options.map(optionLabel); + return `${payload.title}\n\n${payload.question}\n\nOptions: ${labels.join(', ')}`; +} + +function hiddenField(varName: string, value: string): Element { + return xml('field', { var: varName, type: 'hidden' }, xml('value', {}, value)); +} + +function listSingleField(payload: AskQuestionPayload): Element { + const options = payload.options.map((raw, idx) => + xml('option', { label: optionLabel(raw) }, xml('value', {}, String(idx))), + ); + return xml('field', { var: 'response', type: 'list-single', label: 'Choose one' }, ...options); +} + +export function isAskQuestionContent(content: unknown): content is AskQuestionPayload { + if (!content || typeof content !== 'object') return false; + const c = content as Record; + return ( + c.type === 'ask_question' && + typeof c.questionId === 'string' && + typeof c.title === 'string' && + typeof c.question === 'string' && + Array.isArray(c.options) && + c.options.length > 0 + ); +} + +export function buildAskQuestionFormStanza( + req: OutboundDeliverRequest, + fromJid: string, + payload: AskQuestionPayload, +): Element { + const id = ulid(); + const children: Element[] = [ + xml('body', {}, buildBodyFallback(payload)), + xml( + 'x', + { xmlns: DATA_FORM_NS, type: 'form' }, + xml('title', {}, payload.title), + xml('instructions', {}, payload.question), + hiddenField('FORM_TYPE', ASK_QUESTION_FORM_TYPE), + hiddenField('questionId', payload.questionId), + listSingleField(payload), + ), + xml('origin-id', { xmlns: ORIGIN_ID_NS, id }), + ]; + + if (req.threadId) { + children.unshift(xml('thread', {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + const isMuc = isMucJid(req.to); + children.push(xml('reply', { xmlns: REPLY_NS, id: req.inReplyTo, to: isMuc ? req.to : bareJid(req.to) })); + } + + const isMuc = isMucJid(req.to); + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 forms only (never MUC), so the + // form is tracked and resent like any other chat message sent through deliver(). + if (!isMuc) { + children.push(xml('request', { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve the initiating resource for 1:1 replies. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? 'groupchat' : 'chat'; + + return xml('message', { type, id, to, from: fromJid, ...(req.lang ? { 'xml:lang': req.lang } : {}) }, ...children); +} + +function dataFormFieldValue(form: Element, varName: string): string | null { + for (const child of form.children) { + if (typeof child === 'string') continue; + if (child.name !== 'field' || child.attrs.var !== varName) continue; + const value = child.getChildText('value'); + return value ?? null; + } + return null; +} + +export function parseAskQuestionSubmit(stanza: Element): AskQuestionSubmit | null { + if (stanza.name !== 'message') return null; + + const form = stanza.getChild('x', DATA_FORM_NS); + if (!form || form.attrs.type !== 'submit') return null; + + const formType = dataFormFieldValue(form, 'FORM_TYPE'); + if (formType !== ASK_QUESTION_FORM_TYPE) return null; + + const questionId = dataFormFieldValue(form, 'questionId'); + const responseRaw = dataFormFieldValue(form, 'response'); + if (!questionId || responseRaw === null) return null; + + const selectedIndex = Number(responseRaw); + if (!Number.isInteger(selectedIndex) || selectedIndex < 0) return null; + + return { questionId, selectedIndex }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts new file mode 100644 index 000000000..e4c4d0858 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/jid.ts @@ -0,0 +1,9 @@ +/** Shared JID helpers (kept cycle-free so both message.ts and muc.ts can import it). */ + +/** Bare JID (localpart@domain) — strips any /resource. */ +export { bareJid } from '@agent-xmpp/protocol'; + +/** True for MUC room JIDs on the conventional `conference.` / `groups.` service domains. */ +export function isMucJid(jid: string): boolean { + return jid.includes('@conference.') || jid.includes('@groups.'); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/message.ts b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts new file mode 100644 index 000000000..a4b15912d --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/message.ts @@ -0,0 +1,258 @@ +/** + * Message normalization and construction. + * + * The JSON payload nests its content in a + * child per XEP-0335; `datatype` still carries a MIME type rather than a + * schema namespace, and the JSON body is the gateway's own + * kind/contentType/body envelope, not caller-defined XEP-0432 content — + * both are deliberate gateway conventions, not spec violations. The reply + * marker carries `to` per XEP-0461, but always uses `req.inReplyTo` as the + * id regardless of 1:1 vs groupchat context; the spec's groupchat-specific + * stanza-id-selection rule is not implemented. The content-type marker, + * XEP-0334 processing hints, and XEP-0359 origin IDs use their standard + * namespaces. + * + * @see https://xmpp.org/extensions/xep-0432.html + * @see https://xmpp.org/extensions/xep-0481.html + * @see https://xmpp.org/extensions/xep-0461.html + * @see https://xmpp.org/extensions/xep-0334.html + * @see https://xmpp.org/extensions/xep-0359.html + * @see https://xmpp.org/extensions/xep-0335.html + */ + +import { createHash } from 'crypto'; + +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; + +import { bareJid, isMucJid } from './jid.js'; + +import type { + AgentMessage, + InboundMessage, + MessageKind, + MessagePolicy, + OutboundDeliverRequest, + XmppSourceMetadata, +} from '@agent-xmpp/protocol'; + +import { buildAskQuestionFormStanza, isAskQuestionContent } from './data-form.js'; +import { RECEIPTS_NS } from './receipts.js'; + +const JSON_NS = 'urn:xmpp:json-msg:0'; +const ORIGIN_ID_NS = 'urn:xmpp:sid:0'; +const REPLY_NS = 'urn:xmpp:reply:0'; +const STORE_NS = 'urn:xmpp:hints'; +const CONTENT_TYPE_NS = 'urn:xmpp:content'; + +export function extractStableId(stanza: Element): string { + const attrId = stanza.attrs.id as string | undefined; + if (attrId) return attrId; + const origin = stanza.getChild('origin-id', ORIGIN_ID_NS); + if (origin?.attrs.id) return origin.attrs.id as string; + // No stanza id: derive a deterministic id from content so a redelivered stanza + // dedups instead of being processed twice. ponytail: content hash — two identical + // id-less messages collide; acceptable since servers virtually always stamp `id`. + const from = (stanza.attrs.from as string) || ''; + const to = (stanza.attrs.to as string) || ''; + const body = stanza.getChildText('body') || ''; + const thread = stanza.getChild('thread')?.getText() || ''; + const digest = createHash('sha256').update(`${from}\n${to}\n${thread}\n${body}`).digest('hex'); + return `derived-${digest.slice(0, 26)}`; +} + +function payloadText(stanza: Element): string | null { + const payload = stanza.getChild('payload', JSON_NS); + if (!payload) return null; + return payload.getChildText('json', 'urn:xmpp:json:0') || null; +} + +function parseJsonPayload(stanza: Element): { kind: MessageKind; contentType: string; body: unknown } | null { + const payload = stanza.getChild('payload', JSON_NS); + if (!payload) return null; + const datatype = (payload.attrs.datatype as string) || 'application/json'; + const raw = payloadText(stanza); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { + kind?: MessageKind; + contentType?: string; + body?: unknown; + }; + return { + kind: parsed.kind || 'text', + contentType: parsed.contentType || datatype, + body: parsed.body ?? parsed, + }; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed JSON payload falls back to raw text + } catch { + return { kind: 'text', contentType: datatype, body: raw }; + } +} + +function bodyText(stanza: Element): string { + return stanza.getChildText('body') || ''; +} + +export function stanzaToAgentMessage(stanza: Element, agentDomain: string): AgentMessage | null { + if (stanza.name !== 'message') return null; + const type = (stanza.attrs.type as string) || 'chat'; + if (type === 'error' || type === 'headline') return null; + + const from = stanza.attrs.from as string; + const to = stanza.attrs.to as string; + if (!from || !to) return null; + + const id = extractStableId(stanza); + const threadEl = stanza.getChild('thread'); + // XEP-0201: the thread id is the element's text content, not a child or attribute. + const threadId = threadEl?.getText()?.trim() || (threadEl?.attrs as { id?: string })?.id; + + const replyEl = stanza.getChild('reply', REPLY_NS); + const replyTo = replyEl?.attrs.id as string | undefined; + + const json = parseJsonPayload(stanza); + const text = bodyText(stanza); + const isMuc = type === 'groupchat'; + const roomId = isMuc ? bareJid(from) : undefined; + const fromBare = bareJid(from); + + let kind: MessageKind = json?.kind || 'text'; + let contentType = json?.contentType || 'text/plain'; + let body: unknown = json?.body ?? text; + + const ctEl = stanza.getChild('content', CONTENT_TYPE_NS); + if (ctEl?.attrs.type) contentType = ctEl.attrs.type as string; + + if (!json && text.startsWith('{')) { + try { + const parsed = JSON.parse(text); + if (parsed.kind) kind = parsed.kind; + if (parsed.contentType) contentType = parsed.contentType; + body = parsed.body ?? parsed; + // eslint-disable-next-line no-catch-all/no-catch-all -- body looks like JSON but isn't; keep as plain text + } catch { + /* plain text */ + } + } + + // XEP-0513: MUC mentions MUST address by `occupantid` (XEP-0421) when the room + // supports it; only outside MUC (or in occupant-id-less rooms) is `jid` used. + // Accept either so occupant-id-addressed mentions aren't silently dropped. + const mentions = stanza + .getChildren('mention', 'urn:xmpp:mentions:0') + .map((el) => (el.attrs.jid ?? el.attrs.occupantid) as string) + .filter(Boolean); + const extensions: Record = {}; + if (mentions.length) extensions.mentions = mentions; + + return { + id, + from: isMuc ? from : fromBare, + to: bareJid(to), + threadId: threadId || undefined, + roomId, + kind, + contentType, + body, + replyTo, + extensions: Object.keys(extensions).length ? extensions : undefined, + }; +} + +export function buildInboundEnvelope( + msg: AgentMessage, + gatewayId: string, + deliveryId: string, + xmppMeta: XmppSourceMetadata, + redelivered?: boolean, +): InboundMessage { + return { + type: 'inbound.message', + message: msg, + delivery: { + receivedAt: new Date().toISOString(), + gatewayId, + deliveryId, + redelivered, + }, + xmpp: xmppMeta, + }; +} + +export function isAgentJid(jid: string, agentDomain: string): boolean { + const bare = bareJid(jid); + return bare.endsWith(`@${agentDomain}`); +} + +export function resolveTargetAgentJid(to: string, agentDomain: string, defaultAgent: string): string { + const bare = bareJid(to); + if (isAgentJid(bare, agentDomain)) return bare; + // Traffic to the bare component address is attributed to the default agent for this gateway. + return defaultAgent; +} + +export function buildOutboundStanza(req: OutboundDeliverRequest, fromJid: string): Element { + if (isAskQuestionContent(req.content)) { + return buildAskQuestionFormStanza(req, fromJid, req.content); + } + + const id = req.id ?? ulid(); + const text = + typeof req.content === 'string' + ? req.content + : (req.content as { text?: string })?.text || + (typeof req.content === 'object' && req.content !== null ? JSON.stringify(req.content) : String(req.content)); + + const contentType = 'text/plain'; + const payload = { + kind: 'text', + contentType, + body: req.content, + }; + + const children: Element[] = [xml('body', {}, text)]; + const isMuc = isMucJid(req.to); + + if (req.threadId) { + children.push(xml('thread', {}, req.threadId)); + } + + if (req.inReplyTo) { + // XEP-0461: bare JID is only a MAY for 1:1; groupchat wants the full JID. + children.push(xml('reply', { xmlns: REPLY_NS, id: req.inReplyTo, to: isMuc ? req.to : bareJid(req.to) })); + } + + children.push( + xml('origin-id', { xmlns: ORIGIN_ID_NS, id: id }), + xml('content', { xmlns: CONTENT_TYPE_NS, type: contentType }), + xml( + 'payload', + { xmlns: JSON_NS, datatype: contentType }, + xml('json', { xmlns: 'urn:xmpp:json:0' }, JSON.stringify(payload)), + ), + ); + + // XEP-0184 §5.1/§5.5: request a delivery receipt on 1:1 messages only (never MUC), + // so the gateway can confirm the peer received it and resend otherwise. + if (!isMuc) { + children.push(xml('request', { xmlns: RECEIPTS_NS })); + } + + // RFC 6121 section 8.5.2.1: preserve a full JID when replying to the + // resource that originated a 1:1 chat. Proactive sends can still use bare JIDs. + const to = req.threadId && isMuc ? req.to : isMuc ? bareJid(req.to) : req.to; + const type = isMuc ? 'groupchat' : 'chat'; + + return xml('message', { type, id, to, from: fromJid, ...(req.lang ? { 'xml:lang': req.lang } : {}) }, ...children); +} + +export function applyStoreHints(stanza: Element, policy?: MessagePolicy): Element { + if (policy?.store === false) { + return xml('message', stanza.attrs, ...stanza.children, xml('no-store', { xmlns: STORE_NS })); + } + if (policy?.store === true) { + return xml('message', stanza.attrs, ...stanza.children, xml('store', { xmlns: STORE_NS })); + } + return stanza; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts new file mode 100644 index 000000000..066669562 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/muc.ts @@ -0,0 +1,74 @@ +/** + * XEP-0045 Multi-User Chat presence and groupchat messages. + * Mentions use XEP-0513 wire format. Outbound uses the `jid` address form (the + * spec's non-anonymous fallback) — the gateway does not yet track XEP-0421 + * occupant-ids, which XEP-0513 mandates for rooms that support them. No begin/end + * offsets, since the gateway doesn't track where in the body a mention occurs. + * + * @see https://xmpp.org/extensions/xep-0045.html + * @see https://xmpp.org/extensions/xep-0513.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { isMucJid } from './jid.js'; +import { buildOutboundStanza } from './message.js'; + +export { isMucJid }; + +const MUC_NS = 'http://jabber.org/protocol/muc'; + +export interface XmppJoinRoomInput { roomJid: string; nickname?: string; password?: string } +export interface XmppLeaveRoomInput { roomJid: string; nickname?: string } +export interface XmppSendRoomMessageInput { + roomJid: string; + body: string; + threadId?: string; + mentions?: string[]; +} + +export function buildJoinPresence(input: XmppJoinRoomInput, agentJid: string): Element { + const nick = input.nickname || agentJid.split('@')[0]; + const roomWithNick = `${input.roomJid}/${nick}`; + // XEP-0045 §7.2.2: request zero history so joining doesn't flood the agent + // with the room's backlog as fresh inbound messages. + const mucChildren: Element[] = [xml('history', { maxstanzas: '0' })]; + if (input.password) { + mucChildren.unshift(xml('password', {}, input.password)); + } + return xml('presence', { to: roomWithNick, from: agentJid }, xml('x', { xmlns: MUC_NS }, ...mucChildren)); +} + +export function buildLeavePresence(input: XmppLeaveRoomInput, agentJid: string, nickname?: string): Element { + const nick = nickname || input.nickname || agentJid.split('@')[0]; + return xml('presence', { + to: `${input.roomJid}/${nick}`, + from: agentJid, + type: 'unavailable', + }); +} + +export function buildRoomMessage(input: XmppSendRoomMessageInput, fromJid: string): Element { + const stanza = buildOutboundStanza( + { + from: fromJid, + to: input.roomJid, + threadId: input.threadId, + content: input.body, + }, + fromJid, + ); + stanza.attrs.type = 'groupchat'; + + for (const m of input.mentions ?? []) { + stanza.append(xml('mention', { xmlns: 'urn:xmpp:mentions:0', jid: m })); + } + + return stanza; +} + +export function mucRoomFromStanza(from: string): string | null { + if (!from.includes('/')) return null; + const [room] = from.split('/'); + return isMucJid(room) ? room : null; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts new file mode 100644 index 000000000..bfd74980c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/ping.ts @@ -0,0 +1,20 @@ +/** + * XEP-0199 XMPP Ping. + * @see https://xmpp.org/extensions/xep-0199.html + */ +import { xml, type Element } from '@xmpp/xml'; + +export const PING_NS = 'urn:xmpp:ping'; + +export function isPingRequest(stanza: Element): boolean { + return stanza.name === 'iq' && stanza.attrs.type === 'get' && stanza.getChild('ping', PING_NS) != null; +} + +export function buildPingResponse(stanza: Element): Element { + return xml('iq', { + type: 'result', + id: stanza.attrs.id, + from: stanza.attrs.to, + to: stanza.attrs.from, + }); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts new file mode 100644 index 000000000..aa64d1680 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/presence.ts @@ -0,0 +1,91 @@ +/** + * Virtual-agent presence for an XEP-0114 component. + * + * Openfire cannot publish presence for virtual JIDs because they are not C2S + * accounts. The component therefore completes roster subscriptions and + * answers server probes itself. + * + * State mapping (RFC 6121): + * subscribe -> subscribed + available (§3.1.4 approving an inbound request) + * probe / '' -> available (§4.3 responding to a presence probe) + * unsubscribe -> unsubscribed (§3.3.2 canceling a subscription) + * + * @see https://www.rfc-editor.org/rfc/rfc6121#section-3 + */ +import { xml, type Element } from '@xmpp/xml'; + +import { bareJid } from './jid.js'; + +export interface VirtualAgentIdentity { + jid: string; + name: string; +} + +export interface PresenceSubscriptionChange { + subscriberJid: string; + subscribed: boolean; +} + +export interface VirtualAgentPresenceResult { + responses: Element[]; + subscriptionChange?: PresenceSubscriptionChange; +} + +export const VIRTUAL_AGENT_RESOURCE = 'gateway'; + +export function virtualAgentPresenceJid(agent: VirtualAgentIdentity): string { + return `${bareJid(agent.jid)}/${VIRTUAL_AGENT_RESOURCE}`; +} + +export function buildAvailablePresence(agent: VirtualAgentIdentity, to: string): Element { + return xml( + 'presence', + { from: virtualAgentPresenceJid(agent), to }, + xml('show', {}, 'chat'), + xml('status', {}, `${agent.name} is available`), + ); +} + +export function buildUnavailablePresence(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { + type: 'unavailable', + from: virtualAgentPresenceJid(agent), + to, + }); +} + +export function buildSubscriptionAccepted(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { type: 'subscribed', from: bareJid(agent.jid), to }); +} + +export function buildSubscriptionRemoved(agent: VirtualAgentIdentity, to: string): Element { + return xml('presence', { type: 'unsubscribed', from: bareJid(agent.jid), to }); +} + +export function handleVirtualAgentPresence(stanza: Element, agent: VirtualAgentIdentity): VirtualAgentPresenceResult { + if (stanza.name !== 'presence') return { responses: [] }; + const to = String(stanza.attrs.from ?? ''); + if (!to) return { responses: [] }; + const type = String(stanza.attrs.type ?? ''); + const subscriberJid = bareJid(to); + if (type === 'subscribe') { + return { + responses: [buildSubscriptionAccepted(agent, to), buildAvailablePresence(agent, to)], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === 'probe') { + return { + responses: [buildAvailablePresence(agent, to)], + subscriptionChange: { subscriberJid, subscribed: true }, + }; + } + if (type === '') return { responses: [buildAvailablePresence(agent, to)] }; + if (type === 'unsubscribe') { + return { + responses: [buildSubscriptionRemoved(agent, to)], + subscriptionChange: { subscriberJid, subscribed: false }, + }; + } + return { responses: [] }; +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts new file mode 100644 index 000000000..e475d9258 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/receipts.ts @@ -0,0 +1,43 @@ +/** + * XEP-0184 Message Delivery Receipts. + * Bodyless XEP-0085 chat states are filtered by the same routing guard. + * + * @see https://xmpp.org/extensions/xep-0184.html + * @see https://xmpp.org/extensions/xep-0085.html + */ + +import { xml, type Element } from '@xmpp/xml'; + +import { isChatStateStanza } from './chatstate.js'; + +export const RECEIPTS_NS = 'urn:xmpp:receipts'; + +/** The id a peer's acknowledges, or null if the stanza isn't a receipt. */ +export function receivedReceiptId(stanza: Element): string | null { + if (stanza.name !== 'message') return null; + return (stanza.getChild('received', RECEIPTS_NS)?.attrs.id as string | undefined) ?? null; +} + +/** True for XEP-0085 chat states and XEP-0184 receipt stanzas with no conversational body. */ +export function isAckOrReceiptStanza(stanza: Element): boolean { + if (isChatStateStanza(stanza)) return true; + if (stanza.name !== 'message') return false; + const body = stanza.getChildText('body'); + if (body?.trim()) return false; + if (stanza.getChild('received', RECEIPTS_NS)) return true; + if (stanza.getChild('request', RECEIPTS_NS)) return true; + return false; +} + +/** XEP-0184: only ack when the sender opted in with . */ +export function requestsReceipt(stanza: Element): boolean { + return stanza.name === 'message' && stanza.getChild('request', RECEIPTS_NS) != null; +} + +export function buildReceivedReceipt(to: string, from: string, messageId: string): Element { + return xml( + 'message', + { to, from, id: `receipt-${messageId}` }, + xml('received', { xmlns: RECEIPTS_NS, id: messageId }), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts new file mode 100644 index 000000000..2ee1566b6 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/routing.ts @@ -0,0 +1,25 @@ +/** + * Plain-text @nick matching is the compatibility fallback for clients that do + * not send XEP-0513 Explicit Mentions. + * @see https://xmpp.org/extensions/xep-0513.html + */ +export function shouldDeliverInbound(stanzaType: string, isGroup: boolean, isMention: boolean): boolean { + if (!isGroup) return true; + return isMention; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectMention(body: string, agentNick?: string): boolean { + if (!agentNick) return false; + // Escape metachars: a JID localpart can contain '.', '(', etc. Unescaped, they + // either false-match or make new RegExp throw and drop the stanza. + return new RegExp(`@${escapeRegExp(agentNick)}\\b`, 'i').test(body); +} + +export function isMentionForAgent(stanzaType: string, body: string, agentNick: string): boolean { + if (stanzaType === 'chat') return true; + return detectMention(body, agentNick); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/search.ts b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts new file mode 100644 index 000000000..9c994ff35 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/search.ts @@ -0,0 +1,124 @@ +/** + * Agent-directory search via XEP-0055, with legacy fields for widely deployed + * clients and the recommended XEP-0004 form extension. + * + * @see https://xmpp.org/extensions/xep-0055.html + */ +import type { RegisteredAgent } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +import { DATA_FORMS_NS, SEARCH_NS } from '../agent-api-disco.js'; +import { buildRsm, pageRsm, parseRsm } from '../rsm-codec.js'; + +const INSTRUCTIONS = 'Enter a nickname to search for matching agents. Leave it empty to list up to 100 agents.'; +const MAX_SEARCH_RESULTS = 100; + +function resultIq(request: Element, componentJid: string, query: Element): Element { + return xml( + 'iq', + { + type: 'result', + id: request.attrs.id, + from: componentJid, + to: request.attrs.from, + ...(request.attrs['xml:lang'] ? { 'xml:lang': request.attrs['xml:lang'] } : {}), + }, + query, + ); +} + +function dataFormFieldValue(form: Element, name: string): string { + return ( + form + .getChildren('field') + .find((field) => field.attrs.var === name) + ?.getChildText('value') + ?.trim() ?? '' + ); +} + +function nickname(agent: RegisteredAgent): string { + return agent.manifest.agent.title ?? agent.manifest.agent.name; +} + +export function buildSearchFields(request: Element, componentJid: string): Element { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + xml('instructions', {}, INSTRUCTIONS), + xml('nick'), + xml( + 'x', + { xmlns: DATA_FORMS_NS, type: 'form' }, + xml('title', {}, 'Agent Directory Search'), + xml('instructions', {}, INSTRUCTIONS), + xml('field', { type: 'hidden', var: 'FORM_TYPE' }, xml('value', {}, SEARCH_NS)), + xml('field', { type: 'text-single', label: 'Nickname', var: 'nick' }), + ), + ), + ); +} + +export function buildSearchResults(request: Element, componentJid: string, agents: RegisteredAgent[]): Element { + const query = request.getChild('query', SEARCH_NS); + const dataForm = query?.getChild('x', DATA_FORMS_NS); + if (dataForm && dataForm.attrs.type !== 'submit') { + return resultIq(request, componentJid, xml('query', { xmlns: SEARCH_NS })); + } + const submittedForm = dataForm; + const needle = (submittedForm ? dataFormFieldValue(submittedForm, 'nick') : (query?.getChildText('nick') ?? '')) + .trim() + .toLowerCase(); + const matches = agents.filter((agent) => { + if (!needle) return true; + const identity = agent.manifest.agent; + const localpart = identity.jid.split('@', 1)[0] ?? ''; + return [localpart, identity.name, identity.title ?? ''].some((value) => value.toLowerCase().includes(needle)); + }); + const page = pageRsm(matches, (agent) => agent.manifest.agent.jid, parseRsm(query!, MAX_SEARCH_RESULTS)); + + if (submittedForm) { + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + xml( + 'x', + { xmlns: DATA_FORMS_NS, type: 'result' }, + xml('field', { type: 'hidden', var: 'FORM_TYPE' }, xml('value', {}, SEARCH_NS)), + xml( + 'reported', + {}, + xml('field', { var: 'jid', label: 'Jabber ID', type: 'jid-single' }), + xml('field', { var: 'nick', label: 'Nickname', type: 'text-single' }), + ), + ...page.items.map((agent) => + xml( + 'item', + {}, + xml('field', { var: 'jid' }, xml('value', {}, agent.manifest.agent.jid)), + xml('field', { var: 'nick' }, xml('value', {}, nickname(agent))), + ), + ), + ), + buildRsm(page), + ), + ); + } + + return resultIq( + request, + componentJid, + xml( + 'query', + { xmlns: SEARCH_NS }, + ...page.items.map((agent) => xml('item', { jid: agent.manifest.agent.jid }, xml('nick', {}, nickname(agent)))), + buildRsm(page), + ), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts new file mode 100644 index 000000000..867aae44c --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xep-plugins/vcard.ts @@ -0,0 +1,22 @@ +/** vCard-temp identity for virtual agents. @see https://xmpp.org/extensions/xep-0054.html */ +import type { RegisteredAgent } from '@agent-xmpp/protocol'; +import { xml, type Element } from '@xmpp/xml'; + +export const VCARD_TEMP_NS = 'vcard-temp'; + +export function buildAgentVcard(request: Element, agent: RegisteredAgent): Element { + const identity = agent.manifest.agent; + const children = [ + xml('FN', {}, identity.title ?? identity.name), + xml('NICKNAME', {}, identity.name), + xml('JABBERID', {}, identity.jid), + ...(identity.description ? [xml('DESC', {}, identity.description)] : []), + ...(identity.homepage ? [xml('URL', {}, identity.homepage)] : []), + ...(identity.avatarUrl ? [xml('PHOTO', {}, xml('EXTVAL', {}, identity.avatarUrl))] : []), + ]; + return xml( + 'iq', + { type: 'result', id: request.attrs.id, from: identity.jid, to: request.attrs.from }, + xml('vCard', { xmlns: VCARD_TEMP_NS }, ...children), + ); +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-component.ts b/packages/agent-xmpp/gateway/src/xmpp-component.ts new file mode 100644 index 000000000..75764d144 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-component.ts @@ -0,0 +1,434 @@ +/** + * External-component session using XEP-0114 Jabber Component Protocol. + * @see https://xmpp.org/extensions/xep-0114.html + */ +import { component } from '@xmpp/component'; +import { xml, type Element } from '@xmpp/xml'; +import { ulid } from 'ulid'; +import { bareJid } from '@agent-xmpp/protocol'; + +import type { GatewayConfig } from './config.js'; + +export interface XmppComponentSession { + send: (stanza: Element) => Promise; + requestIq: (stanza: Element, options?: IqRequestOptions) => Promise; + start: () => Promise; + stop: () => Promise; + forceReconnect: (reason: string) => Promise; + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + onStateChange: (handler: (state: XmppConnectionState) => void) => void; + onStanza: (handler: (stanza: Element) => void) => void; +} + +export type XmppConnectionState = 'offline' | 'connecting' | 'online' | 'stopping'; + +export interface IqRequestOptions { + timeoutMs?: number; + signal?: AbortSignal; +} + +export class IqResponseError extends Error { + constructor(public readonly response: Element) { + const id = String(response.attrs.id ?? 'unknown'); + const stanzaError = response.getChild('error'); + const condition = stanzaError?.children.find( + (child): child is Element => typeof child !== 'string' && child.name !== 'text', + ); + super(`IQ request ${id} failed${condition ? `: ${condition.name}` : ''}`); + this.name = 'IqResponseError'; + } +} + +export type IqGetHandler = (stanza: Element) => Element | null | Promise; + +const STANZA_ERROR_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas'; +const DEFAULT_IQ_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_PENDING_IQ_REQUESTS = 256; + +interface PendingIqRequest { + resolve: (stanza: Element) => void; + reject: (error: Error) => void; + timer: ReturnType; + signal?: AbortSignal; + onAbort?: () => void; + expectedFrom: string; + expectedTo: string; +} + +function abortError(id: string): Error { + const error = new Error(`IQ request ${id} aborted`); + error.name = 'AbortError'; + return error; +} + +/** + * RFC 6120 §8.3 stanza error for an IQ get/set the gateway does not handle. + * `service-unavailable` (type cancel) is the standard "no such handler" reply; + * the original request payload is echoed back per the SHOULD in §8.3.1. + */ +export function buildIqError(request: Element): Element { + return xml( + 'iq', + { type: 'error', id: request.attrs.id, from: request.attrs.to, to: request.attrs.from }, + ...request.children.filter((c): c is Element => typeof c !== 'string'), + xml('error', { type: 'cancel' }, xml('service-unavailable', { xmlns: STANZA_ERROR_NS })), + ); +} + +function iqMiddlewareReply(response: Element): Element | true { + if (response.attrs.type === 'error') { + return ( + response.getChild('error') ?? + xml('error', { type: 'cancel' }, xml('service-unavailable', { xmlns: STANZA_ERROR_NS })) + ); + } + return response.getChildElements()[0] ?? true; +} + +export type IqDisposition = { kind: 'respond'; stanza: Element } | { kind: 'error' } | { kind: 'dispatch' }; + +/** + * Decide how an inbound stanza is handled by the component: + * - IQ get/set the gateway answers -> `respond` with the built reply + * - IQ get/set nothing handled -> `error` (RFC 6120 §8.2.3 requires a reply) + * - everything else, incl. IQ result/error responses to our own outbound requests + * and all message/presence stanzas -> `dispatch` to the registered stanza handlers + */ +export async function dispositionForStanza(stanza: Element, onIqGet?: IqGetHandler): Promise { + if (stanza.name === 'iq') { + const type = String(stanza.attrs.type ?? ''); + if (type === 'get' || type === 'set') { + const response = (await onIqGet?.(stanza)) ?? null; + return response ? { kind: 'respond', stanza: response } : { kind: 'error' }; + } + } + return { kind: 'dispatch' }; +} + +export function reconnectDelayMs(attempt: number, initialMs: number, maxMs: number, random = Math.random): number { + const exponential = Math.min(maxMs, initialMs * 2 ** Math.min(Math.max(attempt - 1, 0), 30)); + return Math.max(1, Math.round(exponential * (0.8 + random() * 0.4))); +} + +export function createComponentSession(config: GatewayConfig, onIqGet?: IqGetHandler): XmppComponentSession { + const maxPendingIqRequests = config.maxPendingIqRequests ?? DEFAULT_MAX_PENDING_IQ_REQUESTS; + if (!Number.isSafeInteger(maxPendingIqRequests) || maxPendingIqRequests <= 0) { + throw new Error('maxPendingIqRequests must be a positive integer'); + } + const stanzaHandlers: Array<(stanza: Element) => void> = []; + const stateHandlers: Array<(state: XmppConnectionState) => void> = []; + const pendingIqRequests = new Map(); + let activeInboundIq = 0; + let activeClient: ReturnType | null = null; + let state: XmppConnectionState = 'offline'; + let stopped = true; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let lastActivityAt = Date.now(); + let onlineAttempt: { + client: ReturnType; + resolve: () => void; + reject: (error: Error) => void; + } | null = null; + + const transition = (next: XmppConnectionState): void => { + if (state === next) return; + state = next; + for (const handler of stateHandlers) handler(next); + }; + + const settleIqRequest = (id: string, responseOrError: Element | Error): boolean => { + const pending = pendingIqRequests.get(id); + if (!pending) return false; + if (!(responseOrError instanceof Error)) { + const responseFrom = bareJid(String(responseOrError.attrs.from ?? '')); + const responseTo = bareJid(String(responseOrError.attrs.to ?? '')); + if ( + (pending.expectedFrom && responseFrom !== pending.expectedFrom) || + (pending.expectedTo && responseTo !== pending.expectedTo) + ) { + return false; + } + } + + pendingIqRequests.delete(id); + clearTimeout(pending.timer); + if (pending.signal && pending.onAbort) pending.signal.removeEventListener('abort', pending.onAbort); + + if (responseOrError instanceof Error) pending.reject(responseOrError); + else if (responseOrError.attrs.type === 'error') pending.reject(new IqResponseError(responseOrError)); + else pending.resolve(responseOrError); + return true; + }; + + const rejectPendingIqRequests = (reason: string): void => { + for (const id of [...pendingIqRequests.keys()]) { + settleIqRequest(id, new Error(`IQ request ${id} failed: ${reason}`)); + } + }; + + const rejectOnlineAttempt = (client: ReturnType, reason: Error): void => { + if (onlineAttempt?.client !== client) return; + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.reject(reason); + }; + + const clearReconnectTimer = (): void => { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + }; + + const scheduleReconnect = (): void => { + if (stopped || reconnectTimer) return; + reconnectAttempt += 1; + const delay = reconnectDelayMs(reconnectAttempt, config.reconnectInitialMs, config.reconnectMaxMs); + console.error(`[xmpp-gateway] reconnect attempt ${reconnectAttempt} scheduled in ${delay}ms`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectClient(); + }, delay); + reconnectTimer.unref?.(); + }; + + const handleConnectionLoss = (client: ReturnType, reason: string): void => { + if (activeClient !== client || stopped || state === 'stopping') return; + activeClient = null; + transition('offline'); + rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + scheduleReconnect(); + }; + + const createClient = (): ReturnType => { + const client = component({ + service: config.componentService, + domain: config.componentJid, + password: config.componentSecret, + }); + // The package helper makes only one fixed-delay attempt. The gateway owns a + // capped, jittered supervisor and creates a clean client for every attempt. + client.reconnect.stop(); + + // @xmpp/component installs its IQ callee as middleware. Handling IQs only + // from the raw `stanza` event races that callee: it immediately emits + // service-unavailable while our listener later emits the valid result. + // Participate in the middleware chain so every request gets exactly one + // response. + const middlewareClient = client as typeof client & { + middleware: { + use(handler: (context: { stanza: Element }, next: () => Promise) => Promise): unknown; + }; + }; + middlewareClient.middleware.use(async (context, next) => { + const stanza = context.stanza as Element; + const type = String(stanza.attrs.type ?? ''); + if (stanza.name !== 'iq' || (type !== 'get' && type !== 'set')) return next(); + if (activeInboundIq >= maxPendingIqRequests) { + return xml('error', { type: 'wait' }, xml('resource-constraint', { xmlns: STANZA_ERROR_NS })); + } + activeInboundIq++; + try { + const disposition = await dispositionForStanza(stanza, onIqGet); + const response = disposition.kind === 'respond' ? disposition.stanza : buildIqError(stanza); + return iqMiddlewareReply(response); + } catch (err) { + console.error('[xmpp-gateway] inbound IQ handling failed:', err); + return iqMiddlewareReply(buildIqError(stanza)); + } finally { + activeInboundIq--; + } + }); + + client.on('stanza', (stanza: Element) => { + if (activeClient !== client) return; + lastActivityAt = Date.now(); + const type = String(stanza.attrs.type ?? ''); + const id = String(stanza.attrs.id ?? ''); + // Correlate outbound requests before any inbound protocol routing. + if (stanza.name === 'iq' && id && (type === 'result' || type === 'error') && settleIqRequest(id, stanza)) { + return; + } + + if (stanza.name === 'iq' && (type === 'get' || type === 'set')) { + // The @xmpp/component IQ middleware above owns the response. + return; + } + for (const handler of stanzaHandlers) handler(stanza); + }); + + client.on('error', (err: Error) => { + if (activeClient === client) { + console.error('[xmpp-gateway] component error:', err.message); + rejectOnlineAttempt(client, err); + } + }); + client.on('online', () => { + if (activeClient !== client || stopped) return; + reconnectAttempt = 0; + clearReconnectTimer(); + lastActivityAt = Date.now(); + transition('online'); + if (onlineAttempt?.client === client) { + const attempt = onlineAttempt; + onlineAttempt = null; + attempt.resolve(); + } + }); + client.on('disconnect', () => handleConnectionLoss(client, 'component disconnected')); + client.on('offline', () => handleConnectionLoss(client, 'component went offline')); + return client; + }; + + async function connectClient(): Promise { + if (stopped || state === 'connecting' || state === 'online') return; + transition('connecting'); + const client = createClient(); + activeClient = client; + try { + // Avoid Component.start(): @xmpp/connection creates an internal + // `online` promise before `open()`, and both promises reject on a + // connection error. Only one is awaited upstream, producing an + // unhandled rejection during ordinary reconnect failures. + await client.connect(config.componentService); + const onlinePromise = new Promise((resolve, reject) => { + onlineAttempt = { client, resolve, reject }; + }); + // A disconnect can reject this while client.open() is still pending. + // Handle that timing window immediately; awaiting the original promise + // below still propagates the rejection. + void onlinePromise.catch(() => undefined); + try { + await client.open({ domain: config.componentJid }); + await onlinePromise; + } catch (error: unknown) { + rejectOnlineAttempt(client, error instanceof Error ? error : new Error(String(error))); + await onlinePromise.catch(() => undefined); + throw error; + } + if (activeClient === client && !stopped) { + reconnectAttempt = 0; + lastActivityAt = Date.now(); + transition('online'); + console.error(`[xmpp-gateway] component online: ${config.componentJid}`); + } + } catch (error: unknown) { + if (activeClient === client) activeClient = null; + client.reconnect.stop(); + transition('offline'); + const message = error instanceof Error ? error.message : String(error); + console.error(`[xmpp-gateway] component connection failed: ${message}`); + scheduleReconnect(); + await client.stop().catch(() => undefined); + } + } + + const send = async (stanza: Element): Promise => { + const client = activeClient; + if (state !== 'online' || !client) throw new Error('XMPP component is offline'); + await client.send(stanza); + lastActivityAt = Date.now(); + }; + + const requestIq = (stanza: Element, options: IqRequestOptions = {}): Promise => { + if (state !== 'online') return Promise.reject(new Error('Cannot send IQ request while component is offline')); + + const type = String(stanza.attrs.type ?? ''); + if (stanza.name !== 'iq' || (type !== 'get' && type !== 'set')) { + return Promise.reject(new Error('Outbound IQ request must be an or stanza')); + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_IQ_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || !Number.isInteger(timeoutMs) || timeoutMs <= 0) { + return Promise.reject(new Error('IQ request timeoutMs must be a positive integer')); + } + if (pendingIqRequests.size >= maxPendingIqRequests) { + return Promise.reject(new Error(`Too many pending IQ requests (limit ${maxPendingIqRequests})`)); + } + + const id = String(stanza.attrs.id ?? '') || ulid(); + if (pendingIqRequests.has(id)) { + return Promise.reject(new Error(`IQ request id is already pending: ${id}`)); + } + stanza.attrs.id = id; + + if (options.signal?.aborted) return Promise.reject(abortError(id)); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + settleIqRequest(id, new Error(`IQ request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + + const pending: PendingIqRequest = { + resolve, + reject, + timer, + signal: options.signal, + expectedFrom: bareJid(String(stanza.attrs.to ?? '')), + expectedTo: bareJid(String(stanza.attrs.from ?? '')), + }; + if (options.signal) { + pending.onAbort = () => settleIqRequest(id, abortError(id)); + options.signal.addEventListener('abort', pending.onAbort, { once: true }); + } + pendingIqRequests.set(id, pending); + + try { + send(stanza).catch((error: unknown) => { + const sendError = error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + }); + } catch (error: unknown) { + const sendError = error instanceof Error ? error : new Error(String(error)); + settleIqRequest(id, sendError); + } + }); + }; + + return { + send, + requestIq, + start: async () => { + if (!stopped) return; + stopped = false; + reconnectAttempt = 0; + clearReconnectTimer(); + await connectClient(); + }, + stop: async () => { + if (stopped && state === 'offline') return; + stopped = true; + clearReconnectTimer(); + transition('stopping'); + rejectPendingIqRequests('component stopped'); + const client = activeClient; + activeClient = null; + if (client) rejectOnlineAttempt(client, new Error('component stopped')); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + transition('offline'); + }, + forceReconnect: async (reason) => { + if (stopped || state === 'stopping') return; + const client = activeClient; + activeClient = null; + transition('offline'); + if (client) rejectOnlineAttempt(client, new Error(reason)); + rejectPendingIqRequests(reason); + client?.reconnect.stop(); + if (client) await client.stop().catch(() => undefined); + scheduleReconnect(); + }, + getState: () => state, + getLastActivityAt: () => lastActivityAt, + onStateChange: (handler) => stateHandlers.push(handler), + onStanza: (handler) => { + stanzaHandlers.push(handler); + }, + }; +} + +export { xml }; diff --git a/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts new file mode 100644 index 000000000..35b58aabe --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-keepalive.ts @@ -0,0 +1,63 @@ +import type { XmppConnectionState } from './xmpp-component.js'; + +export interface XmppKeepaliveOptions { + intervalMs: number; + failureThreshold: number; +} + +export interface XmppKeepaliveCallbacks { + getState: () => XmppConnectionState; + getLastActivityAt: () => number; + ping: () => Promise; + forceReconnect: (reason: string) => Promise; + now?: () => number; +} + +/** Idle XEP-0199 probe loop. Connection recovery remains owned by the session supervisor. */ +export class XmppKeepalive { + private timer: ReturnType | null = null; + private inFlight = false; + private consecutiveFailures = 0; + + constructor( + private readonly options: XmppKeepaliveOptions, + private readonly callbacks: XmppKeepaliveCallbacks, + ) {} + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.check(), this.options.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.inFlight = false; + this.consecutiveFailures = 0; + } + + private async check(): Promise { + if (this.inFlight || this.callbacks.getState() !== 'online') return; + const now = this.callbacks.now?.() ?? Date.now(); + if (now - this.callbacks.getLastActivityAt() < this.options.intervalMs) return; + + this.inFlight = true; + try { + await this.callbacks.ping(); + this.consecutiveFailures = 0; + } catch (error: unknown) { + this.consecutiveFailures += 1; + const message = error instanceof Error ? error.message : String(error); + console.error( + `[xmpp-gateway] keepalive failed (${this.consecutiveFailures}/${this.options.failureThreshold}): ${message}`, + ); + if (this.consecutiveFailures >= this.options.failureThreshold) { + this.consecutiveFailures = 0; + await this.callbacks.forceReconnect('XEP-0199 keepalive failure threshold reached'); + } + } finally { + this.inFlight = false; + } + } +} diff --git a/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts new file mode 100644 index 000000000..b244f7c52 --- /dev/null +++ b/packages/agent-xmpp/gateway/src/xmpp-shims.d.ts @@ -0,0 +1,53 @@ +declare module '@xmpp/xml' { + export class Element { + name: string; + attrs: Record; + children: Array; + + getChild(name: string, xmlns?: string): Element | undefined; + getChildElements(): Element[]; + getChildren(name: string, xmlns?: string): Element[]; + getChildText(name: string, xmlns?: string): string | null; + getNS(): string; + getText(): string; + append(child: Element | string): this; + toString(): string; + } + + export class Parser { + on(event: 'start' | 'element' | 'end', handler: (element: Element) => void): this; + on(event: 'error', handler: (error: Error) => void): this; + write(data: string): void; + end(data?: string): void; + } + + export function xml( + name: string, + attrs?: Record, + ...children: Array + ): Element; + + export default xml; +} + +declare module '@xmpp/component' { + import type { Element } from '@xmpp/xml'; + + interface ComponentClient { + on(event: 'stanza', handler: (stanza: Element) => void): this; + on(event: 'error', handler: (error: Error) => void): this; + on(event: 'offline', handler: () => void): this; + on(event: 'online', handler: () => void): this; + on(event: 'disconnect', handler: () => void): this; + reconnect: { + stop(): void; + }; + connect(service: string): Promise; + open(options: { domain: string }): Promise; + send(stanza: Element): Promise; + start(): Promise; + stop(): Promise; + } + + export function component(options: { service: string; domain: string; password: string }): ComponentClient; +} diff --git a/packages/agent-xmpp/gateway/tsconfig.json b/packages/agent-xmpp/gateway/tsconfig.json new file mode 100644 index 000000000..f1a44108c --- /dev/null +++ b/packages/agent-xmpp/gateway/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/agent-xmpp/protocol/package.json b/packages/agent-xmpp/protocol/package.json new file mode 100644 index 000000000..0f2138948 --- /dev/null +++ b/packages/agent-xmpp/protocol/package.json @@ -0,0 +1,28 @@ +{ + "name": "@agent-xmpp/protocol", + "version": "0.1.0", + "description": "Shared XMPP agent gateway protocol types", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./schema/event.schema.json": "./schema/event.schema.json", + "./schema/manifest.schema.json": "./schema/manifest.schema.json" + }, + "scripts": { + "build": "rm -rf dist && node ../../../node_modules/typescript/bin/tsc", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + }, + "dependencies": { + "idn-hostname": "15.1.10", + "precis-wasm": "0.1.0" + } +} diff --git a/packages/agent-xmpp/protocol/schema/agent-api.xsd b/packages/agent-xmpp/protocol/schema/agent-api.xsd new file mode 100644 index 000000000..794ff611d --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-api.xsd @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/agent-task.xsd b/packages/agent-xmpp/protocol/schema/agent-task.xsd new file mode 100644 index 000000000..ecb7107a7 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/agent-task.xsd @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/agent-xmpp/protocol/schema/event.schema.json b/packages/agent-xmpp/protocol/schema/event.schema.json new file mode 100644 index 000000000..ce1c03753 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/event.schema.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-task:0:event-json", + "$defs": { + "status": { + "type": "object", + "required": [ + "state", + "updatedAt" + ], + "properties": { + "state": { + "enum": [ + "running", + "input_required", + "cancelling" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "progress": { + "type": "object", + "minProperties": 1, + "properties": { + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "stage": { + "type": "string", + "maxLength": 256 + }, + "message": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "input_required": { + "type": "object", + "required": [ + "requestId", + "question", + "inputSchema", + "createdAt" + ], + "properties": { + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{22,128}$" + }, + "question": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "inputSchema": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "toolResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "structuredContent": {}, + "isError": { + "type": "boolean" + }, + "_meta": { + "type": "object" + } + }, + "additionalProperties": false + }, + "completed": { + "type": "object", + "required": [ + "result" + ], + "properties": { + "result": { + "$ref": "#/$defs/toolResult" + }, + "summary": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "failed": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message", + "retryable" + ], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9_.:-]{1,128}$" + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "object" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "cancelled": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 4096 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/agent-xmpp/protocol/schema/manifest.schema.json b/packages/agent-xmpp/protocol/schema/manifest.schema.json new file mode 100644 index 000000000..b721941c7 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/manifest.schema.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:xmpp:agent-api:0:manifest-json", + "type": "object", + "required": [ + "manifestSpecVersion", + "agent", + "tools" + ], + "properties": { + "manifestSpecVersion": { + "const": "0" + }, + "agent": { + "type": "object", + "required": [ + "jid", + "name", + "version" + ], + "properties": { + "jid": { + "type": "string", + "minLength": 3, + "maxLength": 3071 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 4096 + }, + "version": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,64}$" + }, + "vendor": { + "type": "string", + "maxLength": 256 + }, + "homepage": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + }, + "avatarUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + } + }, + "additionalProperties": false + }, + "implementation": { + "type": "object", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "additionalProperties": false + }, + "mcpProtocolVersion": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "tools": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "required": [ + "name", + "inputSchema" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "maxLength": 256 + }, + "description": { + "type": "string", + "maxLength": 8192 + }, + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "annotations": { + "type": "object" + }, + "execution": { + "type": "object" + }, + "_meta": { + "type": "object" + }, + "urn:xmpp:agent-api:0": { + "type": "object", + "properties": { + "supportsProgress": { + "type": "boolean" + }, + "supportsCancellation": { + "type": "boolean" + }, + "supportsInput": { + "type": "boolean" + }, + "defaultTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "maximumTimeoutSeconds": { + "type": "integer", + "minimum": 1 + }, + "requiredPermissions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "uniqueItems": true + }, + "approvalRequired": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "patternProperties": { + "^[a-z][a-z0-9+.-]*:": {} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packages/agent-xmpp/protocol/schema/namespaces.json b/packages/agent-xmpp/protocol/schema/namespaces.json new file mode 100644 index 000000000..b2f541447 --- /dev/null +++ b/packages/agent-xmpp/protocol/schema/namespaces.json @@ -0,0 +1,10 @@ +[ + "urn:xmpp:agent-directory:0", + "urn:xmpp:agent-api:0", + "urn:xmpp:agent-tools:0", + "urn:xmpp:agent-tool:0", + "urn:xmpp:agent-endpoint:0", + "urn:xmpp:agent-endpoint-info:0", + "urn:xmpp:agent-tool-info:0", + "urn:xmpp:agent-task:0" +] diff --git a/packages/agent-xmpp/protocol/src/agent-api.ts b/packages/agent-xmpp/protocol/src/agent-api.ts new file mode 100644 index 000000000..dddc3e108 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-api.ts @@ -0,0 +1,91 @@ +import type { AGENT_API_NS } from './namespaces.js'; + +export type JsonSchema = Record; + +/** MCP Tool annotations are preserved exactly; absence is distinct from false. */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +export interface McpTool { + name: string; + title?: string; + description?: string; + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + annotations?: McpToolAnnotations; + execution?: Record; + _meta?: Record; + [extension: `${string}:${string}`]: unknown; +} + +export interface XmppToolExtension { + supportsProgress?: boolean; + supportsCancellation?: boolean; + supportsInput?: boolean; + defaultTimeoutSeconds?: number; + maximumTimeoutSeconds?: number; + requiredPermissions?: string[]; + approvalRequired?: boolean; + tags?: string[]; +} + +export interface AgentApiManifest { + manifestSpecVersion: '0'; + agent: { + jid: string; + name: string; + title?: string; + description?: string; + version: string; + vendor?: string; + homepage?: string; + /** Public avatar URI served via XEP-0054 PHOTO/EXTVAL. */ + avatarUrl?: string; + }; + implementation?: { name: string; version: string }; + mcpProtocolVersion?: string; + tools: McpTool[]; +} + +export interface RegisteredTool extends McpTool { + inputSchemaHash: string; + outputSchemaHash?: string; + xmpp?: XmppToolExtension; +} + +export interface RegisteredAgent { + manifest: AgentApiManifest; + manifestHash: string; + canonicalManifest: string; + tools: RegisteredTool[]; + tenantId: string; + active: boolean; + registeredAt: string; +} + +export interface VirtualMcpEndpoint { + endpointId: string; + manifestSpecVersion: AgentApiManifest['manifestSpecVersion']; + implementation?: AgentApiManifest['implementation']; + mcpProtocolVersion?: AgentApiManifest['mcpProtocolVersion']; + server: { + name: string; + title?: string; + description?: string; + version: string; + }; + xmpp: { + jid: string; + toolsNode: string; + features: string[]; + }; + authorization: { visible: boolean; invocable: boolean }; + tools: RegisteredTool[]; +} + +export const XMPP_TOOL_EXTENSION_KEY: typeof AGENT_API_NS = 'urn:xmpp:agent-api:0'; diff --git a/packages/agent-xmpp/protocol/src/agent-message.ts b/packages/agent-xmpp/protocol/src/agent-message.ts new file mode 100644 index 000000000..dac2fd786 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-message.ts @@ -0,0 +1,159 @@ +/** Normative types from Agent XMPP Adapter API Surface v0.1 */ + +export type MessageKind = 'text' | 'task' | 'result' | 'error' | 'file' | 'command' | 'event'; + +export type Sensitivity = 'public' | 'internal' | 'confidential' | 'secret'; + +export interface TraceContext { + tenantId?: string; + workflowId?: string; + runId?: string; + spanId?: string; + correlationId?: string; +} + +export interface MessagePolicy { + store?: boolean; + ttlSeconds?: number | null; + trainingAllowed?: boolean; + containsPii?: boolean; + sensitivity?: Sensitivity; +} + +export interface FileRef { + id?: string; + name?: string; + url: string; + mediaType?: string; + sizeBytes?: number; + sha256?: string; + description?: string; + expiresAt?: string; + encrypted?: boolean; + metadata?: Record; +} + +export interface AgentMessage { + id: string; + from: string; + to: string; + threadId?: string; + roomId?: string; + kind: MessageKind; + contentType: string; + body: unknown; + replyTo?: string; + attachments?: FileRef[]; + trace?: TraceContext; + policy?: MessagePolicy; + extensions?: Record; +} + +export interface XmppSourceMetadata { + stanzaId?: string; + stableId?: string; + stanzaType?: 'chat' | 'groupchat' | 'normal' | 'headline' | 'error'; + fromResource?: string; + toResource?: string; + mucOccupantId?: string; + delayed?: { + stamp: string; + from?: string; + }; + rawNamespaces?: string[]; +} + +export interface DeliveryMeta { + receivedAt: string; + gatewayId: string; + deliveryId: string; + redelivered?: boolean; +} + +export interface InboundMessage { + type: 'inbound.message'; + message: AgentMessage; + delivery: DeliveryMeta; + xmpp?: XmppSourceMetadata; +} + +export interface InboundEvent { + type: 'inbound.event'; + event: Record; + delivery: DeliveryMeta; +} + +export interface InboundCommand { + type: 'inbound.command'; + command: string; + args?: Record; + delivery: DeliveryMeta; +} + +export interface InboundLifecycleEvent { + type: 'inbound.lifecycle'; + lifecycle: Record; + delivery: DeliveryMeta; +} + +export type InboundEnvelope = InboundMessage | InboundEvent | InboundCommand | InboundLifecycleEvent; + +/** ask_user_question payload — shared between host delivery and XMPP form rendering. */ +export interface AskQuestionOption { + label: string; + selectedLabel?: string; + value?: string; +} + +export type AskQuestionOptionInput = string | AskQuestionOption; + +export interface AskQuestionPayload { + type: 'ask_question'; + questionId: string; + title: string; + question: string; + options: AskQuestionOptionInput[]; +} + +/** Bridge webhook payload: routing + normalized message for NanoClaw. */ +export interface BridgeInboundPayload { + platformId: string; + /** Full sender JID used for replies and chat states; routing still uses platformId. */ + replyTo?: string; + threadId: string | null; + isMention?: boolean; + isGroup?: boolean; + agentJid: string; + envelope: InboundMessage; +} + +/** XEP-0004 form submit for ask_user_question — routed to host onAction, not the agent. */ +export interface BridgeFormResponsePayload { + type: 'form_response'; + agentJid: string; + platformId: string; + threadId: string | null; + questionId: string; + selectedIndex: number; + userId: string; + timestamp: string; +} + +export type BridgeWebhookPayload = BridgeInboundPayload | BridgeFormResponsePayload; + +export function isBridgeFormResponsePayload(payload: BridgeWebhookPayload): payload is BridgeFormResponsePayload { + return 'type' in payload && payload.type === 'form_response'; +} + +/** Gateway outbound deliver request from NanoClaw bridge. */ +export interface OutboundDeliverRequest { + id?: string; + from: string; + to: string; + /** BCP 47 language tag used as the stanza's inherited xml:lang. */ + lang?: string; + threadId?: string | null; + content: unknown; + inReplyTo?: string; + files?: Array<{ filename: string; dataBase64: string; mediaType?: string }>; +} diff --git a/packages/agent-xmpp/protocol/src/agent-task.ts b/packages/agent-xmpp/protocol/src/agent-task.ts new file mode 100644 index 000000000..6aafb4246 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/agent-task.ts @@ -0,0 +1,71 @@ +export const taskStates = [ + 'accepted', + 'running', + 'input_required', + 'cancelling', + 'cancelled', + 'failed', + 'completed', +] as const; +export type AgentTaskState = (typeof taskStates)[number]; + +export const terminalTaskStates = new Set(['cancelled', 'failed', 'completed']); + +export interface AgentTaskError { + code: string; + message: string; + retryable: boolean; + details?: Record; +} + +export interface McpToolResult { + content: Array>; + structuredContent?: unknown; + isError?: boolean; + _meta?: Record; +} + +export interface PendingTaskInput { + requestId: string; + question: string; + inputSchema: Record; + createdAt: string; + expiresAt?: string; +} + +export interface AgentTaskRecord { + taskId: string; + requestId: string; + callerJid: string; + notificationJid: string; + targetJid: string; + tenantId: string; + tool: string; + apiVersion: string; + manifestHash: string; + arguments: unknown; + state: AgentTaskState; + revision: number; + fingerprint: string; + callerSessionId?: string; + createdAt: string; + updatedAt: string; + deadline?: string; + retainUntil: string; + result?: McpToolResult; + error?: AgentTaskError; + summary?: string; + pendingInput?: PendingTaskInput; +} + +export const taskEventTypes = ['status', 'progress', 'input_required', 'completed', 'failed', 'cancelled'] as const; +export type AgentTaskEventType = (typeof taskEventTypes)[number]; + +export interface AgentTaskEvent { + taskId: string; + eventId: string; + revision: number; + type: AgentTaskEventType; + payload: Record; + createdAt: string; +} diff --git a/packages/agent-xmpp/protocol/src/bridge.ts b/packages/agent-xmpp/protocol/src/bridge.ts new file mode 100644 index 000000000..e8f9563a2 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/bridge.ts @@ -0,0 +1,59 @@ +import type { AgentMessage, BridgeInboundPayload, InboundMessage } from './agent-message.js'; + +/** + * True when the gateway normalized a XEP-0432-inspired JSON payload (agent-to-agent), + * as opposed to a plain human `` stanza (kind=text, contentType=text/plain, string body). + */ +export function isXmppAgentEnvelope(msg: AgentMessage): boolean { + if (msg.kind !== 'text') return true; + if (msg.contentType !== 'text/plain') return true; + return typeof msg.body !== 'string'; +} + +/** Extract the normative AgentMessage from a NanoClaw XMPP inbound content JSON blob. */ +export function agentMessageFromNanoclawContent(raw: string): AgentMessage | null { + try { + const parsed = JSON.parse(raw) as { envelope?: InboundMessage }; + if (parsed.envelope?.type !== 'inbound.message') return null; + return parsed.envelope.message; + // eslint-disable-next-line no-catch-all/no-catch-all -- malformed inbound content returns null + } catch { + return null; + } +} + +/** Human-readable text from a normative AgentMessage. */ +export function agentMessageText(msg: AgentMessage): string { + if (typeof msg.body === 'string') return msg.body; + if (msg.body && typeof msg.body === 'object' && 'text' in msg.body) { + return String((msg.body as { text?: unknown }).text ?? ''); + } + return JSON.stringify(msg.body); +} + +/** NanoClaw channel adapter inbound shape — preserves normative envelope. */ +export interface NanoclawXmppInbound { + id: string; + kind: 'chat'; + content: { text: string; envelope: InboundMessage }; + timestamp: string; + isMention?: boolean; + isGroup?: boolean; +} + +export function nanoclawInboundFromBridge(payload: BridgeInboundPayload): NanoclawXmppInbound { + const { envelope } = payload; + const text = agentMessageText(envelope.message); + return { + id: envelope.message.id, + // Generic AgentMessage(kind="task") is structured conversation content, + // not a durable gateway task. Only the agent-task stanza codec creates a + // task record and exposes lifecycle tools, so an arbitrary message id can + // never be mistaken for a registered task id. + kind: 'chat', + content: { text, envelope }, + timestamp: envelope.delivery.receivedAt, + isMention: payload.isMention, + isGroup: payload.isGroup, + }; +} diff --git a/packages/agent-xmpp/protocol/src/identifiers.ts b/packages/agent-xmpp/protocol/src/identifiers.ts new file mode 100644 index 000000000..0f844fe5f --- /dev/null +++ b/packages/agent-xmpp/protocol/src/identifiers.ts @@ -0,0 +1,112 @@ +const API_VERSION = /^[A-Za-z0-9._~-]{1,64}$/; +const OPAQUE_IDENTIFIER = /^[A-Za-z0-9._~-]{22,128}$/; +const XEP_0082_DATE_TIME = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/; + +interface Xep0082Instant { + epochSecond: bigint; + fraction: string; +} + +export function isApiVersion(value: string): boolean { + return API_VERSION.test(value); +} + +export function isOpaqueIdentifier(value: string): boolean { + return OPAQUE_IDENTIFIER.test(value); +} + +export function isXep0082DateTime(value: string): boolean { + const match = XEP_0082_DATE_TIME.exec(value); + if (!match) return false; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, , , offsetHourText, offsetMinuteText] = + match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if (year === 0 || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false; + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!; + if (day < 1 || day > daysInMonth) return false; + if (offsetHourText) { + const offsetHour = Number(offsetHourText); + const offsetMinute = Number(offsetMinuteText); + if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return false; + } + return true; +} + +/** + * Compare represented XEP-0082 instants without truncating fractional seconds. + * Both inputs must already satisfy isXep0082DateTime(). + */ +export function compareXep0082DateTimes(left: string, right: string): number { + const a = parseXep0082Instant(left); + const b = parseXep0082Instant(right); + if (!a || !b) throw new Error('invalid XEP-0082 date-time'); + if (a.epochSecond < b.epochSecond) return -1; + if (a.epochSecond > b.epochSecond) return 1; + const width = Math.max(a.fraction.length, b.fraction.length); + const aFraction = a.fraction.padEnd(width, '0'); + const bFraction = b.fraction.padEnd(width, '0'); + return aFraction < bFraction ? -1 : aFraction > bFraction ? 1 : 0; +} + +export function compareXep0082DateTimeToDate(value: string, date: Date): number { + return compareXep0082DateTimes(value, date.toISOString()); +} + +/** Smallest integral epoch millisecond that is not before the represented instant. */ +export function xep0082DateTimeToEpochMillisecondsCeil(value: string): number { + const instant = parseXep0082Instant(value); + if (!instant) throw new Error('invalid XEP-0082 date-time'); + const milliseconds = Number(instant.epochSecond) * 1_000; + const firstThreeDigits = Number(instant.fraction.padEnd(3, '0').slice(0, 3)); + const hasSubMillisecondRemainder = /[1-9]/.test(instant.fraction.slice(3)); + return milliseconds + firstThreeDigits + (hasSubMillisecondRemainder ? 1 : 0); +} + +export function isXml10Text(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint !== 0x09 && + codePoint !== 0x0a && + codePoint !== 0x0d && + (codePoint < 0x20 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + codePoint === 0xfffe || + codePoint === 0xffff || + codePoint > 0x10ffff) + ) { + return false; + } + } + return true; +} + +export function isToolName(value: string): boolean { + return value.length > 0 && isXml10Text(value); +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function parseXep0082Instant(value: string): Xep0082Instant | null { + if (!isXep0082DateTime(value)) return null; + const match = XEP_0082_DATE_TIME.exec(value)!; + const [, year, month, day, hour, minute, second, fraction = '', offsetSign, offsetHour = '0', offsetMinute = '0'] = + match; + const date = new Date(0); + date.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); + date.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const signedOffsetSeconds = + (offsetSign === '-' ? -1 : 1) * (Number(offsetHour) * 60 + Number(offsetMinute)) * 60; + return { + epochSecond: BigInt(date.getTime() / 1_000 - signedOffsetSeconds), + fraction, + }; +} diff --git a/packages/agent-xmpp/protocol/src/index.ts b/packages/agent-xmpp/protocol/src/index.ts new file mode 100644 index 000000000..b5f44d576 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/index.ts @@ -0,0 +1,8 @@ +export * from './namespaces.js'; +export * from './agent-message.js'; +export * from './agent-api.js'; +export * from './agent-task.js'; +export * from './bridge.js'; +export * from './identifiers.js'; +export * from './jid.js'; +export * from './strict-json.js'; diff --git a/packages/agent-xmpp/protocol/src/jid.ts b/packages/agent-xmpp/protocol/src/jid.ts new file mode 100644 index 000000000..bd73cfc91 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/jid.ts @@ -0,0 +1,98 @@ +import { createRequire } from 'node:module'; +import { isIP } from 'node:net'; +import { readFileSync } from 'node:fs'; +import IdnHostname from 'idn-hostname'; +import { initSync, usernamecasemapped_enforce } from 'precis-wasm/precis_wasm.js'; + +/** Return the addressable bare JID, stripping any resource suffix. */ +export function bareJid(jid: string): string { + return jid.split('/')[0] ?? jid; +} + +const LOCALPART_EXCLUDED = /["&'/:<>@]/u; +const DOMAINPART_EXCLUDED = /[/@]/u; +const DNS_LABEL_SEPARATOR_AT_END = /[.\u3002\uff0e\uff61]$/u; +const IPV6_ZONE = /^(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+$/u; +const require = createRequire(import.meta.url); +const { idnHostname, punycode } = IdnHostname; +let precisInitialized = false; + +/** + * Prepare the RFC 7622 bare-JID shape used for ProtoXEP endpoints. + * Endpoint identities require a localpart and deliberately reject resources. + */ +export function normalizeEndpointJid(value: string): string | null { + if (value.includes('/') || value.indexOf('@') <= 0 || value.indexOf('@') !== value.lastIndexOf('@')) return null; + const [rawLocal, rawDomain] = value.split('@'); + const local = normalizeLocalpart(rawLocal!); + const domain = normalizeDomain(rawDomain!); + if (!local || !domain || LOCALPART_EXCLUDED.test(local) || utf8Length(local) > 1023 || utf8Length(domain) > 1023) { + return null; + } + return `${local}@${domain}`; +} + +export function isNormalizedEndpointJid(value: string): boolean { + return normalizeEndpointJid(value) === value; +} + +export function sameEndpointJid(left: string, right: string): boolean { + const preparedLeft = normalizeEndpointJid(left); + return preparedLeft !== null && preparedLeft === normalizeEndpointJid(right); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).length; +} + +function normalizeLocalpart(value: string): string | null { + initializePrecis(); + try { + return usernamecasemapped_enforce(value) as string; + } catch (error) { + if (error instanceof Error || typeof error === 'string') return null; + throw error; + } +} + +function initializePrecis(): void { + if (precisInitialized) return; + const wasmPath = require.resolve('precis-wasm/precis_wasm_bg.wasm'); + initSync({ module: readFileSync(wasmPath) }); + precisInitialized = true; +} + +function normalizeDomain(value: string): string | null { + const withoutFinalSeparator = value.replace(DNS_LABEL_SEPARATOR_AT_END, ''); + if (!withoutFinalSeparator || DOMAINPART_EXCLUDED.test(withoutFinalSeparator)) { + return null; + } + + const ipLiteral = normalizeIpLiteral(withoutFinalSeparator); + if (ipLiteral !== undefined) return ipLiteral; + + try { + const ascii = idnHostname(withoutFinalSeparator); + const unicode = punycode.toUnicode(ascii).normalize('NFC').toLowerCase().normalize('NFC'); + return idnHostname(unicode) === ascii ? unicode : null; + } catch (error) { + if (error instanceof Error) return null; + throw error; + } +} + +function normalizeIpLiteral(value: string): string | null | undefined { + if (!value.startsWith('[') && !value.endsWith(']')) return undefined; + if (!value.startsWith('[') || !value.endsWith(']')) return null; + + const content = value.slice(1, -1); + const zoneDelimiter = content.indexOf('%25'); + const address = zoneDelimiter === -1 ? content : content.slice(0, zoneDelimiter); + const zone = zoneDelimiter === -1 ? undefined : content.slice(zoneDelimiter + 3); + if (isIP(address) !== 6 || (zone !== undefined && !IPV6_ZONE.test(zone))) return null; + + const hostname = new URL(`http://[${address}]/`).hostname.toLowerCase(); + if (zone === undefined) return hostname; + const normalizedZone = zone.replace(/%[0-9A-Fa-f]{2}/gu, (encoded) => encoded.toUpperCase()); + return `${hostname.slice(0, -1)}%25${normalizedZone}]`; +} diff --git a/packages/agent-xmpp/protocol/src/namespaces.ts b/packages/agent-xmpp/protocol/src/namespaces.ts new file mode 100644 index 000000000..5a4dac217 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/namespaces.ts @@ -0,0 +1,63 @@ +/** ProtoXEP XMPP Agent Gateway 0.0.3 protocol constants. */ +export interface AgentXmppNamespaces { + directory: typeof AGENT_DIRECTORY_NS; + api: typeof AGENT_API_NS; + manifest: typeof AGENT_MANIFEST_FEATURE; + schema: typeof AGENT_SCHEMA_FEATURE; + selfRegister: typeof AGENT_SELF_REGISTER_FEATURE; + admin: typeof AGENT_ADMIN_FEATURE; + tools: typeof AGENT_TOOLS_NS; + tool: typeof AGENT_TOOL_NS; + endpoint: typeof AGENT_ENDPOINT_NS; + endpointInfo: typeof AGENT_ENDPOINT_INFO_FORM; + toolInfo: typeof AGENT_TOOL_INFO_FORM; + task: typeof AGENT_TASK_NS; + progress: typeof AGENT_TASK_PROGRESS_FEATURE; + cancel: typeof AGENT_TASK_CANCEL_FEATURE; + input: typeof AGENT_TASK_INPUT_FEATURE; + hashes: typeof HASHES_NS; + rsm: typeof RSM_NS; +} + +export const AGENT_DIRECTORY_NS = 'urn:xmpp:agent-directory:0'; +export const AGENT_API_NS = 'urn:xmpp:agent-api:0'; +export const AGENT_MANIFEST_FEATURE = `${AGENT_API_NS}#manifest` as const; +export const AGENT_SCHEMA_FEATURE = `${AGENT_API_NS}#schema` as const; +export const AGENT_SELF_REGISTER_FEATURE = `${AGENT_API_NS}#self-register` as const; +export const AGENT_ADMIN_FEATURE = `${AGENT_API_NS}#admin` as const; +export const AGENT_TOOLS_NS = 'urn:xmpp:agent-tools:0'; +export const AGENT_TOOL_NS = 'urn:xmpp:agent-tool:0'; +export const AGENT_ENDPOINT_NS = 'urn:xmpp:agent-endpoint:0'; +export const AGENT_ENDPOINT_INFO_FORM = 'urn:xmpp:agent-endpoint-info:0'; +export const AGENT_TOOL_INFO_FORM = 'urn:xmpp:agent-tool-info:0'; +export const AGENT_TASK_NS = 'urn:xmpp:agent-task:0'; +export const AGENT_TASK_PROGRESS_FEATURE = `${AGENT_TASK_NS}#progress` as const; +export const AGENT_TASK_CANCEL_FEATURE = `${AGENT_TASK_NS}#cancel` as const; +export const AGENT_TASK_INPUT_FEATURE = `${AGENT_TASK_NS}#input` as const; +export const HASHES_NS = 'urn:xmpp:hashes:2'; +export const RSM_NS = 'http://jabber.org/protocol/rsm'; + +export const DEFAULT_PROTOCOL_NAMESPACES: Readonly = Object.freeze({ + directory: AGENT_DIRECTORY_NS, + api: AGENT_API_NS, + manifest: AGENT_MANIFEST_FEATURE, + schema: AGENT_SCHEMA_FEATURE, + selfRegister: AGENT_SELF_REGISTER_FEATURE, + admin: AGENT_ADMIN_FEATURE, + tools: AGENT_TOOLS_NS, + tool: AGENT_TOOL_NS, + endpoint: AGENT_ENDPOINT_NS, + endpointInfo: AGENT_ENDPOINT_INFO_FORM, + toolInfo: AGENT_TOOL_INFO_FORM, + task: AGENT_TASK_NS, + progress: AGENT_TASK_PROGRESS_FEATURE, + cancel: AGENT_TASK_CANCEL_FEATURE, + input: AGENT_TASK_INPUT_FEATURE, + hashes: HASHES_NS, + rsm: RSM_NS, +}); + +export const AGENT_MANIFEST_SPEC_VERSION = '0'; +export const AGENT_API_SPEC_VERSION = AGENT_MANIFEST_SPEC_VERSION; +export const JSON_MEDIA_TYPE = 'application/json'; +export const JSON_SCHEMA_MEDIA_TYPE = 'application/schema+json'; diff --git a/packages/agent-xmpp/protocol/src/strict-json.ts b/packages/agent-xmpp/protocol/src/strict-json.ts new file mode 100644 index 000000000..c03ecf161 --- /dev/null +++ b/packages/agent-xmpp/protocol/src/strict-json.ts @@ -0,0 +1,179 @@ +export interface StrictJsonLimits { + maxBytes: number; + maxDepth: number; + maxStringBytes: number; + maxMembers: number; +} + +export const DEFAULT_JSON_LIMITS: Readonly = Object.freeze({ + maxBytes: 1_048_576, + maxDepth: 64, + maxStringBytes: 1_048_576, + maxMembers: 100_000, +}); + +export class JsonResourceLimitError extends Error {} + +const utf8Length = (value: string): number => new TextEncoder().encode(value).length; + +/** A bounded JSON parser that detects duplicate object names before information is lost. */ +export function parseStrictJson(text: string, limits: Partial = {}): unknown { + const resolved = { ...DEFAULT_JSON_LIMITS, ...limits }; + assertJsonLimits(resolved); + if (utf8Length(text) > resolved.maxBytes) throw new JsonResourceLimitError('JSON payload exceeds byte limit'); + let offset = 0; + let members = 0; + const fail = (message: string): never => { + throw new Error(`${message} at JSON offset ${offset}`); + }; + const whitespace = (): void => { + while (offset < text.length && /[\t\n\r ]/.test(text[offset]!)) offset++; + }; + const parseString = (): string => { + if (text[offset++] !== '"') return fail('expected string'); + let result = ''; + while (offset < text.length) { + const character = text[offset++]!; + if (character === '"') { + if (utf8Length(result) > resolved.maxStringBytes) { + throw new JsonResourceLimitError(`JSON string exceeds byte limit at JSON offset ${offset}`); + } + assertUnicodeScalarString(result); + return result; + } + if (character === '\\') { + const escape = text[offset++]!; + const simple: Record = { + '"': '"', + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + }; + if (escape in simple) result += simple[escape]; + else if (escape === 'u') { + const hex = text.slice(offset, offset + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid Unicode escape'); + result += String.fromCharCode(Number.parseInt(hex, 16)); + offset += 4; + } else fail('invalid string escape'); + } else { + if (character.charCodeAt(0) < 0x20) fail('unescaped control character'); + result += character; + } + } + return fail('unterminated string'); + }; + const parseNumber = (): number => { + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(offset)); + if (!match) return fail('invalid number'); + offset += match[0].length; + const number = Number(match[0]); + if (!Number.isFinite(number)) fail('number is not finite'); + if (/^-?[0-9]+$/.test(match[0])) { + const integer = BigInt(match[0]); + if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER)) { + fail('integer is outside the lossless range'); + } + } + return number; + }; + const parseValue = (depth: number): unknown => { + if (depth > resolved.maxDepth) { + throw new JsonResourceLimitError(`JSON nesting exceeds depth limit at JSON offset ${offset}`); + } + whitespace(); + const character = text[offset]; + if (character === '"') return parseString(); + if (character === '{') { + offset++; + const result: Record = {}; + const names = new Set(); + whitespace(); + if (text[offset] === '}') { + offset++; + return result; + } + while (true) { + whitespace(); + if (text[offset] !== '"') fail('expected object member name'); + const name = parseString(); + if (names.has(name)) fail(`duplicate object member ${JSON.stringify(name)}`); + names.add(name); + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError(`JSON member count exceeds limit at JSON offset ${offset}`); + } + whitespace(); + if (text[offset++] !== ':') fail('expected colon'); + result[name] = parseValue(depth + 1); + whitespace(); + const separator = text[offset++]; + if (separator === '}') return result; + if (separator !== ',') fail('expected comma or object end'); + } + } + if (character === '[') { + offset++; + const result: unknown[] = []; + whitespace(); + if (text[offset] === ']') { + offset++; + return result; + } + while (true) { + if (++members > resolved.maxMembers) { + throw new JsonResourceLimitError(`JSON member count exceeds limit at JSON offset ${offset}`); + } + result.push(parseValue(depth + 1)); + whitespace(); + const separator = text[offset++]; + if (separator === ']') return result; + if (separator !== ',') fail('expected comma or array end'); + } + } + if (text.startsWith('true', offset)) { + offset += 4; + return true; + } + if (text.startsWith('false', offset)) { + offset += 5; + return false; + } + if (text.startsWith('null', offset)) { + offset += 4; + return null; + } + if (character === '-' || (character !== undefined && /[0-9]/.test(character))) return parseNumber(); + return fail('expected JSON value'); + }; + const value = parseValue(0); + whitespace(); + if (offset !== text.length) fail('trailing data'); + return value; +} + +function assertJsonLimits(limits: StrictJsonLimits): void { + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) { + throw new Error(`${name} must be a ${name === 'maxDepth' ? 'non-negative' : 'positive'} safe integer`); + } + } +} + +export function assertUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const low = value.charCodeAt(index + 1); + if (!Number.isInteger(low) || low < 0xdc00 || low > 0xdfff) { + throw new Error('lone high surrogate is not permitted'); + } + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error('lone low surrogate is not permitted'); + } + } +} diff --git a/packages/agent-xmpp/protocol/tsconfig.json b/packages/agent-xmpp/protocol/tsconfig.json new file mode 100644 index 000000000..f1a44108c --- /dev/null +++ b/packages/agent-xmpp/protocol/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql new file mode 100644 index 000000000..e5b37b192 --- /dev/null +++ b/packages/db/prisma/migrations/20260826190000_xmpp_agent_tasks/migration.sql @@ -0,0 +1,33 @@ +CREATE TYPE "XmppAgentTaskState" AS ENUM ('ACCEPTED', 'RUNNING', 'CANCELLING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +CREATE TABLE "xmppAgentTask" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "callerJid" TEXT NOT NULL, + "notificationJid" TEXT NOT NULL, + "targetJid" TEXT NOT NULL, + "tool" TEXT NOT NULL, + "apiVersion" TEXT NOT NULL, + "manifestHash" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "arguments" JSONB NOT NULL, + "state" "XmppAgentTaskState" NOT NULL DEFAULT 'ACCEPTED', + "revision" INTEGER NOT NULL DEFAULT 0, + "progress" JSONB, + "result" JSONB, + "error" JSONB, + "summary" TEXT, + "eveSessionId" TEXT, + "deadline" TIMESTAMP(3), + "retainUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "xmppAgentTask_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "xmppAgentTask_organizationId_callerJid_targetJid_requestId_key" ON "xmppAgentTask"("organizationId", "callerJid", "targetJid", "requestId"); +CREATE INDEX "xmppAgentTask_organizationId_state_updatedAt_idx" ON "xmppAgentTask"("organizationId", "state", "updatedAt"); +CREATE INDEX "xmppAgentTask_retainUntil_idx" ON "xmppAgentTask"("retainUntil"); + +ALTER TABLE "xmppAgentTask" ADD CONSTRAINT "xmppAgentTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..f4576ce79 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -209,6 +209,15 @@ enum AgentConversationKind { BUILDER } +enum XmppAgentTaskState { + ACCEPTED + RUNNING + CANCELLING + COMPLETED + FAILED + CANCELLED +} + enum AgentDefinitionStatus { DRAFT DEPLOYING @@ -496,6 +505,37 @@ model AgentTask { @@map("agentTask") } +model XmppAgentTask { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requestId String + callerJid String + notificationJid String + targetJid String + tool String + apiVersion String + manifestHash String + fingerprint String + arguments Json + state XmppAgentTaskState @default(ACCEPTED) + revision Int @default(0) + progress Json? + result Json? + error Json? + summary String? + eveSessionId String? + deadline DateTime? + retainUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, callerJid, targetJid, requestId]) + @@index([organizationId, state, updatedAt]) + @@index([retainUntil]) + @@map("xmppAgentTask") +} + model AgentEvent { id String @id sessionId String @@ -1486,6 +1526,7 @@ model Organization { website String? members Member[] invitations Invitation[] + xmppAgentTasks XmppAgentTask[] @@unique([slug]) @@map("organization") diff --git a/turbo.json b/turbo.json index 110f9bd94..65aae934d 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,28 @@ "VERCEL_OIDC_TOKEN", "AGENT_URL", "AGENT_BRIDGE_SECRET", + "XMPP_COMPONENT_ENABLED", + "XMPP_COMPONENT_JID", + "XMPP_COMPONENT_SECRET", + "XMPP_COMPONENT_SERVICE", + "XMPP_ORGANIZATION_ID", + "XMPP_DEFAULT_AGENT_JID", + "XMPP_AGENT_DOMAIN", + "XMPP_SERVER_DOMAIN", + "XMPP_GATEWAY_ID", + "XMPP_AGENT_VERSION", + "XMPP_ALLOWED_CALLER_DOMAINS", + "XMPP_ALLOW_DESTRUCTIVE_CALLERS", + "XMPP_XML_LANG", + "XMPP_RECEIPT_TIMEOUT_MS", + "XMPP_RECEIPT_MAX_RESENDS", + "XMPP_RECEIPT_SWEEP_MS", + "XMPP_RECONNECT_INITIAL_MS", + "XMPP_RECONNECT_MAX_MS", + "XMPP_PING_INTERVAL_MS", + "XMPP_PING_TIMEOUT_MS", + "XMPP_PING_FAILURE_THRESHOLD", + "XMPP_MAX_PENDING_IQ_REQUESTS", "CRM_TELEMETRY_DISABLED", "DO_NOT_TRACK", "VERCEL",