diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 5525e048cfa..e77c864d184 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps) { ) } +export function ObsidianIcon(props: SVGProps) { + const id = useId() + const bl = `${id}-bl` + const tr = `${id}-tr` + const tl = `${id}-tl` + const br = `${id}-br` + const te = `${id}-te` + const le = `${id}-le` + const be = `${id}-be` + const me = `${id}-me` + const clip = `${id}-clip` + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + export function NotionIcon(props: SVGProps) { return ( @@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps) { ) } +export function EvernoteIcon(props: SVGProps) { + return ( + + + + ) +} + export function ElevenLabsIcon(props: SVGProps) { return ( = { elasticsearch: ElasticsearchIcon, elevenlabs: ElevenLabsIcon, enrich: EnrichSoIcon, + evernote: EvernoteIcon, exa: ExaAIIcon, file_v3: DocumentIcon, firecrawl: FirecrawlIcon, @@ -265,6 +268,7 @@ export const blockTypeToIconMap: Record = { mysql: MySQLIcon, neo4j: Neo4jIcon, notion_v2: NotionIcon, + obsidian: ObsidianIcon, onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, openai: OpenAIIcon, diff --git a/apps/docs/content/docs/en/tools/evernote.mdx b/apps/docs/content/docs/en/tools/evernote.mdx new file mode 100644 index 00000000000..4c024edea38 --- /dev/null +++ b/apps/docs/content/docs/en/tools/evernote.mdx @@ -0,0 +1,267 @@ +--- +title: Evernote +description: Manage notes, notebooks, and tags in Evernote +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags. + + + +## Tools + +### `evernote_copy_note` + +Copy a note to another notebook in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `noteGuid` | string | Yes | GUID of the note to copy | +| `toNotebookGuid` | string | Yes | GUID of the destination notebook | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `note` | object | The copied note metadata | +| ↳ `guid` | string | New note GUID | +| ↳ `title` | string | Note title | +| ↳ `notebookGuid` | string | GUID of the destination notebook | +| ↳ `created` | number | Creation timestamp in milliseconds | +| ↳ `updated` | number | Last updated timestamp in milliseconds | + +### `evernote_create_note` + +Create a new note in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `title` | string | Yes | Title of the note | +| `content` | string | Yes | Content of the note \(plain text or ENML\) | +| `notebookGuid` | string | No | GUID of the notebook to create the note in \(defaults to default notebook\) | +| `tagNames` | string | No | Comma-separated list of tag names to apply | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `note` | object | The created note | +| ↳ `guid` | string | Unique identifier of the note | +| ↳ `title` | string | Title of the note | +| ↳ `content` | string | ENML content of the note | +| ↳ `notebookGuid` | string | GUID of the containing notebook | +| ↳ `tagNames` | array | Tag names applied to the note | +| ↳ `created` | number | Creation timestamp in milliseconds | +| ↳ `updated` | number | Last updated timestamp in milliseconds | + +### `evernote_create_notebook` + +Create a new notebook in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `name` | string | Yes | Name for the new notebook | +| `stack` | string | No | Stack name to group the notebook under | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notebook` | object | The created notebook | +| ↳ `guid` | string | Notebook GUID | +| ↳ `name` | string | Notebook name | +| ↳ `defaultNotebook` | boolean | Whether this is the default notebook | +| ↳ `serviceCreated` | number | Creation timestamp in milliseconds | +| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds | +| ↳ `stack` | string | Notebook stack name | + +### `evernote_create_tag` + +Create a new tag in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `name` | string | Yes | Name for the new tag | +| `parentGuid` | string | No | GUID of the parent tag for hierarchy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | object | The created tag | +| ↳ `guid` | string | Tag GUID | +| ↳ `name` | string | Tag name | +| ↳ `parentGuid` | string | Parent tag GUID | +| ↳ `updateSequenceNum` | number | Update sequence number | + +### `evernote_delete_note` + +Move a note to the trash in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `noteGuid` | string | Yes | GUID of the note to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the note was successfully deleted | +| `noteGuid` | string | GUID of the deleted note | + +### `evernote_get_note` + +Retrieve a note from Evernote by its GUID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `noteGuid` | string | Yes | GUID of the note to retrieve | +| `withContent` | boolean | No | Whether to include note content \(default: true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `note` | object | The retrieved note | +| ↳ `guid` | string | Unique identifier of the note | +| ↳ `title` | string | Title of the note | +| ↳ `content` | string | ENML content of the note | +| ↳ `contentLength` | number | Length of the note content | +| ↳ `notebookGuid` | string | GUID of the containing notebook | +| ↳ `tagGuids` | array | GUIDs of tags on the note | +| ↳ `tagNames` | array | Names of tags on the note | +| ↳ `created` | number | Creation timestamp in milliseconds | +| ↳ `updated` | number | Last updated timestamp in milliseconds | +| ↳ `active` | boolean | Whether the note is active \(not in trash\) | + +### `evernote_get_notebook` + +Retrieve a notebook from Evernote by its GUID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `notebookGuid` | string | Yes | GUID of the notebook to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notebook` | object | The retrieved notebook | +| ↳ `guid` | string | Notebook GUID | +| ↳ `name` | string | Notebook name | +| ↳ `defaultNotebook` | boolean | Whether this is the default notebook | +| ↳ `serviceCreated` | number | Creation timestamp in milliseconds | +| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds | +| ↳ `stack` | string | Notebook stack name | + +### `evernote_list_notebooks` + +List all notebooks in an Evernote account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notebooks` | array | List of notebooks | + +### `evernote_list_tags` + +List all tags in an Evernote account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tags` | array | List of tags | + +### `evernote_search_notes` + +Search for notes in Evernote using the Evernote search grammar + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `query` | string | Yes | Search query using Evernote search grammar \(e.g., "tag:work intitle:meeting"\) | +| `notebookGuid` | string | No | Restrict search to a specific notebook by GUID | +| `offset` | number | No | Starting index for results \(default: 0\) | +| `maxNotes` | number | No | Maximum number of notes to return \(default: 25\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `totalNotes` | number | Total number of matching notes | +| `notes` | array | List of matching note metadata | + +### `evernote_update_note` + +Update an existing note in Evernote + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Evernote developer token | +| `noteGuid` | string | Yes | GUID of the note to update | +| `title` | string | No | New title for the note | +| `content` | string | No | New content for the note \(plain text or ENML\) | +| `notebookGuid` | string | No | GUID of the notebook to move the note to | +| `tagNames` | string | No | Comma-separated list of tag names \(replaces existing tags\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `note` | object | The updated note | +| ↳ `guid` | string | Unique identifier of the note | +| ↳ `title` | string | Title of the note | +| ↳ `content` | string | ENML content of the note | +| ↳ `notebookGuid` | string | GUID of the containing notebook | +| ↳ `tagNames` | array | Tag names on the note | +| ↳ `created` | number | Creation timestamp in milliseconds | +| ↳ `updated` | number | Last updated timestamp in milliseconds | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index f8d851049fe..81dd886faba 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -35,6 +35,7 @@ "elasticsearch", "elevenlabs", "enrich", + "evernote", "exa", "file", "firecrawl", @@ -98,6 +99,7 @@ "mysql", "neo4j", "notion", + "obsidian", "onedrive", "onepassword", "openai", diff --git a/apps/docs/content/docs/en/tools/obsidian.mdx b/apps/docs/content/docs/en/tools/obsidian.mdx new file mode 100644 index 00000000000..c2b28f74cbf --- /dev/null +++ b/apps/docs/content/docs/en/tools/obsidian.mdx @@ -0,0 +1,323 @@ +--- +title: Obsidian +description: Interact with your Obsidian vault via the Local REST API +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin. + + + +## Tools + +### `obsidian_append_active` + +Append content to the currently active file in Obsidian + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `content` | string | Yes | Markdown content to append to the active file | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `appended` | boolean | Whether content was successfully appended | + +### `obsidian_append_note` + +Append content to an existing note in your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) | +| `content` | string | Yes | Markdown content to append to the note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filename` | string | Path of the note | +| `appended` | boolean | Whether content was successfully appended | + +### `obsidian_append_periodic_note` + +Append content to the current periodic note (daily, weekly, monthly, quarterly, or yearly). Creates the note if it does not exist. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly | +| `content` | string | Yes | Markdown content to append to the periodic note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `period` | string | Period type of the note | +| `appended` | boolean | Whether content was successfully appended | + +### `obsidian_create_note` + +Create or replace a note in your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path for the note relative to vault root \(e.g. "folder/note.md"\) | +| `content` | string | Yes | Markdown content for the note | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filename` | string | Path of the created note | +| `created` | boolean | Whether the note was successfully created | + +### `obsidian_delete_note` + +Delete a note from your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path to the note to delete relative to vault root | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filename` | string | Path of the deleted note | +| `deleted` | boolean | Whether the note was successfully deleted | + +### `obsidian_execute_command` + +Execute a command in Obsidian (e.g. open daily note, toggle sidebar) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `commandId` | string | Yes | ID of the command to execute \(use List Commands operation to discover available commands\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commandId` | string | ID of the executed command | +| `executed` | boolean | Whether the command was successfully executed | + +### `obsidian_get_active` + +Retrieve the content of the currently active file in Obsidian + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Markdown content of the active file | +| `filename` | string | Path to the active file | + +### `obsidian_get_note` + +Retrieve the content of a note from your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Markdown content of the note | +| `filename` | string | Path to the note | + +### `obsidian_get_periodic_note` + +Retrieve the current periodic note (daily, weekly, monthly, quarterly, or yearly) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Markdown content of the periodic note | +| `period` | string | Period type of the note | + +### `obsidian_list_commands` + +List all available commands in Obsidian + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commands` | json | List of available commands with IDs and names | +| ↳ `id` | string | Command identifier | +| ↳ `name` | string | Human-readable command name | + +### `obsidian_list_files` + +List files and directories in your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `path` | string | No | Directory path relative to vault root. Leave empty to list root. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `files` | json | List of files and directories | +| ↳ `path` | string | File or directory path | +| ↳ `type` | string | Whether the entry is a file or directory | + +### `obsidian_open_file` + +Open a file in the Obsidian UI (creates the file if it does not exist) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path to the file relative to vault root | +| `newLeaf` | boolean | No | Whether to open the file in a new leaf/tab | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filename` | string | Path of the opened file | +| `opened` | boolean | Whether the file was successfully opened | + +### `obsidian_patch_active` + +Insert or replace content at a specific heading, block reference, or frontmatter field in the active file + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `content` | string | Yes | Content to insert at the target location | +| `operation` | string | Yes | How to insert content: append, prepend, or replace | +| `targetType` | string | Yes | Type of target: heading, block, or frontmatter | +| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) | +| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) | +| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `patched` | boolean | Whether the active file was successfully patched | + +### `obsidian_patch_note` + +Insert or replace content at a specific heading, block reference, or frontmatter field in a note + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) | +| `content` | string | Yes | Content to insert at the target location | +| `operation` | string | Yes | How to insert content: append, prepend, or replace | +| `targetType` | string | Yes | Type of target: heading, block, or frontmatter | +| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) | +| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) | +| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filename` | string | Path of the patched note | +| `patched` | boolean | Whether the note was successfully patched | + +### `obsidian_search` + +Search for text across notes in your Obsidian vault + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings | +| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API | +| `query` | string | Yes | Text to search for across vault notes | +| `contextLength` | number | No | Number of characters of context around each match \(default: 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | json | Search results with filenames, scores, and matching contexts | +| ↳ `filename` | string | Path to the matching note | +| ↳ `score` | number | Relevance score | +| ↳ `matches` | json | Matching text contexts | +| ↳ `context` | string | Text surrounding the match | + + diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index c46dc4f51ee..20cb4879e39 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -19,7 +19,6 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { markExecutionCancelled } from '@/lib/execution/cancellation' -import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import { @@ -631,11 +630,9 @@ async function handleMessageStream( } const encoder = new TextEncoder() - let messageStreamDecremented = false const stream = new ReadableStream({ async start(controller) { - incrementSSEConnections('a2a-message') const sendEvent = (event: string, data: unknown) => { try { const jsonRpcResponse = { @@ -845,19 +842,10 @@ async function handleMessageStream( }) } finally { await releaseLock(lockKey, lockValue) - if (!messageStreamDecremented) { - messageStreamDecremented = true - decrementSSEConnections('a2a-message') - } controller.close() } }, - cancel() { - if (!messageStreamDecremented) { - messageStreamDecremented = true - decrementSSEConnections('a2a-message') - } - }, + cancel() {}, }) return new NextResponse(stream, { @@ -1042,22 +1030,16 @@ async function handleTaskResubscribe( { once: true } ) - let sseDecremented = false const cleanup = () => { isCancelled = true if (pollTimeoutId) { clearTimeout(pollTimeoutId) pollTimeoutId = null } - if (!sseDecremented) { - sseDecremented = true - decrementSSEConnections('a2a-resubscribe') - } } const stream = new ReadableStream({ async start(controller) { - incrementSSEConnections('a2a-resubscribe') const sendEvent = (event: string, data: unknown): boolean => { if (isCancelled || abortSignal.aborted) return false try { diff --git a/apps/sim/app/api/mcp/events/route.ts b/apps/sim/app/api/mcp/events/route.ts index 7def26b345e..fee4ca65fb5 100644 --- a/apps/sim/app/api/mcp/events/route.ts +++ b/apps/sim/app/api/mcp/events/route.ts @@ -14,7 +14,6 @@ import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' import { mcpPubSub } from '@/lib/mcp/pubsub' -import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('McpEventsSSE') @@ -50,14 +49,11 @@ export async function GET(request: NextRequest) { for (const unsub of unsubscribers) { unsub() } - decrementSSEConnections('mcp-events') logger.info(`SSE connection closed for workspace ${workspaceId}`) } const stream = new ReadableStream({ start(controller) { - incrementSSEConnections('mcp-events') - const send = (eventName: string, data: Record) => { if (cleaned) return try { diff --git a/apps/sim/app/api/tools/evernote/copy-note/route.ts b/apps/sim/app/api/tools/evernote/copy-note/route.ts new file mode 100644 index 00000000000..1011072a750 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/copy-note/route.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { copyNote } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteCopyNoteAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, noteGuid, toNotebookGuid } = body + + if (!apiKey || !noteGuid || !toNotebookGuid) { + return NextResponse.json( + { success: false, error: 'apiKey, noteGuid, and toNotebookGuid are required' }, + { status: 400 } + ) + } + + const note = await copyNote(apiKey, noteGuid, toNotebookGuid) + + return NextResponse.json({ + success: true, + output: { note }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to copy note', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/create-note/route.ts b/apps/sim/app/api/tools/evernote/create-note/route.ts new file mode 100644 index 00000000000..ef1c97f5982 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/create-note/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { createNote } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteCreateNoteAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, title, content, notebookGuid, tagNames } = body + + if (!apiKey || !title || !content) { + return NextResponse.json( + { success: false, error: 'apiKey, title, and content are required' }, + { status: 400 } + ) + } + + const parsedTags = tagNames + ? (() => { + const tags = + typeof tagNames === 'string' + ? tagNames + .split(',') + .map((t: string) => t.trim()) + .filter(Boolean) + : tagNames + return tags.length > 0 ? tags : undefined + })() + : undefined + + const note = await createNote(apiKey, title, content, notebookGuid || undefined, parsedTags) + + return NextResponse.json({ + success: true, + output: { note }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to create note', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/create-notebook/route.ts b/apps/sim/app/api/tools/evernote/create-notebook/route.ts new file mode 100644 index 00000000000..37ab2522d86 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/create-notebook/route.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { createNotebook } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteCreateNotebookAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, name, stack } = body + + if (!apiKey || !name) { + return NextResponse.json( + { success: false, error: 'apiKey and name are required' }, + { status: 400 } + ) + } + + const notebook = await createNotebook(apiKey, name, stack || undefined) + + return NextResponse.json({ + success: true, + output: { notebook }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to create notebook', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/create-tag/route.ts b/apps/sim/app/api/tools/evernote/create-tag/route.ts new file mode 100644 index 00000000000..188516cbe87 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/create-tag/route.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { createTag } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteCreateTagAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, name, parentGuid } = body + + if (!apiKey || !name) { + return NextResponse.json( + { success: false, error: 'apiKey and name are required' }, + { status: 400 } + ) + } + + const tag = await createTag(apiKey, name, parentGuid || undefined) + + return NextResponse.json({ + success: true, + output: { tag }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to create tag', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/delete-note/route.ts b/apps/sim/app/api/tools/evernote/delete-note/route.ts new file mode 100644 index 00000000000..e55b298496a --- /dev/null +++ b/apps/sim/app/api/tools/evernote/delete-note/route.ts @@ -0,0 +1,41 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { deleteNote } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteDeleteNoteAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, noteGuid } = body + + if (!apiKey || !noteGuid) { + return NextResponse.json( + { success: false, error: 'apiKey and noteGuid are required' }, + { status: 400 } + ) + } + + await deleteNote(apiKey, noteGuid) + + return NextResponse.json({ + success: true, + output: { + success: true, + noteGuid, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to delete note', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/get-note/route.ts b/apps/sim/app/api/tools/evernote/get-note/route.ts new file mode 100644 index 00000000000..f71c84aa7d5 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/get-note/route.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { getNote } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteGetNoteAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, noteGuid, withContent = true } = body + + if (!apiKey || !noteGuid) { + return NextResponse.json( + { success: false, error: 'apiKey and noteGuid are required' }, + { status: 400 } + ) + } + + const note = await getNote(apiKey, noteGuid, withContent) + + return NextResponse.json({ + success: true, + output: { note }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to get note', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/get-notebook/route.ts b/apps/sim/app/api/tools/evernote/get-notebook/route.ts new file mode 100644 index 00000000000..2f0e6db5d5d --- /dev/null +++ b/apps/sim/app/api/tools/evernote/get-notebook/route.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { getNotebook } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteGetNotebookAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, notebookGuid } = body + + if (!apiKey || !notebookGuid) { + return NextResponse.json( + { success: false, error: 'apiKey and notebookGuid are required' }, + { status: 400 } + ) + } + + const notebook = await getNotebook(apiKey, notebookGuid) + + return NextResponse.json({ + success: true, + output: { notebook }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to get notebook', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/lib/client.ts b/apps/sim/app/api/tools/evernote/lib/client.ts new file mode 100644 index 00000000000..05b80eb4829 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/lib/client.ts @@ -0,0 +1,799 @@ +/** + * Evernote API client using Thrift binary protocol over HTTP. + * Implements only the NoteStore methods needed for the integration. + */ + +import { + ThriftReader, + ThriftWriter, + TYPE_BOOL, + TYPE_I32, + TYPE_I64, + TYPE_LIST, + TYPE_STRING, + TYPE_STRUCT, +} from './thrift' + +export interface EvernoteNotebook { + guid: string + name: string + defaultNotebook: boolean + serviceCreated: number | null + serviceUpdated: number | null + stack: string | null +} + +export interface EvernoteNote { + guid: string + title: string + content: string | null + contentLength: number | null + created: number | null + updated: number | null + deleted: number | null + active: boolean + notebookGuid: string | null + tagGuids: string[] + tagNames: string[] +} + +export interface EvernoteNoteMetadata { + guid: string + title: string | null + contentLength: number | null + created: number | null + updated: number | null + notebookGuid: string | null + tagGuids: string[] +} + +export interface EvernoteTag { + guid: string + name: string + parentGuid: string | null + updateSequenceNum: number | null +} + +export interface EvernoteSearchResult { + startIndex: number + totalNotes: number + notes: EvernoteNoteMetadata[] +} + +/** Extract shard ID from an Evernote developer token */ +function extractShardId(token: string): string { + const match = token.match(/S=s(\d+)/) + if (!match) { + throw new Error('Invalid Evernote token format: cannot extract shard ID') + } + return `s${match[1]}` +} + +/** Get the NoteStore URL for the given token */ +function getNoteStoreUrl(token: string): string { + const shardId = extractShardId(token) + const host = token.includes(':Sandbox') ? 'sandbox.evernote.com' : 'www.evernote.com' + return `https://${host}/shard/${shardId}/notestore` +} + +/** Make a Thrift RPC call to the NoteStore */ +async function callNoteStore(token: string, writer: ThriftWriter): Promise { + const url = getNoteStoreUrl(token) + const body = writer.toBuffer() + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-thrift', + Accept: 'application/x-thrift', + }, + body: new Uint8Array(body), + }) + + if (!response.ok) { + throw new Error(`Evernote API HTTP error: ${response.status} ${response.statusText}`) + } + + const arrayBuffer = await response.arrayBuffer() + const reader = new ThriftReader(arrayBuffer) + const msg = reader.readMessageBegin() + + if (reader.isException(msg.type)) { + const ex = reader.readException() + throw new Error(`Evernote API error: ${ex.message}`) + } + + return reader +} + +/** Check for Evernote-specific exceptions in the response struct. Returns true if handled. */ +function checkEvernoteException(reader: ThriftReader, fieldId: number, fieldType: number): boolean { + if (fieldId === 1 && fieldType === TYPE_STRUCT) { + let message = '' + let errorCode = 0 + reader.readStruct((r, fid, ftype) => { + if (fid === 1 && ftype === TYPE_I32) { + errorCode = r.readI32() + } else if (fid === 2 && ftype === TYPE_STRING) { + message = r.readString() + } else { + r.skip(ftype) + } + }) + throw new Error(`Evernote error (${errorCode}): ${message}`) + } + if (fieldId === 2 && fieldType === TYPE_STRUCT) { + let message = '' + let errorCode = 0 + reader.readStruct((r, fid, ftype) => { + if (fid === 1 && ftype === TYPE_I32) { + errorCode = r.readI32() + } else if (fid === 2 && ftype === TYPE_STRING) { + message = r.readString() + } else { + r.skip(ftype) + } + }) + throw new Error(`Evernote system error (${errorCode}): ${message}`) + } + if (fieldId === 3 && fieldType === TYPE_STRUCT) { + let identifier = '' + let key = '' + reader.readStruct((r, fid, ftype) => { + if (fid === 1 && ftype === TYPE_STRING) { + identifier = r.readString() + } else if (fid === 2 && ftype === TYPE_STRING) { + key = r.readString() + } else { + r.skip(ftype) + } + }) + throw new Error(`Evernote not found: ${identifier}${key ? ` (${key})` : ''}`) + } + return false +} + +function readNotebook(reader: ThriftReader): EvernoteNotebook { + const notebook: EvernoteNotebook = { + guid: '', + name: '', + defaultNotebook: false, + serviceCreated: null, + serviceUpdated: null, + stack: null, + } + + reader.readStruct((r, fieldId, fieldType) => { + switch (fieldId) { + case 1: + if (fieldType === TYPE_STRING) notebook.guid = r.readString() + else r.skip(fieldType) + break + case 2: + if (fieldType === TYPE_STRING) notebook.name = r.readString() + else r.skip(fieldType) + break + case 4: + if (fieldType === TYPE_BOOL) notebook.defaultNotebook = r.readBool() + else r.skip(fieldType) + break + case 5: + if (fieldType === TYPE_I64) notebook.serviceCreated = Number(r.readI64()) + else r.skip(fieldType) + break + case 6: + if (fieldType === TYPE_I64) notebook.serviceUpdated = Number(r.readI64()) + else r.skip(fieldType) + break + case 9: + if (fieldType === TYPE_STRING) notebook.stack = r.readString() + else r.skip(fieldType) + break + default: + r.skip(fieldType) + } + }) + + return notebook +} + +function readNote(reader: ThriftReader): EvernoteNote { + const note: EvernoteNote = { + guid: '', + title: '', + content: null, + contentLength: null, + created: null, + updated: null, + deleted: null, + active: true, + notebookGuid: null, + tagGuids: [], + tagNames: [], + } + + reader.readStruct((r, fieldId, fieldType) => { + switch (fieldId) { + case 1: + if (fieldType === TYPE_STRING) note.guid = r.readString() + else r.skip(fieldType) + break + case 2: + if (fieldType === TYPE_STRING) note.title = r.readString() + else r.skip(fieldType) + break + case 3: + if (fieldType === TYPE_STRING) note.content = r.readString() + else r.skip(fieldType) + break + case 5: + if (fieldType === TYPE_I32) note.contentLength = r.readI32() + else r.skip(fieldType) + break + case 6: + if (fieldType === TYPE_I64) note.created = Number(r.readI64()) + else r.skip(fieldType) + break + case 7: + if (fieldType === TYPE_I64) note.updated = Number(r.readI64()) + else r.skip(fieldType) + break + case 8: + if (fieldType === TYPE_I64) note.deleted = Number(r.readI64()) + else r.skip(fieldType) + break + case 9: + if (fieldType === TYPE_BOOL) note.active = r.readBool() + else r.skip(fieldType) + break + case 11: + if (fieldType === TYPE_STRING) note.notebookGuid = r.readString() + else r.skip(fieldType) + break + case 12: + if (fieldType === TYPE_LIST) { + const { size } = r.readListBegin() + for (let i = 0; i < size; i++) { + note.tagGuids.push(r.readString()) + } + } else { + r.skip(fieldType) + } + break + case 15: + if (fieldType === TYPE_LIST) { + const { size } = r.readListBegin() + for (let i = 0; i < size; i++) { + note.tagNames.push(r.readString()) + } + } else { + r.skip(fieldType) + } + break + default: + r.skip(fieldType) + } + }) + + return note +} + +function readTag(reader: ThriftReader): EvernoteTag { + const tag: EvernoteTag = { + guid: '', + name: '', + parentGuid: null, + updateSequenceNum: null, + } + + reader.readStruct((r, fieldId, fieldType) => { + switch (fieldId) { + case 1: + if (fieldType === TYPE_STRING) tag.guid = r.readString() + else r.skip(fieldType) + break + case 2: + if (fieldType === TYPE_STRING) tag.name = r.readString() + else r.skip(fieldType) + break + case 3: + if (fieldType === TYPE_STRING) tag.parentGuid = r.readString() + else r.skip(fieldType) + break + case 4: + if (fieldType === TYPE_I32) tag.updateSequenceNum = r.readI32() + else r.skip(fieldType) + break + default: + r.skip(fieldType) + } + }) + + return tag +} + +function readNoteMetadata(reader: ThriftReader): EvernoteNoteMetadata { + const meta: EvernoteNoteMetadata = { + guid: '', + title: null, + contentLength: null, + created: null, + updated: null, + notebookGuid: null, + tagGuids: [], + } + + reader.readStruct((r, fieldId, fieldType) => { + switch (fieldId) { + case 1: + if (fieldType === TYPE_STRING) meta.guid = r.readString() + else r.skip(fieldType) + break + case 2: + if (fieldType === TYPE_STRING) meta.title = r.readString() + else r.skip(fieldType) + break + case 5: + if (fieldType === TYPE_I32) meta.contentLength = r.readI32() + else r.skip(fieldType) + break + case 6: + if (fieldType === TYPE_I64) meta.created = Number(r.readI64()) + else r.skip(fieldType) + break + case 7: + if (fieldType === TYPE_I64) meta.updated = Number(r.readI64()) + else r.skip(fieldType) + break + case 11: + if (fieldType === TYPE_STRING) meta.notebookGuid = r.readString() + else r.skip(fieldType) + break + case 12: + if (fieldType === TYPE_LIST) { + const { size } = r.readListBegin() + for (let i = 0; i < size; i++) { + meta.tagGuids.push(r.readString()) + } + } else { + r.skip(fieldType) + } + break + default: + r.skip(fieldType) + } + }) + + return meta +} + +export async function listNotebooks(token: string): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('listNotebooks', 0) + writer.writeStringField(1, token) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + const notebooks: EvernoteNotebook[] = [] + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_LIST) { + const { size } = r.readListBegin() + for (let i = 0; i < size; i++) { + notebooks.push(readNotebook(r)) + } + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + return notebooks +} + +export async function getNote( + token: string, + guid: string, + withContent = true +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('getNote', 0) + writer.writeStringField(1, token) + writer.writeStringField(2, guid) + writer.writeBoolField(3, withContent) + writer.writeBoolField(4, false) + writer.writeBoolField(5, false) + writer.writeBoolField(6, false) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let note: EvernoteNote | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + note = readNote(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!note) { + throw new Error('No note returned from Evernote API') + } + + return note +} + +/** Wrap content in ENML if it's not already */ +function wrapInEnml(content: string): string { + if (content.includes('/g, '>') + .replace(/\n/g, '
') + return `${escaped}` +} + +export async function createNote( + token: string, + title: string, + content: string, + notebookGuid?: string, + tagNames?: string[] +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('createNote', 0) + writer.writeStringField(1, token) + + writer.writeFieldBegin(TYPE_STRUCT, 2) + writer.writeStringField(2, title) + writer.writeStringField(3, wrapInEnml(content)) + if (notebookGuid) { + writer.writeStringField(11, notebookGuid) + } + if (tagNames && tagNames.length > 0) { + writer.writeStringListField(15, tagNames) + } + writer.writeFieldStop() + + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let note: EvernoteNote | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + note = readNote(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!note) { + throw new Error('No note returned from Evernote API') + } + + return note +} + +export async function updateNote( + token: string, + guid: string, + title?: string, + content?: string, + notebookGuid?: string, + tagNames?: string[] +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('updateNote', 0) + writer.writeStringField(1, token) + + writer.writeFieldBegin(TYPE_STRUCT, 2) + writer.writeStringField(1, guid) + if (title !== undefined) { + writer.writeStringField(2, title) + } + if (content !== undefined) { + writer.writeStringField(3, wrapInEnml(content)) + } + if (notebookGuid !== undefined) { + writer.writeStringField(11, notebookGuid) + } + if (tagNames !== undefined) { + writer.writeStringListField(15, tagNames) + } + writer.writeFieldStop() + + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let note: EvernoteNote | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + note = readNote(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!note) { + throw new Error('No note returned from Evernote API') + } + + return note +} + +export async function deleteNote(token: string, guid: string): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('deleteNote', 0) + writer.writeStringField(1, token) + writer.writeStringField(2, guid) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let usn = 0 + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_I32) { + usn = r.readI32() + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + return usn +} + +export async function searchNotes( + token: string, + query: string, + notebookGuid?: string, + offset = 0, + maxNotes = 25 +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('findNotesMetadata', 0) + writer.writeStringField(1, token) + + // NoteFilter (field 2) + writer.writeFieldBegin(TYPE_STRUCT, 2) + if (query) { + writer.writeStringField(3, query) + } + if (notebookGuid) { + writer.writeStringField(4, notebookGuid) + } + writer.writeFieldStop() + + // offset (field 3) + writer.writeI32Field(3, offset) + // maxNotes (field 4) + writer.writeI32Field(4, maxNotes) + + // NotesMetadataResultSpec (field 5) + writer.writeFieldBegin(TYPE_STRUCT, 5) + writer.writeBoolField(2, true) // includeTitle + writer.writeBoolField(5, true) // includeContentLength + writer.writeBoolField(6, true) // includeCreated + writer.writeBoolField(7, true) // includeUpdated + writer.writeBoolField(11, true) // includeNotebookGuid + writer.writeBoolField(12, true) // includeTagGuids + writer.writeFieldStop() + + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + const result: EvernoteSearchResult = { + startIndex: 0, + totalNotes: 0, + notes: [], + } + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + r.readStruct((r2, fid2, ftype2) => { + switch (fid2) { + case 1: + if (ftype2 === TYPE_I32) result.startIndex = r2.readI32() + else r2.skip(ftype2) + break + case 2: + if (ftype2 === TYPE_I32) result.totalNotes = r2.readI32() + else r2.skip(ftype2) + break + case 3: + if (ftype2 === TYPE_LIST) { + const { size } = r2.readListBegin() + for (let i = 0; i < size; i++) { + result.notes.push(readNoteMetadata(r2)) + } + } else { + r2.skip(ftype2) + } + break + default: + r2.skip(ftype2) + } + }) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + return result +} + +export async function getNotebook(token: string, guid: string): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('getNotebook', 0) + writer.writeStringField(1, token) + writer.writeStringField(2, guid) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let notebook: EvernoteNotebook | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + notebook = readNotebook(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!notebook) { + throw new Error('No notebook returned from Evernote API') + } + + return notebook +} + +export async function createNotebook( + token: string, + name: string, + stack?: string +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('createNotebook', 0) + writer.writeStringField(1, token) + + writer.writeFieldBegin(TYPE_STRUCT, 2) + writer.writeStringField(2, name) + if (stack) { + writer.writeStringField(9, stack) + } + writer.writeFieldStop() + + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let notebook: EvernoteNotebook | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + notebook = readNotebook(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!notebook) { + throw new Error('No notebook returned from Evernote API') + } + + return notebook +} + +export async function listTags(token: string): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('listTags', 0) + writer.writeStringField(1, token) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + const tags: EvernoteTag[] = [] + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_LIST) { + const { size } = r.readListBegin() + for (let i = 0; i < size; i++) { + tags.push(readTag(r)) + } + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + return tags +} + +export async function createTag( + token: string, + name: string, + parentGuid?: string +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('createTag', 0) + writer.writeStringField(1, token) + + writer.writeFieldBegin(TYPE_STRUCT, 2) + writer.writeStringField(2, name) + if (parentGuid) { + writer.writeStringField(3, parentGuid) + } + writer.writeFieldStop() + + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let tag: EvernoteTag | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + tag = readTag(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!tag) { + throw new Error('No tag returned from Evernote API') + } + + return tag +} + +export async function copyNote( + token: string, + noteGuid: string, + toNotebookGuid: string +): Promise { + const writer = new ThriftWriter() + writer.writeMessageBegin('copyNote', 0) + writer.writeStringField(1, token) + writer.writeStringField(2, noteGuid) + writer.writeStringField(3, toNotebookGuid) + writer.writeFieldStop() + + const reader = await callNoteStore(token, writer) + let note: EvernoteNote | null = null + + reader.readStruct((r, fieldId, fieldType) => { + if (fieldId === 0 && fieldType === TYPE_STRUCT) { + note = readNote(r) + } else { + if (!checkEvernoteException(r, fieldId, fieldType)) { + r.skip(fieldType) + } + } + }) + + if (!note) { + throw new Error('No note returned from Evernote API') + } + + return note +} diff --git a/apps/sim/app/api/tools/evernote/lib/thrift.ts b/apps/sim/app/api/tools/evernote/lib/thrift.ts new file mode 100644 index 00000000000..3f51b6933b4 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/lib/thrift.ts @@ -0,0 +1,255 @@ +/** + * Minimal Thrift binary protocol encoder/decoder for Evernote API. + * Supports only the types needed for NoteStore operations. + */ + +const THRIFT_VERSION_1 = 0x80010000 +const MESSAGE_CALL = 1 +const MESSAGE_EXCEPTION = 3 + +const TYPE_STOP = 0 +const TYPE_BOOL = 2 +const TYPE_I32 = 8 +const TYPE_I64 = 10 +const TYPE_STRING = 11 +const TYPE_STRUCT = 12 +const TYPE_LIST = 15 + +export class ThriftWriter { + private buffer: number[] = [] + + writeMessageBegin(name: string, seqId: number): void { + this.writeI32(THRIFT_VERSION_1 | MESSAGE_CALL) + this.writeString(name) + this.writeI32(seqId) + } + + writeFieldBegin(type: number, id: number): void { + this.buffer.push(type) + this.writeI16(id) + } + + writeFieldStop(): void { + this.buffer.push(TYPE_STOP) + } + + writeString(value: string): void { + const encoded = new TextEncoder().encode(value) + this.writeI32(encoded.length) + for (const byte of encoded) { + this.buffer.push(byte) + } + } + + writeBool(value: boolean): void { + this.buffer.push(value ? 1 : 0) + } + + writeI16(value: number): void { + this.buffer.push((value >> 8) & 0xff) + this.buffer.push(value & 0xff) + } + + writeI32(value: number): void { + this.buffer.push((value >> 24) & 0xff) + this.buffer.push((value >> 16) & 0xff) + this.buffer.push((value >> 8) & 0xff) + this.buffer.push(value & 0xff) + } + + writeI64(value: bigint): void { + const buf = new ArrayBuffer(8) + const view = new DataView(buf) + view.setBigInt64(0, value, false) + for (let i = 0; i < 8; i++) { + this.buffer.push(view.getUint8(i)) + } + } + + writeStringField(id: number, value: string): void { + this.writeFieldBegin(TYPE_STRING, id) + this.writeString(value) + } + + writeBoolField(id: number, value: boolean): void { + this.writeFieldBegin(TYPE_BOOL, id) + this.writeBool(value) + } + + writeI32Field(id: number, value: number): void { + this.writeFieldBegin(TYPE_I32, id) + this.writeI32(value) + } + + writeStringListField(id: number, values: string[]): void { + this.writeFieldBegin(TYPE_LIST, id) + this.buffer.push(TYPE_STRING) + this.writeI32(values.length) + for (const v of values) { + this.writeString(v) + } + } + + toBuffer(): Buffer { + return Buffer.from(this.buffer) + } +} + +export class ThriftReader { + private view: DataView + private pos = 0 + + constructor(buffer: ArrayBuffer) { + this.view = new DataView(buffer) + } + + readMessageBegin(): { name: string; type: number; seqId: number } { + const versionAndType = this.readI32() + const version = versionAndType & 0xffff0000 + if (version !== (THRIFT_VERSION_1 | 0)) { + throw new Error(`Unsupported Thrift version: 0x${version.toString(16)}`) + } + const type = versionAndType & 0x000000ff + const name = this.readString() + const seqId = this.readI32() + return { name, type, seqId } + } + + readFieldBegin(): { type: number; id: number } { + const type = this.view.getUint8(this.pos++) + if (type === TYPE_STOP) { + return { type: TYPE_STOP, id: 0 } + } + const id = this.view.getInt16(this.pos, false) + this.pos += 2 + return { type, id } + } + + readString(): string { + const length = this.readI32() + const bytes = new Uint8Array(this.view.buffer, this.pos, length) + this.pos += length + return new TextDecoder().decode(bytes) + } + + readBool(): boolean { + return this.view.getUint8(this.pos++) !== 0 + } + + readI32(): number { + const value = this.view.getInt32(this.pos, false) + this.pos += 4 + return value + } + + readI64(): bigint { + const value = this.view.getBigInt64(this.pos, false) + this.pos += 8 + return value + } + + readBinary(): Uint8Array { + const length = this.readI32() + const bytes = new Uint8Array(this.view.buffer, this.pos, length) + this.pos += length + return bytes + } + + readListBegin(): { elementType: number; size: number } { + const elementType = this.view.getUint8(this.pos++) + const size = this.readI32() + return { elementType, size } + } + + /** Skip a value of the given Thrift type */ + skip(type: number): void { + switch (type) { + case TYPE_BOOL: + this.pos += 1 + break + case 6: // I16 + this.pos += 2 + break + case 3: // BYTE + this.pos += 1 + break + case TYPE_I32: + this.pos += 4 + break + case TYPE_I64: + case 4: // DOUBLE + this.pos += 8 + break + case TYPE_STRING: { + const len = this.readI32() + this.pos += len + break + } + case TYPE_STRUCT: + this.skipStruct() + break + case TYPE_LIST: + case 14: { + // SET + const { elementType, size } = this.readListBegin() + for (let i = 0; i < size; i++) { + this.skip(elementType) + } + break + } + case 13: { + // MAP + const keyType = this.view.getUint8(this.pos++) + const valueType = this.view.getUint8(this.pos++) + const count = this.readI32() + for (let i = 0; i < count; i++) { + this.skip(keyType) + this.skip(valueType) + } + break + } + default: + throw new Error(`Cannot skip unknown Thrift type: ${type}`) + } + } + + private skipStruct(): void { + for (;;) { + const { type } = this.readFieldBegin() + if (type === TYPE_STOP) break + this.skip(type) + } + } + + /** Read struct fields, calling the handler for each field */ + readStruct(handler: (reader: ThriftReader, fieldId: number, fieldType: number) => void): void { + for (;;) { + const { type, id } = this.readFieldBegin() + if (type === TYPE_STOP) break + handler(this, id, type) + } + } + + /** Check if this is an exception response */ + isException(messageType: number): boolean { + return messageType === MESSAGE_EXCEPTION + } + + /** Read a Thrift application exception */ + readException(): { message: string; type: number } { + let message = '' + let type = 0 + this.readStruct((reader, fieldId, fieldType) => { + if (fieldId === 1 && fieldType === TYPE_STRING) { + message = reader.readString() + } else if (fieldId === 2 && fieldType === TYPE_I32) { + type = reader.readI32() + } else { + reader.skip(fieldType) + } + }) + return { message, type } + } +} + +export { TYPE_BOOL, TYPE_I32, TYPE_I64, TYPE_LIST, TYPE_STOP, TYPE_STRING, TYPE_STRUCT } diff --git a/apps/sim/app/api/tools/evernote/list-notebooks/route.ts b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts new file mode 100644 index 00000000000..be5e3df9c5f --- /dev/null +++ b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts @@ -0,0 +1,35 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { listNotebooks } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteListNotebooksAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey } = body + + if (!apiKey) { + return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 }) + } + + const notebooks = await listNotebooks(apiKey) + + return NextResponse.json({ + success: true, + output: { notebooks }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to list notebooks', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/list-tags/route.ts b/apps/sim/app/api/tools/evernote/list-tags/route.ts new file mode 100644 index 00000000000..2475d64ee49 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/list-tags/route.ts @@ -0,0 +1,35 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { listTags } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteListTagsAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey } = body + + if (!apiKey) { + return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 }) + } + + const tags = await listTags(apiKey) + + return NextResponse.json({ + success: true, + output: { tags }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to list tags', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/search-notes/route.ts b/apps/sim/app/api/tools/evernote/search-notes/route.ts new file mode 100644 index 00000000000..2687779e593 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/search-notes/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { searchNotes } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteSearchNotesAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, query, notebookGuid, offset = 0, maxNotes = 25 } = body + + if (!apiKey || !query) { + return NextResponse.json( + { success: false, error: 'apiKey and query are required' }, + { status: 400 } + ) + } + + const clampedMaxNotes = Math.min(Math.max(Number(maxNotes) || 25, 1), 250) + + const result = await searchNotes( + apiKey, + query, + notebookGuid || undefined, + Number(offset), + clampedMaxNotes + ) + + return NextResponse.json({ + success: true, + output: { + totalNotes: result.totalNotes, + notes: result.notes, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to search notes', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/evernote/update-note/route.ts b/apps/sim/app/api/tools/evernote/update-note/route.ts new file mode 100644 index 00000000000..4a3fb884504 --- /dev/null +++ b/apps/sim/app/api/tools/evernote/update-note/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { updateNote } from '@/app/api/tools/evernote/lib/client' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('EvernoteUpdateNoteAPI') + +export async function POST(request: NextRequest) { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { apiKey, noteGuid, title, content, notebookGuid, tagNames } = body + + if (!apiKey || !noteGuid) { + return NextResponse.json( + { success: false, error: 'apiKey and noteGuid are required' }, + { status: 400 } + ) + } + + const parsedTags = tagNames + ? (() => { + const tags = + typeof tagNames === 'string' + ? tagNames + .split(',') + .map((t: string) => t.trim()) + .filter(Boolean) + : tagNames + return tags.length > 0 ? tags : undefined + })() + : undefined + + const note = await updateNote( + apiKey, + noteGuid, + title || undefined, + content || undefined, + notebookGuid || undefined, + parsedTags + ) + + return NextResponse.json({ + success: true, + output: { note }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to update note', { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index abebcc18948..fffc3b08e70 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -10,7 +10,6 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' -import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { enrichTableSchema } from '@/lib/table/llm/wand' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' @@ -331,14 +330,10 @@ export async function POST(req: NextRequest) { const encoder = new TextEncoder() const decoder = new TextDecoder() - let wandStreamClosed = false const readable = new ReadableStream({ async start(controller) { - incrementSSEConnections('wand') const reader = response.body?.getReader() if (!reader) { - wandStreamClosed = true - decrementSSEConnections('wand') controller.close() return } @@ -483,18 +478,9 @@ export async function POST(req: NextRequest) { controller.close() } finally { reader.releaseLock() - if (!wandStreamClosed) { - wandStreamClosed = true - decrementSSEConnections('wand') - } - } - }, - cancel() { - if (!wandStreamClosed) { - wandStreamClosed = true - decrementSSEConnections('wand') } }, + cancel() {}, }) return new Response(readable, { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 5207f77c019..3c1e27080e5 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -22,7 +22,6 @@ import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/ev import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' -import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, @@ -764,7 +763,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const encoder = new TextEncoder() const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) let isStreamClosed = false - let sseDecremented = false const eventWriter = createExecutionEventWriter(executionId) setExecutionMeta(executionId, { @@ -775,7 +773,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const stream = new ReadableStream({ async start(controller) { - incrementSSEConnections('workflow-execute') let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null const sendEvent = (event: ExecutionEvent) => { @@ -1159,10 +1156,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (executionId) { await cleanupExecutionBase64Cache(executionId) } - if (!sseDecremented) { - sseDecremented = true - decrementSSEConnections('workflow-execute') - } if (!isStreamClosed) { try { controller.enqueue(encoder.encode('data: [DONE]\n\n')) @@ -1174,10 +1167,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: cancel() { isStreamClosed = true logger.info(`[${requestId}] Client disconnected from SSE stream`) - if (!sseDecremented) { - sseDecremented = true - decrementSSEConnections('workflow-execute') - } }, }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 88e3c874470..1f77ff391d6 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -7,7 +7,6 @@ import { getExecutionMeta, readExecutionEvents, } from '@/lib/execution/event-buffer' -import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections' import { formatSSEEvent } from '@/lib/workflows/executor/execution-events' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -74,10 +73,8 @@ export async function GET( let closed = false - let sseDecremented = false const stream = new ReadableStream({ async start(controller) { - incrementSSEConnections('execution-stream-reconnect') let lastEventId = fromEventId const pollDeadline = Date.now() + MAX_POLL_DURATION_MS @@ -145,20 +142,11 @@ export async function GET( controller.close() } catch {} } - } finally { - if (!sseDecremented) { - sseDecremented = true - decrementSSEConnections('execution-stream-reconnect') - } } }, cancel() { closed = true logger.info('Client disconnected from reconnection stream', { executionId }) - if (!sseDecremented) { - sseDecremented = true - decrementSSEConnections('execution-stream-reconnect') - } }, }) diff --git a/apps/sim/blocks/blocks/evernote.ts b/apps/sim/blocks/blocks/evernote.ts new file mode 100644 index 00000000000..acc7fde5ccb --- /dev/null +++ b/apps/sim/blocks/blocks/evernote.ts @@ -0,0 +1,308 @@ +import { EvernoteIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' + +export const EvernoteBlock: BlockConfig = { + type: 'evernote', + name: 'Evernote', + description: 'Manage notes, notebooks, and tags in Evernote', + longDescription: + 'Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.', + docsLink: 'https://docs.sim.ai/tools/evernote', + category: 'tools', + bgColor: '#E0E0E0', + icon: EvernoteIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Note', id: 'create_note' }, + { label: 'Get Note', id: 'get_note' }, + { label: 'Update Note', id: 'update_note' }, + { label: 'Delete Note', id: 'delete_note' }, + { label: 'Copy Note', id: 'copy_note' }, + { label: 'Search Notes', id: 'search_notes' }, + { label: 'Get Notebook', id: 'get_notebook' }, + { label: 'Create Notebook', id: 'create_notebook' }, + { label: 'List Notebooks', id: 'list_notebooks' }, + { label: 'Create Tag', id: 'create_tag' }, + { label: 'List Tags', id: 'list_tags' }, + ], + value: () => 'create_note', + }, + { + id: 'apiKey', + title: 'Developer Token', + type: 'short-input', + password: true, + placeholder: 'Enter your Evernote developer token', + required: true, + }, + { + id: 'title', + title: 'Title', + type: 'short-input', + placeholder: 'Note title', + condition: { field: 'operation', value: 'create_note' }, + required: { field: 'operation', value: 'create_note' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + placeholder: 'Note content (plain text or ENML)', + condition: { field: 'operation', value: 'create_note' }, + required: { field: 'operation', value: 'create_note' }, + }, + { + id: 'noteGuid', + title: 'Note GUID', + type: 'short-input', + placeholder: 'Enter the note GUID', + condition: { + field: 'operation', + value: ['get_note', 'update_note', 'delete_note', 'copy_note'], + }, + required: { + field: 'operation', + value: ['get_note', 'update_note', 'delete_note', 'copy_note'], + }, + }, + { + id: 'updateTitle', + title: 'New Title', + type: 'short-input', + placeholder: 'New title (leave empty to keep current)', + condition: { field: 'operation', value: 'update_note' }, + }, + { + id: 'updateContent', + title: 'New Content', + type: 'long-input', + placeholder: 'New content (leave empty to keep current)', + condition: { field: 'operation', value: 'update_note' }, + }, + { + id: 'toNotebookGuid', + title: 'Destination Notebook GUID', + type: 'short-input', + placeholder: 'GUID of the destination notebook', + condition: { field: 'operation', value: 'copy_note' }, + required: { field: 'operation', value: 'copy_note' }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'e.g., "tag:work intitle:meeting"', + condition: { field: 'operation', value: 'search_notes' }, + required: { field: 'operation', value: 'search_notes' }, + }, + { + id: 'notebookGuid', + title: 'Notebook GUID', + type: 'short-input', + placeholder: 'Notebook GUID', + condition: { + field: 'operation', + value: ['create_note', 'update_note', 'search_notes', 'get_notebook'], + }, + required: { field: 'operation', value: 'get_notebook' }, + }, + { + id: 'notebookName', + title: 'Notebook Name', + type: 'short-input', + placeholder: 'Name for the new notebook', + condition: { field: 'operation', value: 'create_notebook' }, + required: { field: 'operation', value: 'create_notebook' }, + }, + { + id: 'stack', + title: 'Stack', + type: 'short-input', + placeholder: 'Stack name (optional)', + condition: { field: 'operation', value: 'create_notebook' }, + mode: 'advanced', + }, + { + id: 'tagName', + title: 'Tag Name', + type: 'short-input', + placeholder: 'Name for the new tag', + condition: { field: 'operation', value: 'create_tag' }, + required: { field: 'operation', value: 'create_tag' }, + }, + { + id: 'parentGuid', + title: 'Parent Tag GUID', + type: 'short-input', + placeholder: 'Parent tag GUID (optional)', + condition: { field: 'operation', value: 'create_tag' }, + mode: 'advanced', + }, + { + id: 'tagNames', + title: 'Tags', + type: 'short-input', + placeholder: 'Comma-separated tags (e.g., "work, meeting, urgent")', + condition: { field: 'operation', value: ['create_note', 'update_note'] }, + mode: 'advanced', + }, + { + id: 'maxNotes', + title: 'Max Results', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'search_notes' }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'search_notes' }, + mode: 'advanced', + }, + { + id: 'withContent', + title: 'Include Content', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'get_note' }, + mode: 'advanced', + }, + ], + + tools: { + access: [ + 'evernote_copy_note', + 'evernote_create_note', + 'evernote_create_notebook', + 'evernote_create_tag', + 'evernote_delete_note', + 'evernote_get_note', + 'evernote_get_notebook', + 'evernote_list_notebooks', + 'evernote_list_tags', + 'evernote_search_notes', + 'evernote_update_note', + ], + config: { + tool: (params) => `evernote_${params.operation}`, + params: (params) => { + const { operation, apiKey, ...rest } = params + + switch (operation) { + case 'create_note': + return { + apiKey, + title: rest.title, + content: rest.content, + notebookGuid: rest.notebookGuid || undefined, + tagNames: rest.tagNames || undefined, + } + case 'get_note': + return { + apiKey, + noteGuid: rest.noteGuid, + withContent: rest.withContent !== 'false', + } + case 'update_note': + return { + apiKey, + noteGuid: rest.noteGuid, + title: rest.updateTitle || undefined, + content: rest.updateContent || undefined, + notebookGuid: rest.notebookGuid || undefined, + tagNames: rest.tagNames || undefined, + } + case 'delete_note': + return { + apiKey, + noteGuid: rest.noteGuid, + } + case 'copy_note': + return { + apiKey, + noteGuid: rest.noteGuid, + toNotebookGuid: rest.toNotebookGuid, + } + case 'search_notes': + return { + apiKey, + query: rest.query, + notebookGuid: rest.notebookGuid || undefined, + offset: rest.offset ? Number(rest.offset) : 0, + maxNotes: rest.maxNotes ? Number(rest.maxNotes) : 25, + } + case 'get_notebook': + return { + apiKey, + notebookGuid: rest.notebookGuid, + } + case 'create_notebook': + return { + apiKey, + name: rest.notebookName, + stack: rest.stack || undefined, + } + case 'list_notebooks': + return { apiKey } + case 'create_tag': + return { + apiKey, + name: rest.tagName, + parentGuid: rest.parentGuid || undefined, + } + case 'list_tags': + return { apiKey } + default: + return { apiKey } + } + }, + }, + }, + + inputs: { + apiKey: { type: 'string', description: 'Evernote developer token' }, + operation: { type: 'string', description: 'Operation to perform' }, + title: { type: 'string', description: 'Note title' }, + content: { type: 'string', description: 'Note content' }, + noteGuid: { type: 'string', description: 'Note GUID' }, + updateTitle: { type: 'string', description: 'New note title' }, + updateContent: { type: 'string', description: 'New note content' }, + toNotebookGuid: { type: 'string', description: 'Destination notebook GUID' }, + query: { type: 'string', description: 'Search query' }, + notebookGuid: { type: 'string', description: 'Notebook GUID' }, + notebookName: { type: 'string', description: 'Notebook name' }, + stack: { type: 'string', description: 'Notebook stack name' }, + tagName: { type: 'string', description: 'Tag name' }, + parentGuid: { type: 'string', description: 'Parent tag GUID' }, + tagNames: { type: 'string', description: 'Comma-separated tag names' }, + maxNotes: { type: 'string', description: 'Maximum number of results' }, + offset: { type: 'string', description: 'Starting index for results' }, + withContent: { type: 'string', description: 'Whether to include note content' }, + }, + + outputs: { + note: { type: 'json', description: 'Note data' }, + notebook: { type: 'json', description: 'Notebook data' }, + notebooks: { type: 'json', description: 'List of notebooks' }, + tag: { type: 'json', description: 'Tag data' }, + tags: { type: 'json', description: 'List of tags' }, + totalNotes: { type: 'number', description: 'Total number of matching notes' }, + notes: { type: 'json', description: 'List of note metadata' }, + success: { type: 'boolean', description: 'Whether the operation succeeded' }, + noteGuid: { type: 'string', description: 'GUID of the affected note' }, + }, +} diff --git a/apps/sim/blocks/blocks/obsidian.ts b/apps/sim/blocks/blocks/obsidian.ts new file mode 100644 index 00000000000..533c80fc654 --- /dev/null +++ b/apps/sim/blocks/blocks/obsidian.ts @@ -0,0 +1,270 @@ +import { ObsidianIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' + +export const ObsidianBlock: BlockConfig = { + type: 'obsidian', + name: 'Obsidian', + description: 'Interact with your Obsidian vault via the Local REST API', + longDescription: + 'Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.', + docsLink: 'https://docs.sim.ai/tools/obsidian', + category: 'tools', + bgColor: '#0F0F0F', + icon: ObsidianIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Files', id: 'list_files' }, + { label: 'Get Note', id: 'get_note' }, + { label: 'Create Note', id: 'create_note' }, + { label: 'Append to Note', id: 'append_note' }, + { label: 'Patch Note', id: 'patch_note' }, + { label: 'Delete Note', id: 'delete_note' }, + { label: 'Search', id: 'search' }, + { label: 'Get Active File', id: 'get_active' }, + { label: 'Append to Active File', id: 'append_active' }, + { label: 'Patch Active File', id: 'patch_active' }, + { label: 'Open File', id: 'open_file' }, + { label: 'List Commands', id: 'list_commands' }, + { label: 'Execute Command', id: 'execute_command' }, + { label: 'Get Periodic Note', id: 'get_periodic_note' }, + { label: 'Append to Periodic Note', id: 'append_periodic_note' }, + ], + value: () => 'get_note', + }, + { + id: 'baseUrl', + title: 'Base URL', + type: 'short-input', + placeholder: 'https://127.0.0.1:27124', + value: () => 'https://127.0.0.1:27124', + required: true, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Obsidian Local REST API key', + password: true, + required: true, + }, + { + id: 'path', + title: 'Directory Path', + type: 'short-input', + placeholder: 'Leave empty for vault root (e.g. "Projects/notes")', + condition: { field: 'operation', value: 'list_files' }, + }, + { + id: 'filename', + title: 'Note Path', + type: 'short-input', + placeholder: 'folder/note.md', + condition: { + field: 'operation', + value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'], + }, + required: { + field: 'operation', + value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'], + }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + placeholder: 'Markdown content', + condition: { + field: 'operation', + value: [ + 'create_note', + 'append_note', + 'patch_note', + 'append_active', + 'patch_active', + 'append_periodic_note', + ], + }, + required: { + field: 'operation', + value: [ + 'create_note', + 'append_note', + 'patch_note', + 'append_active', + 'patch_active', + 'append_periodic_note', + ], + }, + }, + { + id: 'patchOperation', + title: 'Patch Operation', + type: 'dropdown', + options: [ + { label: 'Append', id: 'append' }, + { label: 'Prepend', id: 'prepend' }, + { label: 'Replace', id: 'replace' }, + ], + value: () => 'append', + condition: { field: 'operation', value: ['patch_note', 'patch_active'] }, + required: { field: 'operation', value: ['patch_note', 'patch_active'] }, + }, + { + id: 'targetType', + title: 'Target Type', + type: 'dropdown', + options: [ + { label: 'Heading', id: 'heading' }, + { label: 'Block Reference', id: 'block' }, + { label: 'Frontmatter', id: 'frontmatter' }, + ], + value: () => 'heading', + condition: { field: 'operation', value: ['patch_note', 'patch_active'] }, + required: { field: 'operation', value: ['patch_note', 'patch_active'] }, + }, + { + id: 'target', + title: 'Target', + type: 'short-input', + placeholder: 'Heading text, block ID, or frontmatter field', + condition: { field: 'operation', value: ['patch_note', 'patch_active'] }, + required: { field: 'operation', value: ['patch_note', 'patch_active'] }, + }, + { + id: 'targetDelimiter', + title: 'Target Delimiter', + type: 'short-input', + placeholder: ':: (default)', + condition: { field: 'operation', value: ['patch_note', 'patch_active'] }, + mode: 'advanced', + }, + { + id: 'trimTargetWhitespace', + title: 'Trim Target Whitespace', + type: 'switch', + condition: { field: 'operation', value: ['patch_note', 'patch_active'] }, + mode: 'advanced', + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Text to search for', + condition: { field: 'operation', value: 'search' }, + required: { field: 'operation', value: 'search' }, + }, + { + id: 'contextLength', + title: 'Context Length', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, + { + id: 'commandId', + title: 'Command ID', + type: 'short-input', + placeholder: 'e.g. daily-notes:open-today', + condition: { field: 'operation', value: 'execute_command' }, + required: { field: 'operation', value: 'execute_command' }, + }, + { + id: 'newLeaf', + title: 'Open in New Tab', + type: 'switch', + condition: { field: 'operation', value: 'open_file' }, + mode: 'advanced', + }, + { + id: 'period', + title: 'Period', + type: 'dropdown', + options: [ + { label: 'Daily', id: 'daily' }, + { label: 'Weekly', id: 'weekly' }, + { label: 'Monthly', id: 'monthly' }, + { label: 'Quarterly', id: 'quarterly' }, + { label: 'Yearly', id: 'yearly' }, + ], + value: () => 'daily', + condition: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] }, + required: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] }, + }, + ], + + tools: { + access: [ + 'obsidian_append_active', + 'obsidian_append_note', + 'obsidian_append_periodic_note', + 'obsidian_create_note', + 'obsidian_delete_note', + 'obsidian_execute_command', + 'obsidian_get_active', + 'obsidian_get_note', + 'obsidian_get_periodic_note', + 'obsidian_list_commands', + 'obsidian_list_files', + 'obsidian_open_file', + 'obsidian_patch_active', + 'obsidian_patch_note', + 'obsidian_search', + ], + config: { + tool: (params) => `obsidian_${params.operation}`, + params: (params) => { + const result: Record = {} + if (params.contextLength) { + result.contextLength = Number(params.contextLength) + } + if (params.patchOperation) { + result.operation = params.patchOperation + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + baseUrl: { type: 'string', description: 'Base URL for the Obsidian Local REST API' }, + apiKey: { type: 'string', description: 'API key for authentication' }, + filename: { type: 'string', description: 'Path to the note relative to vault root' }, + content: { type: 'string', description: 'Markdown content for the note' }, + path: { type: 'string', description: 'Directory path to list' }, + query: { type: 'string', description: 'Text to search for' }, + contextLength: { type: 'number', description: 'Characters of context around matches' }, + commandId: { type: 'string', description: 'ID of the command to execute' }, + patchOperation: { type: 'string', description: 'Patch operation: append, prepend, or replace' }, + targetType: { type: 'string', description: 'Target type: heading, block, or frontmatter' }, + target: { type: 'string', description: 'Target identifier for patch operations' }, + targetDelimiter: { type: 'string', description: 'Delimiter for nested headings' }, + trimTargetWhitespace: { type: 'boolean', description: 'Trim whitespace from target' }, + newLeaf: { type: 'boolean', description: 'Open file in new tab' }, + period: { type: 'string', description: 'Periodic note period type' }, + }, + + outputs: { + content: { type: 'string', description: 'Markdown content of the note' }, + filename: { type: 'string', description: 'Path to the note' }, + files: { type: 'json', description: 'List of files and directories (path, type)' }, + results: { type: 'json', description: 'Search results (filename, score, matches)' }, + commands: { type: 'json', description: 'List of available commands (id, name)' }, + created: { type: 'boolean', description: 'Whether the note was created' }, + appended: { type: 'boolean', description: 'Whether content was appended' }, + patched: { type: 'boolean', description: 'Whether content was patched' }, + deleted: { type: 'boolean', description: 'Whether the note was deleted' }, + executed: { type: 'boolean', description: 'Whether the command was executed' }, + opened: { type: 'boolean', description: 'Whether the file was opened' }, + commandId: { type: 'string', description: 'ID of the executed command' }, + period: { type: 'string', description: 'Period type of the periodic note' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 7ff0b918dd1..165facc910b 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -38,6 +38,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch' import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' import { EnrichBlock } from '@/blocks/blocks/enrich' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' +import { EvernoteBlock } from '@/blocks/blocks/evernote' import { ExaBlock } from '@/blocks/blocks/exa' import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' @@ -113,6 +114,7 @@ import { MySQLBlock } from '@/blocks/blocks/mysql' import { Neo4jBlock } from '@/blocks/blocks/neo4j' import { NoteBlock } from '@/blocks/blocks/note' import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion' +import { ObsidianBlock } from '@/blocks/blocks/obsidian' import { OneDriveBlock } from '@/blocks/blocks/onedrive' import { OnePasswordBlock } from '@/blocks/blocks/onepassword' import { OpenAIBlock } from '@/blocks/blocks/openai' @@ -234,6 +236,7 @@ export const registry: Record = { elasticsearch: ElasticsearchBlock, elevenlabs: ElevenLabsBlock, enrich: EnrichBlock, + evernote: EvernoteBlock, evaluator: EvaluatorBlock, exa: ExaBlock, file: FileBlock, @@ -320,6 +323,7 @@ export const registry: Record = { note: NoteBlock, notion: NotionBlock, notion_v2: NotionV2Block, + obsidian: ObsidianBlock, onepassword: OnePasswordBlock, onedrive: OneDriveBlock, openai: OpenAIBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 5525e048cfa..e77c864d184 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps) { ) } +export function ObsidianIcon(props: SVGProps) { + const id = useId() + const bl = `${id}-bl` + const tr = `${id}-tr` + const tl = `${id}-tl` + const br = `${id}-br` + const te = `${id}-te` + const le = `${id}-le` + const be = `${id}-be` + const me = `${id}-me` + const clip = `${id}-clip` + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + export function NotionIcon(props: SVGProps) { return ( @@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps) { ) } +export function EvernoteIcon(props: SVGProps) { + return ( + + + + ) +} + export function ElevenLabsIcon(props: SVGProps) { return ( { - // Trigger opportunistic (non-blocking) garbage collection if running on Bun. - // This signals JSC GC + mimalloc page purge without blocking the event loop, - // helping reclaim RSS that mimalloc otherwise retains under sustained load. - const bunGlobal = (globalThis as Record).Bun as - | { gc?: (force: boolean) => void } - | undefined - if (typeof bunGlobal?.gc === 'function') { - bunGlobal.gc(false) - } - const mem = process.memoryUsage() const heap = v8.getHeapStatistics() @@ -49,8 +33,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) { ? process.getActiveResourcesInfo().length : -1, uptimeMin: Math.round(process.uptime() / 60), - activeSSEConnections: getActiveSSEConnectionCount(), - sseByRoute: getActiveSSEConnectionsByRoute(), }) }, intervalMs) timer.unref() diff --git a/apps/sim/lib/monitoring/sse-connections.ts b/apps/sim/lib/monitoring/sse-connections.ts deleted file mode 100644 index b6394ddff6e..00000000000 --- a/apps/sim/lib/monitoring/sse-connections.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Tracks active SSE connections by route for memory leak diagnostics. - * Logged alongside periodic memory telemetry to correlate connection - * counts with heap growth. - */ - -const connections = new Map() - -export function incrementSSEConnections(route: string) { - connections.set(route, (connections.get(route) ?? 0) + 1) -} - -export function decrementSSEConnections(route: string) { - const count = (connections.get(route) ?? 0) - 1 - if (count <= 0) connections.delete(route) - else connections.set(route, count) -} - -export function getActiveSSEConnectionCount(): number { - let total = 0 - for (const count of connections.values()) total += count - return total -} - -export function getActiveSSEConnectionsByRoute(): Record { - return Object.fromEntries(connections) -} diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index c3af3843518..fc1de215771 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1166,6 +1166,12 @@ export async function queueWebhookExecution( }) } + // Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.) + // A JSON body like {"message":"..."} is not a recognized response format and causes modal errors + if (foundWebhook.provider === 'slack') { + return new NextResponse(null, { status: 200 }) + } + // Twilio Voice requires TwiML XML response if (foundWebhook.provider === 'twilio_voice') { const providerConfig = (foundWebhook.providerConfig as Record) || {} @@ -1211,6 +1217,12 @@ export async function queueWebhookExecution( ) } + if (foundWebhook.provider === 'slack') { + // Return empty 200 to avoid Slack showing an error dialog to the user, + // even though processing failed. The error is already logged above. + return new NextResponse(null, { status: 200 }) + } + if (foundWebhook.provider === 'twilio_voice') { const errorTwiml = ` diff --git a/apps/sim/tools/evernote/copy_note.ts b/apps/sim/tools/evernote/copy_note.ts new file mode 100644 index 00000000000..9493e6c9b98 --- /dev/null +++ b/apps/sim/tools/evernote/copy_note.ts @@ -0,0 +1,78 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteCopyNoteParams, EvernoteCopyNoteResponse } from './types' + +export const evernoteCopyNoteTool: ToolConfig = { + id: 'evernote_copy_note', + name: 'Evernote Copy Note', + description: 'Copy a note to another notebook in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + noteGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the note to copy', + }, + toNotebookGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the destination notebook', + }, + }, + + request: { + url: '/api/tools/evernote/copy-note', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + noteGuid: params.noteGuid, + toNotebookGuid: params.toNotebookGuid, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to copy note') + } + return { + success: true, + output: { note: data.output.note }, + } + }, + + outputs: { + note: { + type: 'object', + description: 'The copied note metadata', + properties: { + guid: { type: 'string', description: 'New note GUID' }, + title: { type: 'string', description: 'Note title' }, + notebookGuid: { + type: 'string', + description: 'GUID of the destination notebook', + optional: true, + }, + created: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + updated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/create_note.ts b/apps/sim/tools/evernote/create_note.ts new file mode 100644 index 00000000000..281735f6ac1 --- /dev/null +++ b/apps/sim/tools/evernote/create_note.ts @@ -0,0 +1,101 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteCreateNoteParams, EvernoteCreateNoteResponse } from './types' + +export const evernoteCreateNoteTool: ToolConfig< + EvernoteCreateNoteParams, + EvernoteCreateNoteResponse +> = { + id: 'evernote_create_note', + name: 'Evernote Create Note', + description: 'Create a new note in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Title of the note', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content of the note (plain text or ENML)', + }, + notebookGuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'GUID of the notebook to create the note in (defaults to default notebook)', + }, + tagNames: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated list of tag names to apply', + }, + }, + + request: { + url: '/api/tools/evernote/create-note', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + title: params.title, + content: params.content, + notebookGuid: params.notebookGuid || null, + tagNames: params.tagNames || null, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to create note') + } + return { + success: true, + output: { note: data.output.note }, + } + }, + + outputs: { + note: { + type: 'object', + description: 'The created note', + properties: { + guid: { type: 'string', description: 'Unique identifier of the note' }, + title: { type: 'string', description: 'Title of the note' }, + content: { type: 'string', description: 'ENML content of the note', optional: true }, + notebookGuid: { + type: 'string', + description: 'GUID of the containing notebook', + optional: true, + }, + tagNames: { + type: 'array', + description: 'Tag names applied to the note', + optional: true, + }, + created: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + updated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/create_notebook.ts b/apps/sim/tools/evernote/create_notebook.ts new file mode 100644 index 00000000000..ba46e48b50b --- /dev/null +++ b/apps/sim/tools/evernote/create_notebook.ts @@ -0,0 +1,78 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteCreateNotebookParams, EvernoteCreateNotebookResponse } from './types' + +export const evernoteCreateNotebookTool: ToolConfig< + EvernoteCreateNotebookParams, + EvernoteCreateNotebookResponse +> = { + id: 'evernote_create_notebook', + name: 'Evernote Create Notebook', + description: 'Create a new notebook in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new notebook', + }, + stack: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Stack name to group the notebook under', + }, + }, + + request: { + url: '/api/tools/evernote/create-notebook', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + name: params.name, + stack: params.stack || null, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to create notebook') + } + return { + success: true, + output: { notebook: data.output.notebook }, + } + }, + + outputs: { + notebook: { + type: 'object', + description: 'The created notebook', + properties: { + guid: { type: 'string', description: 'Notebook GUID' }, + name: { type: 'string', description: 'Notebook name' }, + defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' }, + serviceCreated: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + serviceUpdated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + stack: { type: 'string', description: 'Notebook stack name', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/create_tag.ts b/apps/sim/tools/evernote/create_tag.ts new file mode 100644 index 00000000000..aeaa3d2dbf6 --- /dev/null +++ b/apps/sim/tools/evernote/create_tag.ts @@ -0,0 +1,70 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteCreateTagParams, EvernoteCreateTagResponse } from './types' + +export const evernoteCreateTagTool: ToolConfig = + { + id: 'evernote_create_tag', + name: 'Evernote Create Tag', + description: 'Create a new tag in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new tag', + }, + parentGuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'GUID of the parent tag for hierarchy', + }, + }, + + request: { + url: '/api/tools/evernote/create-tag', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + name: params.name, + parentGuid: params.parentGuid || null, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to create tag') + } + return { + success: true, + output: { tag: data.output.tag }, + } + }, + + outputs: { + tag: { + type: 'object', + description: 'The created tag', + properties: { + guid: { type: 'string', description: 'Tag GUID' }, + name: { type: 'string', description: 'Tag name' }, + parentGuid: { type: 'string', description: 'Parent tag GUID', optional: true }, + updateSequenceNum: { + type: 'number', + description: 'Update sequence number', + optional: true, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/evernote/delete_note.ts b/apps/sim/tools/evernote/delete_note.ts new file mode 100644 index 00000000000..6983a78d3f8 --- /dev/null +++ b/apps/sim/tools/evernote/delete_note.ts @@ -0,0 +1,62 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteDeleteNoteParams, EvernoteDeleteNoteResponse } from './types' + +export const evernoteDeleteNoteTool: ToolConfig< + EvernoteDeleteNoteParams, + EvernoteDeleteNoteResponse +> = { + id: 'evernote_delete_note', + name: 'Evernote Delete Note', + description: 'Move a note to the trash in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + noteGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the note to delete', + }, + }, + + request: { + url: '/api/tools/evernote/delete-note', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + noteGuid: params.noteGuid, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to delete note') + } + return { + success: true, + output: { + success: true, + noteGuid: data.output.noteGuid, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the note was successfully deleted', + }, + noteGuid: { + type: 'string', + description: 'GUID of the deleted note', + }, + }, +} diff --git a/apps/sim/tools/evernote/get_note.ts b/apps/sim/tools/evernote/get_note.ts new file mode 100644 index 00000000000..4773bd23700 --- /dev/null +++ b/apps/sim/tools/evernote/get_note.ts @@ -0,0 +1,87 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteGetNoteParams, EvernoteGetNoteResponse } from './types' + +export const evernoteGetNoteTool: ToolConfig = { + id: 'evernote_get_note', + name: 'Evernote Get Note', + description: 'Retrieve a note from Evernote by its GUID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + noteGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the note to retrieve', + }, + withContent: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include note content (default: true)', + }, + }, + + request: { + url: '/api/tools/evernote/get-note', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + noteGuid: params.noteGuid, + withContent: params.withContent ?? true, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to get note') + } + return { + success: true, + output: { note: data.output.note }, + } + }, + + outputs: { + note: { + type: 'object', + description: 'The retrieved note', + properties: { + guid: { type: 'string', description: 'Unique identifier of the note' }, + title: { type: 'string', description: 'Title of the note' }, + content: { type: 'string', description: 'ENML content of the note', optional: true }, + contentLength: { + type: 'number', + description: 'Length of the note content', + optional: true, + }, + notebookGuid: { + type: 'string', + description: 'GUID of the containing notebook', + optional: true, + }, + tagGuids: { type: 'array', description: 'GUIDs of tags on the note', optional: true }, + tagNames: { type: 'array', description: 'Names of tags on the note', optional: true }, + created: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + updated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + active: { type: 'boolean', description: 'Whether the note is active (not in trash)' }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/get_notebook.ts b/apps/sim/tools/evernote/get_notebook.ts new file mode 100644 index 00000000000..78a2fd59fa6 --- /dev/null +++ b/apps/sim/tools/evernote/get_notebook.ts @@ -0,0 +1,71 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteGetNotebookParams, EvernoteGetNotebookResponse } from './types' + +export const evernoteGetNotebookTool: ToolConfig< + EvernoteGetNotebookParams, + EvernoteGetNotebookResponse +> = { + id: 'evernote_get_notebook', + name: 'Evernote Get Notebook', + description: 'Retrieve a notebook from Evernote by its GUID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + notebookGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the notebook to retrieve', + }, + }, + + request: { + url: '/api/tools/evernote/get-notebook', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + notebookGuid: params.notebookGuid, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to get notebook') + } + return { + success: true, + output: { notebook: data.output.notebook }, + } + }, + + outputs: { + notebook: { + type: 'object', + description: 'The retrieved notebook', + properties: { + guid: { type: 'string', description: 'Notebook GUID' }, + name: { type: 'string', description: 'Notebook name' }, + defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' }, + serviceCreated: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + serviceUpdated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + stack: { type: 'string', description: 'Notebook stack name', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/index.ts b/apps/sim/tools/evernote/index.ts new file mode 100644 index 00000000000..08819e0baf4 --- /dev/null +++ b/apps/sim/tools/evernote/index.ts @@ -0,0 +1,12 @@ +export { evernoteCopyNoteTool } from './copy_note' +export { evernoteCreateNoteTool } from './create_note' +export { evernoteCreateNotebookTool } from './create_notebook' +export { evernoteCreateTagTool } from './create_tag' +export { evernoteDeleteNoteTool } from './delete_note' +export { evernoteGetNoteTool } from './get_note' +export { evernoteGetNotebookTool } from './get_notebook' +export { evernoteListNotebooksTool } from './list_notebooks' +export { evernoteListTagsTool } from './list_tags' +export { evernoteSearchNotesTool } from './search_notes' +export * from './types' +export { evernoteUpdateNoteTool } from './update_note' diff --git a/apps/sim/tools/evernote/list_notebooks.ts b/apps/sim/tools/evernote/list_notebooks.ts new file mode 100644 index 00000000000..b2b9756c7e8 --- /dev/null +++ b/apps/sim/tools/evernote/list_notebooks.ts @@ -0,0 +1,64 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteListNotebooksParams, EvernoteListNotebooksResponse } from './types' + +export const evernoteListNotebooksTool: ToolConfig< + EvernoteListNotebooksParams, + EvernoteListNotebooksResponse +> = { + id: 'evernote_list_notebooks', + name: 'Evernote List Notebooks', + description: 'List all notebooks in an Evernote account', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + }, + + request: { + url: '/api/tools/evernote/list-notebooks', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to list notebooks') + } + return { + success: true, + output: { notebooks: data.output.notebooks }, + } + }, + + outputs: { + notebooks: { + type: 'array', + description: 'List of notebooks', + properties: { + guid: { type: 'string', description: 'Notebook GUID' }, + name: { type: 'string', description: 'Notebook name' }, + defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' }, + serviceCreated: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + serviceUpdated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + stack: { type: 'string', description: 'Notebook stack name', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/list_tags.ts b/apps/sim/tools/evernote/list_tags.ts new file mode 100644 index 00000000000..65cb5a04fdd --- /dev/null +++ b/apps/sim/tools/evernote/list_tags.ts @@ -0,0 +1,55 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteListTagsParams, EvernoteListTagsResponse } from './types' + +export const evernoteListTagsTool: ToolConfig = { + id: 'evernote_list_tags', + name: 'Evernote List Tags', + description: 'List all tags in an Evernote account', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + }, + + request: { + url: '/api/tools/evernote/list-tags', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to list tags') + } + return { + success: true, + output: { tags: data.output.tags }, + } + }, + + outputs: { + tags: { + type: 'array', + description: 'List of tags', + properties: { + guid: { type: 'string', description: 'Tag GUID' }, + name: { type: 'string', description: 'Tag name' }, + parentGuid: { type: 'string', description: 'Parent tag GUID', optional: true }, + updateSequenceNum: { + type: 'number', + description: 'Update sequence number', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/search_notes.ts b/apps/sim/tools/evernote/search_notes.ts new file mode 100644 index 00000000000..a75056434d3 --- /dev/null +++ b/apps/sim/tools/evernote/search_notes.ts @@ -0,0 +1,92 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteSearchNotesParams, EvernoteSearchNotesResponse } from './types' + +export const evernoteSearchNotesTool: ToolConfig< + EvernoteSearchNotesParams, + EvernoteSearchNotesResponse +> = { + id: 'evernote_search_notes', + name: 'Evernote Search Notes', + description: 'Search for notes in Evernote using the Evernote search grammar', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Search query using Evernote search grammar (e.g., "tag:work intitle:meeting")', + }, + notebookGuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Restrict search to a specific notebook by GUID', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Starting index for results (default: 0)', + }, + maxNotes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of notes to return (default: 25)', + }, + }, + + request: { + url: '/api/tools/evernote/search-notes', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + query: params.query, + notebookGuid: params.notebookGuid || null, + offset: params.offset ?? 0, + maxNotes: params.maxNotes ?? 25, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to search notes') + } + return { + success: true, + output: { + totalNotes: data.output.totalNotes, + notes: data.output.notes, + }, + } + }, + + outputs: { + totalNotes: { + type: 'number', + description: 'Total number of matching notes', + }, + notes: { + type: 'array', + description: 'List of matching note metadata', + properties: { + guid: { type: 'string', description: 'Note GUID' }, + title: { type: 'string', description: 'Note title', optional: true }, + contentLength: { type: 'number', description: 'Content length in bytes', optional: true }, + created: { type: 'number', description: 'Creation timestamp', optional: true }, + updated: { type: 'number', description: 'Last updated timestamp', optional: true }, + notebookGuid: { type: 'string', description: 'Containing notebook GUID', optional: true }, + tagGuids: { type: 'array', description: 'Tag GUIDs', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/evernote/types.ts b/apps/sim/tools/evernote/types.ts new file mode 100644 index 00000000000..153594b3cc1 --- /dev/null +++ b/apps/sim/tools/evernote/types.ts @@ -0,0 +1,166 @@ +import type { ToolResponse } from '@/tools/types' + +export interface EvernoteBaseParams { + apiKey: string +} + +export interface EvernoteCreateNoteParams extends EvernoteBaseParams { + title: string + content: string + notebookGuid?: string + tagNames?: string +} + +export interface EvernoteGetNoteParams extends EvernoteBaseParams { + noteGuid: string + withContent?: boolean +} + +export interface EvernoteUpdateNoteParams extends EvernoteBaseParams { + noteGuid: string + title?: string + content?: string + notebookGuid?: string + tagNames?: string +} + +export interface EvernoteDeleteNoteParams extends EvernoteBaseParams { + noteGuid: string +} + +export interface EvernoteSearchNotesParams extends EvernoteBaseParams { + query: string + notebookGuid?: string + offset?: number + maxNotes?: number +} + +export interface EvernoteListNotebooksParams extends EvernoteBaseParams {} + +export interface EvernoteGetNotebookParams extends EvernoteBaseParams { + notebookGuid: string +} + +export interface EvernoteCreateNotebookParams extends EvernoteBaseParams { + name: string + stack?: string +} + +export interface EvernoteListTagsParams extends EvernoteBaseParams {} + +export interface EvernoteCreateTagParams extends EvernoteBaseParams { + name: string + parentGuid?: string +} + +export interface EvernoteCopyNoteParams extends EvernoteBaseParams { + noteGuid: string + toNotebookGuid: string +} + +export interface EvernoteNoteOutput { + guid: string + title: string + content: string | null + contentLength: number | null + created: number | null + updated: number | null + active: boolean + notebookGuid: string | null + tagGuids: string[] + tagNames: string[] +} + +export interface EvernoteNotebookOutput { + guid: string + name: string + defaultNotebook: boolean + serviceCreated: number | null + serviceUpdated: number | null + stack: string | null +} + +export interface EvernoteNoteMetadataOutput { + guid: string + title: string | null + contentLength: number | null + created: number | null + updated: number | null + notebookGuid: string | null + tagGuids: string[] +} + +export interface EvernoteTagOutput { + guid: string + name: string + parentGuid: string | null + updateSequenceNum: number | null +} + +export interface EvernoteCreateNoteResponse extends ToolResponse { + output: { + note: EvernoteNoteOutput + } +} + +export interface EvernoteGetNoteResponse extends ToolResponse { + output: { + note: EvernoteNoteOutput + } +} + +export interface EvernoteUpdateNoteResponse extends ToolResponse { + output: { + note: EvernoteNoteOutput + } +} + +export interface EvernoteDeleteNoteResponse extends ToolResponse { + output: { + success: boolean + noteGuid: string + } +} + +export interface EvernoteSearchNotesResponse extends ToolResponse { + output: { + totalNotes: number + notes: EvernoteNoteMetadataOutput[] + } +} + +export interface EvernoteListNotebooksResponse extends ToolResponse { + output: { + notebooks: EvernoteNotebookOutput[] + } +} + +export interface EvernoteGetNotebookResponse extends ToolResponse { + output: { + notebook: EvernoteNotebookOutput + } +} + +export interface EvernoteCreateNotebookResponse extends ToolResponse { + output: { + notebook: EvernoteNotebookOutput + } +} + +export interface EvernoteListTagsResponse extends ToolResponse { + output: { + tags: EvernoteTagOutput[] + } +} + +export interface EvernoteCreateTagResponse extends ToolResponse { + output: { + tag: EvernoteTagOutput + } +} + +export interface EvernoteCopyNoteResponse extends ToolResponse { + output: { + note: EvernoteNoteOutput + } +} diff --git a/apps/sim/tools/evernote/update_note.ts b/apps/sim/tools/evernote/update_note.ts new file mode 100644 index 00000000000..48872e6c6e4 --- /dev/null +++ b/apps/sim/tools/evernote/update_note.ts @@ -0,0 +1,104 @@ +import type { ToolConfig } from '@/tools/types' +import type { EvernoteUpdateNoteParams, EvernoteUpdateNoteResponse } from './types' + +export const evernoteUpdateNoteTool: ToolConfig< + EvernoteUpdateNoteParams, + EvernoteUpdateNoteResponse +> = { + id: 'evernote_update_note', + name: 'Evernote Update Note', + description: 'Update an existing note in Evernote', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Evernote developer token', + }, + noteGuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GUID of the note to update', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New title for the note', + }, + content: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New content for the note (plain text or ENML)', + }, + notebookGuid: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'GUID of the notebook to move the note to', + }, + tagNames: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated list of tag names (replaces existing tags)', + }, + }, + + request: { + url: '/api/tools/evernote/update-note', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + noteGuid: params.noteGuid, + title: params.title || null, + content: params.content || null, + notebookGuid: params.notebookGuid || null, + tagNames: params.tagNames || null, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to update note') + } + return { + success: true, + output: { note: data.output.note }, + } + }, + + outputs: { + note: { + type: 'object', + description: 'The updated note', + properties: { + guid: { type: 'string', description: 'Unique identifier of the note' }, + title: { type: 'string', description: 'Title of the note' }, + content: { type: 'string', description: 'ENML content of the note', optional: true }, + notebookGuid: { + type: 'string', + description: 'GUID of the containing notebook', + optional: true, + }, + tagNames: { type: 'array', description: 'Tag names on the note', optional: true }, + created: { + type: 'number', + description: 'Creation timestamp in milliseconds', + optional: true, + }, + updated: { + type: 'number', + description: 'Last updated timestamp in milliseconds', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/obsidian/append_active.ts b/apps/sim/tools/obsidian/append_active.ts new file mode 100644 index 00000000000..49ec7378d75 --- /dev/null +++ b/apps/sim/tools/obsidian/append_active.ts @@ -0,0 +1,66 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianAppendActiveParams, ObsidianAppendActiveResponse } from './types' + +export const appendActiveTool: ToolConfig< + ObsidianAppendActiveParams, + ObsidianAppendActiveResponse +> = { + id: 'obsidian_append_active', + name: 'Obsidian Append to Active File', + description: 'Append content to the currently active file in Obsidian', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Markdown content to append to the active file', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/active/` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + }), + body: (params) => params.content, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to append to active file: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + appended: true, + }, + } + }, + + outputs: { + appended: { + type: 'boolean', + description: 'Whether content was successfully appended', + }, + }, +} diff --git a/apps/sim/tools/obsidian/append_note.ts b/apps/sim/tools/obsidian/append_note.ts new file mode 100644 index 00000000000..2f0fbed8094 --- /dev/null +++ b/apps/sim/tools/obsidian/append_note.ts @@ -0,0 +1,74 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianAppendNoteParams, ObsidianAppendNoteResponse } from './types' + +export const appendNoteTool: ToolConfig = { + id: 'obsidian_append_note', + name: 'Obsidian Append to Note', + description: 'Append content to an existing note in your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to the note relative to vault root (e.g. "folder/note.md")', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Markdown content to append to the note', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/vault/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + }), + body: (params) => params.content, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to append to note: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + filename: params?.filename ?? '', + appended: true, + }, + } + }, + + outputs: { + filename: { + type: 'string', + description: 'Path of the note', + }, + appended: { + type: 'boolean', + description: 'Whether content was successfully appended', + }, + }, +} diff --git a/apps/sim/tools/obsidian/append_periodic_note.ts b/apps/sim/tools/obsidian/append_periodic_note.ts new file mode 100644 index 00000000000..50b43ea18cf --- /dev/null +++ b/apps/sim/tools/obsidian/append_periodic_note.ts @@ -0,0 +1,78 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianAppendPeriodicNoteParams, ObsidianAppendPeriodicNoteResponse } from './types' + +export const appendPeriodicNoteTool: ToolConfig< + ObsidianAppendPeriodicNoteParams, + ObsidianAppendPeriodicNoteResponse +> = { + id: 'obsidian_append_periodic_note', + name: 'Obsidian Append to Periodic Note', + description: + 'Append content to the current periodic note (daily, weekly, monthly, quarterly, or yearly). Creates the note if it does not exist.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + period: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Period type: daily, weekly, monthly, quarterly, or yearly', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Markdown content to append to the periodic note', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/periodic/${encodeURIComponent(params.period)}/` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + }), + body: (params) => params.content, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to append to periodic note: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + period: params?.period ?? '', + appended: true, + }, + } + }, + + outputs: { + period: { + type: 'string', + description: 'Period type of the note', + }, + appended: { + type: 'boolean', + description: 'Whether content was successfully appended', + }, + }, +} diff --git a/apps/sim/tools/obsidian/create_note.ts b/apps/sim/tools/obsidian/create_note.ts new file mode 100644 index 00000000000..fed38cca8f6 --- /dev/null +++ b/apps/sim/tools/obsidian/create_note.ts @@ -0,0 +1,74 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianCreateNoteParams, ObsidianCreateNoteResponse } from './types' + +export const createNoteTool: ToolConfig = { + id: 'obsidian_create_note', + name: 'Obsidian Create Note', + description: 'Create or replace a note in your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path for the note relative to vault root (e.g. "folder/note.md")', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Markdown content for the note', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/vault/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}` + }, + method: 'PUT', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + }), + body: (params) => params.content, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to create note: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + filename: params?.filename ?? '', + created: true, + }, + } + }, + + outputs: { + filename: { + type: 'string', + description: 'Path of the created note', + }, + created: { + type: 'boolean', + description: 'Whether the note was successfully created', + }, + }, +} diff --git a/apps/sim/tools/obsidian/delete_note.ts b/apps/sim/tools/obsidian/delete_note.ts new file mode 100644 index 00000000000..a6911d85e7f --- /dev/null +++ b/apps/sim/tools/obsidian/delete_note.ts @@ -0,0 +1,66 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianDeleteNoteParams, ObsidianDeleteNoteResponse } from './types' + +export const deleteNoteTool: ToolConfig = { + id: 'obsidian_delete_note', + name: 'Obsidian Delete Note', + description: 'Delete a note from your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to the note to delete relative to vault root', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/vault/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to delete note: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + filename: params?.filename ?? '', + deleted: true, + }, + } + }, + + outputs: { + filename: { + type: 'string', + description: 'Path of the deleted note', + }, + deleted: { + type: 'boolean', + description: 'Whether the note was successfully deleted', + }, + }, +} diff --git a/apps/sim/tools/obsidian/execute_command.ts b/apps/sim/tools/obsidian/execute_command.ts new file mode 100644 index 00000000000..240711b6300 --- /dev/null +++ b/apps/sim/tools/obsidian/execute_command.ts @@ -0,0 +1,70 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianExecuteCommandParams, ObsidianExecuteCommandResponse } from './types' + +export const executeCommandTool: ToolConfig< + ObsidianExecuteCommandParams, + ObsidianExecuteCommandResponse +> = { + id: 'obsidian_execute_command', + name: 'Obsidian Execute Command', + description: 'Execute a command in Obsidian (e.g. open daily note, toggle sidebar)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + commandId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'ID of the command to execute (use List Commands operation to discover available commands)', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/commands/${encodeURIComponent(params.commandId.trim())}/` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to execute command: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + commandId: params?.commandId ?? '', + executed: true, + }, + } + }, + + outputs: { + commandId: { + type: 'string', + description: 'ID of the executed command', + }, + executed: { + type: 'boolean', + description: 'Whether the command was successfully executed', + }, + }, +} diff --git a/apps/sim/tools/obsidian/get_active.ts b/apps/sim/tools/obsidian/get_active.ts new file mode 100644 index 00000000000..56a838d6716 --- /dev/null +++ b/apps/sim/tools/obsidian/get_active.ts @@ -0,0 +1,59 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianGetActiveParams, ObsidianGetActiveResponse } from './types' + +export const getActiveTool: ToolConfig = { + id: 'obsidian_get_active', + name: 'Obsidian Get Active File', + description: 'Retrieve the content of the currently active file in Obsidian', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/active/` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/vnd.olrapi.note+json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + content: data.content ?? '', + filename: data.path ?? null, + }, + } + }, + + outputs: { + content: { + type: 'string', + description: 'Markdown content of the active file', + }, + filename: { + type: 'string', + description: 'Path to the active file', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/obsidian/get_note.ts b/apps/sim/tools/obsidian/get_note.ts new file mode 100644 index 00000000000..118cb7fa6c9 --- /dev/null +++ b/apps/sim/tools/obsidian/get_note.ts @@ -0,0 +1,68 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianGetNoteParams, ObsidianGetNoteResponse } from './types' + +export const getNoteTool: ToolConfig = { + id: 'obsidian_get_note', + name: 'Obsidian Get Note', + description: 'Retrieve the content of a note from your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to the note relative to vault root (e.g. "folder/note.md")', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/vault/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'text/markdown', + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to get note: ${error.message ?? response.statusText}`) + } + const content = await response.text() + return { + success: true, + output: { + content, + filename: params?.filename ?? '', + }, + } + }, + + outputs: { + content: { + type: 'string', + description: 'Markdown content of the note', + }, + filename: { + type: 'string', + description: 'Path to the note', + }, + }, +} diff --git a/apps/sim/tools/obsidian/get_periodic_note.ts b/apps/sim/tools/obsidian/get_periodic_note.ts new file mode 100644 index 00000000000..d37b3169ac4 --- /dev/null +++ b/apps/sim/tools/obsidian/get_periodic_note.ts @@ -0,0 +1,67 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianGetPeriodicNoteParams, ObsidianGetPeriodicNoteResponse } from './types' + +export const getPeriodicNoteTool: ToolConfig< + ObsidianGetPeriodicNoteParams, + ObsidianGetPeriodicNoteResponse +> = { + id: 'obsidian_get_periodic_note', + name: 'Obsidian Get Periodic Note', + description: 'Retrieve the current periodic note (daily, weekly, monthly, quarterly, or yearly)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + period: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Period type: daily, weekly, monthly, quarterly, or yearly', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/periodic/${encodeURIComponent(params.period)}/` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'text/markdown', + }), + }, + + transformResponse: async (response, params) => { + const content = await response.text() + return { + success: true, + output: { + content, + period: params?.period ?? '', + }, + } + }, + + outputs: { + content: { + type: 'string', + description: 'Markdown content of the periodic note', + }, + period: { + type: 'string', + description: 'Period type of the note', + }, + }, +} diff --git a/apps/sim/tools/obsidian/index.ts b/apps/sim/tools/obsidian/index.ts new file mode 100644 index 00000000000..43327f8971d --- /dev/null +++ b/apps/sim/tools/obsidian/index.ts @@ -0,0 +1,16 @@ +export { appendActiveTool as obsidianAppendActiveTool } from './append_active' +export { appendNoteTool as obsidianAppendNoteTool } from './append_note' +export { appendPeriodicNoteTool as obsidianAppendPeriodicNoteTool } from './append_periodic_note' +export { createNoteTool as obsidianCreateNoteTool } from './create_note' +export { deleteNoteTool as obsidianDeleteNoteTool } from './delete_note' +export { executeCommandTool as obsidianExecuteCommandTool } from './execute_command' +export { getActiveTool as obsidianGetActiveTool } from './get_active' +export { getNoteTool as obsidianGetNoteTool } from './get_note' +export { getPeriodicNoteTool as obsidianGetPeriodicNoteTool } from './get_periodic_note' +export { listCommandsTool as obsidianListCommandsTool } from './list_commands' +export { listFilesTool as obsidianListFilesTool } from './list_files' +export { openFileTool as obsidianOpenFileTool } from './open_file' +export { patchActiveTool as obsidianPatchActiveTool } from './patch_active' +export { patchNoteTool as obsidianPatchNoteTool } from './patch_note' +export { searchTool as obsidianSearchTool } from './search' +export * from './types' diff --git a/apps/sim/tools/obsidian/list_commands.ts b/apps/sim/tools/obsidian/list_commands.ts new file mode 100644 index 00000000000..71394db09d0 --- /dev/null +++ b/apps/sim/tools/obsidian/list_commands.ts @@ -0,0 +1,68 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianListCommandsParams, ObsidianListCommandsResponse } from './types' + +export const listCommandsTool: ToolConfig< + ObsidianListCommandsParams, + ObsidianListCommandsResponse +> = { + id: 'obsidian_list_commands', + name: 'Obsidian List Commands', + description: 'List all available commands in Obsidian', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/commands/` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to list commands: ${error.message ?? response.statusText}`) + } + const data = await response.json() + return { + success: true, + output: { + commands: + data.commands?.map((cmd: { id: string; name: string }) => ({ + id: cmd.id ?? '', + name: cmd.name ?? '', + })) ?? [], + }, + } + }, + + outputs: { + commands: { + type: 'json', + description: 'List of available commands with IDs and names', + properties: { + id: { type: 'string', description: 'Command identifier' }, + name: { type: 'string', description: 'Human-readable command name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/obsidian/list_files.ts b/apps/sim/tools/obsidian/list_files.ts new file mode 100644 index 00000000000..6c83880c14a --- /dev/null +++ b/apps/sim/tools/obsidian/list_files.ts @@ -0,0 +1,76 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianListFilesParams, ObsidianListFilesResponse } from './types' + +export const listFilesTool: ToolConfig = { + id: 'obsidian_list_files', + name: 'Obsidian List Files', + description: 'List files and directories in your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + path: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Directory path relative to vault root. Leave empty to list root.', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + const path = params.path + ? `/${params.path.trim().split('/').map(encodeURIComponent).join('/')}/` + : '/' + return `${base}/vault${path}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to list files: ${error.message ?? response.statusText}`) + } + const data = await response.json() + return { + success: true, + output: { + files: + data.files?.map((f: string | { path: string; type: string }) => { + if (typeof f === 'string') { + return { path: f, type: f.endsWith('/') ? 'directory' : 'file' } + } + return { path: f.path ?? '', type: f.type ?? 'file' } + }) ?? [], + }, + } + }, + + outputs: { + files: { + type: 'json', + description: 'List of files and directories', + properties: { + path: { type: 'string', description: 'File or directory path' }, + type: { type: 'string', description: 'Whether the entry is a file or directory' }, + }, + }, + }, +} diff --git a/apps/sim/tools/obsidian/open_file.ts b/apps/sim/tools/obsidian/open_file.ts new file mode 100644 index 00000000000..4100ce1025e --- /dev/null +++ b/apps/sim/tools/obsidian/open_file.ts @@ -0,0 +1,73 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianOpenFileParams, ObsidianOpenFileResponse } from './types' + +export const openFileTool: ToolConfig = { + id: 'obsidian_open_file', + name: 'Obsidian Open File', + description: 'Open a file in the Obsidian UI (creates the file if it does not exist)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to the file relative to vault root', + }, + newLeaf: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to open the file in a new leaf/tab', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + const leafParam = params.newLeaf ? '?newLeaf=true' : '' + return `${base}/open/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}${leafParam}` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to open file: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + filename: params?.filename ?? '', + opened: true, + }, + } + }, + + outputs: { + filename: { + type: 'string', + description: 'Path of the opened file', + }, + opened: { + type: 'boolean', + description: 'Whether the file was successfully opened', + }, + }, +} diff --git a/apps/sim/tools/obsidian/patch_active.ts b/apps/sim/tools/obsidian/patch_active.ts new file mode 100644 index 00000000000..ae72a71218b --- /dev/null +++ b/apps/sim/tools/obsidian/patch_active.ts @@ -0,0 +1,107 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianPatchActiveParams, ObsidianPatchActiveResponse } from './types' + +export const patchActiveTool: ToolConfig = { + id: 'obsidian_patch_active', + name: 'Obsidian Patch Active File', + description: + 'Insert or replace content at a specific heading, block reference, or frontmatter field in the active file', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content to insert at the target location', + }, + operation: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'How to insert content: append, prepend, or replace', + }, + targetType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of target: heading, block, or frontmatter', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Target identifier (heading text, block reference ID, or frontmatter field name)', + }, + targetDelimiter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Delimiter for nested headings (default: "::")', + }, + trimTargetWhitespace: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to trim whitespace from target before matching (default: false)', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/active/` + }, + method: 'PATCH', + headers: (params) => { + const headers: Record = { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + Operation: params.operation, + 'Target-Type': params.targetType, + Target: encodeURIComponent(params.target), + } + if (params.targetDelimiter) { + headers['Target-Delimiter'] = params.targetDelimiter + } + if (params.trimTargetWhitespace) { + headers['Trim-Target-Whitespace'] = 'true' + } + return headers + }, + body: (params) => params.content, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to patch active file: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + patched: true, + }, + } + }, + + outputs: { + patched: { + type: 'boolean', + description: 'Whether the active file was successfully patched', + }, + }, +} diff --git a/apps/sim/tools/obsidian/patch_note.ts b/apps/sim/tools/obsidian/patch_note.ts new file mode 100644 index 00000000000..12013d8b77f --- /dev/null +++ b/apps/sim/tools/obsidian/patch_note.ts @@ -0,0 +1,118 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianPatchNoteParams, ObsidianPatchNoteResponse } from './types' + +export const patchNoteTool: ToolConfig = { + id: 'obsidian_patch_note', + name: 'Obsidian Patch Note', + description: + 'Insert or replace content at a specific heading, block reference, or frontmatter field in a note', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + filename: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Path to the note relative to vault root (e.g. "folder/note.md")', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Content to insert at the target location', + }, + operation: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'How to insert content: append, prepend, or replace', + }, + targetType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Type of target: heading, block, or frontmatter', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Target identifier (heading text, block reference ID, or frontmatter field name)', + }, + targetDelimiter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Delimiter for nested headings (default: "::")', + }, + trimTargetWhitespace: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to trim whitespace from target before matching (default: false)', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + return `${base}/vault/${params.filename.trim().split('/').map(encodeURIComponent).join('/')}` + }, + method: 'PATCH', + headers: (params) => { + const headers: Record = { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'text/markdown', + Operation: params.operation, + 'Target-Type': params.targetType, + Target: encodeURIComponent(params.target), + } + if (params.targetDelimiter) { + headers['Target-Delimiter'] = params.targetDelimiter + } + if (params.trimTargetWhitespace) { + headers['Trim-Target-Whitespace'] = 'true' + } + return headers + }, + body: (params) => params.content, + }, + + transformResponse: async (response, params) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Failed to patch note: ${error.message ?? response.statusText}`) + } + return { + success: true, + output: { + filename: params?.filename ?? '', + patched: true, + }, + } + }, + + outputs: { + filename: { + type: 'string', + description: 'Path of the patched note', + }, + patched: { + type: 'boolean', + description: 'Whether the note was successfully patched', + }, + }, +} diff --git a/apps/sim/tools/obsidian/search.ts b/apps/sim/tools/obsidian/search.ts new file mode 100644 index 00000000000..72551697f6c --- /dev/null +++ b/apps/sim/tools/obsidian/search.ts @@ -0,0 +1,95 @@ +import type { ToolConfig } from '@/tools/types' +import type { ObsidianSearchParams, ObsidianSearchResponse } from './types' + +export const searchTool: ToolConfig = { + id: 'obsidian_search', + name: 'Obsidian Search', + description: 'Search for text across notes in your Obsidian vault', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API key from Obsidian Local REST API plugin settings', + }, + baseUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Base URL for the Obsidian Local REST API', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Text to search for across vault notes', + }, + contextLength: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of characters of context around each match (default: 100)', + }, + }, + + request: { + url: (params) => { + const base = params.baseUrl.replace(/\/$/, '') + const contextParam = params.contextLength ? `&contextLength=${params.contextLength}` : '' + return `${base}/search/simple/?query=${encodeURIComponent(params.query)}${contextParam}` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(`Search failed: ${error.message ?? response.statusText}`) + } + const data = await response.json() + return { + success: true, + output: { + results: + data?.map( + (item: { + filename: string + score: number + matches: Array<{ match: { start: number; end: number }; context: string }> + }) => ({ + filename: item.filename ?? '', + score: item.score ?? 0, + matches: + item.matches?.map((m: { context: string }) => ({ + context: m.context ?? '', + })) ?? [], + }) + ) ?? [], + }, + } + }, + + outputs: { + results: { + type: 'json', + description: 'Search results with filenames, scores, and matching contexts', + properties: { + filename: { type: 'string', description: 'Path to the matching note' }, + score: { type: 'number', description: 'Relevance score' }, + matches: { + type: 'json', + description: 'Matching text contexts', + properties: { + context: { type: 'string', description: 'Text surrounding the match' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/obsidian/types.ts b/apps/sim/tools/obsidian/types.ts new file mode 100644 index 00000000000..6fe9203414a --- /dev/null +++ b/apps/sim/tools/obsidian/types.ts @@ -0,0 +1,190 @@ +import type { ToolResponse } from '@/tools/types' + +export interface ObsidianBaseParams { + apiKey: string + baseUrl: string +} + +export interface ObsidianListFilesParams extends ObsidianBaseParams { + path?: string +} + +export interface ObsidianListFilesResponse extends ToolResponse { + output: { + files: Array<{ + path: string + type: string + }> + } +} + +export interface ObsidianGetNoteParams extends ObsidianBaseParams { + filename: string +} + +export interface ObsidianGetNoteResponse extends ToolResponse { + output: { + content: string + filename: string + } +} + +export interface ObsidianCreateNoteParams extends ObsidianBaseParams { + filename: string + content: string +} + +export interface ObsidianCreateNoteResponse extends ToolResponse { + output: { + filename: string + created: boolean + } +} + +export interface ObsidianAppendNoteParams extends ObsidianBaseParams { + filename: string + content: string +} + +export interface ObsidianAppendNoteResponse extends ToolResponse { + output: { + filename: string + appended: boolean + } +} + +export interface ObsidianPatchNoteParams extends ObsidianBaseParams { + filename: string + content: string + operation: string + targetType: string + target: string + targetDelimiter?: string + trimTargetWhitespace?: boolean +} + +export interface ObsidianPatchNoteResponse extends ToolResponse { + output: { + filename: string + patched: boolean + } +} + +export interface ObsidianDeleteNoteParams extends ObsidianBaseParams { + filename: string +} + +export interface ObsidianDeleteNoteResponse extends ToolResponse { + output: { + filename: string + deleted: boolean + } +} + +export interface ObsidianSearchParams extends ObsidianBaseParams { + query: string + contextLength?: number +} + +export interface ObsidianSearchResponse extends ToolResponse { + output: { + results: Array<{ + filename: string + score: number + matches: Array<{ + context: string + }> + }> + } +} + +export interface ObsidianGetActiveParams extends ObsidianBaseParams {} + +export interface ObsidianGetActiveResponse extends ToolResponse { + output: { + content: string + filename: string | null + } +} + +export interface ObsidianAppendActiveParams extends ObsidianBaseParams { + content: string +} + +export interface ObsidianAppendActiveResponse extends ToolResponse { + output: { + appended: boolean + } +} + +export interface ObsidianPatchActiveParams extends ObsidianBaseParams { + content: string + operation: string + targetType: string + target: string + targetDelimiter?: string + trimTargetWhitespace?: boolean +} + +export interface ObsidianPatchActiveResponse extends ToolResponse { + output: { + patched: boolean + } +} + +export interface ObsidianListCommandsParams extends ObsidianBaseParams {} + +export interface ObsidianListCommandsResponse extends ToolResponse { + output: { + commands: Array<{ + id: string + name: string + }> + } +} + +export interface ObsidianExecuteCommandParams extends ObsidianBaseParams { + commandId: string +} + +export interface ObsidianExecuteCommandResponse extends ToolResponse { + output: { + commandId: string + executed: boolean + } +} + +export interface ObsidianOpenFileParams extends ObsidianBaseParams { + filename: string + newLeaf?: boolean +} + +export interface ObsidianOpenFileResponse extends ToolResponse { + output: { + filename: string + opened: boolean + } +} + +export interface ObsidianGetPeriodicNoteParams extends ObsidianBaseParams { + period: string +} + +export interface ObsidianGetPeriodicNoteResponse extends ToolResponse { + output: { + content: string + period: string + } +} + +export interface ObsidianAppendPeriodicNoteParams extends ObsidianBaseParams { + period: string + content: string +} + +export interface ObsidianAppendPeriodicNoteResponse extends ToolResponse { + output: { + period: string + appended: boolean + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3539724f68d..dfc422e670b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -426,6 +426,19 @@ import { enrichSearchSimilarCompaniesTool, enrichVerifyEmailTool, } from '@/tools/enrich' +import { + evernoteCopyNoteTool, + evernoteCreateNotebookTool, + evernoteCreateNoteTool, + evernoteCreateTagTool, + evernoteDeleteNoteTool, + evernoteGetNotebookTool, + evernoteGetNoteTool, + evernoteListNotebooksTool, + evernoteListTagsTool, + evernoteSearchNotesTool, + evernoteUpdateNoteTool, +} from '@/tools/evernote' import { exaAnswerTool, exaFindSimilarLinksTool, @@ -1450,6 +1463,23 @@ import { notionWriteTool, notionWriteV2Tool, } from '@/tools/notion' +import { + obsidianAppendActiveTool, + obsidianAppendNoteTool, + obsidianAppendPeriodicNoteTool, + obsidianCreateNoteTool, + obsidianDeleteNoteTool, + obsidianExecuteCommandTool, + obsidianGetActiveTool, + obsidianGetNoteTool, + obsidianGetPeriodicNoteTool, + obsidianListCommandsTool, + obsidianListFilesTool, + obsidianOpenFileTool, + obsidianPatchActiveTool, + obsidianPatchNoteTool, + obsidianSearchTool, +} from '@/tools/obsidian' import { onedriveCreateFolderTool, onedriveDeleteTool, @@ -2739,6 +2769,21 @@ export const tools: Record = { notion_create_database_v2: notionCreateDatabaseV2Tool, notion_update_page_v2: notionUpdatePageV2Tool, notion_add_database_row_v2: notionAddDatabaseRowTool, + obsidian_append_active: obsidianAppendActiveTool, + obsidian_append_note: obsidianAppendNoteTool, + obsidian_append_periodic_note: obsidianAppendPeriodicNoteTool, + obsidian_create_note: obsidianCreateNoteTool, + obsidian_delete_note: obsidianDeleteNoteTool, + obsidian_execute_command: obsidianExecuteCommandTool, + obsidian_get_active: obsidianGetActiveTool, + obsidian_get_note: obsidianGetNoteTool, + obsidian_get_periodic_note: obsidianGetPeriodicNoteTool, + obsidian_list_commands: obsidianListCommandsTool, + obsidian_list_files: obsidianListFilesTool, + obsidian_open_file: obsidianOpenFileTool, + obsidian_patch_active: obsidianPatchActiveTool, + obsidian_patch_note: obsidianPatchNoteTool, + obsidian_search: obsidianSearchTool, onepassword_list_vaults: onepasswordListVaultsTool, onepassword_get_vault: onepasswordGetVaultTool, onepassword_list_items: onepasswordListItemsTool, @@ -3122,6 +3167,17 @@ export const tools: Record = { elasticsearch_list_indices: elasticsearchListIndicesTool, elasticsearch_cluster_health: elasticsearchClusterHealthTool, elasticsearch_cluster_stats: elasticsearchClusterStatsTool, + evernote_copy_note: evernoteCopyNoteTool, + evernote_create_note: evernoteCreateNoteTool, + evernote_create_notebook: evernoteCreateNotebookTool, + evernote_create_tag: evernoteCreateTagTool, + evernote_delete_note: evernoteDeleteNoteTool, + evernote_get_note: evernoteGetNoteTool, + evernote_get_notebook: evernoteGetNotebookTool, + evernote_list_notebooks: evernoteListNotebooksTool, + evernote_list_tags: evernoteListTagsTool, + evernote_search_notes: evernoteSearchNotesTool, + evernote_update_note: evernoteUpdateNoteTool, enrich_check_credits: enrichCheckCreditsTool, enrich_company_funding: enrichCompanyFundingTool, enrich_company_lookup: enrichCompanyLookupTool,