import { randomUUID } from "node:crypto"; import { ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER } from "../action-ui.js"; import { AgentActionStopError, type ActionRunContext } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; import type { AgentChatAttachment } from "../agent/types.js"; import { writeAppState } from "../application-state/script-helpers.js"; import { getDbExec, isPostgres } from "../db/client.js"; import { readResource } from "../resources/script-helpers.js"; import { getRequestOrgId, getRequestRunContext, getRequestUserEmail, } from "../server/request-context.js"; import { resolveAccess } from "../sharing/access.js"; import { readWorkspaceFile, type WorkspaceFilesScope, } from "../workspace-files/store.js"; import { ExtensionContentEditError } from "./content-patch.js"; import type { ExtensionContentEdit, ExtensionLegacyPatch, } from "./content-patch.js"; import { getLocalExtension, isLocalExtensionRow, listLocalExtensions, type LocalExtensionRow, } from "./local.js"; import { extensionPath } from "./path.js"; import { addExtensionSlotTarget, installExtensionSlot, uninstallExtensionSlot, listExtensionsForSlot, listSlotsForExtension, } from "./slots/store.js"; import { createExtension, deleteExtension, ensureExtensionsTables, findRecentDuplicateExtension, getHiddenExtensionIdsForCurrentUser, getExtension, getExtensionHistoryVersion, globalHideExtension, globalUnhideExtension, hideExtension, listExtensionHistory, listExtensions, notifyExtensionChangeForResource, restoreExtensionHistoryVersion, unhideExtension, updateExtension, updateExtensionContent, type ExtensionHistoryDetail, type ExtensionHistoryEntry, type ExtensionRow, } from "./store.js"; // A 200k extension body containing JSON-sensitive HTML/JS characters (quotes, // backslashes, and newlines) expands to about 400k characters when pretty-JSON // serialized by the agent loop. A history detail can carry the current and // previous bodies plus both bodies again in its line diff (about 1.6M chars in // the same worst-common-case fixture). These caps add roughly 25% headroom for // the surrounding metadata and indentation while still bounding tool context. const GET_EXTENSION_MAX_RESULT_CHARS = 500_000; const GET_EXTENSION_HISTORY_MAX_RESULT_CHARS = 2_000_000; export function createExtensionActionEntries(): Record { return { "list-extensions": { tool: { description: "List extensions visible in the current user's Extensions list/sidebar. Use this for browsing or when you only know a display name. If or already contains extensionId for the current extension, use get-extension or update-extension with that id directly instead of listing. Do not query the legacy tools table directly for extension management.", parameters: { type: "object", properties: { search: { type: "string", description: "Optional case-insensitive filter matched against id, name, description, and owner email. Example: Connect Zoom.", }, includeHidden: { type: "boolean", description: "Include extensions the current user has hidden from their list. Defaults to false.", }, includeGloballyHidden: { type: "boolean", description: "Include extensions an admin/owner has globally hidden from everyone (via global-hide-extension). Defaults to false. Use this to find ids to unhide for everyone.", }, includeContent: { type: "boolean", description: "Include full Alpine.js content. Defaults to false to keep results concise.", }, limit: { type: "number", description: "Maximum results to return. Defaults to 100.", }, }, }, }, run: async (args) => { const includeHidden = coerceBoolean(args?.includeHidden); const includeGloballyHidden = coerceBoolean( args?.includeGloballyHidden, ); const includeContent = coerceBoolean(args?.includeContent); const search = String(args?.search ?? "") .trim() .toLowerCase(); const limit = coerceLimit(args?.limit); const hiddenIds = await getHiddenExtensionIdsForCurrentUser(); let rows: Array = await listExtensions({ includeHidden, includeGloballyHidden, }); const localRows = await listLocalExtensions(); const allRows: Array = [ ...rows, ...localRows, ]; if (search) { rows = allRows.filter((row) => [row.id, row.name, row.description, row.ownerEmail] .join("\n") .toLowerCase() .includes(search), ); } else { rows = allRows; } rows = rows.slice(0, limit); const extensions = await Promise.all( rows.map((row) => summarizeExtension(row, hiddenIds, includeContent)), ); return { ok: true, count: extensions.length, extensions, }; }, readOnly: true, }, "get-extension": { tool: { description: "Get one existing extension by id. Use this when or contains extensionId for the current extension; do not call list-extensions just to rediscover that id. Defaults to including the full Alpine.js content once per run so you can make a targeted update-extension edit; repeated unchanged reads return compact metadata unless forceContent=true.", parameters: { type: "object", properties: { id: { type: "string", description: "Extension id to read. Prefer the extensionId from or when the user refers to the current extension.", }, includeContent: { type: "boolean", description: "Include full Alpine.js content. Defaults to true for targeted edits.", }, forceContent: { type: "boolean", description: "Return full content even if this run already read the same unchanged body. Use sparingly; prefer update-extension edits after the first read.", }, }, required: ["id"], }, }, run: async (args) => { const id = String(args?.id ?? "").trim(); if (!id) return "Error: id is required."; const includeContent = args?.includeContent === undefined ? true : coerceBoolean(args.includeContent); const forceContent = coerceBoolean(args?.forceContent); const localExtension = await getLocalExtension(id); if (localExtension) { return { ok: true, extension: await summarizeExtensionForAgentRead( localExtension, new Set(), includeContent, forceContent, ), }; } const extension = await getExtension(id); if (!extension) return `Error: extension not found: ${id}`; const hiddenIds = await getHiddenExtensionIdsForCurrentUser(); return { ok: true, extension: await summarizeExtensionForAgentRead( extension, hiddenIds, includeContent, forceContent, ), }; }, // Result is JSON including the full Alpine content; account for JSON // escaping and envelope metadata instead of matching the source cap. maxResultChars: GET_EXTENSION_MAX_RESULT_CHARS, readOnly: true, }, "list-extension-history": { tool: { description: "List saved history snapshots for one extension. Use this when the user asks what changed, wants a changelog, or wants to pick an older version to restore. If the user is viewing the extension, use the extensionId from or .", parameters: { type: "object", properties: { id: { type: "string", description: "Extension id whose history should be listed.", }, limit: { type: "number", description: "Maximum versions to return. Defaults to 50.", }, includeContent: { type: "boolean", description: "Include full HTML content for each version. Defaults to false.", }, }, required: ["id"], }, }, run: async (args) => { const id = String(args?.id ?? "").trim(); if (!id) return "Error: id is required."; const localMessage = await localExtensionReadonlyHistoryMessage(id); if (localMessage) return localMessage; const history = await listExtensionHistory(id, { limit: args?.limit === undefined ? undefined : coerceLimit(args.limit), includeContent: coerceBoolean(args?.includeContent), }); return { ok: true, count: history.length, history, }; }, readOnly: true, }, "get-extension-history-version": { tool: { description: "Get one extension history version with its previous-version diff. Use after list-extension-history when the user wants to inspect exactly what changed. Full HTML bodies are omitted by default; set includeContent=true only when restoring or manually comparing full source.", parameters: { type: "object", properties: { id: { type: "string", description: "Extension id whose history version should be read.", }, version: { type: "number", description: "History version number to inspect.", }, includeContent: { type: "boolean", description: "Include full HTML for the current and previous versions. Defaults to false to keep agent context compact.", }, }, required: ["id", "version"], }, }, run: async (args) => { const id = String(args?.id ?? "").trim(); if (!id) return "Error: id is required."; const localMessage = await localExtensionReadonlyHistoryMessage(id); if (localMessage) return localMessage; const version = Number(args?.version); if (!Number.isInteger(version) || version < 1) { return "Error: version must be a positive integer."; } const detail = await getExtensionHistoryVersion(id, version); if (!detail) { return `Error: extension history version not found: ${id}#${version}`; } return { ok: true, ...compactExtensionHistoryDetail( detail, coerceBoolean(args?.includeContent), ), }; }, // With includeContent, history can contain current + previous source and // repeat both in the diff, so it needs more headroom than get-extension. maxResultChars: GET_EXTENSION_HISTORY_MAX_RESULT_CHARS, readOnly: true, }, "render-inline-extension": { tool: { description: "Render a one-time, transient sandboxed Alpine.js mini-app directly inside the chat. Use this for generated UI that should answer the current turn inline without saving anything to the Extensions view: calculators, adjustable controls, knobs, pickers, visualizers, temporary dashboards, and interactive results. The content must be a self-contained Alpine.js HTML body snippet that can use appAction(), appFetch(), dbQuery(), extensionFetch(), extensionData, agentNative.ui.output(value, opts?), and agentNative.chat.send()/sendToAgentChat(). Use appAction() or extensionData for writes; dbQuery() is for read-only inspection of known app SQL tables. Use agentNative.ui.output for passive current values from knobs, sliders, and selections; it writes application state at inline-ui::output, which the agent can read later with readAppState when the user says to use that value. Use agentNative.chat.send for visible submit/apply actions. For transient UIs, extensionData is browser-local throwaway state; use application_state/appFetch, appAction, ui.output, or chat.send for anything the agent or app must observe. Use semantic Tailwind colors (bg-background, text-foreground, bg-primary, etc.) so it inherits the parent app theme. Use create-extension instead when the user wants the UI saved or reusable.", parameters: { type: "object", properties: { name: { type: "string", description: "Short display name for the inline UI.", }, description: { type: "string", description: "One-sentence summary of what the inline UI does.", }, content: { type: "string", description: "Self-contained Alpine.js HTML body snippet. Do not include a full app build, React code, or source files. Required unless contentFromAttachment is set.", }, contentFromAttachment: { type: "string", description: 'Render a pasted/attached HTML file verbatim without re-typing it. Set to an attachment name or "latest".', }, context: { type: "string", description: "Optional JSON object passed to the iframe as slotContext for initial inputs from chat.", }, initialHeight: { type: "number", description: "Optional initial iframe height in pixels before auto-resize reports. Defaults to 260.", }, }, required: ["name"], }, }, chatUI: { renderer: ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER, title: "Inline extension", }, maxResultChars: 220_000, readOnly: true, run: async (args, ctx) => { const name = String(args?.name ?? "").trim(); if (!name) return "Error: name is required."; const resolved = resolveExtensionContent(args, ctx); if ("error" in resolved) return resolved.error; const content = resolved.content.trim(); if (!content) return "Error: content is required."; const description = String(args?.description ?? "").trim(); const id = `inline-${Date.now().toString(36)}-${Math.random() .toString(36) .slice(2, 8)}`; return { ok: true, inlineExtension: { mode: "transient", id, name, description, content, context: parseInlineContext(args?.context), initialHeight: coerceInlineHeight(args?.initialHeight), }, next: "Rendered inline in chat only. It is not saved in the Extensions view.", }; }, }, "show-extension-inline": { tool: { description: "Render an existing saved extension inline in the chat. Use this when the user asks to load, reopen, reuse, or show a saved extension/widget/dashboard/calculator/mini-app in the conversation. Inline extensions can expose passive current values through agentNative.ui.output(value, opts?), which writes application state at inline-ui::output for the agent to read later with readAppState. Pass id when known; otherwise pass a search string and the action will use the best visible extension match.", parameters: { type: "object", properties: { id: { type: "string", description: "Extension id to render inline.", }, search: { type: "string", description: "Fallback search matched against id, name, description, and owner email when id is unknown.", }, context: { type: "string", description: "Optional JSON object passed to the iframe as slotContext for chat-provided inputs.", }, initialHeight: { type: "number", description: "Optional initial iframe height in pixels before auto-resize reports. Defaults to 260.", }, }, }, }, chatUI: { renderer: ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER, title: "Inline extension", }, readOnly: true, run: async (args) => { let id = String(args?.id ?? "").trim(); const search = String(args?.search ?? "") .trim() .toLowerCase(); if (!id && search) { const hiddenIds = await getHiddenExtensionIdsForCurrentUser(); const rows: Array = [ ...(await listExtensions({ includeHidden: false, includeGloballyHidden: false, })), ...(await listLocalExtensions()), ]; const match = rows.find((row) => [row.id, row.name, row.description, row.ownerEmail] .join("\n") .toLowerCase() .includes(search), ); if (match) { id = match.id; } else { return { ok: false, error: `No extension matched "${args?.search}".`, available: await Promise.all( rows .slice(0, 10) .map((row) => summarizeExtension(row, hiddenIds, false)), ), }; } } if (!id) return "Error: provide id or search."; const localExtension = await getLocalExtension(id); const hiddenIds = await getHiddenExtensionIdsForCurrentUser(); if (localExtension) { const summary = await summarizeExtension( localExtension, hiddenIds, false, ); return { ok: true, inlineExtension: { mode: "persisted", id: summary.id, name: summary.name, description: summary.description, path: summary.path, updatedAt: summary.updatedAt, context: parseInlineContext(args?.context), initialHeight: coerceInlineHeight(args?.initialHeight), }, }; } const extension = await getExtension(id); if (!extension) return `Error: extension not found: ${id}`; const summary = await summarizeExtension(extension, hiddenIds, false); return { ok: true, inlineExtension: { mode: "persisted", id: summary.id, name: summary.name, description: summary.description, path: summary.path, updatedAt: summary.updatedAt, context: parseInlineContext(args?.context), initialHeight: coerceInlineHeight(args?.initialHeight), }, }; }, }, "create-extension": { tool: { description: 'Create a persisted sandboxed Alpine.js mini-app extension and render it inline in the chat. Use this when the user wants generated UI that should be saved, reusable, or visible in the Extensions view: extensions, widgets, dashboards, calculators, mini-apps, and reusable interactive utilities. For one-time chat-only UI, use render-inline-extension instead. The content must be a self-contained Alpine.js HTML body snippet that can use appAction(), appFetch(), dbQuery(), extensionFetch(), extensionData, agentNative.ui.output(value, opts?), and agentNative.chat.send()/sendToAgentChat(). Use appAction() for app data writes and extensionData for extension-owned persisted UI state; dbQuery() is for read-only inspection of known app SQL tables. Use agentNative.ui.output for passive current values from knobs, sliders, and selections; it writes application state at inline-ui::output, which the agent can read later with readAppState when the user says to use that value. Use agentNative.chat.send for visible submit/apply actions. Persist reusable user-edited state with extensionData: if the extension has checkboxes, todos, notes, filters, preferences, or any control whose value should survive reload/reopen, load that state on init and save changes with extensionData, usually at user scope, instead of keeping it only in Alpine state. IMPORTANT — hosting a pasted file: if the user pasted a large HTML/Alpine file (it appears in your context as an block) and asked you to host it as-is, do NOT copy that file into `content`. Instead leave `content` empty and pass `contentFromAttachment` set to that attachment\'s name (or the literal "latest" for the most recent pasted block) — the server reads the file verbatim. Re-emitting a large pasted file as `content` regularly gets cut off mid-stream and stalls the turn. IMPORTANT — cloning a large extension that lives as a workspace resource (not a chat attachment): leave `content` empty and pass `contentFromWorkspaceFile` set to the resource path (e.g. "intuit-analytics-extension.html"); the server reads the full file. Do NOT try to reconstruct the body with run-code or route create-extension through run-code (mutating actions are not callable there). Prefer appAction(name, params) for app data and actions, including read actions mounted as GET; do not call template /api/* routes from appFetch because the extension bridge only allows framework /_agent-native/* paths. Parse JSON string action results before aggregating; use dbQuery() only for known existing SQL tables and never for writes. Keep the initial create-extension payload compact and working; for complex extensions, create a useful v1 first, then use focused update-extension edits for refinements rather than assembling one enormous initial tool input. For any non-trivial component (more than a couple of state fields, any methods, any string formatting, any branching) put the component in a