Repository: rowboatlabs/rowboat Branch: main Commit: 983a4c578f61 Files: 893 Total size: 4.6 MB Directory structure: gitextract_kg5bbks8/ ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── electron-build.yml │ ├── rowboat-build.yml │ └── x-publish.yml ├── .gitignore ├── CLAUDE.md ├── Dockerfile.qdrant ├── LICENSE ├── README.md ├── apps/ │ ├── cli/ │ │ ├── .gitignore │ │ ├── bin/ │ │ │ └── app.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agents/ │ │ │ │ ├── agents.ts │ │ │ │ ├── repo.ts │ │ │ │ └── runtime.ts │ │ │ ├── app.ts │ │ │ ├── application/ │ │ │ │ ├── assistant/ │ │ │ │ │ ├── agent.ts │ │ │ │ │ ├── instructions.ts │ │ │ │ │ ├── runtime-context.ts │ │ │ │ │ └── skills/ │ │ │ │ │ ├── builtin-tools/ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ ├── deletion-guardrails/ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mcp-integration/ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ ├── workflow-authoring/ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ └── workflow-run-ops/ │ │ │ │ │ └── skill.ts │ │ │ │ └── lib/ │ │ │ │ ├── builtin-tools.ts │ │ │ │ ├── bus.ts │ │ │ │ ├── command-executor.ts │ │ │ │ ├── exec-tool.ts │ │ │ │ ├── id-gen.ts │ │ │ │ ├── message-queue.ts │ │ │ │ ├── random-id.ts │ │ │ │ └── stream-renderer.ts │ │ │ ├── config/ │ │ │ │ ├── config.ts │ │ │ │ └── security.ts │ │ │ ├── di/ │ │ │ │ └── container.ts │ │ │ ├── entities/ │ │ │ │ ├── example.ts │ │ │ │ ├── llm-step-events.ts │ │ │ │ ├── message.ts │ │ │ │ └── run-events.ts │ │ │ ├── examples/ │ │ │ │ ├── index.ts │ │ │ │ └── twitter-podcast.json │ │ │ ├── knowledge/ │ │ │ │ ├── sync_calendar.ts │ │ │ │ └── sync_gmail.ts │ │ │ ├── mcp/ │ │ │ │ ├── mcp.ts │ │ │ │ ├── repo.ts │ │ │ │ └── schema.ts │ │ │ ├── models/ │ │ │ │ ├── models.ts │ │ │ │ └── repo.ts │ │ │ ├── runs/ │ │ │ │ ├── lock.ts │ │ │ │ ├── repo.ts │ │ │ │ └── runs.ts │ │ │ ├── scripts/ │ │ │ │ └── migrate-agents.ts │ │ │ ├── server.ts │ │ │ ├── shared/ │ │ │ │ └── prefix-logger.ts │ │ │ └── tui/ │ │ │ ├── api.ts │ │ │ ├── index.tsx │ │ │ └── ui.tsx │ │ ├── todo.md │ │ └── tsconfig.json │ ├── docs/ │ │ ├── .gitignore │ │ ├── docs/ │ │ │ ├── development/ │ │ │ │ ├── contribution-guide.mdx │ │ │ │ └── roadmap.mdx │ │ │ └── getting-started/ │ │ │ ├── introduction.mdx │ │ │ ├── license.mdx │ │ │ └── quickstart.mdx │ │ └── docs.json │ ├── experimental/ │ │ ├── chat_widget/ │ │ │ ├── .dockerignore │ │ │ ├── .eslintrc.json │ │ │ ├── .gitignore │ │ │ ├── Dockerfile │ │ │ ├── README.md │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ └── bootstrap.js/ │ │ │ │ │ └── route.ts │ │ │ │ ├── app.tsx │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ ├── markdown-content.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── providers.tsx │ │ │ ├── next.config.mjs │ │ │ ├── package.json │ │ │ ├── postcss.config.mjs │ │ │ ├── public/ │ │ │ │ └── bootstrap.template.js │ │ │ ├── tailwind.config.ts │ │ │ └── tsconfig.json │ │ ├── simulation_runner/ │ │ │ ├── Dockerfile │ │ │ ├── __init__.py │ │ │ ├── db.py │ │ │ ├── requirements.txt │ │ │ ├── scenario_types.py │ │ │ ├── service.py │ │ │ └── simulation.py │ │ └── tools_webhook/ │ │ ├── Dockerfile │ │ ├── __init__.py │ │ ├── app.py │ │ ├── function_map.py │ │ ├── requirements.txt │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ ├── test_app.py │ │ │ └── test_tool_caller.py │ │ └── tool_caller.py │ ├── python-sdk/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── pyproject.toml │ │ ├── requirements.txt │ │ └── src/ │ │ └── rowboat/ │ │ ├── __init__.py │ │ ├── client.py │ │ └── schema.py │ ├── rowboat/ │ │ ├── .dockerignore │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── app/ │ │ │ ├── actions/ │ │ │ │ ├── assistant-templates.actions.ts │ │ │ │ ├── auth.actions.ts │ │ │ │ ├── billing.actions.ts │ │ │ │ ├── composio.actions.ts │ │ │ │ ├── conversation.actions.ts │ │ │ │ ├── copilot.actions.ts │ │ │ │ ├── custom-mcp-server.actions.ts │ │ │ │ ├── data-source.actions.ts │ │ │ │ ├── job.actions.ts │ │ │ │ ├── playground-chat.actions.ts │ │ │ │ ├── project.actions.ts │ │ │ │ ├── recurring-job-rules.actions.ts │ │ │ │ ├── scheduled-job-rules.actions.ts │ │ │ │ ├── shared-workflow.actions.ts │ │ │ │ └── twilio.actions.ts │ │ │ ├── api/ │ │ │ │ ├── composio/ │ │ │ │ │ └── webhook/ │ │ │ │ │ └── route.ts │ │ │ │ ├── copilot-stream-response/ │ │ │ │ │ └── [streamId]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── generated-images/ │ │ │ │ │ └── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── me/ │ │ │ │ │ └── route.ts │ │ │ │ ├── stream-response/ │ │ │ │ │ └── [streamId]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── tmp-images/ │ │ │ │ │ └── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── twilio/ │ │ │ │ │ ├── inbound_call/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── turn/ │ │ │ │ │ │ └── [callSid]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── uploads/ │ │ │ │ │ └── [fileId]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── v1/ │ │ │ │ │ └── [projectId]/ │ │ │ │ │ └── chat/ │ │ │ │ │ └── route.ts │ │ │ │ └── widget/ │ │ │ │ └── v1/ │ │ │ │ ├── chats/ │ │ │ │ │ ├── [chatId]/ │ │ │ │ │ │ ├── close/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── messages/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── turn/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── session/ │ │ │ │ │ ├── guest/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── user/ │ │ │ │ │ └── route.ts │ │ │ │ └── utils.ts │ │ │ ├── app.tsx │ │ │ ├── billing/ │ │ │ │ ├── app.tsx │ │ │ │ ├── callback/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── browserconfig.xml │ │ │ ├── components/ │ │ │ │ └── ui/ │ │ │ │ └── textarea-with-send.tsx │ │ │ ├── composio/ │ │ │ │ └── oauth2/ │ │ │ │ └── callback/ │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── hero.ts │ │ │ ├── layout.tsx │ │ │ ├── lib/ │ │ │ │ ├── assistant_templates_seed.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── auth0.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── client_utils.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── atmentions.ts │ │ │ │ │ ├── datasource-icon.tsx │ │ │ │ │ ├── dropdown.tsx │ │ │ │ │ ├── form-section.tsx │ │ │ │ │ ├── form-status-button-old.tsx │ │ │ │ │ ├── form-status-button.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ ├── input-field.tsx │ │ │ │ │ ├── label.tsx │ │ │ │ │ ├── markdown-content.tsx │ │ │ │ │ ├── mentions-editor.css │ │ │ │ │ ├── mentions_editor.tsx │ │ │ │ │ ├── menu-item.tsx │ │ │ │ │ ├── message-display.tsx │ │ │ │ │ ├── page-section.tsx │ │ │ │ │ ├── pagination.tsx │ │ │ │ │ ├── reason-badge.tsx │ │ │ │ │ ├── structured-list.tsx │ │ │ │ │ ├── structured-panel.tsx │ │ │ │ │ ├── typewriter.tsx │ │ │ │ │ └── user_button.tsx │ │ │ │ ├── default_tools.ts │ │ │ │ ├── embedding.ts │ │ │ │ ├── feature_flags.ts │ │ │ │ ├── loadenv.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── mongodb.ts │ │ │ │ ├── prebuilt-cards/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── customer-support.json │ │ │ │ │ ├── eisenhower-email-organizer.json │ │ │ │ │ ├── github-data-to-spreadsheet.json │ │ │ │ │ ├── github-issue-to-slack.json │ │ │ │ │ ├── github-pr-to-slack.json │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interview-scheduler.json │ │ │ │ │ ├── meeting-prep-assistant.json │ │ │ │ │ ├── reddit-on-slack.json │ │ │ │ │ ├── tweet-assistant.json │ │ │ │ │ └── twitter-sentiment.json │ │ │ │ ├── project_templates.ts │ │ │ │ ├── qdrant.ts │ │ │ │ ├── redis.ts │ │ │ │ ├── types/ │ │ │ │ │ ├── api_types.ts │ │ │ │ │ ├── billing_types.ts │ │ │ │ │ ├── datasource_types.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── voice_types.ts │ │ │ │ │ └── workflow_types.ts │ │ │ │ ├── uploads_s3_client.ts │ │ │ │ └── utils.ts │ │ │ ├── loading.tsx │ │ │ ├── new-chat-link.tsx │ │ │ ├── onboarding/ │ │ │ │ ├── app.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── projects/ │ │ │ │ ├── [projectId]/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── app.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── project.tsx │ │ │ │ │ │ │ ├── shared-styles.ts │ │ │ │ │ │ │ └── voice.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── conversations/ │ │ │ │ │ │ ├── [conversationId]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── conversation-view.tsx │ │ │ │ │ │ │ └── conversations-list.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── copilot/ │ │ │ │ │ │ ├── app.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── TriggerSetupModal.tsx │ │ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ │ │ ├── messages.tsx │ │ │ │ │ │ │ └── use-trigger-actions.ts │ │ │ │ │ │ ├── example.md │ │ │ │ │ │ ├── use-copilot.tsx │ │ │ │ │ │ └── use-parsed-blocks.tsx │ │ │ │ │ ├── entities/ │ │ │ │ │ │ ├── AgentGraphVisualizer.tsx │ │ │ │ │ │ ├── agent_config.tsx │ │ │ │ │ │ ├── datasource_config.tsx │ │ │ │ │ │ ├── pipeline_config.tsx │ │ │ │ │ │ ├── prompt_config.tsx │ │ │ │ │ │ └── tool_config.tsx │ │ │ │ │ ├── jobs/ │ │ │ │ │ │ ├── [jobId]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── job-view.tsx │ │ │ │ │ │ │ └── jobs-list.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── manage-triggers/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── composio-trigger-deployment-view.tsx │ │ │ │ │ │ │ ├── create-recurring-job-rule-form.tsx │ │ │ │ │ │ │ ├── job-rules-tabs.tsx │ │ │ │ │ │ │ ├── recurring-job-rule-view.tsx │ │ │ │ │ │ │ ├── recurring-job-rules-list.tsx │ │ │ │ │ │ │ └── triggers-tab.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── recurring/ │ │ │ │ │ │ │ ├── [ruleId]/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── new/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── scheduled/ │ │ │ │ │ │ │ ├── [ruleId]/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── create-scheduled-job-rule-form.tsx │ │ │ │ │ │ │ │ ├── scheduled-job-rule-view.tsx │ │ │ │ │ │ │ │ └── scheduled-job-rules-list.tsx │ │ │ │ │ │ │ └── new/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── triggers/ │ │ │ │ │ │ └── [deploymentId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── playground/ │ │ │ │ │ │ ├── app.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── chat.tsx │ │ │ │ │ │ │ ├── feedback-modal.tsx │ │ │ │ │ │ │ ├── messages.tsx │ │ │ │ │ │ │ └── profile-context-box.tsx │ │ │ │ │ │ └── copilot-prompts.ts │ │ │ │ │ ├── sources/ │ │ │ │ │ │ ├── [sourceId]/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── source-page.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── delete.tsx │ │ │ │ │ │ │ ├── files-source.tsx │ │ │ │ │ │ │ ├── scrape-source.tsx │ │ │ │ │ │ │ ├── section.tsx │ │ │ │ │ │ │ ├── self-updating-source-status.tsx │ │ │ │ │ │ │ ├── shared.tsx │ │ │ │ │ │ │ ├── source-status.tsx │ │ │ │ │ │ │ ├── sources-list.tsx │ │ │ │ │ │ │ ├── text-source.tsx │ │ │ │ │ │ │ ├── toggle-source.tsx │ │ │ │ │ │ │ └── web-recrawl.tsx │ │ │ │ │ │ ├── new/ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── tools/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── AddWebhookTool.tsx │ │ │ │ │ │ │ ├── ComposioToolsPanel.tsx │ │ │ │ │ │ │ ├── CustomMcpServer.tsx │ │ │ │ │ │ │ ├── MCPServersCommon.tsx │ │ │ │ │ │ │ ├── McpToolsPanel.tsx │ │ │ │ │ │ │ ├── SelectComposioToolkit.tsx │ │ │ │ │ │ │ ├── ServerCard.tsx │ │ │ │ │ │ │ ├── ToolkitAuthModal.tsx │ │ │ │ │ │ │ ├── ToolkitCard.tsx │ │ │ │ │ │ │ ├── ToolsConfig.tsx │ │ │ │ │ │ │ └── WebhookConfig.tsx │ │ │ │ │ │ └── oauth/ │ │ │ │ │ │ └── callback/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── workflow/ │ │ │ │ │ ├── app.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ComposioTriggerTypesPanel.tsx │ │ │ │ │ │ ├── DataSourcesModal.tsx │ │ │ │ │ │ ├── ToolsModal.tsx │ │ │ │ │ │ ├── TopBar.tsx │ │ │ │ │ │ └── TriggerConfigForm.tsx │ │ │ │ │ ├── config_list.tsx │ │ │ │ │ ├── entity_list.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── pane.tsx │ │ │ │ │ ├── preview-modal.tsx │ │ │ │ │ ├── trigger-transform.ts │ │ │ │ │ └── workflow_editor.tsx │ │ │ │ ├── app.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── build-assistant-section.tsx │ │ │ │ │ ├── create-project.tsx │ │ │ │ │ ├── custom-prompt-card.tsx │ │ │ │ │ ├── project-list.tsx │ │ │ │ │ ├── search-input.tsx │ │ │ │ │ ├── search-projects.tsx │ │ │ │ │ ├── submit-button.tsx │ │ │ │ │ └── templates-section.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── app-layout.tsx │ │ │ │ │ │ ├── menu-item.tsx │ │ │ │ │ │ └── sidebar.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── menu.tsx │ │ │ │ │ └── nav.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── lib/ │ │ │ │ │ └── project-creation-utils.ts │ │ │ │ └── page.tsx │ │ │ ├── providers/ │ │ │ │ ├── help-modal-provider.tsx │ │ │ │ └── theme-provider.tsx │ │ │ ├── providers.tsx │ │ │ ├── scripts/ │ │ │ │ ├── delete_qdrant.ts │ │ │ │ ├── job-rules.worker.ts │ │ │ │ ├── jobs-worker.ts │ │ │ │ ├── mongodb-drop-indexes.ts │ │ │ │ ├── mongodb-ensure-indexes.ts │ │ │ │ ├── rag-worker.ts │ │ │ │ └── setup_qdrant.ts │ │ │ ├── site.webmanifest │ │ │ └── styles/ │ │ │ ├── design-tokens.ts │ │ │ ├── pane-effects.ts │ │ │ └── quill-mentions.css │ │ ├── components/ │ │ │ ├── common/ │ │ │ │ ├── AssistantCard.tsx │ │ │ │ ├── AssistantSection.tsx │ │ │ │ ├── UnifiedTemplatesSection.tsx │ │ │ │ ├── billing-upgrade-modal.tsx │ │ │ │ ├── compose-box-copilot.tsx │ │ │ │ ├── compose-box-playground.tsx │ │ │ │ ├── compose-box.tsx │ │ │ │ ├── copy-as-json-button.tsx │ │ │ │ ├── copy-button.tsx │ │ │ │ ├── help-modal.tsx │ │ │ │ ├── panel-common.tsx │ │ │ │ ├── product-tour.tsx │ │ │ │ ├── project-wide-change-confirmation-modal.tsx │ │ │ │ ├── section-card.tsx │ │ │ │ └── tool-param-card.tsx │ │ │ └── ui/ │ │ │ ├── button.tsx │ │ │ ├── dropdown.tsx │ │ │ ├── horizontal-divider.tsx │ │ │ ├── input.tsx │ │ │ ├── modal.tsx │ │ │ ├── page-header.tsx │ │ │ ├── page-heading.tsx │ │ │ ├── picture-img.tsx │ │ │ ├── progress-bar.tsx │ │ │ ├── resizable.tsx │ │ │ ├── search-bar.tsx │ │ │ ├── section-heading.tsx │ │ │ ├── slide-panel.tsx │ │ │ ├── switch.tsx │ │ │ ├── tabs.tsx │ │ │ └── textarea.tsx │ │ ├── components.json │ │ ├── di/ │ │ │ └── container.ts │ │ ├── hooks/ │ │ │ └── use-click-away.ts │ │ ├── instrumentation-client.ts │ │ ├── lib/ │ │ │ ├── utils/ │ │ │ │ └── date.ts │ │ │ └── utils.ts │ │ ├── middleware.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── scripts.Dockerfile │ │ ├── src/ │ │ │ ├── application/ │ │ │ │ ├── lib/ │ │ │ │ │ ├── agents-runtime/ │ │ │ │ │ │ ├── agent-handoffs.ts │ │ │ │ │ │ ├── agent-tools.ts │ │ │ │ │ │ ├── agent_instructions.ts │ │ │ │ │ │ ├── agents.ts │ │ │ │ │ │ └── pipeline-state-manager.ts │ │ │ │ │ ├── composio/ │ │ │ │ │ │ ├── composio.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── copilot/ │ │ │ │ │ │ ├── copilot.ts │ │ │ │ │ │ ├── copilot_edit_agent.ts │ │ │ │ │ │ ├── copilot_multi_agent.ts │ │ │ │ │ │ ├── copilot_multi_agent_build.ts │ │ │ │ │ │ ├── current_workflow.ts │ │ │ │ │ │ └── example_multi_agent_1.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── is-valid-cron-expression.ts │ │ │ │ │ └── time-to-next-minute.ts │ │ │ │ ├── policies/ │ │ │ │ │ ├── project-action-authorization.policy.ts │ │ │ │ │ └── usage-quota.policy.interface.ts │ │ │ │ ├── repositories/ │ │ │ │ │ ├── api-keys.repository.interface.ts │ │ │ │ │ ├── composio-trigger-deployments.repository.interface.ts │ │ │ │ │ ├── conversations.repository.interface.ts │ │ │ │ │ ├── data-source-docs.repository.interface.ts │ │ │ │ │ ├── data-sources.repository.interface.ts │ │ │ │ │ ├── jobs.repository.interface.ts │ │ │ │ │ ├── project-members.repository.interface.ts │ │ │ │ │ ├── projects.repository.interface.ts │ │ │ │ │ ├── recurring-job-rules.repository.interface.ts │ │ │ │ │ ├── scheduled-job-rules.repository.interface.ts │ │ │ │ │ └── users.repository.interface.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── cache.service.interface.ts │ │ │ │ │ ├── pub-sub.service.interface.ts │ │ │ │ │ ├── temp-binary-cache.ts │ │ │ │ │ └── uploads-storage.service.interface.ts │ │ │ │ ├── use-cases/ │ │ │ │ │ ├── api-keys/ │ │ │ │ │ │ ├── create-api-key.use-case.ts │ │ │ │ │ │ ├── delete-api-key.use-case.ts │ │ │ │ │ │ └── list-api-keys.use-case.ts │ │ │ │ │ ├── composio/ │ │ │ │ │ │ └── webhook/ │ │ │ │ │ │ └── handle-composio-webhook-request.use-case.ts │ │ │ │ │ ├── composio-trigger-deployments/ │ │ │ │ │ │ ├── create-composio-trigger-deployment.use-case.ts │ │ │ │ │ │ ├── delete-composio-trigger-deployment.use-case.ts │ │ │ │ │ │ ├── fetch-composio-trigger-deployment.use-case.ts │ │ │ │ │ │ ├── list-composio-trigger-deployments.use-case.ts │ │ │ │ │ │ └── list-composio-trigger-types.use-case.ts │ │ │ │ │ ├── conversations/ │ │ │ │ │ │ ├── create-cached-turn.use-case.ts │ │ │ │ │ │ ├── create-conversation.use-case.ts │ │ │ │ │ │ ├── fetch-cached-turn.use-case.ts │ │ │ │ │ │ ├── fetch-conversation.use-case.ts │ │ │ │ │ │ ├── list-conversations.use-case.ts │ │ │ │ │ │ └── run-conversation-turn.use-case.ts │ │ │ │ │ ├── copilot/ │ │ │ │ │ │ ├── create-copilot-cached-turn.use-case.ts │ │ │ │ │ │ └── run-copilot-cached-turn.use-case.ts │ │ │ │ │ ├── data-sources/ │ │ │ │ │ │ ├── add-docs-to-data-source.use-case.ts │ │ │ │ │ │ ├── create-data-source.use-case.ts │ │ │ │ │ │ ├── delete-data-source.use-case.ts │ │ │ │ │ │ ├── delete-doc-from-data-source.use-case.ts │ │ │ │ │ │ ├── fetch-data-source.use-case.ts │ │ │ │ │ │ ├── get-download-url-for-file.use-case.ts │ │ │ │ │ │ ├── get-upload-urls-for-files.use-case.ts │ │ │ │ │ │ ├── list-data-sources.use-case.ts │ │ │ │ │ │ ├── list-docs-in-data-source.use-case.ts │ │ │ │ │ │ ├── recrawl-web-data-source.use-case.ts │ │ │ │ │ │ ├── toggle-data-source.use-case.ts │ │ │ │ │ │ └── update-data-source.use-case.ts │ │ │ │ │ ├── jobs/ │ │ │ │ │ │ ├── fetch-job.use-case.ts │ │ │ │ │ │ └── list-jobs.use-case.ts │ │ │ │ │ ├── projects/ │ │ │ │ │ │ ├── add-custom-mcp-server.use-case.ts │ │ │ │ │ │ ├── create-composio-managed-connected-account.use-case.ts │ │ │ │ │ │ ├── create-custom-connected-account.use-case.ts │ │ │ │ │ │ ├── create-project.use-case.ts │ │ │ │ │ │ ├── delete-composio-connected-account.use-case.ts │ │ │ │ │ │ ├── delete-project.use-case.ts │ │ │ │ │ │ ├── fetch-project.use-case.ts │ │ │ │ │ │ ├── get-composio-toolkit.use-case.ts │ │ │ │ │ │ ├── list-composio-toolkits.use-case.ts │ │ │ │ │ │ ├── list-composio-tools.use-case.ts │ │ │ │ │ │ ├── list-projects.use-case.ts │ │ │ │ │ │ ├── remove-custom-mcp-server.use-case.ts │ │ │ │ │ │ ├── revert-to-live-workflow.use-case.ts │ │ │ │ │ │ ├── rotate-secret.use-case.ts │ │ │ │ │ │ ├── sync-connected-account.use-case.ts │ │ │ │ │ │ ├── update-draft-workflow.use-case.ts │ │ │ │ │ │ ├── update-live-workflow.use-case.ts │ │ │ │ │ │ ├── update-project-name.use-case.ts │ │ │ │ │ │ └── update-webhook-url.use-case.ts │ │ │ │ │ ├── recurring-job-rules/ │ │ │ │ │ │ ├── create-recurring-job-rule.use-case.ts │ │ │ │ │ │ ├── delete-recurring-job-rule.use-case.ts │ │ │ │ │ │ ├── fetch-recurring-job-rule.use-case.ts │ │ │ │ │ │ ├── list-recurring-job-rules.use-case.ts │ │ │ │ │ │ ├── toggle-recurring-job-rule.use-case.ts │ │ │ │ │ │ └── update-recurring-job-rule.use-case.ts │ │ │ │ │ └── scheduled-job-rules/ │ │ │ │ │ ├── create-scheduled-job-rule.use-case.ts │ │ │ │ │ ├── delete-scheduled-job-rule.use-case.ts │ │ │ │ │ ├── fetch-scheduled-job-rule.use-case.ts │ │ │ │ │ ├── list-scheduled-job-rules.use-case.ts │ │ │ │ │ └── update-scheduled-job-rule.use-case.ts │ │ │ │ └── workers/ │ │ │ │ ├── job-rules.worker.ts │ │ │ │ └── jobs.worker.ts │ │ │ ├── entities/ │ │ │ │ ├── common/ │ │ │ │ │ └── paginated-list.ts │ │ │ │ ├── errors/ │ │ │ │ │ ├── common.ts │ │ │ │ │ └── job-errors.ts │ │ │ │ └── models/ │ │ │ │ ├── api-key.ts │ │ │ │ ├── assistant-template.ts │ │ │ │ ├── composio-trigger-deployment.ts │ │ │ │ ├── composio-trigger-type.ts │ │ │ │ ├── conversation.ts │ │ │ │ ├── copilot.ts │ │ │ │ ├── data-source-doc.ts │ │ │ │ ├── data-source.ts │ │ │ │ ├── job.ts │ │ │ │ ├── project-member.ts │ │ │ │ ├── project.ts │ │ │ │ ├── recurring-job-rule.ts │ │ │ │ ├── scheduled-job-rule.ts │ │ │ │ ├── turn.ts │ │ │ │ └── user.ts │ │ │ ├── infrastructure/ │ │ │ │ ├── mongodb/ │ │ │ │ │ ├── drop-indexes.ts │ │ │ │ │ └── ensure-indexes.ts │ │ │ │ ├── policies/ │ │ │ │ │ └── redis.usage-quota.policy.ts │ │ │ │ ├── repositories/ │ │ │ │ │ ├── mongodb.api-keys.indexes.ts │ │ │ │ │ ├── mongodb.api-keys.repository.ts │ │ │ │ │ ├── mongodb.assistant-templates.repository.ts │ │ │ │ │ ├── mongodb.community-assistants.indexes.ts │ │ │ │ │ ├── mongodb.composio-trigger-deployments.indexes.ts │ │ │ │ │ ├── mongodb.composio-trigger-deployments.repository.ts │ │ │ │ │ ├── mongodb.conversations.indexes.ts │ │ │ │ │ ├── mongodb.conversations.repository.ts │ │ │ │ │ ├── mongodb.data-source-docs.indexes.ts │ │ │ │ │ ├── mongodb.data-source-docs.repository.ts │ │ │ │ │ ├── mongodb.data-sources.indexes.ts │ │ │ │ │ ├── mongodb.data-sources.repository.ts │ │ │ │ │ ├── mongodb.jobs.indexes.ts │ │ │ │ │ ├── mongodb.jobs.repository.ts │ │ │ │ │ ├── mongodb.project-members.indexes.ts │ │ │ │ │ ├── mongodb.project-members.repository.ts │ │ │ │ │ ├── mongodb.projects.indexes.ts │ │ │ │ │ ├── mongodb.projects.repository.ts │ │ │ │ │ ├── mongodb.recurring-job-rules.indexes.ts │ │ │ │ │ ├── mongodb.recurring-job-rules.repository.ts │ │ │ │ │ ├── mongodb.scheduled-job-rules.indexes.ts │ │ │ │ │ ├── mongodb.scheduled-job-rules.repository.ts │ │ │ │ │ ├── mongodb.shared-workflows.indexes.ts │ │ │ │ │ ├── mongodb.users.indexes.ts │ │ │ │ │ └── mongodb.users.repository.ts │ │ │ │ └── services/ │ │ │ │ ├── local.uploads-storage.service.ts │ │ │ │ ├── redis.cache.service.ts │ │ │ │ ├── redis.pub-sub.service.ts │ │ │ │ └── s3.uploads-storage.service.ts │ │ │ └── interface-adapters/ │ │ │ └── controllers/ │ │ │ ├── api-keys/ │ │ │ │ ├── create-api-key.controller.ts │ │ │ │ ├── delete-api-key.controller.ts │ │ │ │ └── list-api-keys.controller.ts │ │ │ ├── composio/ │ │ │ │ └── webhook/ │ │ │ │ └── handle-composio-webhook-request.controller.ts │ │ │ ├── composio-trigger-deployments/ │ │ │ │ ├── create-composio-trigger-deployment.controller.ts │ │ │ │ ├── delete-composio-trigger-deployment.controller.ts │ │ │ │ ├── fetch-composio-trigger-deployment.controller.ts │ │ │ │ ├── list-composio-trigger-deployments.controller.ts │ │ │ │ └── list-composio-trigger-types.controller.ts │ │ │ ├── conversations/ │ │ │ │ ├── create-cached-turn.controller.ts │ │ │ │ ├── create-playground-conversation.controller.ts │ │ │ │ ├── fetch-conversation.controller.ts │ │ │ │ ├── list-conversations.controller.ts │ │ │ │ ├── run-cached-turn.controller.ts │ │ │ │ └── run-turn.controller.ts │ │ │ ├── copilot/ │ │ │ │ ├── create-copilot-cached-turn.controller.ts │ │ │ │ └── run-copilot-cached-turn.controller.ts │ │ │ ├── data-sources/ │ │ │ │ ├── add-docs-to-data-source.controller.ts │ │ │ │ ├── create-data-source.controller.ts │ │ │ │ ├── delete-data-source.controller.ts │ │ │ │ ├── delete-doc-from-data-source.controller.ts │ │ │ │ ├── fetch-data-source.controller.ts │ │ │ │ ├── get-download-url-for-file.controller.ts │ │ │ │ ├── get-upload-urls-for-files.controller.ts │ │ │ │ ├── list-data-sources.controller.ts │ │ │ │ ├── list-docs-in-data-source.controller.ts │ │ │ │ ├── recrawl-web-data-source.controller.ts │ │ │ │ ├── toggle-data-source.controller.ts │ │ │ │ └── update-data-source.controller.ts │ │ │ ├── jobs/ │ │ │ │ ├── fetch-job.controller.ts │ │ │ │ └── list-jobs.controller.ts │ │ │ ├── projects/ │ │ │ │ ├── add-custom-mcp-server.controller.ts │ │ │ │ ├── create-composio-managed-connected-account.controller.ts │ │ │ │ ├── create-custom-connected-account.controller.ts │ │ │ │ ├── create-project.controller.ts │ │ │ │ ├── delete-composio-connected-account.controller.ts │ │ │ │ ├── delete-project.controller.ts │ │ │ │ ├── fetch-project.controller.ts │ │ │ │ ├── get-composio-toolkit.controller.ts │ │ │ │ ├── list-composio-toolkits.controller.ts │ │ │ │ ├── list-composio-tools.controller.ts │ │ │ │ ├── list-projects.controller.ts │ │ │ │ ├── remove-custom-mcp-server.controller.ts │ │ │ │ ├── revert-to-live-workflow.controller.ts │ │ │ │ ├── rotate-secret.controller.ts │ │ │ │ ├── sync-connected-account.controller.ts │ │ │ │ ├── update-draft-workflow.controller.ts │ │ │ │ ├── update-live-workflow.controller.ts │ │ │ │ ├── update-project-name.controller.ts │ │ │ │ └── update-webhook-url.controller.ts │ │ │ ├── recurring-job-rules/ │ │ │ │ ├── create-recurring-job-rule.controller.ts │ │ │ │ ├── delete-recurring-job-rule.controller.ts │ │ │ │ ├── fetch-recurring-job-rule.controller.ts │ │ │ │ ├── list-recurring-job-rules.controller.ts │ │ │ │ ├── toggle-recurring-job-rule.controller.ts │ │ │ │ └── update-recurring-job-rule.controller.ts │ │ │ └── scheduled-job-rules/ │ │ │ ├── create-scheduled-job-rule.controller.ts │ │ │ ├── delete-scheduled-job-rule.controller.ts │ │ │ ├── fetch-scheduled-job-rule.controller.ts │ │ │ ├── list-scheduled-job-rules.controller.ts │ │ │ └── update-scheduled-job-rule.controller.ts │ │ └── tsconfig.json │ ├── rowboatx/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── app/ │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── ai-elements/ │ │ │ │ ├── artifact.tsx │ │ │ │ ├── canvas.tsx │ │ │ │ ├── chain-of-thought.tsx │ │ │ │ ├── checkpoint.tsx │ │ │ │ ├── code-block.tsx │ │ │ │ ├── confirmation.tsx │ │ │ │ ├── connection.tsx │ │ │ │ ├── context.tsx │ │ │ │ ├── controls.tsx │ │ │ │ ├── conversation.tsx │ │ │ │ ├── edge.tsx │ │ │ │ ├── image.tsx │ │ │ │ ├── inline-citation.tsx │ │ │ │ ├── loader.tsx │ │ │ │ ├── message.tsx │ │ │ │ ├── model-selector.tsx │ │ │ │ ├── node.tsx │ │ │ │ ├── open-in-chat.tsx │ │ │ │ ├── panel.tsx │ │ │ │ ├── plan.tsx │ │ │ │ ├── prompt-input.tsx │ │ │ │ ├── queue.tsx │ │ │ │ ├── reasoning.tsx │ │ │ │ ├── shimmer.tsx │ │ │ │ ├── sources.tsx │ │ │ │ ├── suggestion.tsx │ │ │ │ ├── task.tsx │ │ │ │ ├── tool.tsx │ │ │ │ ├── toolbar.tsx │ │ │ │ └── web-preview.tsx │ │ │ ├── app-sidebar.tsx │ │ │ ├── json-editor.css │ │ │ ├── json-editor.tsx │ │ │ ├── markdown-viewer.css │ │ │ ├── markdown-viewer.tsx │ │ │ ├── nav-main.tsx │ │ │ ├── nav-projects.tsx │ │ │ ├── nav-user.tsx │ │ │ ├── team-switcher.tsx │ │ │ ├── tiptap-markdown-editor.css │ │ │ ├── tiptap-markdown-editor.tsx │ │ │ └── ui/ │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button-group.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-group.tsx │ │ │ ├── input.tsx │ │ │ ├── progress.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ ├── components.json │ │ ├── eslint.config.mjs │ │ ├── global.d.ts │ │ ├── hooks/ │ │ │ └── use-mobile.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── tsconfig.json │ │ └── types/ │ │ └── turndown.d.ts │ └── x/ │ ├── .gitignore │ ├── apps/ │ │ ├── main/ │ │ │ ├── .gitignore │ │ │ ├── bundle.mjs │ │ │ ├── forge.config.cjs │ │ │ ├── icons/ │ │ │ │ └── icon.icns │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── auth-server.ts │ │ │ │ ├── composio-handler.ts │ │ │ │ ├── ipc.ts │ │ │ │ ├── main.ts │ │ │ │ ├── oauth-handler.ts │ │ │ │ └── test-agent.ts │ │ │ └── tsconfig.json │ │ ├── preload/ │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ └── preload.ts │ │ │ └── tsconfig.json │ │ └── renderer/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── components.json │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── ai-elements/ │ │ │ │ │ ├── ask-human-request.tsx │ │ │ │ │ ├── context.tsx │ │ │ │ │ ├── conversation.tsx │ │ │ │ │ ├── file-path-card.tsx │ │ │ │ │ ├── markdown-code-override.tsx │ │ │ │ │ ├── message.tsx │ │ │ │ │ ├── permission-request.tsx │ │ │ │ │ ├── prompt-input.tsx │ │ │ │ │ ├── reasoning.tsx │ │ │ │ │ ├── shimmer.tsx │ │ │ │ │ ├── suggestions.tsx │ │ │ │ │ ├── tool.tsx │ │ │ │ │ └── web-search-result.tsx │ │ │ │ ├── background-task-detail.tsx │ │ │ │ ├── chat-button.tsx │ │ │ │ ├── chat-input-with-mentions.tsx │ │ │ │ ├── chat-message-attachments.tsx │ │ │ │ ├── chat-sidebar.tsx │ │ │ │ ├── composio-api-key-modal.tsx │ │ │ │ ├── connectors-popover.tsx │ │ │ │ ├── editor-toolbar.tsx │ │ │ │ ├── google-client-id-modal.tsx │ │ │ │ ├── graph-view.tsx │ │ │ │ ├── help-popover.tsx │ │ │ │ ├── markdown-editor.tsx │ │ │ │ ├── mention-popover.tsx │ │ │ │ ├── onboarding-modal.tsx │ │ │ │ ├── search-dialog.tsx │ │ │ │ ├── settings-dialog.tsx │ │ │ │ ├── sidebar-content.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ │ ├── badge.tsx │ │ │ │ │ ├── button-group.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── collapsible.tsx │ │ │ │ │ ├── command.tsx │ │ │ │ │ ├── context-menu.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ ├── hover-card.tsx │ │ │ │ │ ├── input-group.tsx │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── popover.tsx │ │ │ │ │ ├── progress.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── sheet.tsx │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ ├── sonner.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ ├── textarea.tsx │ │ │ │ │ └── tooltip.tsx │ │ │ │ └── version-history-panel.tsx │ │ │ ├── contexts/ │ │ │ │ ├── file-card-context.tsx │ │ │ │ ├── sidebar-context.tsx │ │ │ │ └── theme-context.tsx │ │ │ ├── extensions/ │ │ │ │ ├── image-upload.tsx │ │ │ │ └── wiki-link.ts │ │ │ ├── global.d.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-debounce.ts │ │ │ │ ├── use-mention-detection.ts │ │ │ │ ├── use-mobile.ts │ │ │ │ └── useOAuth.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── attachment-presentation.ts │ │ │ │ ├── chat-conversation.ts │ │ │ │ ├── file-utils.ts │ │ │ │ ├── google-client-id-store.ts │ │ │ │ ├── mention-files.ts │ │ │ │ ├── mention-highlights.ts │ │ │ │ ├── textarea-caret.ts │ │ │ │ ├── toast.ts │ │ │ │ ├── utils.ts │ │ │ │ └── wiki-links.ts │ │ │ ├── main.tsx │ │ │ └── styles/ │ │ │ └── editor.css │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── eslint.config.mts │ ├── package.json │ ├── packages/ │ │ ├── core/ │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── agent-schedule/ │ │ │ │ │ ├── repo.ts │ │ │ │ │ ├── runner.ts │ │ │ │ │ └── state-repo.ts │ │ │ │ ├── agents/ │ │ │ │ │ ├── repo.ts │ │ │ │ │ └── runtime.ts │ │ │ │ ├── application/ │ │ │ │ │ ├── assistant/ │ │ │ │ │ │ ├── agent.ts │ │ │ │ │ │ ├── instructions.ts │ │ │ │ │ │ ├── runtime-context.ts │ │ │ │ │ │ └── skills/ │ │ │ │ │ │ ├── background-agents/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── builtin-tools/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── create-presentations/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── deletion-guardrails/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── doc-collab/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── draft-emails/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── mcp-integration/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── meeting-prep/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── organize-files/ │ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ │ ├── slack/ │ │ │ │ │ │ │ ├── skill.ts │ │ │ │ │ │ │ └── tool-catalog.ts │ │ │ │ │ │ └── web-search/ │ │ │ │ │ │ └── skill.ts │ │ │ │ │ └── lib/ │ │ │ │ │ ├── builtin-tools.ts │ │ │ │ │ ├── bus.ts │ │ │ │ │ ├── command-executor.ts │ │ │ │ │ ├── exec-tool.ts │ │ │ │ │ ├── id-gen.ts │ │ │ │ │ └── message-queue.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── client-repo.ts │ │ │ │ │ ├── oauth-client.ts │ │ │ │ │ ├── provider-client-id.ts │ │ │ │ │ ├── providers.ts │ │ │ │ │ ├── repo.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── composio/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── repo.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── config/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── initConfigs.ts │ │ │ │ │ ├── note_creation_config.ts │ │ │ │ │ ├── security.ts │ │ │ │ │ └── strictness_analyzer.ts │ │ │ │ ├── di/ │ │ │ │ │ └── container.ts │ │ │ │ ├── index.ts │ │ │ │ ├── knowledge/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── build_graph.ts │ │ │ │ │ ├── fireflies-client-factory.ts │ │ │ │ │ ├── google-client-factory.ts │ │ │ │ │ ├── granola/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── repo.ts │ │ │ │ │ │ ├── sync.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── graph_state.ts │ │ │ │ │ ├── knowledge_index.ts │ │ │ │ │ ├── limit_event_items.ts │ │ │ │ │ ├── note_creation_high.ts │ │ │ │ │ ├── note_creation_low.ts │ │ │ │ │ ├── note_creation_medium.ts │ │ │ │ │ ├── sync_calendar.ts │ │ │ │ │ ├── sync_fireflies.ts │ │ │ │ │ ├── sync_gmail.ts │ │ │ │ │ ├── version_history.ts │ │ │ │ │ └── welcome.md │ │ │ │ ├── mcp/ │ │ │ │ │ ├── mcp.ts │ │ │ │ │ └── repo.ts │ │ │ │ ├── models/ │ │ │ │ │ ├── models-dev.ts │ │ │ │ │ ├── models.ts │ │ │ │ │ └── repo.ts │ │ │ │ ├── pre_built/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── email-draft.md │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── meeting-prep.md │ │ │ │ │ ├── runner.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── runs/ │ │ │ │ │ ├── abort-registry.ts │ │ │ │ │ ├── bus.ts │ │ │ │ │ ├── lock.ts │ │ │ │ │ ├── repo.ts │ │ │ │ │ └── runs.ts │ │ │ │ ├── search/ │ │ │ │ │ └── search.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── service_bus.ts │ │ │ │ │ └── service_logger.ts │ │ │ │ └── workspace/ │ │ │ │ ├── watcher.ts │ │ │ │ ├── wiki-link-rewrite.ts │ │ │ │ └── workspace.ts │ │ │ └── tsconfig.json │ │ └── shared/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agent-schedule-state.ts │ │ │ ├── agent-schedule.ts │ │ │ ├── agent.ts │ │ │ ├── example.ts │ │ │ ├── index.ts │ │ │ ├── ipc.ts │ │ │ ├── llm-step-events.ts │ │ │ ├── mcp.ts │ │ │ ├── message.ts │ │ │ ├── models.ts │ │ │ ├── prefix-logger.ts │ │ │ ├── runs.ts │ │ │ ├── service-events.ts │ │ │ └── workspace.ts │ │ └── tsconfig.json │ ├── pnpm-workspace.yaml │ └── tsconfig.base.json ├── build-electron.sh ├── docker-compose.yml ├── google-setup.md └── start.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ ================================================ FILE: .github/workflows/electron-build.yml ================================================ name: Build Electron App on: release: types: [published] permissions: contents: write # Required to upload release assets jobs: build-macos: runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' - name: Extract version from tag id: version run: | VERSION="${GITHUB_REF#refs/tags/v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "Extracted version: ${VERSION}" - name: Update package.json versions run: | node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); console.log('Updated version to:', version); " - name: Import Code Signing Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | # Create a temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) # Create keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Decode and import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" # Allow codesign to access the keychain security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Add keychain to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" # Clean up certificate file rm -f $RUNNER_TEMP/certificate.p12 - name: Install dependencies run: pnpm install --frozen-lockfile working-directory: apps/x - name: Build electron app env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx electron-forge publish --arch=arm64,x64 --platform=darwin working-directory: apps/x/apps/main - name: Cleanup keychain if: always() run: | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db if [ -f "$KEYCHAIN_PATH" ]; then security delete-keychain "$KEYCHAIN_PATH" || true fi - name: Upload workflow artifacts uses: actions/upload-artifact@v6 with: name: distributables path: apps/x/apps/main/out/make/* retention-days: 30 build-linux: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' - name: Extract version from tag id: version run: | VERSION="${GITHUB_REF#refs/tags/v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "Extracted version: ${VERSION}" - name: Update package.json versions run: | node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); console.log('Updated version to:', version); " - name: Install dependencies run: pnpm install --frozen-lockfile working-directory: apps/x - name: Build electron app env: VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx electron-forge publish --arch=x64,arm64 --platform=linux working-directory: apps/x/apps/main - name: Upload workflow artifacts uses: actions/upload-artifact@v6 with: name: distributables-linux path: apps/x/apps/main/out/make/* retention-days: 30 build-windows: runs-on: windows-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' - name: Extract version from tag id: version shell: bash run: | VERSION="${GITHUB_REF#refs/tags/v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "Extracted version: ${VERSION}" - name: Update package.json versions shell: bash run: | node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); console.log('Updated version to:', version); " - name: Install dependencies run: pnpm install --frozen-lockfile working-directory: apps/x - name: Build electron app env: VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx electron-forge publish --arch=x64 --platform=win32 working-directory: apps/x/apps/main - name: Upload workflow artifacts uses: actions/upload-artifact@v6 with: name: distributables-windows path: apps/x/apps/main/out/make/* retention-days: 30 ================================================ FILE: .github/workflows/rowboat-build.yml ================================================ name: Rowboat Build on: pull_request: jobs: build-rowboat-nextjs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: cache-dependency-path: 'apps/rowboat/package-lock.json' node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci working-directory: apps/rowboat - name: Build Rowboat run: npm run build working-directory: apps/rowboat build-rowboatx: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: cache-dependency-path: 'apps/rowboat/package-lock.json' node-version: '24' cache: 'npm' - name: Install dependencies run: npm ci working-directory: apps/cli - name: Build Rowboat run: npm run build working-directory: apps/cli ================================================ FILE: .github/workflows/x-publish.yml ================================================ name: Publish to npm on: workflow_dispatch permissions: id-token: write # Required for OIDC contents: read jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org/ - name: Update npm run: npm install -g npm@latest - name: Install deps run: npm ci working-directory: apps/cli # optional: run tests # - run: npm test - name: Build run: npm run build working-directory: apps/cli - name: Pack run: npm pack working-directory: apps/cli - name: Publish to npm run: npm publish --access public working-directory: apps/cli ================================================ FILE: .gitignore ================================================ .DS_Store .env .vscode/ data/ .venv/ ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md - AI Coding Agent Context This file provides context for AI coding agents working on the Rowboat monorepo. ## Quick Reference Commands ```bash # Electron App (apps/x) cd apps/x && pnpm install # Install dependencies cd apps/x && npm run deps # Build workspace packages (shared → core → preload) cd apps/x && npm run dev # Development mode (builds deps, runs app) cd apps/x && npm run lint # Lint check cd apps/x/apps/main && npm run package # Production build (.app) cd apps/x/apps/main && npm run make # Create DMG distributable ``` ## Monorepo Structure ``` rowboat/ ├── apps/ │ ├── x/ # Electron desktop app (focus of this doc) │ ├── rowboat/ # Next.js web dashboard │ ├── rowboatx/ # Next.js frontend │ ├── cli/ # CLI tool │ ├── python-sdk/ # Python SDK │ └── docs/ # Documentation site ├── CLAUDE.md # This file └── README.md # User-facing readme ``` ## Electron App Architecture (`apps/x`) The Electron app is a **nested pnpm workspace** with its own package management. ``` apps/x/ ├── package.json # Workspace root, dev scripts ├── pnpm-workspace.yaml # Defines workspace packages ├── pnpm-lock.yaml # Lockfile ├── apps/ │ ├── main/ # Electron main process │ │ ├── src/ # Main process source │ │ ├── forge.config.cjs # Electron Forge config │ │ └── bundle.mjs # esbuild bundler │ ├── renderer/ # React UI (Vite) │ │ ├── src/ # React components │ │ └── vite.config.ts │ └── preload/ # Electron preload scripts │ └── src/ └── packages/ ├── shared/ # @x/shared - Types, utilities, validators └── core/ # @x/core - Business logic, AI, OAuth, MCP ``` ### Build Order (Dependencies) ``` shared (no deps) ↓ core (depends on shared) ↓ preload (depends on shared) ↓ renderer (depends on shared) main (depends on shared, core) ``` **The `npm run deps` command builds:** shared → core → preload ### Key Entry Points | Component | Entry | Output | |-----------|-------|--------| | main | `apps/main/src/main.ts` | `.package/dist/main.cjs` | | renderer | `apps/renderer/src/main.tsx` | `apps/renderer/dist/` | | preload | `apps/preload/src/preload.ts` | `apps/preload/dist/preload.js` | ## Build System - **Package manager:** pnpm (required for `workspace:*` protocol) - **Main bundler:** esbuild (bundles to single CommonJS file) - **Renderer bundler:** Vite - **Packaging:** Electron Forge - **TypeScript:** ES2022 target ### Why esbuild bundling? pnpm uses symlinks for workspace packages. Electron Forge's dependency walker can't follow these symlinks. esbuild bundles everything into a single file, eliminating the need for node_modules in the packaged app. ## Key Files Reference | Purpose | File | |---------|------| | Electron main entry | `apps/x/apps/main/src/main.ts` | | React app entry | `apps/x/apps/renderer/src/main.tsx` | | Forge config (packaging) | `apps/x/apps/main/forge.config.cjs` | | Main process bundler | `apps/x/apps/main/bundle.mjs` | | Vite config | `apps/x/apps/renderer/vite.config.ts` | | Shared types | `apps/x/packages/shared/src/` | | Core business logic | `apps/x/packages/core/src/` | | Workspace config | `apps/x/pnpm-workspace.yaml` | | Root scripts | `apps/x/package.json` | ## Common Tasks ### LLM configuration (single provider) - Config file: `~/.rowboat/config/models.json` - Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }` - Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only) ### Add a new shared type 1. Edit `apps/x/packages/shared/src/` 2. Run `cd apps/x && npm run deps` to rebuild ### Modify main process 1. Edit `apps/x/apps/main/src/` 2. Restart dev server (main doesn't hot-reload) ### Modify renderer (React UI) 1. Edit `apps/x/apps/renderer/src/` 2. Changes hot-reload automatically in dev mode ### Add a new dependency to main 1. `cd apps/x/apps/main && pnpm add ` 2. Import in source - esbuild will bundle it ### Verify compilation ```bash cd apps/x && npm run deps && npm run lint ``` ## Tech Stack | Layer | Technology | |-------|------------| | Desktop | Electron 39.x | | UI | React 19, Vite 7 | | Styling | TailwindCSS, Radix UI | | State | React hooks | | AI | Vercel AI SDK, OpenAI/Anthropic/Google/OpenRouter providers, Vercel AI Gateway, Ollama, models.dev catalog | | IPC | Electron contextBridge | | Build | TypeScript 5.9, esbuild, Electron Forge | ## Environment Variables (for packaging) For production builds with code signing: - `APPLE_ID` - Apple Developer ID - `APPLE_PASSWORD` - App-specific password - `APPLE_TEAM_ID` - Team ID Not required for local development. ================================================ FILE: Dockerfile.qdrant ================================================ FROM qdrant/qdrant:latest RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [2024] [RowBoat Labs] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ rowboat-github-2

rowboatlabs/rowboat | Trendshift

Website Discord Twitter Y Combinator

# Rowboat **Open-source AI coworker that turns work into a knowledge graph and acts on it**
Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine. You can do things like: - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) ## Demo [![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) [Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I) --- ## Installation **Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads) **All release files:** https://github.com/rowboatlabs/rowboat/releases/latest ### Google setup To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). ### Voice notes To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: ``` { "apiKey": "" } ``` ### Web search To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json. To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. (same format as above) ## What it does Rowboat is a **local-first AI coworker** that can: - **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments) - **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc) - **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides) Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit. ## Integrations Rowboat builds memory from the work you already do, including: - **Gmail** (email) - **Granola** (meeting notes) - **Fireflies** (meeting notes) ## How it’s different Most AI tools reconstruct context on demand by searching transcripts or documents. Rowboat maintains **long-lived knowledge** instead: - context accumulates over time - relationships are explicit and inspectable - notes are editable by you, not hidden inside a model - everything lives on your machine as plain Markdown The result is memory that compounds, rather than retrieval that starts cold every time. ## What you can do with it - **Meeting prep** from prior decisions, threads, and open questions - **Email drafting** grounded in history and commitments - **Docs & decks** generated from your ongoing context (including PDF slides) - **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped - **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) ## Background agents Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. Examples: - Draft email replies in the background (grounded in your past context and commitments) - Generate a daily voice note each morning (agenda, priorities, upcoming meetings) - Create recurring project updates from the latest emails/notes - Keep your knowledge graph up to date as new information comes in You control what runs, when it runs, and what gets written back into your local Markdown vault. ## Bring your own model Rowboat works with the model setup you prefer: - **Local models** via Ollama or LM Studio - **Hosted models** (bring your own API key/provider) - Swap models anytime — your data stays in your local Markdown vault ## Extend Rowboat with tools (MCP) Rowboat can connect to external tools and services via **Model Context Protocol (MCP)**. That means you can plug in (for example) search, databases, CRMs, support tools, and automations - or your own internal tools. Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, GitHub, and more. ## Local-first by design - All data is stored locally as plain Markdown - No proprietary formats or hosted lock-in - You can inspect, edit, back up, or delete everything at any time ---
[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
================================================ FILE: apps/cli/.gitignore ================================================ node_modules/ dist/ .vercel ================================================ FILE: apps/cli/bin/app.js ================================================ #!/usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { app, modelConfig, importExample, listExamples, exportWorkflow } from '../dist/app.js'; import { runTui } from '../dist/tui/index.js'; yargs(hideBin(process.argv)) .command( "$0", "Run rowboatx", (y) => y .option("agent", { type: "string", description: "The agent to run", default: "copilot", }) .option("run_id", { type: "string", description: "Continue an existing run", }) .option("input", { type: "string", description: "The input to the agent", }) .option("no-interactive", { type: "boolean", description: "Do not interact with the user", default: false, }), (argv) => { app({ agent: argv.agent, runId: argv.run_id, input: argv.input, noInteractive: argv.noInteractive, }); } ) .command( "ui", "Launch the interactive Rowboat dashboard", (y) => y .option("server-url", { type: "string", description: "Rowboat server base URL", }), (argv) => { runTui({ serverUrl: argv.serverUrl, }); } ) .command( "import", "Import an example workflow (--example) or custom workflow from file (--file)", (y) => y .option("example", { type: "string", description: "Name of built-in example to import", }) .option("file", { type: "string", description: "Path to custom workflow JSON file", }) .check((argv) => { if (!argv.example && !argv.file) { throw new Error("Either --example or --file must be provided"); } if (argv.example && argv.file) { throw new Error("Cannot use both --example and --file at the same time"); } return true; }), async (argv) => { try { if (argv.example) { await importExample(String(argv.example).trim()); } else if (argv.file) { await importExample(undefined, String(argv.file).trim()); } } catch (error) { console.error("Error:", error?.message ?? error); process.exit(1); } } ) .command( "list-examples", "List all available example workflows", (y) => y, async () => { try { const examples = await listExamples(); if (examples.length === 0) { console.error("No packaged examples are available to list."); return; } for (const example of examples) { console.log(example); } } catch (error) { console.error(error?.message ?? error); process.exit(1); } } ) .command( "export", "Export a workflow with all dependencies (outputs to stdout)", (y) => y .option("agent", { type: "string", description: "Entry agent name to export", demandOption: true, }), async (argv) => { try { await exportWorkflow(String(argv.agent).trim()); } catch (error) { console.error("Error:", error?.message ?? error); process.exit(1); } } ) .command( "model-config", "Select model", (y) => y, (argv) => { modelConfig(); } ) .parse(); ================================================ FILE: apps/cli/package.json ================================================ { "name": "@rowboatlabs/rowboatx", "version": "0.16.0", "main": "index.js", "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "rm -rf dist && tsc", "server": "node dist/server.js", "migrate-agents": "node dist/scripts/migrate-agents.js" }, "files": [ "dist", "bin" ], "bin": { "rowboatx": "bin/app.js" }, "keywords": [], "author": "Rowboat Labs", "license": "Apache-2.0", "description": "", "devDependencies": { "@types/node": "^24.9.1", "@types/react": "^18.3.12", "typescript": "^5.9.3" }, "dependencies": { "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", "@google-cloud/local-auth": "^3.0.1", "@hono/node-server": "^1.19.6", "@hono/standard-validator": "^0.1.5", "@modelcontextprotocol/sdk": "^1.20.2", "@openrouter/ai-sdk-provider": "^1.2.6", "ai": "^5.0.102", "awilix": "^12.0.5", "eventsource-parser": "^1.1.2", "google-auth-library": "^10.5.0", "googleapis": "^169.0.0", "hono": "^4.10.7", "hono-openapi": "^1.1.1", "ink": "^5.1.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", "node-html-markdown": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4", "react": "^18.3.1", "yaml": "^2.8.2", "yargs": "^18.0.0", "zod": "^4.1.12" } } ================================================ FILE: apps/cli/src/agents/agents.ts ================================================ import { z } from "zod"; export const BaseTool = z.object({ name: z.string(), }); export const BuiltinTool = BaseTool.extend({ type: z.literal("builtin"), }); export const McpTool = BaseTool.extend({ type: z.literal("mcp"), description: z.string(), inputSchema: z.any(), mcpServerName: z.string(), }); export const AgentAsATool = BaseTool.extend({ type: z.literal("agent"), }); export const ToolAttachment = z.discriminatedUnion("type", [ BuiltinTool, McpTool, AgentAsATool, ]); export const Agent = z.object({ name: z.string(), provider: z.string().optional(), model: z.string().optional(), description: z.string().optional(), instructions: z.string(), tools: z.record(z.string(), ToolAttachment).optional(), }); ================================================ FILE: apps/cli/src/agents/repo.ts ================================================ import { WorkDir } from "../config/config.js"; import fs from "fs/promises"; import { glob } from "node:fs/promises"; import path from "path"; import z from "zod"; import { Agent } from "./agents.js"; import { parse, stringify } from "yaml"; const UpdateAgentSchema = Agent.omit({ name: true }); export interface IAgentsRepo { list(): Promise[]>; fetch(id: string): Promise>; create(agent: z.infer): Promise; update(id: string, agent: z.infer): Promise; delete(id: string): Promise; } export class FSAgentsRepo implements IAgentsRepo { private readonly agentsDir = path.join(WorkDir, "agents"); async list(): Promise[]> { const result: z.infer[] = []; // list all md files in workdir/agents/ const matches = await Array.fromAsync(glob("**/*.md", { cwd: this.agentsDir })); for (const file of matches) { try { const agent = await this.parseAgentMd(path.join(this.agentsDir, file)); result.push(agent); } catch (error) { console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`); continue; } } return result; } private async parseAgentMd(filePath: string): Promise> { const raw = await fs.readFile(filePath, "utf8"); // strip the path prefix from the file name // and the .md extension const agentName = filePath .replace(this.agentsDir + "/", "") .replace(/\.md$/, ""); let agent: z.infer = { name: agentName, instructions: raw, }; let content = raw; // check for frontmatter markers at start if (raw.startsWith("---")) { const end = raw.indexOf("\n---", 3); if (end !== -1) { const fm = raw.slice(3, end).trim(); // YAML text content = raw.slice(end + 4).trim(); // body after frontmatter const yaml = parse(fm); const parsed = Agent .omit({ name: true, instructions: true }) .parse(yaml); agent = { ...agent, ...parsed, instructions: content, }; } } return agent; } async fetch(id: string): Promise> { return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`)); } async create(agent: z.infer): Promise { const { instructions, ...rest } = agent; const contents = `---\n${stringify(rest)}\n---\n${instructions}`; await fs.writeFile(path.join(this.agentsDir, `${agent.name}.md`), contents); } async update(id: string, agent: z.infer): Promise { const { instructions, ...rest } = agent; const contents = `---\n${stringify(rest)}\n---\n${instructions}`; await fs.writeFile(path.join(this.agentsDir, `${id}.md`), contents); } async delete(id: string): Promise { await fs.unlink(path.join(this.agentsDir, `${id}.md`)); } } ================================================ FILE: apps/cli/src/agents/runtime.ts ================================================ import { jsonSchema, ModelMessage, modelMessageSchema } from "ai"; import fs from "fs"; import path from "path"; import { WorkDir } from "../config/config.js"; import { Agent, ToolAttachment } from "./agents.js"; import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; import { z } from "zod"; import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; import { execTool } from "../application/lib/exec-tool.js"; import { MessageEvent, AskHumanRequestEvent, RunEvent, ToolInvocationEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { CopilotAgent } from "../application/assistant/agent.js"; import { isBlocked } from "../application/lib/command-executor.js"; import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; import { getProvider } from "../models/models.js"; import { IAgentsRepo } from "./repo.js"; import { IdGen, IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; import { IMessageQueue } from "../application/lib/message-queue.js"; import { IRunsRepo } from "../runs/repo.js"; import { IRunsLock } from "../runs/lock.js"; import { PrefixLogger } from "../shared/prefix-logger.js"; export interface IAgentRuntime { trigger(runId: string): Promise; } export class AgentRuntime implements IAgentRuntime { private runsRepo: IRunsRepo; private idGenerator: IMonotonicallyIncreasingIdGenerator; private bus: IBus; private messageQueue: IMessageQueue; private modelConfigRepo: IModelConfigRepo; private runsLock: IRunsLock; constructor({ runsRepo, idGenerator, bus, messageQueue, modelConfigRepo, runsLock, }: { runsRepo: IRunsRepo; idGenerator: IMonotonicallyIncreasingIdGenerator; bus: IBus; messageQueue: IMessageQueue; modelConfigRepo: IModelConfigRepo; runsLock: IRunsLock; }) { this.runsRepo = runsRepo; this.idGenerator = idGenerator; this.bus = bus; this.messageQueue = messageQueue; this.modelConfigRepo = modelConfigRepo; this.runsLock = runsLock; } async trigger(runId: string): Promise { if (!await this.runsLock.lock(runId)) { console.log(`unable to acquire lock on run ${runId}`); return; } try { await this.bus.publish({ runId, type: "run-processing-start", subflow: [], }); while (true) { let eventCount = 0; const run = await this.runsRepo.fetch(runId); if (!run) { throw new Error(`Run ${runId} not found`); } const state = new AgentState(); for (const event of run.log) { state.ingest(event); } for await (const event of streamAgent({ state, idGenerator: this.idGenerator, runId, messageQueue: this.messageQueue, modelConfigRepo: this.modelConfigRepo, })) { eventCount++; if (event.type !== "llm-stream-event") { await this.runsRepo.appendEvents(runId, [event]); } await this.bus.publish(event); } // if no events, break if (!eventCount) { break; } } } finally { await this.runsLock.release(runId); await this.bus.publish({ runId, type: "run-processing-end", subflow: [], }); } } } export async function mapAgentTool(t: z.infer): Promise { switch (t.type) { case "mcp": return tool({ name: t.name, description: t.description, inputSchema: jsonSchema(t.inputSchema), }); case "agent": const agent = await loadAgent(t.name); if (!agent) { throw new Error(`Agent ${t.name} not found`); } return tool({ name: t.name, description: agent.description, inputSchema: z.object({ message: z.string().describe("The message to send to the workflow"), }), }); case "builtin": if (t.name === "ask-human") { return tool({ description: "Ask a human before proceeding", inputSchema: z.object({ question: z.string().describe("The question to ask the human"), }), }); } const match = BuiltinTools[t.name]; if (!match) { throw new Error(`Unknown builtin tool: ${t.name}`); } return tool({ description: match.description, inputSchema: match.inputSchema, }); } } export class RunLogger { private logFile: string; private fileHandle: fs.WriteStream; ensureRunsDir() { const runsDir = path.join(WorkDir, "runs"); if (!fs.existsSync(runsDir)) { fs.mkdirSync(runsDir, { recursive: true }); } } constructor(runId: string) { this.ensureRunsDir(); this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); this.fileHandle = fs.createWriteStream(this.logFile, { flags: "a", encoding: "utf8", }); } log(event: z.infer) { if (event.type !== "llm-stream-event") { this.fileHandle.write(JSON.stringify(event) + "\n"); } } close() { this.fileHandle.close(); } } export class StreamStepMessageBuilder { private parts: z.infer[] = []; private textBuffer: string = ""; private reasoningBuffer: string = ""; private providerOptions: z.infer | undefined = undefined; flushBuffers() { // skip reasoning // if (this.reasoningBuffer) { // this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); // this.reasoningBuffer = ""; // } if (this.textBuffer) { this.parts.push({ type: "text", text: this.textBuffer }); this.textBuffer = ""; } } ingest(event: z.infer) { switch (event.type) { case "reasoning-start": case "reasoning-end": case "text-start": case "text-end": this.flushBuffers(); break; case "reasoning-delta": this.reasoningBuffer += event.delta; break; case "text-delta": this.textBuffer += event.delta; break; case "tool-call": this.parts.push({ type: "tool-call", toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.input, providerOptions: event.providerOptions, }); break; case "finish-step": this.providerOptions = event.providerOptions; break; } } get(): z.infer { this.flushBuffers(); return { role: "assistant", content: this.parts, providerOptions: this.providerOptions, }; } } function normaliseAskHumanToolCall(message: z.infer) { if (typeof message.content === "string") { return; } let askHumanToolCall: z.infer | null = null; const newParts = []; for (const part of message.content as z.infer[]) { if (part.type === "tool-call" && part.toolName === "ask-human") { if (!askHumanToolCall) { askHumanToolCall = part; } else { (askHumanToolCall as z.infer).arguments += "\n" + part.arguments; } break; } else { newParts.push(part); } } if (askHumanToolCall) { newParts.push(askHumanToolCall); } message.content = newParts; } export async function loadAgent(id: string): Promise> { if (id === "copilot" || id === "rowboatx") { return CopilotAgent; } const repo = container.resolve('agentsRepo'); return await repo.fetch(id); } export function convertFromMessages(messages: z.infer[]): ModelMessage[] { const result: ModelMessage[] = []; for (const msg of messages) { const { providerOptions } = msg; switch (msg.role) { case "assistant": if (typeof msg.content === 'string') { result.push({ role: "assistant", content: msg.content, providerOptions, }); } else { result.push({ role: "assistant", content: msg.content.map(part => { switch (part.type) { case 'text': return part; case 'reasoning': return part; case 'tool-call': return { type: 'tool-call', toolCallId: part.toolCallId, toolName: part.toolName, input: part.arguments, providerOptions: part.providerOptions, }; } }), providerOptions, }); } break; case "system": result.push({ role: "system", content: msg.content, providerOptions, }); break; case "user": result.push({ role: "user", content: msg.content, providerOptions, }); break; case "tool": result.push({ role: "tool", content: [ { type: "tool-result", toolCallId: msg.toolCallId, toolName: msg.toolName, output: { type: "text", value: msg.content, }, }, ], providerOptions, }); break; } } // doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262 return JSON.parse(JSON.stringify(result)); } async function buildTools(agent: z.infer): Promise { const tools: ToolSet = {}; for (const [name, tool] of Object.entries(agent.tools ?? {})) { try { tools[name] = await mapAgentTool(tool); } catch (error) { console.error(`Error mapping tool ${name}:`, error); continue; } } return tools; } export class AgentState { runId: string | null = null; agent: z.infer | null = null; agentName: string | null = null; messages: z.infer = []; lastAssistantMsg: z.infer | null = null; subflowStates: Record = {}; toolCallIdMap: Record> = {}; pendingToolCalls: Record = {}; pendingToolPermissionRequests: Record> = {}; pendingAskHumanRequests: Record> = {}; allowedToolCallIds: Record = {}; deniedToolCallIds: Record = {}; getPendingPermissions(): z.infer[] { const response: z.infer[] = []; for (const [id, subflowState] of Object.entries(this.subflowStates)) { for (const perm of subflowState.getPendingPermissions()) { response.push({ ...perm, subflow: [id, ...perm.subflow], }); } } for (const perm of Object.values(this.pendingToolPermissionRequests)) { response.push({ ...perm, subflow: [], }); } return response; } getPendingAskHumans(): z.infer[] { const response: z.infer[] = []; for (const [id, subflowState] of Object.entries(this.subflowStates)) { for (const ask of subflowState.getPendingAskHumans()) { response.push({ ...ask, subflow: [id, ...ask.subflow], }); } } for (const ask of Object.values(this.pendingAskHumanRequests)) { response.push({ ...ask, subflow: [], }); } return response; } finalResponse(): string { if (!this.lastAssistantMsg) { return ''; } if (typeof this.lastAssistantMsg.content === "string") { return this.lastAssistantMsg.content; } return this.lastAssistantMsg.content.reduce((acc, part) => { if (part.type === "text") { return acc + part.text; } return acc; }, ""); } ingest(event: z.infer) { if (event.subflow.length > 0) { const { subflow, ...rest } = event; if (!this.subflowStates[subflow[0]]) { this.subflowStates[subflow[0]] = new AgentState(); } this.subflowStates[subflow[0]].ingest({ ...rest, subflow: subflow.slice(1), }); return; } switch (event.type) { case "start": this.runId = event.runId; this.agentName = event.agentName; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. if (!this.subflowStates[event.toolCallId]) { this.subflowStates[event.toolCallId] = new AgentState(); } this.subflowStates[event.toolCallId].agentName = event.agentName; break; case "message": this.messages.push(event.message); if (event.message.content instanceof Array) { for (const part of event.message.content) { if (part.type === "tool-call") { this.toolCallIdMap[part.toolCallId] = part; this.pendingToolCalls[part.toolCallId] = true; } } } if (event.message.role === "tool") { const message = event.message as z.infer; delete this.pendingToolCalls[message.toolCallId]; } if (event.message.role === "assistant") { this.lastAssistantMsg = event.message; } break; case "tool-permission-request": this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event; break; case "tool-permission-response": switch (event.response) { case "approve": this.allowedToolCallIds[event.toolCallId] = true; break; case "deny": this.deniedToolCallIds[event.toolCallId] = true; break; } delete this.pendingToolPermissionRequests[event.toolCallId]; break; case "ask-human-request": this.pendingAskHumanRequests[event.toolCallId] = event; break; case "ask-human-response": // console.error('im here', this.agentName, this.runId, event.subflow); const ogEvent = this.pendingAskHumanRequests[event.toolCallId]; this.messages.push({ role: "tool", content: JSON.stringify({ userResponse: event.response, }), toolCallId: ogEvent.toolCallId, toolName: this.toolCallIdMap[ogEvent.toolCallId]!.toolName, }); delete this.pendingAskHumanRequests[ogEvent.toolCallId]; break; } } } export async function* streamAgent({ state, idGenerator, runId, messageQueue, modelConfigRepo, }: { state: AgentState, idGenerator: IMonotonicallyIncreasingIdGenerator; runId: string; messageQueue: IMessageQueue; modelConfigRepo: IModelConfigRepo; }): AsyncGenerator, void, unknown> { const logger = new PrefixLogger(`run-${runId}-${state.agentName}`); async function* processEvent(event: z.infer): AsyncGenerator, void, unknown> { state.ingest(event); yield event; } const modelConfig = await modelConfigRepo.getConfig(); if (!modelConfig) { throw new Error("Model config not found"); } // set up agent const agent = await loadAgent(state.agentName!); // set up tools const tools = await buildTools(agent); // set up provider + model const provider = await getProvider(agent.provider); const model = provider.languageModel(agent.model || modelConfig.defaults.model); let loopCounter = 0; while (true) { loopCounter++; let loopLogger = logger.child(`iter-${loopCounter}`); loopLogger.log('starting loop iteration'); // execute any pending tool calls for (const toolCallId of Object.keys(state.pendingToolCalls)) { const toolCall = state.toolCallIdMap[toolCallId]; let _logger = loopLogger.child(`tc-${toolCallId}-${toolCall.toolName}`); _logger.log('processing'); // if ask-human, skip if (toolCall.toolName === "ask-human") { _logger.log('skipping, reason: ask-human'); continue; } // if tool has been denied, deny if (state.deniedToolCallIds[toolCallId]) { _logger.log('returning denied tool message, reason: tool has been denied'); yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { role: "tool", content: "Unable to execute this tool: Permission was denied.", toolCallId: toolCallId, toolName: toolCall.toolName, }, subflow: [], }); continue; } // if permission is pending on this tool call, skip execution if (state.pendingToolPermissionRequests[toolCallId]) { _logger.log('skipping, reason: permission is pending'); continue; } // execute approved tool _logger.log('executing tool'); yield* processEvent({ runId, type: "tool-invocation", toolCallId, toolName: toolCall.toolName, input: JSON.stringify(toolCall.arguments), subflow: [], }); let result: any = null; if (agent.tools![toolCall.toolName].type === "agent") { let subflowState = state.subflowStates[toolCallId]; for await (const event of streamAgent({ state: subflowState, idGenerator, runId, messageQueue, modelConfigRepo, })) { yield* processEvent({ ...event, subflow: [toolCallId, ...event.subflow], }); } if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { result = subflowState.finalResponse(); } } else { result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments); } if (result) { const resultMsg: z.infer = { role: "tool", content: JSON.stringify(result), toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, }; yield* processEvent({ runId, type: "tool-result", toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, result: result, subflow: [], }); yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: resultMsg, subflow: [], }); } } // if waiting on user permission or ask-human, exit if (state.getPendingAskHumans().length || state.getPendingPermissions().length) { loopLogger.log('exiting loop, reason: pending asks or permissions'); return; } // get any queued user messages while (true) { const msg = await messageQueue.dequeue(runId); if (!msg) { break; } loopLogger.log('dequeued user message', msg.messageId); yield* processEvent({ runId, type: "message", messageId: msg.messageId, message: { role: "user", content: msg.message, }, subflow: [], }); } // if last response is from assistant and text, exit const lastMessage = state.messages[state.messages.length - 1]; if (lastMessage && lastMessage.role === "assistant" && (typeof lastMessage.content === "string" || !lastMessage.content.some(part => part.type === "tool-call") ) ) { loopLogger.log('exiting loop, reason: last message is from assistant and text'); return; } // run one LLM turn. loopLogger.log('running llm turn'); // stream agent response and build message const messageBuilder = new StreamStepMessageBuilder(); for await (const event of streamLlm( model, state.messages, agent.instructions, tools, )) { loopLogger.log('got llm-stream-event:', event.type) messageBuilder.ingest(event); yield* processEvent({ runId, type: "llm-stream-event", event: event, subflow: [], }); } // build and emit final message from agent response const message = messageBuilder.get(); yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message, subflow: [], }); // if there were any ask-human calls, emit those events if (message.content instanceof Array) { for (const part of message.content) { if (part.type === "tool-call") { const underlyingTool = agent.tools![part.toolName]; if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") { loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId); yield* processEvent({ runId, type: "ask-human-request", toolCallId: part.toolCallId, query: part.arguments.question, subflow: [], }); } if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") { // if command is blocked, then seek permission if (isBlocked(part.arguments.command)) { loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); yield* processEvent({ runId, type: "tool-permission-request", toolCall: part, subflow: [], }); } } if (underlyingTool.type === "agent" && underlyingTool.name) { loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); yield* processEvent({ runId, type: "spawn-subflow", agentName: underlyingTool.name, toolCallId: part.toolCallId, subflow: [], }); yield* processEvent({ runId, messageId: await idGenerator.next(), type: "message", message: { role: "user", content: part.arguments.message, }, subflow: [part.toolCallId], }); } } } } } } async function* streamLlm( model: LanguageModel, messages: z.infer, instructions: string, tools: ToolSet, ): AsyncGenerator, void, unknown> { const { fullStream } = streamText({ model, messages: convertFromMessages(messages), system: instructions, tools, stopWhen: stepCountIs(1), }); for await (const event of fullStream) { // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); switch (event.type) { case "reasoning-start": yield { type: "reasoning-start", providerOptions: event.providerMetadata, }; break; case "reasoning-delta": yield { type: "reasoning-delta", delta: event.text, providerOptions: event.providerMetadata, }; break; case "reasoning-end": yield { type: "reasoning-end", providerOptions: event.providerMetadata, }; break; case "text-start": yield { type: "text-start", providerOptions: event.providerMetadata, }; break; case "text-delta": yield { type: "text-delta", delta: event.text, providerOptions: event.providerMetadata, }; break; case "tool-call": yield { type: "tool-call", toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, providerOptions: event.providerMetadata, }; break; case "finish-step": yield { type: "finish-step", usage: event.usage, finishReason: event.finishReason, providerOptions: event.providerMetadata, }; break; default: // console.warn("Unknown event type", event); continue; } } } export const MappedToolCall = z.object({ toolCall: ToolCallPart, agentTool: ToolAttachment, }); ================================================ FILE: apps/cli/src/app.ts ================================================ import { AgentState, streamAgent } from "./agents/runtime.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; import { stdin as input, stdout as output } from "node:process"; import fs from "fs"; import { promises as fsp } from "fs"; import path from "path"; import { WorkDir } from "./config/config.js"; import { RunEvent } from "./entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; import { ToolCallPart } from "./entities/message.js"; import { Agent } from "./agents/agents.js"; import { McpServerConfig, McpServerDefinition } from "./mcp/schema.js"; import { Example } from "./entities/example.js"; import { z } from "zod"; import { Flavor } from "./models/models.js"; import { examples } from "./examples/index.js"; import container from "./di/container.js"; import { IModelConfigRepo } from "./models/repo.js"; function renderGreeting() { const logo = ` $$\\ $$\\ $$ | $$ | $$$$$$\\ $$$$$$\\ $$\\ $$\\ $$\\ $$$$$$$\\ $$$$$$\\ $$$$$$\\ $$$$$$\\ $$\\ $$\\ $$ __$$\\ $$ __$$\\ $$ | $$ | $$ |$$ __$$\\ $$ __$$\\ \\____$$\\_$$ _| \\$$\\ $$ | $$ | \\__|$$ / $$ |$$ | $$ | $$ |$$ | $$ |$$ / $$ | $$$$$$$ | $$ | \\$$$$ / $$ | $$ | $$ |$$ | $$ | $$ |$$ | $$ |$$ | $$ |$$ __$$ | $$ |$$\\ $$ $$< $$ | \\$$$$$$ |\\$$$$$\\$$$$ |$$$$$$$ |\\$$$$$$ |\\$$$$$$$ | \\$$$$ |$$ /\\$$\\ \\__| \\______/ \\_____\\____/ \\_______/ \\______/ \\_______| \\____/ \\__/ \\__| `; console.log(logo); console.log("\nHow can i help you today?"); } export async function app(opts: { agent: string; runId?: string; input?: string; noInteractive?: boolean; }) { throw new Error("Not implemented"); /* const renderer = new StreamRenderer(); const state = new AgentState(opts.agent, opts.runId); if (opts.agent === "copilot" && !opts.runId) { renderGreeting(); } // load existing and assemble state if required let runId = opts.runId; if (runId) { console.error("loading run", runId); let stream: fs.ReadStream | null = null; let rl: Interface | null = null; try { const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); stream = fs.createReadStream(logFile, { encoding: "utf8" }); rl = createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim() === "") { continue; } const parsed = JSON.parse(line); const event = RunEvent.parse(parsed); state.ingest(event); } } finally { stream?.close(); } } let rl: Interface | null = null; if (!opts.noInteractive) { rl = createInterface({ input, output }); } let inputConsumed = false; try { while (true) { // ask for pending tool permissions for (const perm of Object.values(state.getPendingPermissions())) { if (opts.noInteractive) { return; } const response = await getToolCallPermission(perm.toolCall, rl!); state.ingestAndLog({ type: "tool-permission-response", response, toolCallId: perm.toolCall.toolCallId, subflow: perm.subflow, }); } // ask for pending human input for (const ask of Object.values(state.getPendingAskHumans())) { if (opts.noInteractive) { return; } const response = await getAskHumanResponse(ask.query, rl!); state.ingestAndLog({ type: "ask-human-response", response, toolCallId: ask.toolCallId, subflow: ask.subflow, }); } // run one turn for await (const event of streamAgent(state)) { renderer.render(event); if (event?.type === "error") { process.exitCode = 1; } } // if nothing pending, get user input if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) { if (opts.input && !inputConsumed) { state.ingestAndLog({ type: "message", message: { role: "user", content: opts.input, }, subflow: [], }); inputConsumed = true; continue; } if (opts.noInteractive) { return; } const response = await getUserInput(rl!); state.ingestAndLog({ type: "message", message: { role: "user", content: response, }, subflow: [], }); } } } finally { rl?.close(); } */ } async function getToolCallPermission( call: z.infer, rl: Interface, ): Promise<"approve" | "deny"> { const question = `Do you want to allow running the following tool: ${call.toolName}?: Tool name: ${call.toolName} Tool arguments: ${JSON.stringify(call.arguments)} Choices: y/n/a/d: - y: approve - n: deny `; const input = await rl.question(question); if (input.toLowerCase() === "y") return "approve"; if (input.toLowerCase() === "n") return "deny"; return "deny"; } async function getAskHumanResponse( query: string, rl: Interface, ): Promise { const input = await rl.question(`The agent is asking for your help with the following query: Question: ${query} Please respond to the question. `); return input; } async function getUserInput( rl: Interface, ): Promise { const input = await rl.question("You: "); if (["quit", "exit", "q"].includes(input.toLowerCase().trim())) { console.error("Bye!"); process.exit(0); } return input; } export async function modelConfig() { // load existing model config const repo = container.resolve('modelConfigRepo'); const config = await repo.getConfig(); const rl = createInterface({ input, output }); try { const defaultApiKeyEnvVars: Record, string> = { "rowboat [free]": "", openai: "OPENAI_API_KEY", aigateway: "AI_GATEWAY_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY", ollama: "", "openai-compatible": "", openrouter: "", }; const defaultBaseUrls: Record, string> = { "rowboat [free]": "", openai: "https://api.openai.com/v1", aigateway: "https://ai-gateway.vercel.sh/v1/ai", anthropic: "https://api.anthropic.com/v1", google: "https://generativelanguage.googleapis.com/v1beta", ollama: "http://localhost:11434", "openai-compatible": "http://localhost:8080/v1", openrouter: "https://openrouter.ai/api/v1", }; const defaultModels: Record, string> = { "rowboat [free]": "google/gemini-3-pro-preview", openai: "gpt-5.1", aigateway: "gpt-5.1", anthropic: "claude-sonnet-4-5", google: "gemini-2.5-pro", ollama: "llama3.1", "openai-compatible": "openai/gpt-5.1", openrouter: "openrouter/auto", }; const currentProvider = config?.defaults?.provider; const currentModel = config?.defaults?.model; const currentProviderConfig = currentProvider ? config?.providers?.[currentProvider] : undefined; if (config) { renderCurrentModel(currentProvider || "none", currentProviderConfig?.flavor || "", currentModel || "none"); } const FlavorList = [...Flavor.options]; const flavorPromptLines = FlavorList .map((f, idx) => ` ${idx + 1}. ${f}`) .join("\n"); const flavorAnswer = await rl.question( `Select a provider type:\n${flavorPromptLines}\nEnter number or name: ` ); let selectedFlavorRaw = flavorAnswer.trim(); let selectedFlavor: z.infer | null = null; if (/^\d+$/.test(selectedFlavorRaw)) { const idx = parseInt(selectedFlavorRaw, 10) - 1; if (idx >= 0 && idx < FlavorList.length) { selectedFlavor = FlavorList[idx]; } } else if (FlavorList.includes(selectedFlavorRaw as z.infer)) { selectedFlavor = selectedFlavorRaw as z.infer; } if (!selectedFlavor) { console.error("Invalid selection. Exiting."); return; } const existingAliases = Object.keys(config?.providers || {}).filter( (name) => config?.providers?.[name]?.flavor === selectedFlavor, ); let providerName: string | null = null; let chooseMode: "existing" | "add" = "add"; if (existingAliases.length > 0) { const listLines = existingAliases .map((alias, idx) => ` ${idx + 1}. use existing: ${alias}`) .join("\n"); const addIndex = existingAliases.length + 1; const providerSelect = await rl.question( `Found existing providers for ${selectedFlavor}:\n${listLines}\n ${addIndex}. add new\nEnter number or name/alias [${addIndex}]: `, ); const sel = providerSelect.trim(); if (sel === "" || sel.toLowerCase() === "add" || sel.toLowerCase() === "new") { chooseMode = "add"; } else if (/^\d+$/.test(sel)) { const idx = parseInt(sel, 10) - 1; if (idx >= 0 && idx < existingAliases.length) { providerName = existingAliases[idx]; chooseMode = "existing"; } else if (idx === existingAliases.length) { chooseMode = "add"; } else { console.error("Invalid selection. Exiting."); return; } } else if (existingAliases.includes(sel)) { providerName = sel; chooseMode = "existing"; } else { console.error("Invalid selection. Exiting."); return; } } if (chooseMode === "existing" && !providerName) { console.error("No provider selected. Exiting."); return; } if (chooseMode === "existing") { const modelDefault = currentProvider === providerName && currentModel ? currentModel : defaultModels[selectedFlavor]; const modelAns = await rl.question( `Specify model for ${selectedFlavor} [${modelDefault}]: `, ); const model = modelAns.trim() || modelDefault; await repo.setDefault(providerName!, model); console.log(`Model configuration updated. Provider set to '${providerName}'.`); return; } const headers: Record = {}; if (selectedFlavor !== "rowboat [free]") { const providerNameAns = await rl.question( `Enter a name/alias for this provider [${selectedFlavor}]: `, ); providerName = providerNameAns.trim() || selectedFlavor; } else { providerName = selectedFlavor; } let baseURL: string | undefined = undefined; if (selectedFlavor !== "rowboat [free]") { const baseUrlAns = await rl.question( `Enter baseURL for ${selectedFlavor} [${defaultBaseUrls[selectedFlavor]}]: `, ); baseURL = baseUrlAns.trim() || undefined; } let apiKey: string | undefined = undefined; if (selectedFlavor !== "ollama" && selectedFlavor !== "rowboat [free]") { let autopickText = ""; if (defaultApiKeyEnvVars[selectedFlavor]) { autopickText = ` (leave blank to pick from environment variable ${defaultApiKeyEnvVars[selectedFlavor]})`; } const apiKeyAns = await rl.question( `Enter API key for ${selectedFlavor}${autopickText}: `, ); apiKey = apiKeyAns.trim() || undefined; } if (selectedFlavor === "ollama") { const keyAns = await rl.question( `Enter API key for ${selectedFlavor} (optional): ` ); const key = keyAns.trim(); if (key) { headers["Authorization"] = `Bearer ${key}`; } } const modelDefault = defaultModels[selectedFlavor]; const modelAns = await rl.question( `Specify model for ${selectedFlavor} [${modelDefault}]: `, ); const model = modelAns.trim() || modelDefault; await repo.upsert(providerName, { flavor: selectedFlavor, apiKey, baseURL, headers, }); await repo.setDefault(providerName, model); renderCurrentModel(providerName, selectedFlavor, model); console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`); } finally { rl.close(); } } function renderCurrentModel(provider: string, flavor: string, model: string) { console.log("Currently using:"); console.log(`- provider: ${provider}${flavor ? ` (${flavor})` : ""}`); console.log(`- model: ${model}`); console.log(""); } async function listAvailableExamples(): Promise { return Object.keys(examples); } async function writeAgents(agents: z.infer[] | undefined) { if (!agents) { return; } await fsp.mkdir(path.join(WorkDir, "agents"), { recursive: true }); await Promise.all( agents.map(async (agent) => { const agentPath = path.join(WorkDir, "agents", `${agent.name}.json`); await fsp.writeFile(agentPath, JSON.stringify(agent, null, 2), "utf8"); }), ); } async function mergeMcpServers(servers: Record>) { const result = { added: [] as string[], skipped: [] as string[] }; // Early return if no servers to process if (!servers || Object.keys(servers).length === 0) { return result; } const configPath = path.join(WorkDir, "config", "mcp.json"); // Read existing config let currentConfig: z.infer = { mcpServers: {} }; try { const contents = await fsp.readFile(configPath, "utf8"); currentConfig = McpServerConfig.parse(JSON.parse(contents)); } catch (error: any) { if (error?.code !== "ENOENT") { throw new Error(`Unable to read MCP config: ${error.message ?? error}`); } // File doesn't exist yet, use empty config } // Merge servers for (const [name, definition] of Object.entries(servers)) { if (currentConfig.mcpServers[name]) { result.skipped.push(name); } else { currentConfig.mcpServers[name] = definition; result.added.push(name); } } // Only write if we added new servers if (result.added.length > 0) { await fsp.mkdir(path.dirname(configPath), { recursive: true }); await fsp.writeFile(configPath, JSON.stringify(currentConfig, null, 2), "utf8"); } return result; } export async function importExample(exampleName?: string, filePath?: string) { let example: z.infer; let sourceName: string; if (exampleName) { // Load from built-in examples example = examples[exampleName]; if (!example) { const availableExamples = Object.keys(examples); const listMessage = availableExamples.length ? `Available examples: ${availableExamples.join(", ")}` : "No packaged examples are available."; throw new Error(`Unknown example '${exampleName}'. ${listMessage}`); } sourceName = exampleName; } else if (filePath) { // Load from file path try { const fileContent = await fsp.readFile(filePath, "utf8"); example = Example.parse(JSON.parse(fileContent)); sourceName = path.basename(filePath, ".json"); } catch (error: any) { if (error?.code === "ENOENT") { throw new Error(`File not found: ${filePath}`); } else if (error?.name === "ZodError") { throw new Error(`Invalid workflow file format: ${error.message}`); } throw new Error(`Failed to read workflow file: ${error.message ?? error}`); } } else { throw new Error("Either exampleName or filePath must be provided"); } // Import agents and MCP servers await writeAgents(example.agents); let serverMerge = { added: [] as string[], skipped: [] as string[] }; if (example.mcpServers) { serverMerge = await mergeMcpServers(example.mcpServers); } // Build and display output message const importedAgents = example.agents?.map((agent) => agent.name) ?? []; const entryAgent = example.entryAgent ?? importedAgents[0] ?? ""; const output = [ `✓ Imported workflow '${sourceName}'`, ` Agents: ${importedAgents.join(", ")}`, ` Primary: ${entryAgent}`, ]; if (serverMerge.added.length > 0) { output.push(` MCP servers added: ${serverMerge.added.join(", ")}`); } if (serverMerge.skipped.length > 0) { output.push(` MCP servers skipped (already configured): ${serverMerge.skipped.join(", ")}`); } console.log(output.join("\n")); // Display post-install instructions if present if (example.instructions) { console.log("\n" + "=".repeat(60)); console.log("POST-INSTALL INSTRUCTIONS"); console.log("=".repeat(60)); console.log(example.instructions); console.log("=".repeat(60) + "\n"); } // Display next steps console.log(`\nRun: rowboatx --agent ${entryAgent}`); } export async function listExamples() { return listAvailableExamples(); } export async function exportWorkflow(entryAgentName: string) { const agentsDir = path.join(WorkDir, "agents"); const mcpConfigPath = path.join(WorkDir, "config", "mcp.json"); // Read MCP config let mcpConfig: z.infer = { mcpServers: {} }; try { const mcpContent = await fsp.readFile(mcpConfigPath, "utf8"); mcpConfig = McpServerConfig.parse(JSON.parse(mcpContent)); } catch (error: any) { if (error?.code !== "ENOENT") { throw new Error(`Failed to read MCP config: ${error.message ?? error}`); } } // Recursively discover all agents and MCP servers const discoveredAgents = new Map>(); const discoveredMcpServers = new Set(); async function discoverAgent(agentName: string) { if (discoveredAgents.has(agentName)) { return; // Already processed } // Load agent const agentPath = path.join(agentsDir, `${agentName}.json`); let agentContent: string; try { agentContent = await fsp.readFile(agentPath, "utf8"); } catch (error: any) { if (error?.code === "ENOENT") { throw new Error(`Agent not found: ${agentName}`); } throw new Error(`Failed to read agent ${agentName}: ${error.message ?? error}`); } const agent = Agent.parse(JSON.parse(agentContent)); discoveredAgents.set(agentName, agent); // Process tools if (agent.tools) { for (const [toolKey, tool] of Object.entries(agent.tools)) { if (tool.type === "agent") { // Recursively discover dependent agent await discoverAgent(tool.name); } else if (tool.type === "mcp") { // Track MCP server discoveredMcpServers.add(tool.mcpServerName); } } } } // Start discovery from entry agent await discoverAgent(entryAgentName); // Build MCP servers object const workflowMcpServers: Record> = {}; for (const serverName of discoveredMcpServers) { if (mcpConfig.mcpServers[serverName]) { workflowMcpServers[serverName] = mcpConfig.mcpServers[serverName]; } else { throw new Error(`MCP server '${serverName}' is referenced but not found in config`); } } // Build workflow object const workflow: z.infer = { id: entryAgentName, entryAgent: entryAgentName, agents: Array.from(discoveredAgents.values()), ...(Object.keys(workflowMcpServers).length > 0 ? { mcpServers: workflowMcpServers } : {}), }; // Output to stdout console.log(JSON.stringify(workflow, null, 2)); } ================================================ FILE: apps/cli/src/application/assistant/agent.ts ================================================ import { Agent, ToolAttachment } from "../../agents/agents.js"; import z from "zod"; import { CopilotInstructions } from "./instructions.js"; import { BuiltinTools } from "../lib/builtin-tools.js"; const tools: Record> = {}; for (const [name, tool] of Object.entries(BuiltinTools)) { tools[name] = { type: "builtin", name, }; } export const CopilotAgent: z.infer = { name: "rowboatx", description: "Rowboatx copilot", instructions: CopilotInstructions, tools, } ================================================ FILE: apps/cli/src/application/assistant/instructions.ts ================================================ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks. ## General Capabilities In addition to Rowboat-specific workflow management, you can help users with general tasks like answering questions, explaining concepts, brainstorming ideas, solving problems, writing and debugging code, analyzing information, and providing explanations on a wide range of topics. Be conversational, helpful, and engaging. For tasks requiring external capabilities (web search, APIs, etc.), use MCP tools as described below. Use the catalog below to decide which skills to load for each user request. Before acting: - Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string. - Apply the instructions from every loaded skill while working on the request. ${skillCatalog} Always consult this catalog first so you load the right skills before taking action. # Communication & Execution Style ## Communication principles - Be concise and direct. Avoid verbose explanations unless the user asks for details. - Only show JSON output when explicitly requested by the user. Otherwise, summarize results in plain language. - Break complex efforts into clear, sequential steps the user can follow. - Explain reasoning briefly as you work, and confirm outcomes before moving on. - Be proactive about understanding missing context; ask clarifying questions when needed. - Summarize completed work and suggest logical next steps at the end of a task. - Always ask for confirmation before taking destructive actions. ## MCP Tool Discovery (CRITICAL) **ALWAYS check for MCP tools BEFORE saying you can't do something.** When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools. **DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first! ## Execution reminders - Explore existing files and structure before creating new assets. - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. ${runtimeContextPrompt} ## Workspace access & scope - You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. - Prefer builtin file tools (\`createFile\`, \`updateFile\`, \`deleteFile\`, \`exploreDirectory\`) for workspace changes. Reserve refusal or "you do it" responses for cases that are truly outside the Rowboat sandbox. ## Builtin Tools vs Shell Commands **IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries: - \`deleteFile\`, \`createFile\`, \`updateFile\`, \`readFile\` - File operations - \`listFiles\`, \`exploreDirectory\` - Directory exploration - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`. **CRITICAL: MCP Server Configuration** - ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving - NEVER manually edit \`config/mcp.json\` using \`createFile\` or \`updateFile\` for MCP servers - Invalid MCP configs will prevent the agent from starting with validation errors **Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`deleteFile\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`createFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools. `; ================================================ FILE: apps/cli/src/application/assistant/runtime-context.ts ================================================ export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; export interface RuntimeContext { platform: NodeJS.Platform; osName: RuntimeOsName; shellDialect: RuntimeShellDialect; shellExecutable: string; } export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; } export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { if (platform === 'win32') { return { platform, osName: 'Windows', shellDialect: 'windows-cmd', shellExecutable: getExecutionShell(platform), }; } if (platform === 'darwin') { return { platform, osName: 'macOS', shellDialect: 'posix-sh', shellExecutable: getExecutionShell(platform), }; } if (platform === 'linux') { return { platform, osName: 'Linux', shellDialect: 'posix-sh', shellExecutable: getExecutionShell(platform), }; } return { platform, osName: 'Unknown', shellDialect: 'posix-sh', shellExecutable: getExecutionShell(platform), }; } export function getRuntimeContextPrompt(runtime: RuntimeContext): string { if (runtime.shellDialect === 'windows-cmd') { return `## Runtime Platform (CRITICAL) - Detected platform: **${runtime.platform}** - Detected OS: **${runtime.osName}** - Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) - Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). - Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). - Do not assume macOS/Linux command syntax when the runtime is Windows.`; } return `## Runtime Platform (CRITICAL) - Detected platform: **${runtime.platform}** - Detected OS: **${runtime.osName}** - Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) - Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). - Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). - Do not assume Windows command syntax when the runtime is POSIX.`; } ================================================ FILE: apps/cli/src/application/assistant/skills/builtin-tools/skill.ts ================================================ export const skill = String.raw` # Builtin Tools Reference Load this skill when creating or modifying agents that need access to Rowboat's builtin tools (shell execution, file operations, etc.). ## Available Builtin Tools Agents can use builtin tools by declaring them in the \`"tools"\` object with \`"type": "builtin"\` and the appropriate \`"name"\`. ### executeCommand **The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. **Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. **Agent tool declaration:** \`\`\`json "tools": { "bash": { "type": "builtin", "name": "executeCommand" } } \`\`\` **What it can do:** - Run package managers (npm, pip, apt, brew, cargo, go get, etc.) - Git operations (clone, commit, push, pull, status, diff, log, etc.) - System operations (ps, top, df, du, find, grep, kill, etc.) - Build and compilation (make, cargo build, go build, npm run build, etc.) - Network operations (curl, wget, ping, ssh, netstat, etc.) - Text processing (awk, sed, grep, jq, yq, cut, sort, uniq, etc.) - Database operations (psql, mysql, mongo, redis-cli, etc.) - Container operations (docker, kubectl, podman, etc.) - Testing and debugging (pytest, jest, cargo test, etc.) - File operations (cat, head, tail, wc, diff, patch, etc.) - Any CLI tool or script execution **Agent instruction examples:** - "Use the bash tool to run git commands for version control operations" - "Execute curl commands using the bash tool to fetch data from APIs" - "Use bash to run 'npm install' and 'npm test' commands" - "Run Python scripts using the bash tool with 'python script.py'" - "Use bash to execute 'docker ps' and inspect container status" - "Run database queries using 'psql' or 'mysql' commands via bash" - "Use bash to execute system monitoring commands like 'top' or 'ps aux'" **Pro tips for agent instructions:** - Commands can be chained with && for sequential execution - Use pipes (|) to combine Unix tools (e.g., "cat file.txt | grep pattern | wc -l") - Redirect output with > or >> when needed - Full bash shell features are available (variables, loops, conditionals, etc.) - Tools like jq, yq, awk, sed can parse and transform data **Example agent with executeCommand:** \`\`\`json { "name": "arxiv-feed-reader", "description": "A feed reader for the arXiv", "model": "gpt-5.1", "instructions": "Extract latest papers from the arXiv feed and summarize them. Use curl to fetch the RSS feed, then parse it with yq and jq:\n\ncurl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"'\n\nThis will give you papers containing 'agent' in the title.", "tools": { "bash": { "type": "builtin", "name": "executeCommand" } } } \`\`\` **Another example - System monitoring agent:** \`\`\`json { "name": "system-monitor", "description": "Monitor system resources and processes", "model": "gpt-5.1", "instructions": "Monitor system resources using bash commands. Use 'df -h' for disk usage, 'free -h' for memory, 'top -bn1' for processes, 'ps aux' for process list. Parse the output and report any issues.", "tools": { "bash": { "type": "builtin", "name": "executeCommand" } } } \`\`\` **Another example - Git automation agent:** \`\`\`json { "name": "git-helper", "description": "Automate git operations", "model": "gpt-5.1", "instructions": "Help with git operations. Use commands like 'git status', 'git log --oneline -10', 'git diff', 'git branch -a' to inspect the repository. Can also run 'git add', 'git commit', 'git push' when instructed.", "tools": { "bash": { "type": "builtin", "name": "executeCommand" } } } \`\`\` ## Agent-to-Agent Calling Agents can call other agents as tools to create complex multi-step workflows. This is the core mechanism for building multi-agent systems in the CLI. **Tool declaration:** \`\`\`json "tools": { "summariser": { "type": "agent", "name": "summariser_agent" } } \`\`\` **When to use:** - Breaking complex tasks into specialized sub-agents - Creating reusable agent components - Orchestrating multi-step workflows - Delegating specialized tasks (e.g., summarization, data processing, audio generation) **How it works:** - The agent calls the tool like any other tool - The target agent receives the input and processes it - Results are returned as tool output - The calling agent can then continue processing or delegate further **Example - Agent that delegates to a summarizer:** \`\`\`json { "name": "paper_analyzer", "model": "gpt-5.1", "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the summariser. Don't ask for human input.", "tools": { "summariser": { "type": "agent", "name": "summariser_agent" } } } \`\`\` **Tips for agent chaining:** - Make instructions explicit about when to call other agents - Pass clear, structured data between agents - Add "Don't ask for human input" for autonomous workflows - Keep each agent focused on a single responsibility ## Additional Builtin Tools While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`. ### Copilot-Specific Builtin Tools The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration: #### File & Directory Operations - \`exploreDirectory\` - Recursively explore directory structure - \`readFile\` - Read and parse file contents - \`createFile\` - Create a new file with content - \`updateFile\` - Update or overwrite existing file contents - \`deleteFile\` - Delete a file - \`listFiles\` - List all files and directories #### Agent Operations - \`analyzeAgent\` - Read and analyze an agent file structure - \`loadSkill\` - Load a Rowboat skill definition into context #### MCP Operations - \`addMcpServer\` - Add or update an MCP server configuration (with validation) - \`listMcpServers\` - List all available MCP servers - \`listMcpTools\` - List all available tools from a specific MCP server - \`executeMcpTool\` - **Execute a specific MCP tool on behalf of the user** #### Using executeMcpTool as Copilot The \`executeMcpTool\` builtin allows the copilot to directly execute MCP tools without creating an agent. Load the "mcp-integration" skill for complete guidance on discovering and executing MCP tools, including workflows, schema matching, and examples. **When to use executeMcpTool vs creating an agent:** - Use \`executeMcpTool\` for immediate, one-time tasks - Create an agent when the user needs repeated use or autonomous operation - Create an agent for complex multi-step workflows involving multiple tools ## Best Practices 1. **Give agents clear examples** in their instructions showing exact bash commands to run 2. **Explain output parsing** - show how to use jq, yq, grep, awk to extract data 3. **Chain commands efficiently** - use && for sequences, | for pipes 4. **Handle errors** - remind agents to check exit codes and stderr 5. **Be specific** - provide example commands rather than generic descriptions 6. **Security** - remind agents to validate inputs and avoid dangerous operations ## When to Use Builtin Tools vs MCP Tools vs Agent Tools - **Use builtin executeCommand** when you need: CLI tools, system operations, data processing, git operations, any shell command - **Use MCP tools** when you need: Web scraping (firecrawl), text-to-speech (elevenlabs), specialized APIs, external service integrations - **Use agent tools (\`"type": "agent"\`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning Many tasks can be accomplished with just \`executeCommand\` and common Unix tools - it's incredibly powerful! ## Key Insight: Multi-Agent Workflows In the CLI, multi-agent workflows are built by: 1. Creating specialized agents for specific tasks (in \`agents/\` directory) 2. Creating an orchestrator agent that has other agents in its \`tools\` 3. Running the orchestrator with \`rowboatx --agent orchestrator_name\` There are no separate "workflow" files - everything is an agent! `; export default skill; ================================================ FILE: apps/cli/src/application/assistant/skills/deletion-guardrails/skill.ts ================================================ export const skill = String.raw` # Deletion Guardrails Load this skill when a user asks to delete agents or workflows so you follow the required confirmation steps. ## Workflow deletion protocol 1. Read the workflow file to identify every agent it references. 2. Report those agents to the user and ask whether they should be deleted too. 3. Wait for explicit confirmation before deleting anything. 4. Only remove the workflow and/or agents the user authorizes. ## Agent deletion protocol 1. Inspect the agent file to discover which workflows reference it. 2. List those workflows to the user and ask whether they should be updated or deleted. 3. Pause for confirmation before modifying workflows or removing the agent. 4. Perform only the deletions the user approves. ## Safety checklist - Never delete cascaded resources automatically. - Keep a clear audit trail in your responses describing what was removed. - If the user’s instructions are ambiguous, ask clarifying questions before taking action. `; export default skill; ================================================ FILE: apps/cli/src/application/assistant/skills/index.ts ================================================ import path from "node:path"; import { fileURLToPath } from "node:url"; import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; const CURRENT_FILE = fileURLToPath(import.meta.url); const CURRENT_DIR = path.dirname(CURRENT_FILE); const CATALOG_PREFIX = "src/application/assistant/skills"; type SkillDefinition = { id: string; title: string; folder: string; summary: string; content: string; }; type ResolvedSkill = { id: string; catalogPath: string; content: string; }; const definitions: SkillDefinition[] = [ { id: "workflow-authoring", title: "Workflow Authoring", folder: "workflow-authoring", summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", content: workflowAuthoringSkill, }, { id: "builtin-tools", title: "Builtin Tools Reference", folder: "builtin-tools", summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.", content: builtinToolsSkill, }, { id: "mcp-integration", title: "MCP Integration Guidance", folder: "mcp-integration", summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.", content: mcpIntegrationSkill, }, { id: "deletion-guardrails", title: "Deletion Guardrails", folder: "deletion-guardrails", summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, { id: "workflow-run-ops", title: "Workflow Run Operations", folder: "workflow-run-ops", summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", content: workflowRunOpsSkill, }, ]; const skillEntries = definitions.map((definition) => ({ ...definition, catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`, })); const catalogSections = skillEntries.map((entry) => [ `## ${entry.title}`, `- **Skill file:** \`${entry.catalogPath}\``, `- **Use it for:** ${entry.summary}`, ].join("\n")); export const skillCatalog = [ "# Rowboat Skill Catalog", "", "Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.", "", catalogSections.join("\n\n"), ].join("\n"); const normalizeIdentifier = (value: string) => value.trim().replace(/\\/g, "/").replace(/^\.\/+/, ""); const aliasMap = new Map(); const registerAlias = (alias: string, entry: ResolvedSkill) => { const normalized = normalizeIdentifier(alias); if (!normalized) return; aliasMap.set(normalized, entry); }; const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { const normalized = normalizeIdentifier(alias); if (!normalized) return; const variants = new Set([normalized]); if (/\.(ts|js)$/i.test(normalized)) { variants.add(normalized.replace(/\.(ts|js)$/i, "")); variants.add( normalized.endsWith(".ts") ? normalized.replace(/\.ts$/i, ".js") : normalized.replace(/\.js$/i, ".ts"), ); } else { variants.add(`${normalized}.ts`); variants.add(`${normalized}.js`); } for (const variant of variants) { registerAlias(variant, entry); } }; for (const entry of skillEntries) { const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts"); const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js"); const resolvedEntry: ResolvedSkill = { id: entry.id, catalogPath: entry.catalogPath, content: entry.content, }; const baseAliases = [ entry.id, entry.folder, `${entry.folder}/skill`, `${entry.folder}/skill.ts`, `${entry.folder}/skill.js`, `skills/${entry.folder}/skill.ts`, `skills/${entry.folder}/skill.js`, `${CATALOG_PREFIX}/${entry.folder}/skill.ts`, `${CATALOG_PREFIX}/${entry.folder}/skill.js`, absoluteTs, absoluteJs, ]; for (const alias of baseAliases) { registerAliasVariants(alias, resolvedEntry); } } export const availableSkills = skillEntries.map((entry) => entry.id); export function resolveSkill(identifier: string): ResolvedSkill | null { const normalized = normalizeIdentifier(identifier); if (!normalized) return null; return aliasMap.get(normalized) ?? null; } ================================================ FILE: apps/cli/src/application/assistant/skills/mcp-integration/skill.ts ================================================ export const skill = String.raw` # MCP Integration Guidance **Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools. ## CRITICAL: Always Check MCP Tools First **IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS: 1. **First check**: Call \`listMcpServers\` to see what's available 2. **Then list tools**: Call \`listMcpTools\` on relevant servers 3. **Execute if possible**: Use \`executeMcpTool\` if a tool matches the need 4. **Only then decline**: If no MCP tool can help, explain what's not possible **DO NOT** immediately say "I can't do that" or "I don't have internet access" without checking MCP tools first! ### Common User Requests and MCP Tools | User Request | Check For | Likely Tool | |--------------|-----------|-------------| | "Search the web/internet" | firecrawl, composio, fetch | \`firecrawl_search\`, \`COMPOSIO_SEARCH_WEB\` | | "Scrape this website" | firecrawl | \`firecrawl_scrape\` | | "Read/write files" | filesystem | \`read_file\`, \`write_file\` | | "Get current time/date" | time | \`get_current_time\` | | "Make HTTP request" | fetch | \`fetch\`, \`post\` | | "GitHub operations" | github | \`create_issue\`, \`search_repos\` | | "Generate audio/speech" | elevenLabs | \`text_to_speech\` | | "Tweet/social media" | twitter, composio | Various social tools | ## Key concepts - MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`. - Agents reference MCP tools through the \`"tools"\` block by specifying \`type\`, \`name\`, \`description\`, \`mcpServerName\`, and a full \`inputSchema\`. - Tool schemas can include optional property descriptions; only include \`"required"\` when parameters are mandatory. ## CRITICAL: Adding MCP Servers **ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors. **NEVER manually create or edit \`config/mcp.json\`** using \`createFile\` or \`updateFile\` for MCP servers—this bypasses validation and will cause errors. ### MCP Server Configuration Schema There are TWO types of MCP servers: #### 1. STDIO (Command-based) Servers For servers that run as local processes (Node.js, Python, etc.): **Required fields:** - \`command\`: string (e.g., "npx", "node", "python", "uvx") **Optional fields:** - \`args\`: array of strings (command arguments) - \`env\`: object with string key-value pairs (environment variables) - \`type\`: "stdio" (optional, inferred from presence of \`command\`) **Schema:** \`\`\`json { "type": "stdio", "command": "string (REQUIRED)", "args": ["string", "..."], "env": { "KEY": "value" } } \`\`\` **Valid STDIO examples:** \`\`\`json { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/data"] } \`\`\` \`\`\`json { "command": "python", "args": ["-m", "mcp_server_git"], "env": { "GIT_REPO_PATH": "/path/to/repo" } } \`\`\` \`\`\`json { "command": "uvx", "args": ["mcp-server-fetch"] } \`\`\` #### 2. HTTP/SSE Servers For servers that expose HTTP or Server-Sent Events endpoints: **Required fields:** - \`url\`: string (complete URL including protocol and path) **Optional fields:** - \`headers\`: object with string key-value pairs (HTTP headers) - \`type\`: "http" (optional, inferred from presence of \`url\`) **Schema:** \`\`\`json { "type": "http", "url": "string (REQUIRED)", "headers": { "Authorization": "Bearer token", "Custom-Header": "value" } } \`\`\` **Valid HTTP examples:** \`\`\`json { "url": "http://localhost:3000/sse" } \`\`\` \`\`\`json { "url": "https://api.example.com/mcp", "headers": { "Authorization": "Bearer sk-1234567890" } } \`\`\` ### Common Validation Errors to Avoid ❌ **WRONG - Missing required field:** \`\`\`json { "args": ["some-arg"] } \`\`\` Error: Missing \`command\` for stdio OR \`url\` for http ❌ **WRONG - Empty object:** \`\`\`json {} \`\`\` Error: Must have either \`command\` (stdio) or \`url\` (http) ❌ **WRONG - Mixed types:** \`\`\`json { "command": "npx", "url": "http://localhost:3000" } \`\`\` Error: Cannot have both \`command\` and \`url\` ✅ **CORRECT - Minimal stdio:** \`\`\`json { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-time"] } \`\`\` ✅ **CORRECT - Minimal http:** \`\`\`json { "url": "http://localhost:3000/sse" } \`\`\` ### Using addMcpServer Tool **Example 1: Add stdio server** \`\`\`json { "serverName": "filesystem", "serverType": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/data"] } \`\`\` **Example 2: Add HTTP server** \`\`\`json { "serverName": "custom-api", "serverType": "http", "url": "https://api.example.com/mcp", "headers": { "Authorization": "Bearer token123" } } \`\`\` **Example 3: Add Python MCP server** \`\`\`json { "serverName": "github", "serverType": "stdio", "command": "python", "args": ["-m", "mcp_server_github"], "env": { "GITHUB_TOKEN": "ghp_xxxxx" } } \`\`\` ## Operator actions 1. Use \`listMcpServers\` to enumerate configured servers. 2. Use \`addMcpServer\` to add or update MCP server configurations (with validation). 3. Use \`listMcpTools\` for a server to understand the available operations and schemas. 4. Use \`executeMcpTool\` to run MCP tools directly on behalf of the user. 5. Explain which MCP tools match the user's needs before editing agent definitions. 6. When adding a tool to an agent, document what it does and ensure the schema mirrors the MCP definition. ## Executing MCP Tools Directly (Copilot) As the copilot, you can execute MCP tools directly on behalf of the user using the \`executeMcpTool\` builtin. This allows you to use MCP tools without creating an agent. ### When to Execute MCP Tools Directly - User asks you to perform a task that an MCP tool can handle (web search, file operations, API calls, etc.) - User wants immediate results from an MCP tool without setting up an agent - You need to test or demonstrate an MCP tool's functionality - You're helping the user accomplish a one-time task ### Workflow for Executing MCP Tools 1. **Discover available servers**: Use \`listMcpServers\` to see what MCP servers are configured 2. **List tools from a server**: Use \`listMcpTools\` with the server name to see available tools and their schemas 3. **CAREFULLY EXAMINE THE SCHEMA**: Look at the \`inputSchema\` to understand exactly what parameters are required 4. **Execute the tool**: Use \`executeMcpTool\` with the server name, tool name, and required arguments (matching the schema exactly) 5. **Return results**: Present the results to the user in a helpful format ### CRITICAL: Schema Matching **ALWAYS** examine the \`inputSchema\` from \`listMcpTools\` before calling \`executeMcpTool\`. The schema tells you: - What parameters are required (check the \`"required"\` array) - What type each parameter should be (string, number, boolean, object, array) - Parameter descriptions and examples **Example schema from listMcpTools:** \`\`\`json { "name": "COMPOSIO_SEARCH_WEB", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query" }, "limit": { "type": "number", "description": "Number of results" } }, "required": ["query"] } } \`\`\` **Correct executeMcpTool call:** \`\`\`json { "serverName": "composio", "toolName": "COMPOSIO_SEARCH_WEB", "arguments": { "query": "elon musk latest news" } } \`\`\` **WRONG - Missing arguments:** \`\`\`json { "serverName": "composio", "toolName": "COMPOSIO_SEARCH_WEB" } \`\`\` **WRONG - Wrong parameter name:** \`\`\`json { "serverName": "composio", "toolName": "COMPOSIO_SEARCH_WEB", "arguments": { "search": "elon musk" // Wrong! Should be "query" } } \`\`\` ### Example: Using Firecrawl to Search the Web **Step 1: List servers** \`\`\`json // Call: listMcpServers // Response: { "servers": [{"name": "firecrawl", "type": "stdio", ...}] } \`\`\` **Step 2: List tools** \`\`\`json // Call: listMcpTools with serverName: "firecrawl" // Response: { "tools": [{"name": "firecrawl_search", "description": "Search the web", "inputSchema": {...}}] } \`\`\` **Step 3: Execute the tool** \`\`\`json { "serverName": "firecrawl", "toolName": "firecrawl_search", "arguments": { "query": "latest AI news", "limit": 5 } } \`\`\` ### Example: Using Filesystem Tool **Execute a filesystem read operation:** \`\`\`json { "serverName": "filesystem", "toolName": "read_file", "arguments": { "path": "/path/to/file.txt" } } \`\`\` ### Tips for Executing MCP Tools - Always check the \`inputSchema\` from \`listMcpTools\` to know what arguments are required - Match argument types exactly (string, number, boolean, object, array) - Provide helpful context to the user about what the tool is doing - Handle errors gracefully and suggest alternatives if a tool fails - For complex tasks, consider creating an agent instead of one-off tool calls ### Discovery Pattern (Recommended) When a user asks for something that might be accomplished with an MCP tool: 1. **Identify the need**: "You want to search the web? Let me check what MCP tools are available..." 2. **List servers**: Call \`listMcpServers\` 3. **Check for relevant tools**: If you find a relevant server (e.g., "firecrawl" for web search), call \`listMcpTools\` 4. **Execute the tool**: Once you find the right tool and understand its schema, call \`executeMcpTool\` 5. **Present results**: Format and explain the results to the user ### Common MCP Servers and Their Tools Based on typical configurations, you might find: - **firecrawl**: Web scraping, search, crawling (\`firecrawl_search\`, \`firecrawl_scrape\`, \`firecrawl_crawl\`) - **filesystem**: File operations (\`read_file\`, \`write_file\`, \`list_directory\`) - **github**: GitHub operations (\`create_issue\`, \`create_pr\`, \`search_repositories\`) - **fetch**: HTTP requests (\`fetch\`, \`post\`) - **time**: Time/date operations (\`get_current_time\`, \`convert_timezone\`) Always use \`listMcpServers\` and \`listMcpTools\` to discover what's actually available rather than assuming. ## Adding MCP Tools to Agents Once an MCP server is configured, add its tools to agent definitions: ### MCP Tool Format in Agent \`\`\`json "tools": { "descriptive_key": { "type": "mcp", "name": "actual_tool_name_from_server", "description": "What the tool does", "mcpServerName": "server_name_from_config", "inputSchema": { "type": "object", "properties": { "param1": {"type": "string", "description": "What param1 means"} }, "required": ["param1"] } } } \`\`\` ### Tool Schema Rules - Use \`listMcpTools\` to get the exact \`inputSchema\` from the server - Copy the schema exactly as provided by the MCP server - Only include \`"required"\` array if parameters are truly mandatory - Add descriptions to help the agent understand parameter usage ### Example snippets to reference - Firecrawl search (required param): \`\`\`json "tools": { "search": { "type": "mcp", "name": "firecrawl_search", "description": "Search the web", "mcpServerName": "firecrawl", "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "number", "description": "Number of results"} }, "required": ["query"] } } } \`\`\` - ElevenLabs text-to-speech (no required array): \`\`\`json "tools": { "text_to_speech": { "type": "mcp", "name": "text_to_speech", "description": "Generate audio from text", "mcpServerName": "elevenLabs", "inputSchema": { "type": "object", "properties": { "text": {"type": "string"} } } } } \`\`\` ## Safety reminders - ALWAYS use \`addMcpServer\` to configure MCP servers—never manually edit config files - Only recommend MCP tools that are actually configured (use \`listMcpServers\` first) - Clarify any missing details (required parameters, server names) before modifying files - Test server connection with \`listMcpTools\` after adding a new server - Invalid MCP configs prevent agents from starting—validation is critical `; export default skill; ================================================ FILE: apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts ================================================ export const skill = String.raw` # Agent and Workflow Authoring Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace. ## Core Concepts **IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - **All definitions live in \`agents/*.json\`** - there is no separate workflows folder - Agents configure a model, instructions, and the tools they can use - Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents** - **"Workflows" are just agents that orchestrate other agents** by having them as tools ## How multi-agent workflows work 1. **Create an orchestrator agent** that has other agents in its \`tools\` 2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\` 3. The orchestrator calls other agents as tools when needed 4. Data flows through tool call parameters and responses ## Agent File Schema Agent files MUST conform to this exact schema. Invalid agents will fail to load. ### Complete Agent Schema \`\`\`json { "name": "string (REQUIRED, must match filename without .json)", "description": "string (REQUIRED, what this agent does)", "instructions": "string (REQUIRED, detailed instructions for the agent)", "model": "string (OPTIONAL, e.g., 'gpt-5.1', 'claude-sonnet-4-5')", "provider": "string (OPTIONAL, provider alias from models.json)", "tools": { "descriptive_key": { "type": "builtin | mcp | agent (REQUIRED)", "name": "string (REQUIRED)", // Additional fields depend on type - see below } } } \`\`\` ### Required Fields - \`name\`: Agent identifier (must exactly match the filename without .json) - \`description\`: Brief description of agent's purpose - \`instructions\`: Detailed instructions for how the agent should behave ### Optional Fields - \`model\`: Model to use (defaults to model config if not specified) - \`provider\`: Provider alias from models.json (optional) - \`tools\`: Object containing tool definitions (can be empty or omitted) ### Naming Rules - Agent filename MUST match the \`name\` field exactly - Example: If \`name\` is "summariser_agent", file must be "summariser_agent.json" - Use lowercase with underscores for multi-word names - No spaces or special characters in names ### Agent Format Example \`\`\`json { "name": "agent_name", "description": "Description of the agent", "model": "gpt-5.1", "instructions": "Instructions for the agent", "tools": { "descriptive_tool_key": { "type": "mcp", "name": "actual_mcp_tool_name", "description": "What the tool does", "mcpServerName": "server_name_from_config", "inputSchema": { "type": "object", "properties": { "param1": {"type": "string", "description": "What the parameter means"} } } } } } \`\`\` ## Tool Types & Schemas Tools in agents must follow one of three types. Each has specific required fields. ### 1. Builtin Tools Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) **Schema:** \`\`\`json { "type": "builtin", "name": "tool_name" } \`\`\` **Required fields:** - \`type\`: Must be "builtin" - \`name\`: Builtin tool name (e.g., "executeCommand", "readFile") **Example:** \`\`\`json "bash": { "type": "builtin", "name": "executeCommand" } \`\`\` **Available builtin tools:** - \`executeCommand\` - Execute shell commands - \`readFile\`, \`createFile\`, \`updateFile\`, \`deleteFile\` - File operations - \`listFiles\`, \`exploreDirectory\` - Directory operations - \`analyzeAgent\` - Analyze agent structure - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\` - MCP management - \`loadSkill\` - Load skill guidance ### 2. MCP Tools Tools from external MCP servers (APIs, databases, web scraping, etc.) **Schema:** \`\`\`json { "type": "mcp", "name": "tool_name_from_server", "description": "What the tool does", "mcpServerName": "server_name_from_config", "inputSchema": { "type": "object", "properties": { "param": {"type": "string", "description": "Parameter description"} }, "required": ["param"] } } \`\`\` **Required fields:** - \`type\`: Must be "mcp" - \`name\`: Exact tool name from MCP server - \`description\`: What the tool does (helps agent understand when to use it) - \`mcpServerName\`: Server name from config/mcp.json - \`inputSchema\`: Full JSON Schema object for tool parameters **Example:** \`\`\`json "search": { "type": "mcp", "name": "firecrawl_search", "description": "Search the web", "mcpServerName": "firecrawl", "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"} }, "required": ["query"] } } \`\`\` **Important:** - Use \`listMcpTools\` to get the exact inputSchema from the server - Copy the schema exactly—don't modify property types or structure - Only include \`"required"\` array if parameters are mandatory ### 3. Agent Tools (for chaining agents) Reference other agents as tools to build multi-agent workflows **Schema:** \`\`\`json { "type": "agent", "name": "target_agent_name" } \`\`\` **Required fields:** - \`type\`: Must be "agent" - \`name\`: Name of the target agent (must exist in agents/ directory) **Example:** \`\`\`json "summariser": { "type": "agent", "name": "summariser_agent" } \`\`\` **How it works:** - Use \`"type": "agent"\` to call other agents as tools - The target agent will be invoked with the parameters you pass - Results are returned as tool output - This is how you build multi-agent workflows - The referenced agent file must exist (e.g., agents/summariser_agent.json) ## Complete Multi-Agent Workflow Example **Podcast creation workflow** - This is all done through agents calling other agents: **1. Task-specific agent** (does one thing): \`\`\`json { "name": "summariser_agent", "description": "Summarises an arxiv paper", "model": "gpt-5.1", "instructions": "Download and summarise an arxiv paper. Use curl to fetch the PDF. Output just the GIST in two lines. Don't ask for human input.", "tools": { "bash": {"type": "builtin", "name": "executeCommand"} } } \`\`\` **2. Agent that delegates to other agents**: \`\`\`json { "name": "summarise-a-few", "description": "Summarises multiple arxiv papers", "model": "gpt-5.1", "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the tool. Don't ask for human input.", "tools": { "summariser": { "type": "agent", "name": "summariser_agent" } } } \`\`\` **3. Orchestrator agent** (coordinates the whole workflow): \`\`\`json { "name": "podcast_workflow", "description": "Create a podcast from arXiv papers", "model": "gpt-5.1", "instructions": "1. Fetch arXiv papers about agents using bash\n2. Pick papers and summarise them using summarise_papers\n3. Create a podcast transcript\n4. Generate audio using text_to_speech\n\nExecute these steps in sequence.", "tools": { "bash": {"type": "builtin", "name": "executeCommand"}, "summarise_papers": { "type": "agent", "name": "summarise-a-few" }, "text_to_speech": { "type": "mcp", "name": "text_to_speech", "mcpServerName": "elevenLabs", "description": "Generate audio", "inputSchema": { "type": "object", "properties": {...}} } } } \`\`\` **To run this workflow**: \`rowboatx --agent podcast_workflow\` ## Naming and organization rules - **All agents live in \`agents/*.json\`** - no other location - Agent filenames must match the \`"name"\` field exactly - When referencing an agent as a tool, use its \`"name"\` value - Always keep filenames and \`"name"\` fields perfectly aligned - Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users ## Best practices for multi-agent design 1. **Single responsibility**: Each agent should do one specific thing well 2. **Clear delegation**: Agent instructions should explicitly say when to call other agents 3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows 4. **Data passing**: Make it clear what data to extract and pass between agents 5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") 6. **Orchestration**: Create a top-level agent that coordinates the workflow ## Validation & Best Practices ### CRITICAL: Schema Compliance - Agent files MUST have \`name\`, \`description\`, and \`instructions\` fields - Agent filename MUST exactly match the \`name\` field - Tools MUST have valid \`type\` ("builtin", "mcp", or "agent") - MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema - Agent tools MUST reference existing agent files - Invalid agents will fail to load and prevent workflow execution ### File Creation/Update Process 1. When creating an agent, use \`createFile\` with complete, valid JSON 2. When updating an agent, read it first with \`readFile\`, modify, then use \`updateFile\` 3. Validate JSON syntax before writing—malformed JSON breaks the agent 4. Test agent loading after creation/update by using \`analyzeAgent\` ### Common Validation Errors to Avoid ❌ **WRONG - Missing required fields:** \`\`\`json { "name": "my_agent" // Missing description and instructions } \`\`\` ❌ **WRONG - Filename mismatch:** - File: agents/my_agent.json - Content: {"name": "myagent", ...} ❌ **WRONG - Invalid tool type:** \`\`\`json "tool1": { "type": "custom", // Invalid type "name": "something" } \`\`\` ❌ **WRONG - MCP tool missing required fields:** \`\`\`json "search": { "type": "mcp", "name": "firecrawl_search" // Missing: description, mcpServerName, inputSchema } \`\`\` ✅ **CORRECT - Minimal valid agent:** \`\`\`json { "name": "simple_agent", "description": "A simple agent", "instructions": "Do simple tasks" } \`\`\` ✅ **CORRECT - Complete MCP tool:** \`\`\`json "search": { "type": "mcp", "name": "firecrawl_search", "description": "Search the web", "mcpServerName": "firecrawl", "inputSchema": { "type": "object", "properties": { "query": {"type": "string"} } } } \`\`\` ## Capabilities checklist 1. Explore \`agents/\` directory to understand existing agents before editing 2. Read existing agents with \`readFile\` before making changes 3. Validate all required fields are present before creating/updating agents 4. Ensure filename matches the \`name\` field exactly 5. Use \`analyzeAgent\` to verify agent structure after creation/update 6. When creating multi-agent workflows, create an orchestrator agent 7. Add other agents as tools with \`"type": "agent"\` for chaining 8. Use \`listMcpServers\` and \`listMcpTools\` when adding MCP integrations 9. Confirm work done and outline next steps once changes are complete `; export default skill; ================================================ FILE: apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts ================================================ export const skill = String.raw` # Agent Run Operations Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling. ## When to use - User wants to run an agent (including multi-agent workflows) - User wants to list or filter agent runs (all runs, by agent, time range, or paused for input) - User wants to inspect cron jobs or change agent schedules - User asks how to set up monitoring for waiting runs ## Running Agents **To run any agent**: \`\`\`bash rowboatx --agent \`\`\` **With input**: \`\`\`bash rowboatx --agent --input "your input here" \`\`\` **Non-interactive** (for automation/cron): \`\`\`bash rowboatx --agent --input "input" --no-interactive \`\`\` **Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow. ## Run monitoring examples Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. 1. **List all runs** ls ~/.rowboat/runs 2. **Filter by agent** grep -rl '"agent":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r Replace with the desired agent name. 3. **Filter by time window** To the previous commands add the below through unix pipe awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"' Use the correct timestamps. 4. **Show runs waiting for human input** awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}' Prints the files whose last line equals 'pause-for-human-input'. ## Cron management examples For scheduling agents to run automatically at specific times. 1. **View current cron schedule** \`\`\`bash crontab -l 2>/dev/null || echo 'No crontab entries configured.' \`\`\` 2. **Schedule an agent to run periodically** \`\`\`bash (crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent --input "input" --no-interactive >> ~/.rowboat/logs/.log 2>&1') | crontab - \`\`\` Example (runs daily at 10 AM): \`\`\`bash (crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab - \`\`\` 3. **Unschedule/remove an agent** \`\`\`bash crontab -l | grep -v '' | crontab - \`\`\` ## Common cron schedule patterns - \`0 10 * * *\` - Daily at 10 AM - \`0 */6 * * *\` - Every 6 hours - \`0 9 * * 1\` - Every Monday at 9 AM - \`*/30 * * * *\` - Every 30 minutes `; export default skill; ================================================ FILE: apps/cli/src/application/lib/builtin-tools.ts ================================================ import { z, ZodType } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; import { WorkDir as BASE_DIR } from "../../config/config.js"; import { executeCommand } from "./command-executor.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; import container from "../../di/container.js"; import { IMcpConfigRepo } from "../..//mcp/repo.js"; import { McpServerDefinition } from "../../mcp/schema.js"; const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), inputSchema: z.custom(), execute: z.function({ input: z.any(), output: z.promise(z.any()), }), })); export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", inputSchema: z.object({ skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"), }), execute: async ({ skillName }: { skillName: string }) => { const resolved = resolveSkill(skillName); if (!resolved) { return { success: false, message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`, }; } return { success: true, skillName: resolved.id, path: resolved.catalogPath, content: resolved.content, }; }, }, exploreDirectory: { description: 'Recursively explore directory structure to understand existing agents and file organization', inputSchema: z.object({ subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), }), execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => { async function explore(dir: string, depth: number = 0): Promise { if (depth > maxDepth) return null; try { const entries = await fs.readdir(dir, { withFileTypes: true }); const result: any = { files: [], directories: {} }; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isFile()) { const ext = path.extname(entry.name); const size = (await fs.stat(fullPath)).size; result.files.push({ name: entry.name, type: ext || 'no-extension', size: size, relativePath: path.relative(BASE_DIR, fullPath), }); } else if (entry.isDirectory()) { result.directories[entry.name] = await explore(fullPath, depth + 1); } } return result; } catch (error) { return { error: error instanceof Error ? error.message : 'Unknown error' }; } } const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; const structure = await explore(dirPath); return { success: true, basePath: path.relative(BASE_DIR, dirPath) || '.', structure, }; }, }, readFile: { description: 'Read and parse file contents. For JSON files, provides parsed structure.', inputSchema: z.object({ filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'), }), execute: async ({ filename }: { filename: string }) => { try { const filePath = path.join(BASE_DIR, filename); const content = await fs.readFile(filePath, 'utf-8'); let parsed = null; let fileType = path.extname(filename); if (fileType === '.json') { try { parsed = JSON.parse(content); } catch { parsed = { error: 'Invalid JSON' }; } } return { success: true, filename, fileType, content, parsed, path: filePath, size: content.length, }; } catch (error) { return { success: false, message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, createFile: { description: 'Create a new file with content. Automatically creates parent directories if needed.', inputSchema: z.object({ filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'), content: z.string().describe('The content to write to the file'), description: z.string().optional().describe('Optional description of why this file is being created'), }), execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => { try { const filePath = path.join(BASE_DIR, filename); const dir = path.dirname(filePath); // Ensure directory exists await fs.mkdir(dir, { recursive: true }); // Write file await fs.writeFile(filePath, content, 'utf-8'); return { success: true, message: `File '${filename}' created successfully`, description: description || 'No description provided', path: filePath, size: content.length, }; } catch (error) { return { success: false, message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, updateFile: { description: 'Update or overwrite the contents of an existing file', inputSchema: z.object({ filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'), content: z.string().describe('The new content to write to the file'), reason: z.string().optional().describe('Optional reason for the update'), }), execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => { try { const filePath = path.join(BASE_DIR, filename); // Check if file exists await fs.access(filePath); // Update file await fs.writeFile(filePath, content, 'utf-8'); return { success: true, message: `File '${filename}' updated successfully`, reason: reason || 'No reason provided', path: filePath, size: content.length, }; } catch (error) { return { success: false, message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, deleteFile: { description: 'Delete a file from the .rowboat directory', inputSchema: z.object({ filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'), }), execute: async ({ filename }: { filename: string }) => { try { const filePath = path.join(BASE_DIR, filename); await fs.unlink(filePath); return { success: true, message: `File '${filename}' deleted successfully`, path: filePath, }; } catch (error) { return { success: false, message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, listFiles: { description: 'List all files and directories in the .rowboat directory or subdirectory', inputSchema: z.object({ subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'), }), execute: async ({ subdirectory }: { subdirectory?: string }) => { try { const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; const entries = await fs.readdir(dirPath, { withFileTypes: true }); const files = entries .filter(entry => entry.isFile()) .map(entry => ({ name: entry.name, type: path.extname(entry.name) || 'no-extension', relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)), })); const directories = entries .filter(entry => entry.isDirectory()) .map(entry => entry.name); return { success: true, path: dirPath, relativePath: path.relative(BASE_DIR, dirPath) || '.', files, directories, totalFiles: files.length, totalDirectories: directories.length, }; } catch (error) { return { success: false, message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, analyzeAgent: { description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'), }), execute: async ({ agentName }: { agentName: string }) => { try { const filename = agentName.endsWith('.json') ? agentName : `${agentName}.json`; const filePath = path.join(BASE_DIR, 'agents', filename); const content = await fs.readFile(filePath, 'utf-8'); const agent = JSON.parse(content); // Extract key information const toolsList = agent.tools ? Object.keys(agent.tools) : []; const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]: [string, any]) => ({ key, type: tool.type, name: tool.name || key, })) : []; const analysis = { name: agent.name, description: agent.description || 'No description', model: agent.model || 'Not specified', toolCount: toolsList.length, tools: agentTools, hasOtherAgents: agentTools.some((t: any) => t.type === 'agent'), structure: agent, }; return { success: true, filePath: path.relative(BASE_DIR, filePath), analysis, }; } catch (error) { return { success: false, message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, addMcpServer: { description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.', inputSchema: z.object({ serverName: z.string().describe('Name/alias for the MCP server'), config: McpServerDefinition, }), execute: async ({ serverName, config }: { serverName: string; config: z.infer; }) => { try { const validationResult = McpServerDefinition.safeParse(config); if (!validationResult.success) { return { success: false, message: 'Server definition failed validation. Check the errors below.', validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`), providedDefinition: config, }; } const repo = container.resolve('mcpConfigRepo'); await repo.upsert(serverName, config); return { success: true, serverName, }; } catch (error) { return { error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, listMcpServers: { description: 'List all available MCP servers from the configuration', inputSchema: z.object({}), execute: async () => { try { const result = await listServers(); return { result, count: Object.keys(result.mcpServers).length, }; } catch (error) { return { error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, listMcpTools: { description: 'List all available tools from a specific MCP server', inputSchema: z.object({ serverName: z.string().describe('Name of the MCP server to query'), cursor: z.string().optional(), }), execute: async ({ serverName, cursor }: { serverName: string, cursor?: string }) => { try { const result = await listTools(serverName, cursor); return { serverName, result, count: result.tools.length, }; } catch (error) { return { error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, }, executeMcpTool: { description: 'Execute a specific tool from an MCP server. Use this to run MCP tools on behalf of the user. IMPORTANT: Always use listMcpTools first to get the tool\'s inputSchema, then match the required parameters exactly in the arguments field.', inputSchema: z.object({ serverName: z.string().describe('Name of the MCP server that provides the tool'), toolName: z.string().describe('Name of the tool to execute'), arguments: z.record(z.string(), z.any()).optional().describe('Arguments to pass to the tool (as key-value pairs matching the tool\'s input schema). MUST include all required parameters from the tool\'s inputSchema.'), }), execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record }) => { try { const result = await executeTool(serverName, toolName, args); return { success: true, serverName, toolName, result, message: `Successfully executed tool '${toolName}' from server '${serverName}'`, }; } catch (error) { return { success: false, error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`, hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.', }; } }, }, executeCommand: { description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', inputSchema: z.object({ command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'), }), execute: async ({ command, cwd }: { command: string, cwd?: string }) => { try { const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR; const result = await executeCommand(command, { cwd: workingDir }); return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, command, workingDir, }; } catch (error) { return { success: false, message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`, command, }; } }, }, }; ================================================ FILE: apps/cli/src/application/lib/bus.ts ================================================ import { RunEvent } from "../../entities/run-events.js"; import z from "zod"; export interface IBus { publish(event: z.infer): Promise; // subscribe accepts a handler to handle events // and returns a function to unsubscribe subscribe(runId: string, handler: (event: z.infer) => Promise): Promise<() => void>; } export class InMemoryBus implements IBus { private subscribers: Map) => Promise)[]> = new Map(); async publish(event: z.infer): Promise { const pending: Promise[] = []; for (const subscriber of this.subscribers.get(event.runId) || []) { pending.push(subscriber(event)); } for (const subscriber of this.subscribers.get('*') || []) { pending.push(subscriber(event)); } await Promise.all(pending); } async subscribe(runId: string, handler: (event: z.infer) => Promise): Promise<() => void> { if (!this.subscribers.has(runId)) { this.subscribers.set(runId, []); } this.subscribers.get(runId)!.push(handler); return () => { this.subscribers.get(runId)!.splice(this.subscribers.get(runId)!.indexOf(handler), 1); }; } } ================================================ FILE: apps/cli/src/application/lib/command-executor.ts ================================================ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"]+|['"]+$/g, ''); } function extractCommandNames(command: string): string[] { const discovered = new Set(); const segments = command.split(COMMAND_SPLIT_REGEX); for (const segment of segments) { const tokens = segment.trim().split(/\s+/).filter(Boolean); if (!tokens.length) continue; let index = 0; while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index])) { index++; } if (index >= tokens.length) continue; const primary = sanitizeToken(tokens[index]).toLowerCase(); if (!primary) continue; discovered.add(primary); if (WRAPPER_COMMANDS.has(primary) && index + 1 < tokens.length) { const wrapped = sanitizeToken(tokens[index + 1]).toLowerCase(); if (wrapped) { discovered.add(wrapped); } } } return Array.from(discovered); } function findBlockedCommands(command: string): string[] { const invoked = extractCommandNames(command); if (!invoked.length) return []; const allowList = getSecurityAllowList(); if (!allowList.length) return invoked; const allowSet = new Set(allowList); if (allowSet.has('*')) return []; return invoked.filter((cmd) => !allowSet.has(cmd)); } // export const BlockedResult = { // stdout: '', // stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`, // exitCode: 126, // }; export function isBlocked(command: string): boolean { const blocked = findBlockedCommands(command); return blocked.length > 0; } export interface CommandResult { stdout: string; stderr: string; exitCode: number; } /** * Executes an arbitrary shell command * @param command - The command to execute (e.g., "cat abc.txt | grep 'abc@gmail.com'") * @param options - Optional execution options * @returns Promise with stdout, stderr, and exit code */ export async function executeCommand( command: string, options?: { cwd?: string; timeout?: number; // timeout in milliseconds maxBuffer?: number; // max buffer size in bytes } ): Promise { try { const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB shell: EXECUTION_SHELL, }); return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0, }; } catch (error: any) { // exec throws an error if the command fails or times out return { stdout: error.stdout?.trim() || '', stderr: error.stderr?.trim() || error.message, exitCode: error.code || 1, }; } } /** * Executes a command synchronously (blocking) * Use with caution - prefer executeCommand for async execution */ export function executeCommandSync( command: string, options?: { cwd?: string; timeout?: number; } ): CommandResult { try { const stdout = execSync(command, { cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', shell: EXECUTION_SHELL, }); return { stdout: stdout.trim(), stderr: '', exitCode: 0, }; } catch (error: any) { return { stdout: error.stdout?.toString().trim() || '', stderr: error.stderr?.toString().trim() || error.message, exitCode: error.status || 1, }; } } ================================================ FILE: apps/cli/src/application/lib/exec-tool.ts ================================================ import { ToolAttachment } from "../../agents/agents.js"; import { z } from "zod"; import { BuiltinTools } from "./builtin-tools.js"; import { executeTool } from "../../mcp/mcp.js"; async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { const result = await executeTool(agentTool.mcpServerName, agentTool.name, input); return result; } export async function execTool(agentTool: z.infer, input: any): Promise { switch (agentTool.type) { case "mcp": return execMcpTool(agentTool, input); case "builtin": const builtinTool = BuiltinTools[agentTool.name]; if (!builtinTool || !builtinTool.execute) { throw new Error(`Unsupported builtin tool: ${agentTool.name}`); } return builtinTool.execute(input); } } ================================================ FILE: apps/cli/src/application/lib/id-gen.ts ================================================ export interface IMonotonicallyIncreasingIdGenerator { next(): Promise; } export class IdGen implements IMonotonicallyIncreasingIdGenerator { private lastMs = 0; private seq = 0; private readonly pid: string; private readonly hostTag: string; constructor() { this.pid = String(process.pid).padStart(7, "0"); this.hostTag = ""; } /** * Returns an ISO8601-based, lexicographically sortable id string. * Example: 2025-11-11T04-36-29Z-0001234-h1-000 */ async next(): Promise { const now = Date.now(); const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp this.seq = ms === this.lastMs ? this.seq + 1 : 0; this.lastMs = ms; // Build ISO string (UTC) and remove milliseconds for cleaner filenames const iso = new Date(ms).toISOString() // e.g. 2025-11-11T04:36:29.123Z .replace(/\.\d{3}Z$/, "Z") // drop .123 part .replace(/:/g, "-"); // safe for files: 2025-11-11T04-36-29Z const seqStr = String(this.seq).padStart(3, "0"); return `${iso}-${this.pid}${this.hostTag}-${seqStr}`; } } ================================================ FILE: apps/cli/src/application/lib/message-queue.ts ================================================ import z from "zod"; import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; const EnqueuedMessage = z.object({ messageId: z.string(), message: z.string(), }); export interface IMessageQueue { enqueue(runId: string, message: string): Promise; dequeue(runId: string): Promise | null>; } export class InMemoryMessageQueue implements IMessageQueue { private store: Record[]> = {}; private idGenerator: IMonotonicallyIncreasingIdGenerator; constructor({ idGenerator, }: { idGenerator: IMonotonicallyIncreasingIdGenerator; }) { this.idGenerator = idGenerator; } async enqueue(runId: string, message: string): Promise { if (!this.store[runId]) { this.store[runId] = []; } const id = await this.idGenerator.next(); this.store[runId].push({ messageId: id, message, }); return id; } async dequeue(runId: string): Promise | null> { if (!this.store[runId]) { return null; } return this.store[runId].shift() ?? null; } } ================================================ FILE: apps/cli/src/application/lib/random-id.ts ================================================ import { customAlphabet } from 'nanoid'; const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz-'; const nanoid = customAlphabet(alphabet, 7); export async function randomId(): Promise { return nanoid(); } ================================================ FILE: apps/cli/src/application/lib/stream-renderer.ts ================================================ import { z } from "zod"; import { RunEvent } from "../../entities/run-events.js"; import { LlmStepStreamEvent } from "../../entities/llm-step-events.js"; export interface StreamRendererOptions { showHeaders?: boolean; dimReasoning?: boolean; jsonIndent?: number; truncateJsonAt?: number; } export class StreamRenderer { private options: Required; private reasoningActive = false; private textActive = false; private firstText = true; constructor(options?: StreamRendererOptions) { this.options = { showHeaders: true, dimReasoning: true, jsonIndent: 2, truncateJsonAt: 500, ...options, }; } render(event: z.infer) { switch (event.type) { case "start": { this.onStart(event.agentName, event.runId); break; } case "llm-stream-event": { this.renderLlmEvent(event.event); break; } case "message": { // this.onStepMessage(event.stepId, event.message); break; } case "tool-invocation": { this.onStepToolInvocation(event.toolName, event.input); break; } case "tool-result": { this.onStepToolResult(event.toolName, event.result); break; } case "error": { this.onError(event.error); break; } } } private renderLlmEvent(event: z.infer) { switch (event.type) { case "reasoning-start": this.onReasoningStart(); break; case "reasoning-delta": this.onReasoningDelta(event.delta); break; case "reasoning-end": this.onReasoningEnd(); break; case "text-start": this.onTextStart(); break; case "text-delta": this.onTextDelta(event.delta); break; case "text-end": this.onTextEnd(); break; case "tool-call": this.onToolCall(event.toolCallId, event.toolName, event.input); break; case "finish-step": this.onFinishStep(event.finishReason, event.usage); break; } } private onStart(agentName: string, runId: string) { this.write("\n"); this.write(this.bold(`▶ Agent ${agentName} (run ${runId})`)); this.write("\n"); this.write(this.dim(`╰─────────────────────────────────────────────────\n`)); } private onEnd() { this.write("\n"); this.write(this.dim("─".repeat(50))); this.write("\n"); this.write(this.green(this.bold("✓ Complete"))); this.write("\n\n"); } private onError(error: string) { this.write("\n"); this.write(this.red(this.bold("✖ Error"))); this.write("\n"); this.write(this.red(this.indent(error))); this.write("\n\n"); } private onStepStart() { this.write("\n"); this.write(this.dim("│ ")); this.write(this.dim("Step in progress...")); this.write("\n"); } private onStepEnd() { // More subtle step end - just add a little spacing this.write(this.dim("\n")); } private onStepMessage(stepIndex: number, message: any) { const role = message?.role ?? "message"; const content = message?.content; this.write(this.bold(`${role}: `)); if (typeof content === "string") { this.write(content + "\n"); } else { const pretty = this.truncate(JSON.stringify(message, null, this.options.jsonIndent)); this.write(this.dim("\n" + this.indent(pretty) + "\n")); } } private onStepToolInvocation(toolName: string, input: string) { this.write("\n"); this.write(this.cyan("┌─ ") + this.bold(this.cyan(`🔧 ${toolName}`))); this.write("\n"); if (input && input.length) { this.write(this.dim("│ ") + this.dim(this.indent(this.truncate(input)).replace(/\n/g, "\n│ "))); this.write("\n"); } } private onStepToolResult(toolName: string, result: unknown) { const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent)); this.write(this.dim("│\n")); this.write(this.green("└─ ") + this.dim(this.green(`Result`))); this.write("\n"); this.write(this.dim(" " + this.indent(res).replace(/\n/g, "\n "))); this.write("\n"); } private onReasoningStart() { if (this.reasoningActive) return; this.reasoningActive = true; if (this.options.showHeaders) { this.write("\n"); this.write(this.dim("│ ")); this.write(this.dim(this.italic("thinking... "))); } } private onReasoningDelta(delta: string) { if (!this.reasoningActive) this.onReasoningStart(); this.write(this.options.dimReasoning ? this.dim(delta) : delta); } private onReasoningEnd() { if (!this.reasoningActive) return; this.reasoningActive = false; this.write("\n"); } private onTextStart() { if (this.textActive) return; this.textActive = true; if (this.options.showHeaders && this.firstText) { this.write("\n"); this.write(this.bold("╭─ ") + this.bold("Response")); this.write("\n"); this.write(this.dim("│\n")); this.firstText = false; } else if (this.options.showHeaders) { this.write("\n"); this.write(this.dim("│ ")); } } private onTextDelta(delta: string) { // Add subtle left margin to assistant text for better readability const formattedDelta = this.neutral(delta); if (delta.includes("\n")) { this.write(formattedDelta.replace(/\n/g, "\n ")); } else { this.write(formattedDelta); } } private onTextEnd() { if (!this.textActive) return; this.textActive = false; this.write("\n"); } private onToolCall(toolCallId: string, toolName: string, input: unknown) { const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent)); this.write("\n"); this.write(this.magenta("┌─ ") + this.bold(this.magenta(`⚡ ${toolName}`))); this.write(this.dim(` (${toolCallId.slice(0, 8)}...)`)); this.write("\n"); this.write(this.dim("│ ") + this.dim(this.indent(inputStr).replace(/\n/g, "\n│ "))); this.write("\n"); this.write(this.dim("└─────────────\n")); } private onPauseForHumanInput(toolCallId: string, question: string) { this.write(this.cyan(`\n→ Pause for human input (${toolCallId})`)); this.write("\n"); this.write(this.bold("Question: ") + question); this.write("\n"); } private onFinishStep( finishReason: "stop" | "tool-calls" | "length" | "content-filter" | "error" | "other" | "unknown", usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; reasoningTokens?: number; cachedInputTokens?: number; }) { const parts: string[] = []; if (usage.inputTokens !== undefined) parts.push(`${this.dim("in:")} ${usage.inputTokens}`); if (usage.outputTokens !== undefined) parts.push(`${this.dim("out:")} ${usage.outputTokens}`); if (usage.reasoningTokens !== undefined) parts.push(`${this.dim("reasoning:")} ${usage.reasoningTokens}`); if (usage.cachedInputTokens !== undefined) parts.push(`${this.dim("cached:")} ${usage.cachedInputTokens}`); if (usage.totalTokens !== undefined) parts.push(`${this.dim("total:")} ${this.bold(usage.totalTokens.toString())}`); const line = parts.join(this.dim(" | ")); this.write("\n"); this.write(this.bold("╭─ ") + this.bold("Finish")); this.write("\n"); this.write(this.dim("│ ") + this.dim("reason: ") + finishReason); if (line.length) { this.write("\n"); this.write(this.dim("│ ") + line); } this.write("\n"); this.write(this.dim("╰─────────────\n")); } // Formatting helpers private write(text: string) { process.stdout.write(text); } private indent(text: string): string { return text .split("\n") .map((line) => (line.length ? ` ${line}` : line)) .join("\n"); } private truncate(text: string): string { if (text.length <= this.options.truncateJsonAt) return text; return text.slice(0, this.options.truncateJsonAt) + "…"; } private bold(text: string): string { return "\x1b[1m" + text + "\x1b[0m"; } private dim(text: string): string { return "\x1b[2m" + text + "\x1b[0m"; } private italic(text: string): string { return "\x1b[3m" + text + "\x1b[0m"; } private cyan(text: string): string { return "\x1b[36m" + text + "\x1b[0m"; } private green(text: string): string { return "\x1b[32m" + text + "\x1b[0m"; } private red(text: string): string { return "\x1b[31m" + text + "\x1b[0m"; } private magenta(text: string): string { return "\x1b[35m" + text + "\x1b[0m"; } private yellow(text: string): string { return "\x1b[33m" + text + "\x1b[0m"; } private neutral(text: string): string { return "\x1b[38;5;250m" + text + "\x1b[0m"; } } ================================================ FILE: apps/cli/src/config/config.ts ================================================ import path from "path"; import fs from "fs"; import { homedir } from "os"; // Resolve app root relative to compiled file location (dist/...) export const WorkDir = path.join(homedir(), ".rowboat"); function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; ensure(WorkDir); ensure(path.join(WorkDir, "agents")); ensure(path.join(WorkDir, "config")); } ensureDirs(); ================================================ FILE: apps/cli/src/config/security.ts ================================================ import path from "path"; import fs from "fs"; import { WorkDir } from "./config.js"; export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"); const DEFAULT_ALLOW_LIST = [ "cat", "curl", "date", "echo", "grep", "jq", "ls", "pwd", "yq", "whoami" ] let cachedAllowList: string[] | null = null; let cachedMtimeMs: number | null = null; function ensureSecurityConfig() { if (!fs.existsSync(SECURITY_CONFIG_PATH)) { fs.writeFileSync( SECURITY_CONFIG_PATH, JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n", "utf8", ); } } function normalizeList(commands: unknown[]): string[] { const seen = new Set(); for (const entry of commands) { if (typeof entry !== "string") continue; const normalized = entry.trim().toLowerCase(); if (!normalized) continue; seen.add(normalized); } return Array.from(seen); } function parseSecurityPayload(payload: unknown): string[] { if (Array.isArray(payload)) { return normalizeList(payload); } if (payload && typeof payload === "object") { const maybeObject = payload as Record; if (Array.isArray(maybeObject.allowedCommands)) { return normalizeList(maybeObject.allowedCommands); } const dynamicList = Object.entries(maybeObject) .filter(([, value]) => Boolean(value)) .map(([key]) => key); return normalizeList(dynamicList); } return []; } function readAllowList(): string[] { ensureSecurityConfig(); try { const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8"); const parsed = JSON.parse(configContent); return parseSecurityPayload(parsed); } catch (error) { console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`); return DEFAULT_ALLOW_LIST; } } export function getSecurityAllowList(): string[] { ensureSecurityConfig(); try { const stats = fs.statSync(SECURITY_CONFIG_PATH); if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { return cachedAllowList; } const allowList = readAllowList(); cachedAllowList = allowList; cachedMtimeMs = stats.mtimeMs; return allowList; } catch { cachedAllowList = null; cachedMtimeMs = null; return readAllowList(); } } export function resetSecurityAllowListCache() { cachedAllowList = null; cachedMtimeMs = null; } ================================================ FILE: apps/cli/src/di/container.ts ================================================ import { asClass, createContainer, InjectionMode } from "awilix"; import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js"; import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js"; import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js"; import { FSRunsRepo, IRunsRepo } from "../runs/repo.js"; import { IMonotonicallyIncreasingIdGenerator, IdGen } from "../application/lib/id-gen.js"; import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-queue.js"; import { IBus, InMemoryBus } from "../application/lib/bus.js"; import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js"; import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, strict: true, }); container.register({ idGenerator: asClass(IdGen).singleton(), messageQueue: asClass(InMemoryMessageQueue).singleton(), bus: asClass(InMemoryBus).singleton(), runsLock: asClass(InMemoryRunsLock).singleton(), agentRuntime: asClass(AgentRuntime).singleton(), mcpConfigRepo: asClass(FSMcpConfigRepo).singleton(), modelConfigRepo: asClass(FSModelConfigRepo).singleton(), agentsRepo: asClass(FSAgentsRepo).singleton(), runsRepo: asClass(FSRunsRepo).singleton(), }); export default container; ================================================ FILE: apps/cli/src/entities/example.ts ================================================ import z from "zod" import { Agent } from "../agents/agents.js" import { McpServerDefinition } from "../mcp/schema.js"; export const Example = z.object({ id: z.string(), instructions: z.string().optional(), description: z.string().optional(), entryAgent: z.string().optional(), agents: z.array(Agent).optional(), mcpServers: z.record(z.string(), McpServerDefinition).optional(), }); ================================================ FILE: apps/cli/src/entities/llm-step-events.ts ================================================ import { z } from "zod"; import { ProviderOptions } from "./message.js"; const BaseEvent = z.object({ providerOptions: ProviderOptions.optional(), }) export const LlmStepStreamReasoningStartEvent = BaseEvent.extend({ type: z.literal("reasoning-start"), }); export const LlmStepStreamReasoningDeltaEvent = BaseEvent.extend({ type: z.literal("reasoning-delta"), delta: z.string(), }); export const LlmStepStreamReasoningEndEvent = BaseEvent.extend({ type: z.literal("reasoning-end"), }); export const LlmStepStreamTextStartEvent = BaseEvent.extend({ type: z.literal("text-start"), }); export const LlmStepStreamTextDeltaEvent = BaseEvent.extend({ type: z.literal("text-delta"), delta: z.string(), }); export const LlmStepStreamTextEndEvent = BaseEvent.extend({ type: z.literal("text-end"), }); export const LlmStepStreamToolCallEvent = BaseEvent.extend({ type: z.literal("tool-call"), toolCallId: z.string(), toolName: z.string(), input: z.any(), }); export const LlmStepStreamFinishStepEvent = z.object({ type: z.literal("finish-step"), finishReason: z.enum(["stop", "tool-calls", "length", "content-filter", "error", "other", "unknown"]), usage: z.object({ inputTokens: z.number().optional(), outputTokens: z.number().optional(), totalTokens: z.number().optional(), reasoningTokens: z.number().optional(), cachedInputTokens: z.number().optional(), }), providerOptions: ProviderOptions.optional(), }); export const LlmStepStreamEvent = z.union([ LlmStepStreamReasoningStartEvent, LlmStepStreamReasoningDeltaEvent, LlmStepStreamReasoningEndEvent, LlmStepStreamTextStartEvent, LlmStepStreamTextDeltaEvent, LlmStepStreamTextEndEvent, LlmStepStreamToolCallEvent, LlmStepStreamFinishStepEvent, ]); ================================================ FILE: apps/cli/src/entities/message.ts ================================================ import { z } from "zod"; export const ProviderOptions = z.record(z.string(), z.record(z.string(), z.json())); export const TextPart = z.object({ type: z.literal("text"), text: z.string(), providerOptions: ProviderOptions.optional(), }); export const ReasoningPart = z.object({ type: z.literal("reasoning"), text: z.string(), providerOptions: ProviderOptions.optional(), }); export const ToolCallPart = z.object({ type: z.literal("tool-call"), toolCallId: z.string(), toolName: z.string(), arguments: z.any(), providerOptions: ProviderOptions.optional(), }); export const AssistantContentPart = z.union([ TextPart, ReasoningPart, ToolCallPart, ]); export const UserMessage = z.object({ role: z.literal("user"), content: z.string(), providerOptions: ProviderOptions.optional(), }); export const AssistantMessage = z.object({ role: z.literal("assistant"), content: z.union([ z.string(), z.array(AssistantContentPart), ]), providerOptions: ProviderOptions.optional(), }); export const SystemMessage = z.object({ role: z.literal("system"), content: z.string(), providerOptions: ProviderOptions.optional(), }); export const ToolMessage = z.object({ role: z.literal("tool"), content: z.string(), toolCallId: z.string(), toolName: z.string(), providerOptions: ProviderOptions.optional(), }); export const Message = z.discriminatedUnion("role", [ AssistantMessage, SystemMessage, ToolMessage, UserMessage, ]); export const MessageList = z.array(Message); ================================================ FILE: apps/cli/src/entities/run-events.ts ================================================ import { LlmStepStreamEvent } from "./llm-step-events.js"; import { Message, ToolCallPart } from "./message.js"; import z from "zod"; const BaseRunEvent = z.object({ runId: z.string(), ts: z.iso.datetime().optional(), subflow: z.array(z.string()), }); export const RunProcessingStartEvent = BaseRunEvent.extend({ type: z.literal("run-processing-start"), }); export const RunProcessingEndEvent = BaseRunEvent.extend({ type: z.literal("run-processing-end"), }); export const StartEvent = BaseRunEvent.extend({ type: z.literal("start"), agentName: z.string(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ type: z.literal("spawn-subflow"), agentName: z.string(), toolCallId: z.string(), }); export const LlmStreamEvent = BaseRunEvent.extend({ type: z.literal("llm-stream-event"), event: LlmStepStreamEvent, }); export const MessageEvent = BaseRunEvent.extend({ type: z.literal("message"), messageId: z.string(), message: Message, }); export const ToolInvocationEvent = BaseRunEvent.extend({ type: z.literal("tool-invocation"), toolCallId: z.string().optional(), toolName: z.string(), input: z.string(), }); export const ToolResultEvent = BaseRunEvent.extend({ type: z.literal("tool-result"), toolCallId: z.string().optional(), toolName: z.string(), result: z.any(), }); export const AskHumanRequestEvent = BaseRunEvent.extend({ type: z.literal("ask-human-request"), toolCallId: z.string(), query: z.string(), }); export const AskHumanResponseEvent = BaseRunEvent.extend({ type: z.literal("ask-human-response"), toolCallId: z.string(), response: z.string(), }); export const ToolPermissionRequestEvent = BaseRunEvent.extend({ type: z.literal("tool-permission-request"), toolCall: ToolCallPart, }); export const ToolPermissionResponseEvent = BaseRunEvent.extend({ type: z.literal("tool-permission-response"), toolCallId: z.string(), response: z.enum(["approve", "deny"]), }); export const RunErrorEvent = BaseRunEvent.extend({ type: z.literal("error"), error: z.string(), }); export const RunEvent = z.union([ RunProcessingStartEvent, RunProcessingEndEvent, StartEvent, SpawnSubFlowEvent, LlmStreamEvent, MessageEvent, ToolInvocationEvent, ToolResultEvent, AskHumanRequestEvent, AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, RunErrorEvent, ]); ================================================ FILE: apps/cli/src/examples/index.ts ================================================ import twitterPodcast from './twitter-podcast.json' with { type: 'json' }; import { Example } from '../entities/example.js'; import z from 'zod'; export const examples: Record> = { "twitter-podcast": Example.parse(twitterPodcast), }; ================================================ FILE: apps/cli/src/examples/twitter-podcast.json ================================================ { "id": "twitter-podcast", "instructions": "This example workflow generates a narrated podcast episode from recent AI-related tweets using multiple agents.", "description": "Generates a narrated podcast episode from recent AI-related tweets using multiple agents.", "entryAgent": "tweet-podcast", "agents": [ { "name": "tweet-podcast", "description": "An agent that will produce a podcast from recent tweets", "model": "gpt-5.1", "instructions": "You are the orchestrator for producing a short podcast episode end-to-end. Follow these steps in order and only advance once each step succeeds:\n\n1. Tweets: call the tweets workflow to collect the latest tweets, .\n\n2.Transcript creation: Provide the resulting tweets to the podcast_transcript_agent tool so it can script a ~1 minute alternating dialogue between John and Chloe that references the tweets and a balanced conversation about AI bubble.\n\n4. Audio production: Send the transcript to the elevenlabs_audio_gen tool create an audio file.", "tools": { "tweets": { "type": "agent", "name": "tweets" }, "podcast_transcript_agent": { "type": "agent", "name": "podcast_transcript_agent" }, "elevenlabs_audio_gen": { "type": "agent", "name": "elevenlabs_audio_gen" } } }, { "name": "tweets", "description": "Checks latest tweets", "model": "gpt-4.1", "instructions": "Pulls the recent 10 recent tweets each on OpenAI, Anthropic, Nvidia, Grok, Gemini", "tools": { "search_tweets": { "type": "mcp", "name": "TWITTER_RECENT_SEARCH", "description": "Search recent Tweets from the last 7 days using X/Twitter's search syntax via Composio's Twitter MCP server.", "mcpServerName": "twitter", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query for matching Tweets. Use X search operators like from:username, -is:retweet, -is:reply, has:media, lang:en, etc. Limited to last 7 days." }, "start_time": { "type": "string", "description": "Oldest UTC timestamp (YYYY-MM-DDTHH:mm:ssZ) for results, within the last 7 days." }, "end_time": { "type": "string", "description": "Newest UTC timestamp (YYYY-MM-DDTHH:mm:ssZ) for results; exclusive." }, "max_results": { "type": "integer", "description": "Number of Tweets to return (up to 2000 per call).", "default": 10 }, "sort_order": { "type": "string", "enum": [ "recency", "relevancy" ], "description": "Order of results: 'recency' (most recent first) or 'relevancy'." }, "tweet_fields": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "article", "attachments", "author_id", "card_uri", "context_annotations", "conversation_id", "created_at", "edit_controls", "edit_history_tweet_ids", "entities", "geo", "id", "in_reply_to_user_id", "lang", "non_public_metrics", "note_tweet", "organic_metrics", "possibly_sensitive", "promoted_metrics", "public_metrics", "referenced_tweets", "reply_settings", "scopes", "source", "text", "withheld" ] } }, { "type": "null" } ], "default": null, "description": "Tweet fields to include in the response. Example: ['created_at','author_id','public_metrics']." }, "expansions": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "article.cover_media", "article.media_entities", "attachments.media_keys", "attachments.media_source_tweet", "attachments.poll_ids", "author_id", "author_screen_name", "edit_history_tweet_ids", "entities.mentions.username", "entities.note.mentions.username", "geo.place_id", "in_reply_to_user_id", "referenced_tweets.id", "referenced_tweets.id.author_id" ] } }, { "type": "null" } ], "default": null, "description": "Expansions to hydrate related objects like users, media, polls, and places." }, "media_fields": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "alt_text", "duration_ms", "height", "media_key", "non_public_metrics", "organic_metrics", "preview_image_url", "promoted_metrics", "public_metrics", "type", "url", "variants", "width" ] } }, { "type": "null" } ], "default": null, "description": "Media fields to include when media keys are expanded." }, "place_fields": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "contained_within", "country", "country_code", "full_name", "geo", "id", "name", "place_type" ] } }, { "type": "null" } ], "default": null, "description": "Place fields to include when place IDs are expanded." }, "poll_fields": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "duration_minutes", "end_datetime", "id", "options", "voting_status" ] } }, { "type": "null" } ], "default": null, "description": "Poll fields to include when poll IDs are expanded." }, "user_fields": { "anyOf": [ { "type": "array", "items": { "type": "string", "enum": [ "affiliation", "connection_status", "created_at", "description", "entities", "id", "location", "most_recent_tweet_id", "name", "pinned_tweet_id", "profile_banner_url", "profile_image_url", "protected", "public_metrics", "receives_your_dm", "subscription_type", "url", "verified", "verified_type", "withheld", "username" ] } }, { "type": "null" } ], "default": null, "description": "User fields to include when user IDs are expanded. Username is always returned by default." }, "since_id": { "type": "string", "description": "Return Tweets more recent than this ID (cannot be used with start_time)." }, "until_id": { "type": "string", "description": "Return Tweets older than this ID (cannot be used with end_time)." }, "next_token": { "type": "string", "description": "Pagination token from a previous response's meta.next_token." }, "pagination_token": { "type": "string", "description": "Alternative pagination token from a previous meta.next_token; next_token is preferred." } }, "required": [ "query" ], "additionalProperties": false } }, "bash": { "type": "builtin", "name": "executeCommand", "description": "Execute bash commands to manipulate files like tweets.txt, e.g. writing search results to disk or appending logs.", "inputSchema": { "type": "object", "properties": { "command": { "type": "string", "description": "The bash command to execute, such as 'echo \"text\" >> tweets.txt' or 'cat tweets.txt'." } }, "required": [ "command" ], "additionalProperties": false } } } }, { "name": "podcast_transcript_agent", "description": "An agent that will generate a transcript of a podcast", "model": "gpt-4.1", "instructions": "You job is to create a NotebookLM style 1 minute podcast between 2 speakers John and Chloe. Each line should be a new speaker. The podcast should be about the contents of the two papers (that were selected). You can use [sighs], [inhales then exhales], [chuckles], [laughs], [clears throat], [coughs], [sniffs], [pauses] etc. to make the podcast more natural." }, { "name": "elevenlabs_audio_gen", "description": "An agent that will generate an audio file from a text", "model": "gpt-4.1", "instructions": "Your job is to take the mutli speaker transcript and generate an audio file from it. Use the elevenlabs text to speech tool to do this. For each speaker turn, you should generate an audio file and then combine them all into a single audio file. Use the voice_name 'Liam' for John and 'Cassidy' for Chloe. Make sure to remove the speaker names from the text before generating the audio files. Use the eleven_v3 model_id. In addition, you should use the compose_music tool to generate a short musical intro and outro for the podcast. The intro should be a small 5-10 second clip modeled after popular podcasts which fades and the podcast starts. The outro should be 10-15 seconds of a related sound. Save the intro and outro to files, and then use the bash tool to stitch them with the main podcast audio so that the final output audio file starts with the intro music, then the full conversation, and ends with the outro music. Place all generated audio on the Desktop by default unless otherwise instructed. Don't wait for confirmation - go ahead and produce the podcast.", "tools": { "text_to_speech": { "type": "mcp", "name": "text_to_speech", "description": "Generate an audio file from a text", "mcpServerName": "elevenLabs", "inputSchema": { "type": "object", "properties": { "text": { "type": "string", "description": "The text to generate an audio file from" }, "voice_name": { "type": "string", "description": "The voice name to use for the audio file" }, "model_id": { "type": "string", "description": "The model id to use for the audio file" } } } }, "compose_music": { "type": "mcp", "name": "compose_music", "description": "Generate intro and outro music for the podcast and save as audio files", "mcpServerName": "elevenLabs", "inputSchema": { "type": "object", "properties": { "prompt": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "title": "Prompt" }, "output_directory": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "title": "Output Directory" }, "composition_plan": { "anyOf": [ { "$ref": "#/$defs/MusicPrompt" }, { "type": "null" } ], "default": null }, "music_length_ms": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null, "title": "Music Length Ms" } }, "$defs": { "MusicPrompt": { "additionalProperties": true, "properties": { "positive_global_styles": { "items": { "type": "string" }, "title": "Positive Global Styles", "type": "array" }, "negative_global_styles": { "items": { "type": "string" }, "title": "Negative Global Styles", "type": "array" }, "sections": { "items": { "$ref": "#/$defs/SongSection" }, "title": "Sections", "type": "array" } }, "required": [ "positive_global_styles", "negative_global_styles", "sections" ], "title": "MusicPrompt", "type": "object" }, "SectionSource": { "additionalProperties": true, "properties": { "song_id": { "title": "Song Id", "type": "string" }, "range": { "$ref": "#/$defs/TimeRange" }, "negative_ranges": { "anyOf": [ { "items": { "$ref": "#/$defs/TimeRange" }, "type": "array" }, { "type": "null" } ], "default": null, "title": "Negative Ranges" } }, "required": [ "song_id", "range" ], "title": "SectionSource", "type": "object" }, "SongSection": { "additionalProperties": true, "properties": { "section_name": { "title": "Section Name", "type": "string" }, "positive_local_styles": { "items": { "type": "string" }, "title": "Positive Local Styles", "type": "array" }, "negative_local_styles": { "items": { "type": "string" }, "title": "Negative Local Styles", "type": "array" }, "duration_ms": { "title": "Duration Ms", "type": "integer" }, "lines": { "items": { "type": "string" }, "title": "Lines", "type": "array" }, "source_from": { "anyOf": [ { "$ref": "#/$defs/SectionSource" }, { "type": "null" } ], "default": null } }, "required": [ "section_name", "positive_local_styles", "negative_local_styles", "duration_ms", "lines" ], "title": "SongSection", "type": "object" }, "TimeRange": { "additionalProperties": true, "properties": { "start_ms": { "title": "Start Ms", "type": "integer" }, "end_ms": { "title": "End Ms", "type": "integer" } }, "required": [ "start_ms", "end_ms" ], "title": "TimeRange", "type": "object" } }, "title": "compose_musicArguments" } }, "bash": { "type": "builtin", "name": "executeCommand" } } } ], "mcpServers": { "elevenLabs": { "command": "uvx", "args": [ "elevenlabs-mcp" ], "env": { "ELEVENLABS_API_KEY": "" } }, "calendar": { "type": "http", "url": "" }, "twitter": { "type": "http", "url": "" } } } ================================================ FILE: apps/cli/src/knowledge/sync_calendar.ts ================================================ import fs from 'fs'; import path from 'path'; import { google } from 'googleapis'; import { authenticate } from '@google-cloud/local-auth'; import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' // Configuration const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json'); const TOKEN_PATH = path.join(process.cwd(), 'token_calendar_notes.json'); // Changed to force re-auth with new scopes const SYNC_INTERVAL_MS = 60 * 1000; const SCOPES = [ 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/drive.readonly' ]; const nhm = new NodeHtmlMarkdown(); // --- Auth Functions --- async function loadSavedCredentialsIfExist(): Promise { try { if (!fs.existsSync(TOKEN_PATH)) return null; const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8'); const tokenData = JSON.parse(tokenContent); const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8'); const keys = JSON.parse(credsContent); const key = keys.installed || keys.web; const client = new google.auth.OAuth2( key.client_id, key.client_secret, key.redirect_uris ? key.redirect_uris[0] : 'http://localhost' ); client.setCredentials({ refresh_token: tokenData.refresh_token || tokenData.refreshToken, access_token: tokenData.token || tokenData.access_token, expiry_date: tokenData.expiry || tokenData.expiry_date, scope: tokenData.scope }); return client; } catch (err) { console.error("Error loading saved credentials:", err); return null; } } async function saveCredentials(client: OAuth2Client) { const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8'); const keys = JSON.parse(content); const key = keys.installed || keys.web; const payload = JSON.stringify({ type: 'authorized_user', client_id: key.client_id, client_secret: key.client_secret, refresh_token: client.credentials.refresh_token, access_token: client.credentials.access_token, expiry_date: client.credentials.expiry_date, }, null, 2); fs.writeFileSync(TOKEN_PATH, payload); } async function authorize(): Promise { let client = await loadSavedCredentialsIfExist(); if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) { console.log("Using existing valid token."); return client; } if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) { console.log("Refreshing expired token..."); try { await client.refreshAccessToken(); await saveCredentials(client); return client; } catch (e) { console.error("Failed to refresh token:", e); if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH); } } console.log("Performing new OAuth authentication..."); client = await authenticate({ scopes: SCOPES, keyfilePath: CREDENTIALS_PATH, }) as any; if (client && client.credentials) { await saveCredentials(client); } return client!; } // --- Helper Functions --- function cleanFilename(name: string): string { return name.replace(/[\\/*?:\"<>|]/g, "").replace(/\s+/g, "_").substring(0, 100).trim(); } // --- Sync Logic --- function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { if (!fs.existsSync(syncDir)) return; const files = fs.readdirSync(syncDir); for (const filename of files) { if (filename === 'sync_state.json') continue; // We expect files like: // {eventId}.json // {eventId}_doc_{docId}.md let eventId: string | null = null; if (filename.endsWith('.json')) { eventId = filename.replace('.json', ''); } else if (filename.endsWith('.md')) { // Try to extract eventId from prefix // Assuming eventId doesn't contain underscores usually, but if it does, this split might be fragile. // Google Calendar IDs are usually alphanumeric. // Let's rely on the delimiter we use: "_doc_" const parts = filename.split('_doc_'); if (parts.length > 1) { eventId = parts[0]; } } if (eventId && !currentEventIds.has(eventId)) { try { fs.unlinkSync(path.join(syncDir, filename)); console.log(`Removed old/out-of-window file: ${filename}`); } catch (e) { console.error(`Error deleting file ${filename}:`, e); } } } } async function saveEvent(event: any, syncDir: string): Promise { const eventId = event.id; if (!eventId) return false; const filePath = path.join(syncDir, `${eventId}.json`); try { fs.writeFileSync(filePath, JSON.stringify(event, null, 2)); return true; } catch (e) { console.error(`Error saving event ${eventId}:`, e); return false; } } async function processAttachments(drive: any, event: any, syncDir: string) { if (!event.attachments || event.attachments.length === 0) return; const eventId = event.id; const eventTitle = event.summary || 'Untitled'; const eventDate = event.start?.dateTime || event.start?.date || 'Unknown'; const organizer = event.organizer?.email || 'Unknown'; for (const att of event.attachments) { // We only care about Google Docs if (att.mimeType === 'application/vnd.google-apps.document') { const fileId = att.fileId; const safeTitle = cleanFilename(att.title); // Unique filename linked to event const filename = `${eventId}_doc_${safeTitle}.md`; const filePath = path.join(syncDir, filename); // Simple cache check: if file exists, skip. // Ideally we check modifiedTime, but that requires an extra API call per file. // Given the loop interval, we can just check existence to save quota. // If user updates notes, they might want them re-synced. // For now, let's just check existence. To be smarter, we'd need a state file or check API. if (fs.existsSync(filePath)) continue; try { const res = await drive.files.export({ fileId: fileId, mimeType: 'text/html' }); const html = res.data; const md = nhm.translate(html); const frontmatter = [ `# ${att.title}`, `**Event:** ${eventTitle}`, `**Date:** ${eventDate}`, `**Organizer:** ${organizer}`, `**Link:** ${att.fileUrl}`, `---`, `` ].join('\n'); fs.writeFileSync(filePath, frontmatter + md); console.log(`Synced Note: ${att.title} for event ${eventTitle}`); } catch (e) { console.error(`Failed to download note ${att.title}:`, e); } } } } async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) { // Calculate window const now = new Date(); const lookbackMs = lookbackDays * 24 * 60 * 60 * 1000; const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); console.log(`Syncing calendar from ${timeMin} to ${timeMax} (lookback: ${lookbackDays} days)...`); const calendar = google.calendar({ version: 'v3', auth }); const drive = google.drive({ version: 'v3', auth }); try { const res = await calendar.events.list({ calendarId: 'primary', timeMin: timeMin, timeMax: timeMax, singleEvents: true, orderBy: 'startTime' }); const events = res.data.items || []; const currentEventIds = new Set(); if (events.length === 0) { console.log("No events found in this window."); } else { console.log(`Found ${events.length} events.`); for (const event of events) { if (event.id) { await saveEvent(event, syncDir); await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); } } } cleanUpOldFiles(currentEventIds, syncDir); } catch (error) { console.error("An error occurred during calendar sync:", error); } } async function main() { console.log("Starting Google Calendar & Notes Sync (TS)..."); const syncDirArg = process.argv[2]; const lookbackDaysArg = process.argv[3]; const SYNC_DIR = syncDirArg || 'synced_calendar_events'; const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 14; if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) { console.error("Error: Lookback days must be a positive number."); process.exit(1); } if (!fs.existsSync(SYNC_DIR)) { fs.mkdirSync(SYNC_DIR, { recursive: true }); } try { const auth = await authorize(); console.log("Authorization successful."); while (true) { await syncCalendarWindow(auth, SYNC_DIR, LOOKBACK_DAYS); console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); } } catch (error) { console.error("Fatal error in main loop:", error); } } main().catch(console.error); ================================================ FILE: apps/cli/src/knowledge/sync_gmail.ts ================================================ import fs from 'fs'; import path from 'path'; import { google } from 'googleapis'; import { authenticate } from '@google-cloud/local-auth'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; // Configuration const DEFAULT_SYNC_DIR = 'synced_emails_ts'; const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json'); const TOKEN_PATH = path.join(process.cwd(), 'token_api.json'); // Reuse Python's token const SYNC_INTERVAL_MS = 60 * 1000; const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']; const nhm = new NodeHtmlMarkdown(); // --- Auth Functions --- async function loadSavedCredentialsIfExist(): Promise { try { const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8'); const tokenData = JSON.parse(tokenContent); const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8'); const keys = JSON.parse(credsContent); const key = keys.installed || keys.web; // Manually construct credentials for google.auth.fromJSON const credentials = { type: 'authorized_user', client_id: key.client_id, client_secret: key.client_secret, refresh_token: tokenData.refresh_token || tokenData.refreshToken, // Handle both cases access_token: tokenData.token || tokenData.access_token, // Handle both cases expiry_date: tokenData.expiry || tokenData.expiry_date }; return google.auth.fromJSON(credentials) as OAuth2Client; } catch (err) { console.error("Error loading saved credentials:", err); return null; } } async function saveCredentials(client: OAuth2Client) { const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8'); const keys = JSON.parse(content); const key = keys.installed || keys.web; const payload = JSON.stringify({ type: 'authorized_user', client_id: key.client_id, client_secret: key.client_secret, refresh_token: client.credentials.refresh_token, access_token: client.credentials.access_token, expiry_date: client.credentials.expiry_date, }, null, 2); fs.writeFileSync(TOKEN_PATH, payload); } async function authorize(): Promise { let client = await loadSavedCredentialsIfExist(); if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) { console.log("Using existing valid token."); return client; } if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) { console.log("Refreshing expired token..."); try { await client.refreshAccessToken(); await saveCredentials(client); // Save refreshed token return client; } catch (e) { console.error("Failed to refresh token:", e); // Fall through to full re-auth if refresh fails fs.existsSync(TOKEN_PATH) && fs.unlinkSync(TOKEN_PATH); } } console.log("Performing new OAuth authentication..."); client = await authenticate({ scopes: SCOPES, keyfilePath: CREDENTIALS_PATH, }) as any; if (client && client.credentials) { await saveCredentials(client); } return client!; } // --- Helper Functions --- function cleanFilename(name: string): string { return name.replace(/[\\/*?:":<>|]/g, "").substring(0, 100).trim(); } function decodeBase64(data: string): string { return Buffer.from(data, 'base64').toString('utf-8'); } function getBody(payload: any): string { let body = ""; if (payload.parts) { for (const part of payload.parts) { if (part.mimeType === 'text/plain' && part.body && part.body.data) { const text = decodeBase64(part.body.data); // Strip quoted lines const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>')); body += cleanLines.join('\n'); } else if (part.mimeType === 'text/html' && part.body && part.body.data) { const html = decodeBase64(part.body.data); let md = nhm.translate(html); // Simple quote stripping for MD const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>')); body += cleanLines.join('\n'); } else if (part.parts) { body += getBody(part); } } } else if (payload.body && payload.body.data) { const data = decodeBase64(payload.body.data); if (payload.mimeType === 'text/html') { let md = nhm.translate(data); body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); } else { body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); } } return body; } async function saveAttachment(gmail: any, userId: string, msgId: string, part: any, attachmentsDir: string): Promise { const filename = part.filename; const attId = part.body?.attachmentId; if (!filename || !attId) return null; const safeName = `${msgId}_${cleanFilename(filename)}`; const filePath = path.join(attachmentsDir, safeName); if (fs.existsSync(filePath)) return safeName; try { const res = await gmail.users.messages.attachments.get({ userId, messageId: msgId, id: attId }); const data = res.data.data; if (data) { fs.writeFileSync(filePath, Buffer.from(data, 'base64')); console.log(`Saved attachment: ${safeName}`); return safeName; } } catch (e) { console.error(`Error saving attachment ${filename}:`, e); } return null; } // --- Sync Logic --- async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) { const gmail = google.gmail({ version: 'v1', auth }); try { const res = await gmail.users.threads.get({ userId: 'me', id: threadId }); const thread = res.data; const messages = thread.messages; if (!messages || messages.length === 0) return; // Subject from first message const firstHeader = messages[0].payload?.headers; const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)'; let mdContent = `# ${subject}\n\n`; mdContent += `**Thread ID:** ${threadId}\n`; mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; for (const msg of messages) { const msgId = msg.id!; const headers = msg.payload?.headers || []; const from = headers.find(h => h.name === 'From')?.value || 'Unknown'; const date = headers.find(h => h.name === 'Date')?.value || 'Unknown'; mdContent += `### From: ${from}\n`; mdContent += `**Date:** ${date}\n\n`; const body = getBody(msg.payload); mdContent += `${body}\n\n`; // Attachments const parts: any[] = []; const traverseParts = (pList: any[]) => { for (const p of pList) { parts.push(p); if (p.parts) traverseParts(p.parts); } }; if (msg.payload?.parts) traverseParts(msg.payload.parts); let attachmentsFound = false; for (const part of parts) { if (part.filename && part.body?.attachmentId) { const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir); if (savedName) { if (!attachmentsFound) { mdContent += "**Attachments:**\n"; attachmentsFound = true; } mdContent += `- [${part.filename}](attachments/${savedName})\n`; } } } mdContent += "\n---\n\n"; } fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); console.log(`Synced Thread: ${subject} (${threadId})`); } catch (error) { console.error(`Error processing thread ${threadId}:`, error); } } function loadState(stateFile: string): { historyId?: string } { if (fs.existsSync(stateFile)) { return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } return {}; } function saveState(historyId: string, stateFile: string) { fs.writeFileSync(stateFile, JSON.stringify({ historyId, last_sync: new Date().toISOString() }, null, 2)); } async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { console.log(`Performing full sync of last ${lookbackDays} days...`); const gmail = google.gmail({ version: 'v1', auth }); const pastDate = new Date(); pastDate.setDate(pastDate.getDate() - lookbackDays); const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); // Get History ID const profile = await gmail.users.getProfile({ userId: 'me' }); const currentHistoryId = profile.data.historyId!; let pageToken: string | undefined; do { const res: any = await gmail.users.threads.list({ userId: 'me', q: `after:${dateQuery}`, pageToken }); const threads = res.data.threads; if (threads) { for (const thread of threads) { await processThread(auth, thread.id!, syncDir, attachmentsDir); } } pageToken = res.data.nextPageToken; } while (pageToken); saveState(currentHistoryId, stateFile); console.log("Full sync complete."); } async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { console.log(`Checking updates since historyId ${startHistoryId}...`); const gmail = google.gmail({ version: 'v1', auth }); try { const res = await gmail.users.history.list({ userId: 'me', startHistoryId, historyTypes: ['messageAdded'] }); const changes = res.data.history; if (!changes || changes.length === 0) { console.log("No new changes."); const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); return; } console.log(`Found ${changes.length} history records.`); const threadIds = new Set(); for (const record of changes) { if (record.messagesAdded) { for (const item of record.messagesAdded) { if (item.message?.threadId) { threadIds.add(item.message.threadId); } } } } for (const tid of threadIds) { await processThread(auth, tid, syncDir, attachmentsDir); } const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); } catch (error: any) { if (error.response?.status === 404) { console.log("History ID expired. Falling back to full sync."); await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays); } else { console.error("Error during partial sync:", error); // If 401, remove token to force re-auth next run if (error.response?.status === 401 && fs.existsSync(TOKEN_PATH)) { console.log("401 Unauthorized. Deleting token to force re-authentication."); fs.unlinkSync(TOKEN_PATH); } } } } async function main() { console.log("Starting Gmail Sync (TS)..."); const syncDirArg = process.argv[2]; const lookbackDaysArg = process.argv[3]; const SYNC_DIR = syncDirArg || DEFAULT_SYNC_DIR; const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 7; // Default to 7 days if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) { console.error("Error: Lookback days must be a positive number."); process.exit(1); } const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); // Ensure directories exist if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); try { const auth = await authorize(); console.log("Authorization successful."); while (true) { const state = loadState(STATE_FILE); if (!state.historyId) { console.log("No history ID found, starting full sync..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else { console.log("History ID found, starting partial sync..."); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); } } catch (error) { console.error("Fatal error in main loop:", error); } } main().catch(console.error); ================================================ FILE: apps/cli/src/mcp/mcp.ts ================================================ import container from "../di/container.js"; import { Client } from "@modelcontextprotocol/sdk/client"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import z from "zod"; import { IMcpConfigRepo } from "./repo.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { connectionState, ListToolsResponse, McpServerDefinition, McpServerList, } from "./schema.js"; type mcpState = { state: z.infer, client: Client | null, error: string | null, }; const clients: Record = {}; async function getClient(serverName: string): Promise { if (clients[serverName] && clients[serverName].state === "connected") { return clients[serverName].client!; } const repo = container.resolve('mcpConfigRepo'); const { mcpServers } = await repo.getConfig(); const config = mcpServers[serverName]; if (!config) { throw new Error(`MCP server ${serverName} not found`); } let transport: Transport | undefined = undefined; try { // create transport if ("command" in config) { transport = new StdioClientTransport({ command: config.command, args: config.args, env: config.env, }); } else { try { transport = new StreamableHTTPClientTransport(new URL(config.url)); } catch (error) { // if that fails, try sse transport transport = new SSEClientTransport(new URL(config.url)); } } if (!transport) { throw new Error(`No transport found for ${serverName}`); } // create client const client = new Client({ name: 'rowboatx', version: '1.0.0', }); await client.connect(transport); // store clients[serverName] = { state: "connected", client, error: null, }; return client; } catch (error) { clients[serverName] = { state: "error", client: null, error: error instanceof Error ? error.message : "Unknown error", }; transport?.close(); throw error; } } export async function cleanup() { for (const [serverName, { client }] of Object.entries(clients)) { await client?.transport?.close(); await client?.close(); delete clients[serverName]; } } export async function listServers(): Promise> { const repo = container.resolve('mcpConfigRepo'); const { mcpServers } = await repo.getConfig(); const result: z.infer = { mcpServers: {}, }; for (const [serverName, config] of Object.entries(mcpServers)) { const state = clients[serverName]; result.mcpServers[serverName] = { config, state: state ? state.state : "disconnected", error: state ? state.error : null, }; } return result; } export async function listTools(serverName: string, cursor?: string): Promise> { const client = await getClient(serverName); const { tools, nextCursor } = await client.listTools({ cursor, }); return { tools, nextCursor, } } export async function executeTool(serverName: string, toolName: string, input: any): Promise { const client = await getClient(serverName); const result = await client.callTool({ name: toolName, arguments: input, }); return result; } ================================================ FILE: apps/cli/src/mcp/repo.ts ================================================ import { WorkDir } from "../config/config.js"; import { McpServerConfig, McpServerDefinition } from "./schema.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; export interface IMcpConfigRepo { getConfig(): Promise>; upsert(serverName: string, config: z.infer): Promise; delete(serverName: string): Promise; } export class FSMcpConfigRepo implements IMcpConfigRepo { private readonly configPath = path.join(WorkDir, "config", "mcp.json"); constructor() { this.ensureDefaultConfig(); } private async ensureDefaultConfig(): Promise { try { await fs.access(this.configPath); } catch (error) { await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2)); } } async getConfig(): Promise> { const config = await fs.readFile(this.configPath, "utf8"); return McpServerConfig.parse(JSON.parse(config)); } async upsert(serverName: string, config: z.infer): Promise { const conf = await this.getConfig(); conf.mcpServers[serverName] = config; await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); } async delete(serverName: string): Promise { const conf = await this.getConfig(); delete conf.mcpServers[serverName]; await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); } } ================================================ FILE: apps/cli/src/mcp/schema.ts ================================================ import z from "zod"; export const StdioMcpServerConfig = z.object({ type: z.literal("stdio").optional(), command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), }); export const HttpMcpServerConfig = z.object({ type: z.literal("http").optional(), url: z.string(), headers: z.record(z.string(), z.string()).optional(), }); export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]); export const McpServerConfig = z.object({ mcpServers: z.record(z.string(), McpServerDefinition), }); export const connectionState = z.enum(["disconnected", "connected", "error"]); export const McpServerList = z.object({ mcpServers: z.record(z.string(), z.object({ config: McpServerDefinition, state: connectionState, error: z.string().nullable(), })), }); export const Tool = z.object({ name: z.string(), description: z.string().optional(), inputSchema: z.object({ type: z.literal("object"), properties: z.record(z.string(), z.any()).optional(), required: z.array(z.string()).optional(), }), outputSchema: z.object({ type: z.literal("object"), properties: z.record(z.string(), z.any()).optional(), required: z.array(z.string()).optional(), }).optional(), }); export const ListToolsResponse = z.object({ tools: z.array(Tool), nextCursor: z.string().optional(), }); ================================================ FILE: apps/cli/src/models/models.ts ================================================ import { ProviderV2 } from "@ai-sdk/provider"; import { createGateway } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createOllama } from "ollama-ai-provider-v2"; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { IModelConfigRepo } from "./repo.js"; import container from "../di/container.js"; import z from "zod"; export const Flavor = z.enum([ "rowboat [free]", "aigateway", "anthropic", "google", "ollama", "openai", "openai-compatible", "openrouter", ]); export const Provider = z.object({ flavor: Flavor, apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), }); export const ModelConfig = z.object({ providers: z.record(z.string(), Provider), defaults: z.object({ provider: z.string(), model: z.string(), }), }); const providerMap: Record = {}; export async function getProvider(name: string = ""): Promise { // get model conf const repo = container.resolve("modelConfigRepo"); const modelConfig = await repo.getConfig(); if (!modelConfig) { throw new Error("Model config not found"); } if (!name) { name = modelConfig.defaults.provider; } if (providerMap[name]) { return providerMap[name]; } const providerConfig = modelConfig.providers[name]; if (!providerConfig) { throw new Error(`Provider ${name} not found`); } const { apiKey, baseURL, headers } = providerConfig; switch (providerConfig.flavor) { case "rowboat [free]": providerMap[name] = createGateway({ apiKey: "rowboatx", baseURL: "https://ai-gateway.rowboatlabs.com/v1/ai", }); break; case "openai": providerMap[name] = createOpenAI({ apiKey, baseURL, headers, }); break; case "aigateway": providerMap[name] = createGateway({ apiKey, baseURL, headers }); break; case "anthropic": providerMap[name] = createAnthropic({ apiKey, baseURL, headers }); break; case "google": providerMap[name] = createGoogleGenerativeAI({ apiKey, baseURL, headers }); break; case "ollama": providerMap[name] = createOllama({ baseURL, headers }); break; case "openai-compatible": providerMap[name] = createOpenAICompatible({ name, apiKey, baseURL : baseURL || "", headers, }); break; case "openrouter": providerMap[name] = createOpenRouter({ apiKey, baseURL, headers }); break; default: throw new Error(`Provider ${name} not found`); } return providerMap[name]; } ================================================ FILE: apps/cli/src/models/repo.ts ================================================ import { ModelConfig, Provider } from "./models.js"; import { WorkDir } from "../config/config.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; export interface IModelConfigRepo { getConfig(): Promise>; upsert(providerName: string, config: z.infer): Promise; delete(providerName: string): Promise; setDefault(providerName: string, model: string): Promise; } const defaultConfig: z.infer = { providers: { "openai": { flavor: "openai", } }, defaults: { provider: "openai", model: "gpt-5.1", } }; export class FSModelConfigRepo implements IModelConfigRepo { private readonly configPath = path.join(WorkDir, "config", "models.json"); constructor() { this.ensureDefaultConfig(); } private async ensureDefaultConfig(): Promise { try { await fs.access(this.configPath); } catch (error) { await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2)); } } async getConfig(): Promise> { const config = await fs.readFile(this.configPath, "utf8"); return ModelConfig.parse(JSON.parse(config)); } private async setConfig(config: z.infer): Promise { await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); } async upsert(providerName: string, config: z.infer): Promise { const conf = await this.getConfig(); conf.providers[providerName] = config; await this.setConfig(conf); } async delete(providerName: string): Promise { const conf = await this.getConfig(); delete conf.providers[providerName]; await this.setConfig(conf); } async setDefault(providerName: string, model: string): Promise { const conf = await this.getConfig(); conf.defaults = { provider: providerName, model, }; await this.setConfig(conf); } } ================================================ FILE: apps/cli/src/runs/lock.ts ================================================ export interface IRunsLock { lock(runId: string): Promise; release(runId: string): Promise; } export class InMemoryRunsLock implements IRunsLock { private locks: Record = {}; async lock(runId: string): Promise { if (this.locks[runId]) { return false; } this.locks[runId] = true; return true; } async release(runId: string): Promise { delete this.locks[runId]; } } ================================================ FILE: apps/cli/src/runs/repo.ts ================================================ import { Run } from "./runs.js"; import z from "zod"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { WorkDir } from "../config/config.js"; import path from "path"; import fsp from "fs/promises"; import { RunEvent, StartEvent } from "../entities/run-events.js"; export const ListRunsResponse = z.object({ runs: z.array(Run.pick({ id: true, createdAt: true, agentId: true, })), nextCursor: z.string().optional(), }); export const CreateRunOptions = Run.pick({ agentId: true, }); export interface IRunsRepo { create(options: z.infer): Promise>; fetch(id: string): Promise>; list(cursor?: string): Promise>; appendEvents(runId: string, events: z.infer[]): Promise; } export class FSRunsRepo implements IRunsRepo { private idGenerator: IMonotonicallyIncreasingIdGenerator; constructor({ idGenerator, }: { idGenerator: IMonotonicallyIncreasingIdGenerator; }) { this.idGenerator = idGenerator; } async appendEvents(runId: string, events: z.infer[]): Promise { await fsp.appendFile( path.join(WorkDir, 'runs', `${runId}.jsonl`), events.map(event => JSON.stringify(event)).join("\n") + "\n" ); } async create(options: z.infer): Promise> { const runId = await this.idGenerator.next(); const ts = new Date().toISOString(); const start: z.infer = { type: "start", runId, agentName: options.agentId, subflow: [], ts, }; await this.appendEvents(runId, [start]); return { id: runId, createdAt: ts, agentId: options.agentId, log: [start], }; } async fetch(id: string): Promise> { const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8'); const events = contents.split('\n') .filter(line => line.trim() !== '') .map(line => RunEvent.parse(JSON.parse(line))); if (events.length === 0 || events[0].type !== 'start') { throw new Error('Corrupt run data'); } return { id, createdAt: events[0].ts!, agentId: events[0].agentName, log: events, }; } async list(cursor?: string): Promise> { const runsDir = path.join(WorkDir, 'runs'); const PAGE_SIZE = 20; let files: string[] = []; try { const entries = await fsp.readdir(runsDir, { withFileTypes: true }); files = entries .filter(e => e.isFile() && e.name.endsWith('.jsonl')) .map(e => e.name); } catch (err: any) { if (err && err.code === 'ENOENT') { return { runs: [] }; } throw err; } files.sort((a, b) => b.localeCompare(a)); const cursorFile = cursor; let startIndex = 0; if (cursorFile) { const exact = files.indexOf(cursorFile); if (exact >= 0) { startIndex = exact + 1; } else { const firstOlder = files.findIndex(name => name.localeCompare(cursorFile) < 0); startIndex = firstOlder === -1 ? files.length : firstOlder; } } const selected = files.slice(startIndex, startIndex + PAGE_SIZE); const runs: z.infer['runs'] = []; for (const name of selected) { const runId = name.slice(0, -'.jsonl'.length); try { const contents = await fsp.readFile(path.join(runsDir, name), 'utf8'); const firstLine = contents.split('\n').find(line => line.trim() !== ''); if (!firstLine) { continue; } const start = StartEvent.parse(JSON.parse(firstLine)); runs.push({ id: runId, createdAt: start.ts!, agentId: start.agentName, }); } catch { continue; } } const hasMore = startIndex + PAGE_SIZE < files.length; const nextCursor = hasMore && selected.length > 0 ? selected[selected.length - 1] : undefined; return { runs, ...(nextCursor ? { nextCursor } : {}), }; } } ================================================ FILE: apps/cli/src/runs/runs.ts ================================================ import z from "zod"; import container from "../di/container.js"; import { IMessageQueue } from "../application/lib/message-queue.js"; import { AskHumanResponseEvent, RunEvent, ToolPermissionResponseEvent } from "../entities/run-events.js"; import { CreateRunOptions, IRunsRepo } from "./repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; import { IBus } from "../application/lib/bus.js"; export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({ subflow: true, toolCallId: true, response: true, }); export const AskHumanResponsePayload = AskHumanResponseEvent.pick({ subflow: true, toolCallId: true, response: true, }); export const Run = z.object({ id: z.string(), createdAt: z.iso.datetime(), agentId: z.string(), log: z.array(RunEvent), }); export async function createRun(opts: z.infer): Promise> { const repo = container.resolve('runsRepo'); const bus = container.resolve('bus'); const run = await repo.create(opts); await bus.publish(run.log[0]); return run; } export async function createMessage(runId: string, message: string): Promise { const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message); const runtime = container.resolve('agentRuntime'); runtime.trigger(runId); return id; } export async function authorizePermission(runId: string, ev: z.infer): Promise { const repo = container.resolve('runsRepo'); const event: z.infer = { ...ev, runId, type: "tool-permission-response", }; await repo.appendEvents(runId, [event]); const runtime = container.resolve('agentRuntime'); runtime.trigger(runId); } export async function replyToHumanInputRequest(runId: string, ev: z.infer): Promise { const repo = container.resolve('runsRepo'); const event: z.infer = { ...ev, runId, type: "ask-human-response", }; await repo.appendEvents(runId, [event]); const runtime = container.resolve('agentRuntime'); runtime.trigger(runId); } export async function stop(runId: string): Promise { throw new Error('Not implemented'); } ================================================ FILE: apps/cli/src/scripts/migrate-agents.ts ================================================ import { Agent } from "../agents/agents.js"; import { IAgentsRepo } from "../agents/repo.js"; import { WorkDir } from "../config/config.js"; import container from "../di/container.js"; import { glob, readFile } from "node:fs/promises"; import path from "path"; const main = async () => { const agentsRepo = container.resolve("agentsRepo"); const matches = await Array.fromAsync(glob("**/*.json", { cwd: path.join(WorkDir, "agents") })); for (const file of matches) { try { const agent = Agent.parse(JSON.parse(await readFile(path.join(WorkDir, "agents", file), "utf8"))); await agentsRepo.create(agent); console.error(`migrated agent ${file}`); } catch (error) { console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`); continue; } } } main(); ================================================ FILE: apps/cli/src/server.ts ================================================ import { Hono } from 'hono'; import { serve } from '@hono/node-server' import { streamSSE } from 'hono/streaming' import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi" import z from 'zod'; import container from './di/container.js'; import { executeTool, listServers, listTools } from "./mcp/mcp.js"; import { ListToolsResponse, McpServerDefinition, McpServerList } from "./mcp/schema.js"; import { IMcpConfigRepo } from './mcp/repo.js'; import { IModelConfigRepo } from './models/repo.js'; import { ModelConfig, Provider } from "./models/models.js"; import { IAgentsRepo } from "./agents/repo.js"; import { Agent } from "./agents/agents.js"; import { AskHumanResponsePayload, authorizePermission, createMessage, createRun, replyToHumanInputRequest, Run, stop, ToolPermissionAuthorizePayload } from './runs/runs.js'; import { IRunsRepo, CreateRunOptions, ListRunsResponse } from './runs/repo.js'; import { IBus } from './application/lib/bus.js'; import { cors } from 'hono/cors'; let id = 0; const routes = new Hono() .post( '/runs/:runId/messages/new', describeRoute({ summary: 'Create a new message', description: 'Create a new message', responses: { 200: { description: 'Message created', content: { 'application/json': { schema: resolver(z.object({ messageId: z.string(), })), }, }, }, }, }), validator('param', z.object({ runId: z.string(), })), validator('json', z.object({ message: z.string(), })), async (c) => { const messageId = await createMessage(c.req.valid('param').runId, c.req.valid('json').message); return c.json({ messageId, }); } ) .post( '/runs/:runId/permissions/authorize', describeRoute({ summary: 'Authorize permission', description: 'Authorize a permission', responses: { 200: { description: 'Permission authorized', content: { 'application/json': { schema: resolver(z.object({ success: z.literal(true), })), }, } }, }, }), validator('param', z.object({ runId: z.string(), })), validator('json', ToolPermissionAuthorizePayload), async (c) => { const response = await authorizePermission( c.req.valid('param').runId, c.req.valid('json') ); return c.json({ success: true, }); } ) .post( '/runs/:runId/human-input-requests/:requestId/reply', describeRoute({ summary: 'Reply to human input request', description: 'Reply to a human input request', responses: { 200: { description: 'Human input request replied', }, }, }), validator('param', z.object({ runId: z.string(), })), validator('json', AskHumanResponsePayload), async (c) => { const response = await replyToHumanInputRequest( c.req.valid('param').runId, c.req.valid('json') ); return c.json({ success: true, }); } ) .post( '/runs/:runId/stop', describeRoute({ summary: 'Stop run', description: 'Stop a run', responses: { 200: { description: 'Run stopped', }, }, }), validator('param', z.object({ runId: z.string(), })), async (c) => { const response = await stop(c.req.valid('param').runId); return c.json({ success: true, }); } ) .get( '/stream', describeRoute({ summary: 'Subscribe to run events', description: 'Subscribe to run events', }), async (c) => { return streamSSE(c, async (stream) => { const bus = container.resolve('bus'); let id = 0; let unsub: (() => void) | null = null; let aborted = false; stream.onAbort(() => { aborted = true; if (unsub) { unsub(); } }); // Subscribe to your bus unsub = await bus.subscribe('*', async (event) => { if (aborted) return; await stream.writeSSE({ data: JSON.stringify(event), event: "message", id: String(id++), }); }); // Keep the function alive until the client disconnects while (!aborted) { await stream.sleep(1000); // any interval is fine } }); } ) ; const app = new Hono() .use("/*", cors()) .route("/", routes) .get( "/openapi.json", openAPIRouteHandler(routes, { documentation: { info: { title: "Hono", version: "1.0.0", description: "RowboatX API", }, }, }), ); // export default app; serve({ fetch: app.fetch, port: Number(process.env.PORT) || 3000, }); // GET /skills // POST /skills/new // GET /skills/ // PUT /skills/ // DELETE /skills/ // GET /sse ================================================ FILE: apps/cli/src/shared/prefix-logger.ts ================================================ // create a PrefixLogger class that wraps console.log with a prefix // and allows chaining with a parent logger export class PrefixLogger { private prefix: string; private parent: PrefixLogger | null; constructor(prefix: string, parent: PrefixLogger | null = null) { this.prefix = prefix; this.parent = parent; } log(...args: any[]) { const timestamp = new Date().toISOString(); const prefix = '[' + this.prefix + ']'; if (this.parent) { this.parent.log(prefix, ...args); } else { console.log(timestamp, prefix, ...args); } } child(childPrefix: string): PrefixLogger { return new PrefixLogger(childPrefix, this); } } ================================================ FILE: apps/cli/src/tui/api.ts ================================================ import { createParser } from "eventsource-parser"; import { Agent } from "../agents/agents.js"; import { AskHumanResponsePayload, Run, ToolPermissionAuthorizePayload } from "../runs/runs.js"; import { ListRunsResponse } from "../runs/repo.js"; import { ModelConfig } from "../models/models.js"; import { RunEvent } from "../entities/run-events.js"; import z from "zod"; const HealthSchema = z.object({ status: z.literal("ok"), }); const MessageResponse = z.object({ messageId: z.string(), }); const SuccessSchema = z.object({ success: z.literal(true), }); type RunEventType = z.infer; export interface RowboatApiOptions { baseUrl?: string; } export class RowboatApi { readonly baseUrl: string; constructor({ baseUrl }: RowboatApiOptions = {}) { this.baseUrl = baseUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000"; } private buildUrl(pathname: string): string { return new URL(pathname, this.baseUrl).toString(); } private async request(pathname: string, init?: RequestInit): Promise { const headers: Record = { Accept: "application/json", }; if (init?.headers instanceof Headers) { init.headers.forEach((value, key) => { headers[key] = value; }); } else if (Array.isArray(init?.headers)) { for (const [key, value] of init.headers) { headers[key] = value; } } else if (init?.headers) { Object.assign(headers, init.headers as Record); } if (init?.body && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } const response = await fetch(this.buildUrl(pathname), { method: "GET", ...init, headers, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Request to ${pathname} failed (${response.status}): ${text || response.statusText}`); } if (response.status === 204) { return undefined as T; } const text = await response.text(); if (!text) { return undefined as T; } return JSON.parse(text) as T; } async getHealth(): Promise> { const payload = await this.request("/health"); return HealthSchema.parse(payload); } async getModelConfig(): Promise> { const payload = await this.request("/models"); return ModelConfig.parse(payload); } async listAgents(): Promise[]> { const payload = await this.request("/agents"); return Agent.array().parse(payload); } async listRuns(cursor?: string): Promise> { const searchParams = new URLSearchParams(); if (cursor) { searchParams.set("cursor", cursor); } const payload = await this.request(`/runs${searchParams.size ? `?${searchParams.toString()}` : ""}`); return ListRunsResponse.parse(payload); } async getRun(runId: string): Promise> { const payload = await this.request(`/runs/${encodeURIComponent(runId)}`); return Run.parse(payload); } async createRun(agentId: string): Promise> { const payload = await this.request("/runs/new", { method: "POST", body: JSON.stringify({ agentId }), }); return Run.parse(payload); } async sendMessage(runId: string, message: string): Promise> { const payload = await this.request(`/runs/${encodeURIComponent(runId)}/messages/new`, { method: "POST", body: JSON.stringify({ message }), }); return MessageResponse.parse(payload); } async authorizeTool(runId: string, payload: z.infer): Promise { const response = await this.request(`/runs/${encodeURIComponent(runId)}/permissions/authorize`, { method: "POST", body: JSON.stringify(payload), }); SuccessSchema.parse(response); } async replyToHuman(runId: string, requestId: string, payload: z.infer): Promise { const response = await this.request(`/runs/${encodeURIComponent(runId)}/human-input-requests/${encodeURIComponent(requestId)}/reply`, { method: "POST", body: JSON.stringify(payload), }); SuccessSchema.parse(response); } async stopRun(runId: string): Promise { const response = await this.request(`/runs/${encodeURIComponent(runId)}/stop`, { method: "POST", }); SuccessSchema.parse(response); } async subscribeToEvents(onEvent: (event: RunEventType) => void, onError?: (error: Error) => void): Promise<() => void> { const controller = new AbortController(); const response = await fetch(this.buildUrl("/stream"), { method: "GET", headers: { Accept: "text/event-stream", }, signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(`Failed to subscribe to event stream (${response.status})`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); const parser = createParser((event) => { if (event.type !== "event" || !event.data) { return; } try { const parsed = RunEvent.parse(JSON.parse(event.data)); onEvent(parsed); } catch (error) { onError?.(error instanceof Error ? error : new Error(String(error))); } }); (async () => { try { while (true) { const { value, done } = await reader.read(); if (done) { break; } parser.feed(decoder.decode(value, { stream: true })); } } catch (error) { if (controller.signal.aborted) { return; } onError?.(error instanceof Error ? error : new Error(String(error))); } })(); return () => { controller.abort(); reader.cancel().catch(() => undefined); }; } } ================================================ FILE: apps/cli/src/tui/index.tsx ================================================ import React from "react"; import { render } from "ink"; import { RowboatTui } from "./ui.js"; export function runTui({ serverUrl }: { serverUrl?: string }) { const baseUrl = serverUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000"; render(); } ================================================ FILE: apps/cli/src/tui/ui.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useInput, useStdout } from "ink"; import Spinner from "ink-spinner"; import SelectInput from "ink-select-input"; import TextInput from "ink-text-input"; import z from "zod"; import { RowboatApi } from "./api.js"; import { ModelConfig } from "../models/models.js"; import { Agent } from "../agents/agents.js"; import { ListRunsResponse } from "../runs/repo.js"; import { Run } from "../runs/runs.js"; import { RunEvent } from "../entities/run-events.js"; type AgentType = z.infer; type ModelConfigType = z.infer; type RunSummary = z.infer["runs"][number]; type RunType = z.infer; type RunEventType = z.infer; type Toast = { type: "info" | "error" | "success"; text: string; }; type ChatLine = { text: string; color?: string; variant?: "user" | "assistant" | "streaming" | "thinking" | "system" | "tool" | "other"; }; type ModalState = | { type: "agent-picker" } | { type: "human-response"; runId: string; requestId: string; subflow: string[]; prompt: string; value: string; submitting: boolean; }; type ConnectionState = "connecting" | "ready" | "error"; type FocusTarget = "chat" | "sidebar"; type PendingPermission = { toolCallId: string; toolName: string; args: unknown; subflow: string[]; }; type PendingHuman = { toolCallId: string; query: string; subflow: string[]; }; type SidebarItem = | { kind: "action"; action: "new-copilot" | "new-agent"; label: string; hint?: string } | { kind: "run"; run: RunSummary; status: { label: string; color: string } }; export function RowboatTui({ serverUrl }: { serverUrl: string }) { const api = useMemo(() => new RowboatApi({ baseUrl: serverUrl }), [serverUrl]); const { exit } = useApp(); const { stdout } = useStdout(); const [connectionState, setConnectionState] = useState("connecting"); const [connectionError, setConnectionError] = useState(null); const [modelConfig, setModelConfig] = useState(null); const [agents, setAgents] = useState([]); const [runs, setRuns] = useState([]); const [runsCursor, setRunsCursor] = useState(); const [runsLoading, setRunsLoading] = useState(false); const [runDetails, setRunDetails] = useState>({}); const [activeRunId, setActiveRunId] = useState(null); const [draftAgent, setDraftAgent] = useState("copilot"); const [composerValue, setComposerValue] = useState(""); const [composerBusy, setComposerBusy] = useState(false); const [focusTarget, setFocusTarget] = useState("chat"); const [sidebarIndex, setSidebarIndex] = useState(0); const [toast, setToast] = useState(null); const [modal, setModal] = useState(null); const [streamError, setStreamError] = useState(null); const [eventStreamActive, setEventStreamActive] = useState(false); const [chatScrollOffset, setChatScrollOffset] = useState(0); const selectedRun = activeRunId ? runDetails[activeRunId] : undefined; const pendingPermissions = useMemo(() => derivePendingPermissions(selectedRun), [selectedRun]); const pendingHuman = useMemo(() => derivePendingHuman(selectedRun), [selectedRun]); const defaultCopilot = useMemo(() => { return "copilot"; }, [agents]); useEffect(() => { if (!agents.length) { return; } setDraftAgent((prev) => prev || defaultCopilot); }, [agents, defaultCopilot]); const runStatusMap = useMemo(() => { const map: Record = {}; for (const summary of runs) { map[summary.id] = getRunStatus(runDetails[summary.id]); } return map; }, [runs, runDetails]); const sidebarItems: SidebarItem[] = useMemo(() => { const items: SidebarItem[] = [ { kind: "action", action: "new-copilot", label: `+ New chat (${defaultCopilot})`, hint: "Ctrl+N", }, { kind: "action", action: "new-agent", label: "+ New chat (choose agent)", hint: "Ctrl+G", }, ]; for (const run of runs) { items.push({ kind: "run", run, status: runStatusMap[run.id] ?? { label: "loading…", color: "gray" }, }); } return items; }, [defaultCopilot, runStatusMap, runs]); useEffect(() => { setSidebarIndex((idx) => { if (sidebarItems.length === 0) { return 0; } return Math.min(idx, sidebarItems.length - 1); }); }, [sidebarItems.length]); const showToast = useCallback((next: Toast) => { setToast(next); }, []); useEffect(() => { if (!toast) { return; } const timer = setTimeout(() => { setToast(null); }, 4000); return () => clearTimeout(timer); }, [toast]); const loadInitial = useCallback(async () => { setConnectionState("connecting"); setConnectionError(null); try { const [health, config, agentList, runsResponse] = await Promise.all([ api.getHealth(), api.getModelConfig(), api.listAgents(), api.listRuns(), ]); if (health.status !== "ok") { throw new Error("Server is not healthy"); } setModelConfig(config); setAgents(agentList); setRuns(runsResponse.runs); setRunsCursor(runsResponse.nextCursor); setConnectionState("ready"); } catch (error) { setConnectionState("error"); setConnectionError(error instanceof Error ? error.message : String(error)); } }, [api]); useEffect(() => { loadInitial(); }, [loadInitial]); useEffect(() => { if (!activeRunId) { return; } if (runDetails[activeRunId]) { return; } let cancelled = false; (async () => { try { const run = await api.getRun(activeRunId); if (!cancelled) { setRunDetails((prev) => ({ ...prev, [run.id]: run, })); } } catch (error) { if (!cancelled) { showToast({ type: "error", text: `Failed to load run: ${error instanceof Error ? error.message : String(error)}`, }); } } })(); return () => { cancelled = true; }; }, [activeRunId, api, runDetails, showToast]); const refreshRuns = useCallback(async () => { setRunsLoading(true); try { const response = await api.listRuns(); setRuns(response.runs); setRunsCursor(response.nextCursor); } catch (error) { showToast({ type: "error", text: `Failed to refresh runs: ${error instanceof Error ? error.message : String(error)}`, }); } finally { setRunsLoading(false); } }, [api, showToast]); useEffect(() => { if (connectionState !== "ready") { return; } let unsub: (() => void) | null = null; let cancelled = false; setStreamError(null); setEventStreamActive(false); (async () => { try { unsub = await api.subscribeToEvents((event) => { if (cancelled) { return; } setEventStreamActive(true); if (event.type === "start") { setRuns((prev) => { const next = [...prev]; const idx = next.findIndex((r) => r.id === event.runId); const summary: RunSummary = { id: event.runId, agentId: event.agentName, createdAt: event.ts ?? new Date().toISOString(), }; if (idx >= 0) { next[idx] = summary; return next; } return [summary, ...next]; }); } setRunDetails((prev) => { const existing = prev[event.runId]; if (!existing) { return prev; } return { ...prev, [event.runId]: { ...existing, log: [...existing.log, event], }, }; }); }, (error) => { setStreamError(error.message); }); } catch (error) { if (!cancelled) { setStreamError(error instanceof Error ? error.message : String(error)); } } })(); return () => { cancelled = true; unsub?.(); }; }, [api, connectionState]); const startDraftChat = useCallback((agentName: string) => { setActiveRunId(null); setDraftAgent(agentName); setComposerValue(""); setFocusTarget("chat"); setSidebarIndex(0); }, []); const composeMessage = useCallback(async (value: string) => { const trimmed = value.trim(); if (!trimmed) { return; } setComposerBusy(true); try { let runId = activeRunId; if (!runId) { const agentName = draftAgent || defaultCopilot; const run = await api.createRun(agentName); runId = run.id; setRuns((prev) => { const without = prev.filter((r) => r.id !== run.id); return [ { id: run.id, createdAt: run.createdAt, agentId: run.agentId, }, ...without, ]; }); setRunDetails((prev) => ({ ...prev, [run.id]: run, })); setActiveRunId(run.id); } await api.sendMessage(runId, trimmed); setComposerValue(""); showToast({ type: "success", text: "Message queued", }); } catch (error) { showToast({ type: "error", text: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, }); } finally { setComposerBusy(false); } }, [activeRunId, api, defaultCopilot, draftAgent, showToast]); const handleApprovePermission = useCallback(async () => { const run = selectedRun; const pending = pendingPermissions[0]; if (!run || !pending) { showToast({ type: "info", text: "No pending tool permissions" }); return; } try { await api.authorizeTool(run.id, { toolCallId: pending.toolCallId, response: "approve", subflow: pending.subflow, }); showToast({ type: "success", text: `Approved ${pending.toolName}` }); } catch (error) { showToast({ type: "error", text: `Failed to approve: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, pendingPermissions, selectedRun, showToast]); const handleDenyPermission = useCallback(async () => { const run = selectedRun; const pending = pendingPermissions[0]; if (!run || !pending) { showToast({ type: "info", text: "No pending tool permissions" }); return; } try { await api.authorizeTool(run.id, { toolCallId: pending.toolCallId, response: "deny", subflow: pending.subflow, }); showToast({ type: "success", text: `Denied ${pending.toolName}` }); } catch (error) { showToast({ type: "error", text: `Failed to deny: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, pendingPermissions, selectedRun, showToast]); const handleStopRun = useCallback(async () => { if (!selectedRun) { showToast({ type: "info", text: "No run selected" }); return; } try { await api.stopRun(selectedRun.id); showToast({ type: "success", text: `Stop requested for ${selectedRun.id}` }); } catch (error) { showToast({ type: "error", text: `Failed to stop: ${error instanceof Error ? error.message : String(error)}`, }); } }, [api, selectedRun, showToast]); const handleReplyHuman = useCallback(async (value: string, context: PendingHuman | undefined) => { if (!selectedRun || !context) { showToast({ type: "info", text: "No pending human requests" }); return; } try { await api.replyToHuman(selectedRun.id, context.toolCallId, { toolCallId: context.toolCallId, response: value, subflow: context.subflow, }); showToast({ type: "success", text: "Reply sent" }); } catch (error) { showToast({ type: "error", text: `Failed to send reply: ${error instanceof Error ? error.message : String(error)}`, }); throw error; } }, [api, selectedRun, showToast]); const currentHumanRequest = pendingHuman[0]; const maxVisibleEvents = Math.max(8, (stdout?.rows ?? 40) - 14); const chatTimeline = useMemo(() => { if (!selectedRun) { return { visibleEvents: [] as ChatLine[], maxOffset: 0, total: 0, }; } const lines: ChatLine[] = []; let streamingText = ""; let streamingActive = false; let reasoningText = ""; let reasoningActive = false; for (const event of selectedRun.log) { if (event.type === "llm-stream-event") { const step = event.event; switch (step.type) { case "text-start": streamingActive = true; streamingText = ""; break; case "text-delta": streamingActive = true; streamingText += step.delta; break; case "text-end": case "finish-step": streamingActive = false; break; case "reasoning-start": reasoningActive = true; reasoningText = ""; break; case "reasoning-delta": reasoningActive = true; reasoningText += step.delta; break; case "reasoning-end": reasoningActive = false; break; default: break; } continue; } const formatted = formatEvent(event); if (formatted) { lines.push(formatted); } } if (reasoningActive && reasoningText) { lines.push({ text: `assistant (thinking): ${reasoningText}`, color: "black", variant: "thinking", }); } if (streamingActive && streamingText) { lines.push({ text: `assistant (streaming): ${streamingText}`, color: "black", variant: "streaming", }); } const total = lines.length; const maxOffset = Math.max(0, total - maxVisibleEvents); const clampedOffset = Math.min(chatScrollOffset, maxOffset); const end = total - clampedOffset; const start = Math.max(0, end - maxVisibleEvents); return { visibleEvents: lines.slice(start, end), maxOffset, total, }; }, [chatScrollOffset, maxVisibleEvents, selectedRun]); useEffect(() => { setChatScrollOffset(0); }, [selectedRun?.id]); useEffect(() => { setChatScrollOffset((offset) => Math.min(offset, chatTimeline.maxOffset)); }, [chatTimeline.maxOffset]); useInput((input, key) => { if (modal) { if (key.escape) { setModal(null); } return; } if (key.tab) { setFocusTarget((prev) => (prev === "chat" ? "sidebar" : "chat")); return; } if (key.ctrl && input === "q") { exit(); return; } if (key.ctrl && input === "n") { startDraftChat(defaultCopilot); return; } if (key.ctrl && input === "g") { if (agents.length === 0) { showToast({ type: "error", text: "No agents available" }); return; } setModal({ type: "agent-picker" }); return; } if (key.ctrl && input === "l") { refreshRuns(); return; } if (key.ctrl && input === "a") { handleApprovePermission(); return; } if (key.ctrl && input === "d") { handleDenyPermission(); return; } if (key.ctrl && input === "s") { handleStopRun(); return; } if (key.ctrl && input === "h") { if (!currentHumanRequest) { showToast({ type: "info", text: "No pending human input requests" }); return; } if (!selectedRun) { showToast({ type: "info", text: "Select a run to respond" }); return; } setModal({ type: "human-response", runId: selectedRun.id, requestId: currentHumanRequest.toolCallId, subflow: currentHumanRequest.subflow, prompt: currentHumanRequest.query, value: "", submitting: false, }); return; } if (focusTarget === "sidebar") { if (key.upArrow) { setSidebarIndex((idx) => Math.max(0, idx - 1)); return; } if (key.downArrow) { setSidebarIndex((idx) => Math.min(sidebarItems.length - 1, idx + 1)); return; } if (key.return) { const item = sidebarItems[sidebarIndex]; if (!item) { return; } if (item.kind === "action") { if (item.action === "new-copilot") { startDraftChat(defaultCopilot); } else { if (agents.length === 0) { showToast({ type: "error", text: "No agents available" }); } else { setModal({ type: "agent-picker" }); } } } else { setActiveRunId(item.run.id); setFocusTarget("chat"); } } } if (focusTarget === "chat") { const scrollStep = Math.max(3, Math.floor(maxVisibleEvents / 2)); if (key.pageUp) { setChatScrollOffset((offset) => Math.min(chatTimeline.maxOffset, offset + scrollStep)); return; } if (key.pageDown) { setChatScrollOffset((offset) => Math.max(0, offset - scrollStep)); return; } } }); return (
0} scrollHint={chatTimeline.maxOffset > 0} /> Tab toggles focus · Ctrl+N new Copilot chat · Ctrl+G choose agent · Ctrl+L refresh chats · Ctrl+Q quit {toast && ( {toast.text} )} {modal && ( {modal.type === "agent-picker" && ( { setModal(null); startDraftChat(agent); }} onCancel={() => setModal(null)} /> )} {modal.type === "human-response" && ( setModal({ ...modal, value })} onSubmit={async (value) => { const ctx: PendingHuman = { toolCallId: modal.requestId, query: modal.prompt, subflow: modal.subflow, }; setModal({ ...modal, submitting: true }); try { await handleReplyHuman(value.trim(), ctx); setModal(null); } catch { setModal({ ...modal, submitting: false }); } }} onCancel={() => setModal(null)} /> )} )} ); } function Header({ serverUrl, state, error, modelConfig, agentsCount, runsCount, runsCursor, streamError, listening, }: { serverUrl: string; state: ConnectionState; error: string | null; modelConfig: ModelConfigType | null; agentsCount: number; runsCount: number; runsCursor: string | undefined; streamError: string | null; listening: boolean; }) { return ( RowboatX chat · Server {serverUrl} {state === "connecting" && ( <> {" "} Connecting… )} {state === "ready" && ( Connected · default {modelConfig?.defaults?.provider ?? "n/a"}/{modelConfig?.defaults?.model ?? "n/a"} )} {state === "error" && ( Offline: {error ?? "Unknown error"} · Ctrl+L to retry )} Agents: {agentsCount} · Chats loaded: {runsCount} {runsCursor ? " (+ more)" : ""} {streamError && ( Event stream issue: {streamError} )} {state === "ready" && listening === false && ( Listening for run events… )} ); } function Sidebar({ items, focus, index, activeRunId, runsLoading, }: { items: SidebarItem[]; focus: boolean; index: number; activeRunId: string | null; runsLoading: boolean; }) { return ( Chats {focus ? "↑/↓ move · Enter select · Esc to leave" : "Tab to focus sidebar"} {runsLoading && ( refreshing… )} {items.length === 0 && No chats yet.} {items.map((item, idx) => { let divider: React.ReactNode = null; const isCursor = focus && idx === index; if (item.kind === "action") { return ( {isCursor ? "❯" : " "} {item.label} {item.hint ? `(${item.hint})` : ""} ); } const previousRuns = items.slice(0, idx).some((entry) => entry.kind === "run"); if (!previousRuns) { divider = ( ── recent chats ── ); } const isActiveRun = item.run.id === activeRunId; return ( {divider} {isCursor ? "❯" : isActiveRun ? "●" : " "} {" "} {item.run.agentId}{" "} {item.run.id}{" "} {item.status.label}{" "} {timeAgo(item.run.createdAt)} ); })} ); } function ChatPanel({ focus, draftAgent, run, events, composerValue, composerBusy, onChangeComposer, onSubmitComposer, pendingPermissions, pendingHuman, showHumanHint, showPermissionHint, scrollHint, }: { focus: boolean; draftAgent: string; run: RunType | undefined; events: ChatLine[]; composerValue: string; composerBusy: boolean; onChangeComposer: (value: string) => void; onSubmitComposer: (value: string) => void; pendingPermissions: PendingPermission[]; pendingHuman: PendingHuman[]; showHumanHint: boolean; showPermissionHint: boolean; scrollHint: boolean; }) { return ( {run ? run.agentId : draftAgent} {" "} {run ? ( <> · Run {run.id} · started {formatTimestamp(run.createdAt)} ({timeAgo(run.createdAt)}) ) : ( · new chat )} {!run && ( Type a prompt and press enter to spin up a new {draftAgent} chat. )} {showPermissionHint && ( Tool approval pending · Ctrl+A approve · Ctrl+D deny )} {showHumanHint && ( Agent asked for help · Ctrl+H to reply )} {run && events.length === 0 && ( Loading chat log… )} {!run && ( No messages yet. )} {events.map((event, idx) => ( ))} {focus ? `Enter to send · Ctrl+N new chat${scrollHint ? " · PgUp/PgDn scroll" : ""}` : "Tab to focus composer"} onSubmitComposer(value)} focus={focus && !composerBusy} placeholder="Send a message…" /> {composerBusy && ( Sending… )} ); } function ModalSurface({ children }: { children: React.ReactNode }) { return ( {children} ); } function AgentPickerModal({ agents, onSelect, onCancel, }: { agents: AgentType[]; onSelect: (agentName: string) => void; onCancel: () => void; }) { const items = agents.map((agent) => ({ label: `${agent.name}${agent.description ? ` – ${truncate(agent.description, 40)}` : ""}`, value: agent.name, })); return ( Select an agent (esc to cancel) {items.length === 0 ? ( No agents configured. ) : ( items={items} onSelect={(item) => onSelect(item.value)} /> )} {items.length} agents available. ); } function MessageModal({ typeLabel, prompt, value, submitting, onChange, onSubmit, onCancel, }: { typeLabel: string; prompt?: string; value: string; submitting: boolean; onChange: (value: string) => void; onSubmit: (value: string) => Promise; onCancel: () => void; }) { return ( {typeLabel} (esc to cancel) {prompt && ( {truncate(prompt, 120)} )} { if (!text.trim()) { return; } onSubmit(text); }} focus={!submitting} placeholder="Type your response…" /> {submitting ? ( Sending… ) : ( Enter to submit · esc to cancel )} ); } function derivePendingPermissions(run: RunType | undefined): PendingPermission[] { if (!run) { return []; } const responded = new Set( run.log .filter((event) => event.type === "tool-permission-response") .map((event) => event.toolCallId), ); const pending: PendingPermission[] = []; for (const event of run.log) { if (event.type === "tool-permission-request") { const id = event.toolCall.toolCallId; if (!responded.has(id)) { pending.push({ toolCallId: id, toolName: event.toolCall.toolName, args: event.toolCall.arguments, subflow: event.subflow, }); } } } return pending; } function derivePendingHuman(run: RunType | undefined): PendingHuman[] { if (!run) { return []; } const responded = new Set( run.log .filter((event) => event.type === "ask-human-response") .map((event) => event.toolCallId), ); const pending: PendingHuman[] = []; for (const event of run.log) { if (event.type === "ask-human-request" && !responded.has(event.toolCallId)) { pending.push({ toolCallId: event.toolCallId, query: event.query, subflow: event.subflow, }); } } return pending; } function getRunStatus(run: RunType | undefined): { label: string; color: string } { if (!run) { return { label: "loading…", color: "gray" }; } const last = run.log[run.log.length - 1]; if (last?.type === "error") { return { label: "error", color: "red" }; } if (derivePendingHuman(run).length > 0) { return { label: "awaiting human", color: "magenta" }; } if (derivePendingPermissions(run).length > 0) { return { label: "needs approval", color: "yellow" }; } return { label: "running", color: "green" }; } function MessageBubble({ event }: { event: ChatLine }) { const isUser = event.variant === "user"; const isAssistant = event.variant === "assistant" || event.variant === "streaming"; const align = isUser ? "flex-end" : "flex-start"; const bubbleColor = isUser ? "blue" : undefined; const textColor = isUser ? "white" : event.color; return ( {event.text} ); } function formatEvent(event: RunEventType): ChatLine | null { switch (event.type) { case "start": return { text: `▶ Start · ${event.agentName}`, color: "green", variant: "system" }; case "message": { const content = typeof event.message.content === "string" ? event.message.content : event.message.content .map((part) => { if (part.type === "text" || part.type === "reasoning") { return part.text; } if (part.type === "tool-call") { return `[tool:${part.toolName}] ${JSON.stringify(part.arguments)}`; } return ""; }) .join("\n"); return { text: `${event.message.role}: ${content}`, color: event.message.role === "user" ? "black" : event.message.role === "assistant" ? "black" : "white", variant: event.message.role === "user" ? "user" : event.message.role === "assistant" ? "assistant" : "system", }; } case "tool-invocation": return { text: `🔧 Invoking ${event.toolName} ${JSON.stringify(event.input)}`, color: "yellow", variant: "tool" }; case "tool-result": return { text: `✅ ${event.toolName} → ${truncate(JSON.stringify(event.result), 120)}`, color: "green", variant: "tool" }; case "tool-permission-request": return { text: `⚠️ Permission needed for ${event.toolCall.toolName}`, color: "yellow", variant: "system" }; case "tool-permission-response": return { text: `Permission ${event.response} for ${event.toolCallId}`, color: event.response === "approve" ? "green" : "red", variant: "system" }; case "ask-human-request": return { text: `🧑 Agent asks: ${truncate(event.query, 120)}`, color: "magenta", variant: "system" }; case "ask-human-response": return { text: `🙋 Human replied`, color: "magenta", variant: "system" }; case "llm-stream-event": return { text: `… ${event.event.type}`, color: "gray" }; case "error": return { text: `✖ ${event.error}`, color: "red", variant: "system" }; case "spawn-subflow": return { text: `↳ Spawned ${event.agentName}`, color: "cyan", variant: "system" }; default: return { text: "unknown event", color: "white", variant: "other" }; } } function truncate(input: string, len = 60): string { if (input.length <= len) { return input; } return `${input.slice(0, len - 1)}…`; } function formatTimestamp(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } return date.toLocaleString(); } function timeAgo(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } const diff = Date.now() - date.getTime(); const seconds = Math.floor(diff / 1000); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } ================================================ FILE: apps/cli/todo.md ================================================ runtime --- o stream out responses o terminal logging o file logging - accept initial user input from CLI - mcp tool calls (http + stdio) - human input support - bash tool support - cli wrapper (node commander) rowboat agent --- - create agent ================================================ FILE: apps/cli/tsconfig.json ================================================ { // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { "rootDir": "./src", "outDir": "./dist", "module": "nodenext", "target": "esnext", "lib": ["esnext"], "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "sourceMap": true, "jsx": "react-jsx", "paths": { "@/*": [ "./src/*" ] } } } ================================================ FILE: apps/docs/.gitignore ================================================ site/ ================================================ FILE: apps/docs/docs/development/contribution-guide.mdx ================================================ --- title: "Contribution Guide" description: "How to contribute to Rowboat — from bug reports to pull requests." icon: "github" --- # Contributing to Rowboat Rowboat is open-source and we welcome contributions of all kinds — bug reports, feature ideas, code, and docs improvements. **Quick links:** - [GitHub Repository](https://github.com/rowboatlabs/rowboat) - [Discord Community](https://discord.gg/wajrgmJQ6b) - [Open Issues](https://github.com/rowboatlabs/rowboat/issues) --- ## Ways to Contribute **Report bugs or suggest improvements** — If something feels off or could be better, [open an issue](https://github.com/rowboatlabs/rowboat/issues/new). Include steps to reproduce for bugs, or a clear description of the improvement you have in mind. **Fix an existing issue** — Browse [open issues](https://github.com/rowboatlabs/rowboat/issues) and comment on one to let us know you're working on it. **Propose a new feature or integration** — Open an issue first so we can discuss the approach before you invest time building it. **Improve documentation** — Typos, unclear explanations, missing examples — all fair game. --- ## Contribution Workflow 1. **Fork** [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) and clone it locally. 2. **Create a branch from `dev`** with a descriptive name (`fix-tool-crash`, `feature-mcp-discovery`). 3. **Make your changes.** Keep PRs focused on a single issue or feature. 4. **Test your changes** locally before submitting. 5. **Open a pull request against `dev`** (not `main`) with a clear description of what you changed and why. Screenshots or short demos are appreciated for UI changes. 6. **Respond to feedback** — maintainers may request changes. This is normal and collaborative. 7. **Merge!** Once approved, we'll merge your PR. For small fixes like typos or formatting, try bundling related changes into a single PR rather than submitting them individually. --- ## Guidelines - **One PR, one concern.** Don't mix unrelated changes in the same pull request. - **Write clear commit messages.** A reviewer should understand what changed from the message alone. - **Follow existing code style.** Match the patterns you see in the codebase. - **Be patient and respectful.** We review PRs as quickly as we can. A polite ping on Discord is always welcome if things go quiet. --- ## Getting Help If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked. ================================================ FILE: apps/docs/docs/development/roadmap.mdx ================================================ --- icon: "road" --- # Roadmap Explore the future development plans and upcoming features for Rowboat. ================================================ FILE: apps/docs/docs/getting-started/introduction.mdx ================================================ --- title: "Introduction" description: "Welcome to the official Rowboat documentation! Rowboat is an open-source AI coworker that turns work into a knowledge graph and acts on it." icon: "book-open" --- [![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) --- ## What is Rowboat? Rowboat is a local-first AI coworker, with work memory. Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine. You can do things like: - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph --- ## What it does Rowboat is a **local-first AI coworker** that can: - **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments) - **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc) - **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides) Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit. ## Integrations Rowboat builds memory from the work you already do, including: - **Gmail** (email) - **Granola** (meeting notes) - **Fireflies** (meeting notes) ## How it’s different Most AI tools reconstruct context on demand by searching transcripts or documents. Rowboat maintains **long-lived knowledge** instead: - context accumulates over time - relationships are explicit and inspectable - notes are editable by you, not hidden inside a model - everything lives on your machine as plain Markdown The result is memory that compounds, rather than retrieval that starts cold every time. ## What you can do with it - **Meeting prep** from prior decisions, threads, and open questions - **Email drafting** grounded in history and commitments - **Docs & decks** generated from your ongoing context (including PDF slides) - **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped - **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) ## Background agents Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. Examples: - Draft email replies in the background (grounded in your past context and commitments) - Generate a daily voice note each morning (agenda, priorities, upcoming meetings) - Create recurring project updates from the latest emails/notes - Keep your knowledge graph up to date as new information comes in You control what runs, when it runs, and what gets written back into your local Markdown vault. ## Bring your own model Rowboat works with the model setup you prefer: - **Local models** via Ollama or LM Studio - **Hosted models** (bring your own API key/provider) - Swap models anytime — your data stays in your local Markdown vault ## Extend Rowboat with tools (MCP) Rowboat can connect to external tools and services via **Model Context Protocol (MCP)**. That means you can plug in (for example) search, databases, CRMs, support tools, and automations - or your own internal tools. Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, GitHub, and more. ## Local-first by design - All data is stored locally as plain Markdown - No proprietary formats or hosted lock-in - You can inspect, edit, back up, or delete everything at any time ---
[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
--- ## Contributing Want to contribute to Rowboat? Please consider checking out our [Contribution Guide](/docs/development/contribution-guide) Star us on github! ## Community Need help using Rowboat? Join our community! Join our growing discord community and interact with hundreds of developer using Rowboat! ================================================ FILE: apps/docs/docs/getting-started/license.mdx ================================================ --- title: "License" icon: "file" mode: "center" # url: "https://github.com/rowboatlabs/rowboat/blob/main/LICENSE" ## An alternate display we could use --- RowBoat is available under the [Apache 2.0 License](https://github.com/rowboatlabs/rowboat/blob/main/LICENSE): ---- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [2024] [RowBoat Labs] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: apps/docs/docs/getting-started/quickstart.mdx ================================================ --- title: "Quickstart" description: "guide to getting started with rowboat" icon: "rocket" --- **Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads) **All release files:** https://github.com/rowboatlabs/rowboat/releases/latest ## Google setup (optional) To connect Gmail, Calendar, and Drive, follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). ## Voice notes (optional) To enable voice notes, add a Deepgram API key in `~/.rowboat/config/deepgram.json`: ```json { "apiKey": "" } ``` ## Web search (optional) To use Brave web search, add the Brave API key in `~/.rowboat/config/brave-search.json`. To use Exa research search, add the Exa API key in `~/.rowboat/config/exa-search.json`. (Use the same JSON format as above.) ================================================ FILE: apps/docs/docs.json ================================================ { "$schema": "https://mintlify.com/docs.json", "theme": "maple", "name": "Rowboat", "description": "Rowboat is an open-source platform for building multi-agent systems. It helps you orchestrate tools, RAG, memory, and deployable agents with ease.", "favicon": "/favicon.ico", "colors": { "primary": "#6366F1", "light": "#6366F1", "dark": "#6366F1" }, "icons": { "library": "fontawesome" }, "navigation": { "groups": [ { "group": "Getting Started", "pages": [ "docs/getting-started/introduction", "docs/getting-started/quickstart" ] }, { "group": "Development", "pages": ["docs/development/contribution-guide", "docs/getting-started/license"] } ] }, "background": { "decoration": "gradient", "color": { "light": "#FFFFFF", "dark": "#0D0A09" } }, "navbar": { "primary": { "type": "button", "label": "Try Rowboat", "href": "https://app.rowboatlabs.com" } }, "footer": { "socials": { "github": "https://github.com/rowboatlabs/rowboat", "linkedin": "https://www.linkedin.com/company/rowboat-labs", "discord": "https://discord.gg/rxB8pzHxaS" } }, "contextual": { "options": [ "copy", "view", "chatgpt", "claude" ] } } ================================================ FILE: apps/experimental/chat_widget/.dockerignore ================================================ Dockerfile .dockerignore node_modules npm-debug.log README.md .next .git .env* ================================================ FILE: apps/experimental/chat_widget/.eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "next/typescript"] } ================================================ FILE: apps/experimental/chat_widget/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files (can opt-in for commiting if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: apps/experimental/chat_widget/Dockerfile ================================================ # syntax=docker.io/docker/dockerfile:1 FROM node:18-alpine AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. # ENV NEXT_TELEMETRY_DISABLED=1 RUN \ if [ -f yarn.lock ]; then yarn run build; \ elif [ -f package-lock.json ]; then npm run build; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ else echo "Lockfile not found." && exit 1; \ fi # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output ENV HOSTNAME="0.0.0.0" ENV PORT=3000 CMD echo "Starting server $CHAT_WIDGET_HOST, $ROWBOAT_HOST" && node server.js #CMD ["node", "server.js"] ================================================ FILE: apps/experimental/chat_widget/README.md ================================================ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ================================================ FILE: apps/experimental/chat_widget/app/api/bootstrap.js/route.ts ================================================ export const dynamic = 'force-dynamic' // Fetch template once when module loads const templatePromise = fetch(process.env.CHAT_WIDGET_HOST + '/bootstrap.template.js') .then(res => res.text()); export async function GET() { try { // Reuse the cached content const template = await templatePromise; // Replace placeholder values with actual URLs const contents = template .replace('__CHAT_WIDGET_HOST__', process.env.CHAT_WIDGET_HOST || '') .replace('__ROWBOAT_HOST__', process.env.ROWBOAT_HOST || ''); return new Response(contents, { headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }); } catch (error) { console.error('Error serving bootstrap.js:', error); return new Response('Error loading script', { status: 500 }); } } ================================================ FILE: apps/experimental/chat_widget/app/app.tsx ================================================ "use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { apiV1 } from "rowboat-shared"; import { z } from "zod"; import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Textarea } from "@nextui-org/react"; import MarkdownContent from "./markdown-content"; type Message = { role: "user" | "assistant" | "system" | "tool"; content: string; tool_call_id?: string; tool_name?: string; } function ChatWindowHeader({ chatId, closeChat, closed, setMinimized, }: { chatId: string | null; closeChat: () => Promise; closed: boolean; setMinimized: (minimized: boolean) => void; }) { return
Chat
{(chatId && !closed) && { if (key === "close") { closeChat(); } }}> Close chat }
} function LoadingAssistantResponse() { return
} function AssistantMessage({ children, }: { children: React.ReactNode; }) { return
Assistant
{typeof children === 'string' ? : children}
} function UserMessage({ children, }: { children: React.ReactNode; }) { return
{typeof children === 'string' ? : children}
} function ChatWindowMessages({ messages, loadingAssistantResponse, }: { messages: Message[]; loadingAssistantResponse: boolean; }) { const messagesEndRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); return
Hello! I'm Rowboat, your personal assistant. How can I help you today? {messages.map((message, index) => { switch (message.role) { case "user": return {message.content}; case "assistant": return {message.content}; case "system": return null; // Hide system messages from the UI case "tool": return Tool response ({message.tool_name}): {message.content} ; default: return null; } })} {loadingAssistantResponse && }
} function ChatWindowInput({ handleUserMessage, }: { handleUserMessage: (message: string) => Promise; }) { const [prompt, setPrompt] = useState(""); function handleInputKeyDown(event: React.KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); const input = prompt.trim(); setPrompt(''); handleUserMessage(input); } } return