Merge branch 'rowboatlabs:main' into main

This commit is contained in:
Abishek Raj R R 2026-03-04 08:58:14 +05:30 committed by GitHub
commit 0d68f30033
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 5551 additions and 2583 deletions

View file

@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@ -21,7 +21,7 @@ jobs:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
@ -107,7 +107,7 @@ jobs:
fi fi
- name: Upload workflow artifacts - name: Upload workflow artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: distributables name: distributables
path: apps/x/apps/main/out/make/* path: apps/x/apps/main/out/make/*
@ -118,7 +118,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@ -126,7 +126,7 @@ jobs:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
@ -171,7 +171,7 @@ jobs:
working-directory: apps/x/apps/main working-directory: apps/x/apps/main
- name: Upload workflow artifacts - name: Upload workflow artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: distributables-linux name: distributables-linux
path: apps/x/apps/main/out/make/* path: apps/x/apps/main/out/make/*
@ -182,7 +182,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@ -190,7 +190,7 @@ jobs:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
@ -237,7 +237,7 @@ jobs:
working-directory: apps/x/apps/main working-directory: apps/x/apps/main
- name: Upload workflow artifacts - name: Upload workflow artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: distributables-windows name: distributables-windows
path: apps/x/apps/main/out/make/* path: apps/x/apps/main/out/make/*

View file

@ -8,10 +8,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
cache-dependency-path: 'apps/rowboat/package-lock.json' cache-dependency-path: 'apps/rowboat/package-lock.json'
node-version: '20' node-version: '20'
@ -29,10 +29,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
cache-dependency-path: 'apps/rowboat/package-lock.json' cache-dependency-path: 'apps/rowboat/package-lock.json'
node-version: '24' node-version: '24'

View file

@ -12,10 +12,10 @@ jobs:
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
registry-url: https://registry.npmjs.org/ registry-url: https://registry.npmjs.org/

View file

@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G
- No proprietary formats or hosted lock-in - No proprietary formats or hosted lock-in
- You can inspect, edit, back up, or delete everything at any time - You can inspect, edit, back up, or delete everything at any time
## Looking for Rowboat Web Studio?
If youre looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
--- ---
<div align="center"> <div align="center">

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js"; import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.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. 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.
@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
- Keep user data safedouble-check before editing or deleting important resources. - Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## Workspace access & scope ## 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. - 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. - 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.

View file

@ -0,0 +1,69 @@
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.`;
}

View file

@ -1,11 +1,13 @@
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec); const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string { function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, ''); return token.trim().replace(/^['"]+|['"]+$/g, '');
@ -91,7 +93,7 @@ export async function executeCommand(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility shell: EXECUTION_SHELL,
}); });
return { return {
@ -125,7 +127,7 @@ export function executeCommandSync(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
encoding: 'utf-8', encoding: 'utf-8',
shell: '/bin/sh', shell: EXECUTION_SHELL,
}); });
return { return {

View file

@ -18,36 +18,12 @@
"group": "Getting Started", "group": "Getting Started",
"pages": [ "pages": [
"docs/getting-started/introduction", "docs/getting-started/introduction",
"docs/getting-started/quickstart", "docs/getting-started/quickstart"
"docs/getting-started/license"
] ]
}, },
{
"group": "Using Rowboat",
"pages": [
"docs/using-rowboat/rowboat-studio",
"docs/using-rowboat/agents",
"docs/using-rowboat/tools",
"docs/using-rowboat/rag",
"docs/using-rowboat/triggers",
"docs/using-rowboat/jobs",
"docs/using-rowboat/conversations",
{
"group": "Customise",
"icon": "sliders",
"pages": [
"docs/using-rowboat/customise/custom-llms"
]
}
]
},
{
"group": "API & SDK",
"pages": ["docs/api-sdk/using_the_api", "docs/api-sdk/using_the_sdk"]
},
{ {
"group": "Development", "group": "Development",
"pages": ["docs/development/contribution-guide", "docs/development/roadmap"] "pages": ["docs/development/contribution-guide", "docs/getting-started/license"]
} }
] ]
}, },

View file

@ -1,173 +0,0 @@
---
title: "Using the API"
description: "This is a guide on using the HTTP API to power conversations with the assistant created in Studio."
icon: "code"
---
## Deploy your assistant to production on Studio
<Frame>
<img src="/docs/img/prod-deploy.png" alt="Prod Deploy" />
</Frame>
## Obtain API key and Project ID
Generate API keys via the developer configs in your project. Copy the Project ID from the same page.
<Frame>
<img src="/docs/img/dev-config.png" alt="Developer Configs" />
</Frame>
## API Endpoint
```
POST <HOST>/api/v1/<PROJECT_ID>/chat
```
Where:
- For self-hosted: `<HOST>` is `http://localhost:3000`
## Authentication
Include your API key in the Authorization header:
```
Authorization: Bearer <API_KEY>
```
## Examples
### First Turn
```bash
curl --location '<HOST>/api/v1/<PROJECT_ID>/chat' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
"messages": [
{
"role": "user",
"content": "Hello, can you help me?"
}
],
"state": null
}'
```
Response:
```json
{
"messages": [
{
"role": "assistant",
"content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
"agenticResponseType": "external"
}
],
"state": {
"last_agent_name": "MainAgent"
}
}
```
### Subsequent Turn
Notice how we include both the previous messages and the state from the last response:
```bash
curl --location '<HOST>/api/v1/<PROJECT_ID>/chat' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
"messages": [
{
"role": "user",
"content": "Hello, can you help me?"
},
{
"role": "assistant",
"content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
"agenticResponseType": "external"
},
{
"role": "user",
"content": "What services do you offer?"
}
],
"state": {
"last_agent_name": "MainAgent"
}
}'
```
## API Specification
### Request Schema
```typescript
{
// Required fields
messages: Message[]; // Array of message objects representing the conversation history
state: any; // State object from previous response, or null for first message
// Optional fields
workflowId?: string; // Specific workflow ID to use (defaults to production workflow)
testProfileId?: string; // Test profile ID for simulation
}
```
### Message Types
Messages can be one of the following types:
1. System Message
```typescript
{
role: "system";
content: string;
}
```
2. User Message
```typescript
{
role: "user";
content: string;
}
```
3. Assistant Message
```typescript
{
role: "assistant";
content: string;
agenticResponseType: "internal" | "external";
agenticSender?: string | null;
}
```
### Response Schema
```typescript
{
messages: Message[]; // Array of new messages from this turn
state: any; // State object to pass in the next request
}
```
## Important Notes
1. Always pass the complete conversation history in the `messages` array
2. Always include the `state` from the previous response in your next request
3. The last message in the response's `messages` array will be a user-facing assistant message (`agenticResponseType: "external"`)
## Rate Limiting
The API has rate limits per project. If exceeded, you'll receive a 429 status code.
## Error Responses
- 400: Invalid request body or missing/invalid Authorization header
- 403: Invalid API key
- 404: Project or workflow not found
- 429: Rate limit exceeded

View file

@ -1,91 +0,0 @@
---
title: "Using the SDK"
description: "This is a guide on using the RowBoat Python SDK as an alternative to the [RowBoat HTTP API](/using_the_api) to power conversations with the assistant created in Studio."
icon: "toolbox"
---
## Prerequisites
- ``` pip install rowboat ```
- [Deploy your assistant to production](/using_the_api/#deploy-your-assistant-to-production-on-studio)
- [Obtain your `<API_KEY>` and `<PROJECT_ID>`](/using_the_api/#obtain-api-key-and-project-id)
### API Host
- For the open source installation, the `<HOST>` is [http://localhost:3000](http://localhost:3000)
- When using the hosted app, the `<HOST>` is [https://app.rowboatlabs.com](https://app.rowboatlabs.com)
## Usage
### Basic Usage
The main way to interact with Rowboat is using the `Client` class, which provides a stateless chat API. You can manage conversation state using the `conversationId` returned in each response.
```python
from rowboat.client import Client
from rowboat.schema import UserMessage
# Initialize the client
client = Client(
host="<HOST>",
projectId="<PROJECT_ID>",
apiKey="<API_KEY>"
)
# Start a new conversation
result = client.run_turn(
messages=[
UserMessage(role='user', content="What is the capital of France?")
]
)
print(result.turn.output[-1].content)
# The capital of France is Paris.
print("Conversation ID:", result.conversationId)
# Continue the conversation by passing the conversationId
result = client.run_turn(
messages=[
UserMessage(role='user', content="What other major cities are in that country?")
],
conversationId=result.conversationId
)
print(result.turn.output[-1].content)
# Other major cities in France include Lyon, Marseille, Toulouse, and Nice.
result = client.run_turn(
messages=[
UserMessage(role='user', content="What's the population of the first city you mentioned?")
],
conversationId=result.conversationId
)
print(result.turn.output[-1].content)
# Lyon has a population of approximately 513,000 in the city proper.
```
### Using Tool Overrides (Mock Tools)
You can provide tool override instructions to test a specific configuration using the `mockTools` argument:
```python
result = client.run_turn(
messages=[
UserMessage(role='user', content="What's the weather?")
],
mockTools={
"weather_lookup": "The weather in any city is sunny and 25°C.",
"calculator": "The result of any calculation is 42."
}
)
print(result.turn.output[-1].content)
```
### Message Types
You can use different message types as defined in `rowboat.schema`, such as `UserMessage`, `SystemMessage`, `AssistantMessage`, etc. See `schema.py` for all available message types.
### Error Handling
If the API returns a non-200 status code, a `ValueError` will be raised with the error details.
---
For more advanced usage, see the docstrings in `client.py` and the message schemas in `schema.py`.

View file

@ -1,59 +1,55 @@
--- ---
title: "Contribution Guide" title: "Contribution Guide"
description: "Learn how to contribute to the Rowboat project and help improve our platform." description: "How to contribute to Rowboat — from bug reports to pull requests."
icon: "github" icon: "github"
--- ---
# Join the Rowboat Voyage # Contributing to Rowboat
We're building Rowboat as an open-source, community-powered platform — and we'd love for you to hop aboard! Whether you're fixing typos, suggesting a new tool integration, or designing your dream multi-agent workflow, your contributions are valuable and welcome. Rowboat is open-source and we welcome contributions of all kinds — bug reports, feature ideas, code, and docs improvements.
<Frame> **Quick links:**
<img src="/docs/img/contribution-guide-hero.png" className="w-full max-w-[600px] rounded-xl" alt="Contribution guide hero image showing community collaboration" /> - [GitHub Repository](https://github.com/rowboatlabs/rowboat)
</Frame> - [Discord Community](https://discord.gg/wajrgmJQ6b)
- [Open Issues](https://github.com/rowboatlabs/rowboat/issues)
--- ---
## How You Can Contribute ## 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.
- **Tackle Open Issues** **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.
Browse our [GitHub Issues](https://github.com/rowboatlabs/rowboat/issues) for tags like `good first issue`, `help wanted`, or `bug` to find a spot that fits your skillset.
- **Join the Community** **Propose a new feature or integration** — Open an issue first so we can discuss the approach before you invest time building it.
Our [Discord](https://discord.gg/rxB8pzHxaS) is the go-to hub for brainstorming, feedback, and finding contributors for bigger efforts.
- **Propose Something New**
Have a new tool integration idea or found a bug? Open an issue and lets discuss it!
**Improve documentation** — Typos, unclear explanations, missing examples — all fair game.
--- ---
## Contribution Workflow & Best Practices ## Contribution Workflow
Whether it's your first contribution or your fiftieth, here's what a great contribution looks like:
| Step / Tip | Description |
|-------------------------------|-----------------------------------------------------------------------------------------------|
| **1. Fork the Repository** | Create a personal copy of [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) to start contributing. |
| **2. Create a New Branch** | Use a descriptive name like `fix-tool-crash` or `feature-new-mcp`. Avoid committing to `main`. |
| **3. Make Your Changes** | Focus your PR on a single issue or feature to keep things clean and reviewable. |
| **4. Write Tests if Needed** | If you change logic, add relevant tests so your contribution is future-proof. |
| **5. Run Tests & Lint Locally**| Make sure your branch passes all tests and code quality checks before pushing. |
| **6. Document or Demo It** | Add helpful context: screenshots, example scripts, or a short video/gif to demonstrate your changes. |
| **7. Submit a Pull Request** | Open a PR with a clear description of your changes and any context reviewers should know. |
| **8. Collaborate on Feedback** | Our maintainers may leave comments — its all part of the process. Lets improve it together. |
| **9. Dont Be Shy to Follow Up** | Feel free to ping the PR/issue if you're waiting on feedback. We appreciate polite nudges. |
| **10. Celebrate the Merge!** | You just made Rowboat better. Thanks for contributing 🚀 |
<Tip>If you're fixing typos, spacing, or small tweaks — try bundling those into a related PR instead of sending them standalone. It helps keep reviews focused. </Tip>
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.
<Tip>For small fixes like typos or formatting, try bundling related changes into a single PR rather than submitting them individually.</Tip>
--- ---
## Come Build With Us ## Guidelines
We believe great ideas come from the community — and that means **you**. Whether you're an engineer, designer, AI tinkerer, or curious beginner, theres room on this boat for everyone. - **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.
Lets build the future of AI workflows — together. 🫶 ---
## 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.

View file

@ -1,60 +1,96 @@
--- ---
title: "Introduction" title: "Introduction"
description: "Welcome to the official Rowboat documentation! Rowboat is a low-code AI IDE to build tool connected multi-agent assistants. Rowboat copilot builds the agents for you based on your requirements with the option do everything manually as well." 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" icon: "book-open"
--- ---
[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I)
<Frame>
<img src="../videos/Intro-Video.gif" alt="Intro Video" />
</Frame>
## What is RowBoat?
**RowBoat is a state-of-art platform to build multi-agent AI systems in a visual interface, with the help of a copilot.**
RowBoat enables you to build, manage and deploy user-facing assistants. An assistant is made up of multiple agents, each having access to a set of tools and working together to interact with the user as a single assistant. You can connect any tool to the agents.
For example, you can build a *meeting prep assistant* that helps you prepare for upcoming meetings. One agent can access your Google Calendar to see your scheduled meetings, another agent can research the meeting attendees (such as finding their LinkedIn profiles or recent news), and a third agent can compile this research and send it to your email before the meeting. This way, you get automated, personalized meeting prep without manual effort.
--- ---
## How RowBoat works ## 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.
### RowBoat Studio You can do things like:
RowBoat Studio lets you create AI agents in minutes, using a visual interface and plain language. - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
There are key components that you will work with: - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
- Agents - Visualize, edit, and update your knowledge graph anytime (its just Markdown)
- Playground - Record voice memos that automatically capture and update key takeaways in the graph
- Copilot
<Card title="Using Rowboat" icon="puzzle-piece" horizontal href="/docs/using-rowboat/rowboat-studio">
Learn about Rowboat Studio and key concepts used in building assistants
</Card>
### RowBoat Chat API & SDK
- [RowBoat Chat API](/docs/api-sdk/using_the_api) is a stateless HTTP API to interface with the assistant created on RowBoat Studio. You can use the API to drive end-user facing conversations in your app or website.
- [RowBoat Chat SDK](/docs/api-sdk/using_the_sdk) is a simple Python SDK which wraps the HTTP API under the hood. It provides a clean interface for managing conversations using conversation IDs for state management.
--- ---
## Why RowBoat? ## What it does
Rowboat is the fastest way to build and deploy multi-agent assistants.
<Steps> Rowboat is a **local-first AI coworker** that can:
<Step title="Build complex assistants"> - **Remember** the important context you dont want to re-explain (people, projects, decisions, commitments)
Use plain language and a powerful visual interface to design and orchestrate multi-agent assistants with ease. - **Understand** whats relevant right now (before a meeting, while replying to an email, when writing a doc)
</Step> - **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
<Step title="Integrate tools and MCP servers"> Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
Add tools and connect to MCP servers in just minutes — no complex setup required.
</Step>
<Step title="Expedite your AI roadmap"> ## Integrations
Accelerate development with battle-tested tooling tailored for building production-ready, multi-agent AI systems.
</Step>
</Steps>
Rowboat builds memory from the work you already do, including:
- **Gmail** (email)
- **Granola** (meeting notes)
- **Fireflies** (meeting notes)
## How its 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
---
<div align="center">
[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
</div>
--- ---
## Contributing ## Contributing
@ -72,7 +108,7 @@ Need help using Rowboat? Join our community!
<Card <Card
title="Discord" title="Discord"
icon="discord" icon="discord"
horizontal href="https://discord.gg/rxB8pzHxaS" horizontal href="https://discord.gg/wajrgmJQ6b"
> >
Join our growing discord community and interact with hundreds of developer using Rowboat! Join our growing discord community and interact with hundreds of developer using Rowboat!
</Card> </Card>

View file

@ -3,92 +3,25 @@ title: "Quickstart"
description: "guide to getting started with rowboat" description: "guide to getting started with rowboat"
icon: "rocket" icon: "rocket"
--- ---
--- **Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads)
# Cloud Setup
Using the open-source version of Rowboat requires more technical skill to set up and navigate. For the smoothest experience, we recommend using our [hosted version](https://dev.rowboatlabs.com/) **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).
# Local Setup ## Voice notes (optional)
To enable voice notes, add a Deepgram API key in `~/.rowboat/config/deepgram.json`:
<Note>Pre-requisite: Ensure Docker is installed on your machine. You'll also need an OpenAI account and API key to use the Copilot and agents.</Note> ```json
{
"apiKey": "<key>"
}
```
## Web search (optional)
To use Brave web search, add the Brave API key in `~/.rowboat/config/brave-search.json`.
<Steps> To use Exa research search, add the Exa API key in `~/.rowboat/config/exa-search.json`.
<Step title="Set your OpenAI key">
Export your OpenAI API key in your terminal:
```bash (Use the same JSON format as above.)
export OPENAI_API_KEY=your-openai-api-key
```
</Step>
<Step title="Set up Composio for tools and triggers (optional)">
To use external tools and triggers, export your Composio API key:
```bash
export COMPOSIO_API_KEY=your-composio-api-key
export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
```
<Note>For more detailed setup instructions, see the [Triggers](/docs/using-rowboat/triggers#local-setup) page.</Note>
</Step>
<Step title="Clone the repository and start Rowboat Docker">
Clone the Rowboat repository and start the app using Docker:
```bash
git clone git@github.com:rowboatlabs/rowboat.git
cd rowboat
./start.sh
```
</Step>
<Step title="Access the app">
Once Docker is running, open your browser and go to:
[http://localhost:3000](http://localhost:3000)
</Step>
</Steps>
<Info>See the [Using custom LLM providers](#using-custom-llm-providers) section below for using custom providers like OpenRouter and LiteLLM. </Info>
---
## Demo
{/* (would be better to change this to a Getiing Started Tutorial) */}
#### Create a multi-agent assistant with MCP tools by chatting with Rowboat
[![Screenshot 2025-04-23 at 00 25 31](https://github.com/user-attachments/assets/c8a41622-8e0e-459f-becb-767503489866)](https://youtu.be/YRTCw9UHRbU)
---
## Integrate with Rowboat agents
There are 2 ways to integrate with the agents you create in Rowboat
<Columns cols={2}>
<Card title="Using the API" icon="code" horizontal href="/docs/api-sdk/using_the_api">
Guide on using the HTTP API
</Card>
<Card title="Using the SDK" icon="toolbox" horizontal href="/docs/api-sdk/using_the_sdk">
Guide on using the Python SDK
</Card>
</Columns>
---
## Using custom LLM providers
By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
Check out our page on customising
<Card title="Customise" icon="sliders" horizontal href="/docs/using-rowboat/customise">
Learn more about customising your Rowboat experience here
</Card>

View file

@ -1,141 +0,0 @@
---
title: "Agents"
description: "Learn about creating and configuring individual agents within your multi-agent system"
icon: "robot"
---
## Overview
Agents are the core building blocks of Rowboat's multi-agent system. Each agent carries out a specific part of a conversation, handles tasks via tools, and can collaborate with other agents to orchestrate complex workflows.
They are powered by LLMs and can:
- Respond to user input
- Trigger tools or APIs
- Pass control to other agents using @mentions
- Fetch or process internal data
- Execute RAG (Retrieval-Augmented Generation) queries
- Participate in sequential pipeline workflows
---
## Agent Types
Rowboat supports several types of agents, each designed for specific use cases:
| Name | Purpose | Characteristics |
|------|---------|-----------------|
| **Conversational Agents** (`conversation`) | Primary user-facing agents that interact directly with users and orchestrate workflows. | • Can respond to users and orchestrate workflows<br />• Typically serve as the start agent (Hub Agent)|
| **Task Agents** (`internal`) | Specialized agents that perform specific tasks without direct user interaction. | • Focused on specific functions<br />• Return results to parent agents|
| **Pipeline Agents** (`pipeline`) | Sequential workflow execution agents that process data in a chain. | • Execute in sequence within a pipeline<br />• Cannot transfer to other agents directly|
---
## Agent Configuration
Agents are configured through two main tabs in the Rowboat Studio interface:
### **Instructions Tab**
#### Description
A clear description of the agent's role and responsibilities
#### Instructions
Instructions are the backbone of the agent's behavior. Use the Copilot's structured format for consistency:
**Recommended Structure:**
```
## 🧑‍💼 Role:
[Clear description of the agent's role]
## ⚙️ Steps to Follow:
1. [Step 1]
2. [Step 2]
3. [Step 3]
## 🎯 Scope:
✅ In Scope:
- [What the agent should handle]
❌ Out of Scope:
- [What the agent should NOT handle]
## 📋 Guidelines:
✔️ Dos:
- [Positive behaviors]
🚫 Don'ts:
- [Negative behaviors]
```
#### Examples
These help agents behave correctly in specific situations. Each example can include:
- A sample user message
- The expected agent response
- Any tool calls (if applicable)
### **Configurations Tab**
#### Name
Name of the agent
#### Behaviour
- **Agent Type**: Choose from `conversation`, `internal`, or `pipeline`
- **Model**: Select the LLM model (GPT-4.1, GPT-4o, google/gemini-2.5-flash, etc.)
#### RAG
- **Add Source**: Connect data sources to enable RAG capabilities for the agent
---
## Creating Your Initial Set of Agents
Let Copilot bootstrap your agent graph.
### Instruct Copilot
Start by telling Copilot what your assistant is meant to do — it'll generate an initial set of agents with best-practice instructions, role definitions, and connected agents.
<Frame>
<img src="/docs/img/create-agents-delivery.png" className="w-full max-w-[400px] rounded-xl" alt="Creating agents with Copilot" />
</Frame>
### Inspect the Output
After applying the suggested agents, take a close look at each one's:
- **Instructions**: Define how the agent behaves
- **Examples**: Guide agent responses and tool use
<Frame>
<img src="/docs/img/agent-instruction.png" alt="Inspect agent instructions" />
</Frame>
---
## Updating Agent Behavior
There are three ways to update an agent:
### 1. With Copilot
Copilot understands the current chat context and can help rewrite or improve an agent's behavior based on how it performed.
<Frame>
<img src="/docs/img/update-agent-copilot.png" className="w-full max-w-[400px] rounded-xl" alt="Update agent using Copilot" />
</Frame>
### 2. Manual Edits
You can always manually edit the agent's instructions.
<Frame>
<img src="/docs/img/update-agent-manual.png" alt="Manually edit agent" />
</Frame>
---

View file

@ -1,52 +0,0 @@
---
title: "Conversations"
description: "View and manage all conversations with your Rowboat agents"
icon: "list-check"
---
## Overview
The Conversations page in Rowboat shows you all the interactions between users and your agents. Here you can monitor conversations, view detailed message exchanges, and understand how your agents are performing.
<Frame>
<img src="/docs/img/conversations-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Conversations page UI showing list of conversations" />
</Frame>
## What You'll See
The Conversations page displays a list of all conversations organized by time:
- **Today**: Recent conversations from today
- **This week**: Conversations from the current week
- **This month**: Conversations from the current month
- **Older**: Conversations from previous months
Each conversation shows:
- **Conversation ID**: Unique identifier for the conversation
- **Created time**: When the conversation started
- **Reason**: What triggered the conversation (chat or job)
## Viewing Conversation Details
Click on any conversation to see the detailed view with all the message exchanges:
<Frame>
<img src="/docs/img/conversations-inside-run.png" className="w-full max-w-[800px] rounded-xl" alt="Conversation details showing expanded view of a conversation with turns and messages" />
</Frame>
**Conversation Metadata**: Shows the Conversation ID, creation time, and last update time.
**Workflow**: Shows the workflow JSON
**Turns**: Each conversation is made up of turns, where:
- **Turn #1, #2, etc.**: Numbered sequence of interactions
- **Reason badge**: Shows why each turn happened (chat, API, job, etc.)
- **Timestamp**: When each turn occurred
- **Input messages**: What was sent to your agents
- **Output messages**: What your agents responded with
### Turn Details
Each turn displays:
- **Input**: The messages sent to your agents (user messages, system messages)
- **Output**: The responses from your agents
- **Error information**: Any issues that occurred during processing

View file

@ -1,53 +0,0 @@
---
title: "Custom LLMs"
description: "How to use and configure custom LLMs in Rowboat."
---
<Note> This is currently only possible in the self hosted version of Rowboat</Note>
## Using custom LLM providers
By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
<Steps>
<Step title="Set up your LLM provider">
Configure your environment variables to point to your preferred LLM backend. Example using LiteLLM:
```bash
export PROVIDER_BASE_URL=http://host.docker.internal:4000/
export PROVIDER_API_KEY=sk-1234
```
Rowboat uses <code>gpt-4.1</code> as the default model for agents and copilot. You can override these:
```bash
export PROVIDER_DEFAULT_MODEL=claude-3-7-sonnet-latest
export PROVIDER_COPILOT_MODEL=gpt-4.1
```
**Notes:**
- Copilot is optimized for <code>gpt-4o</code>/<code>gpt-4.1</code>. We strongly recommend using these models for best results.
- You can use different models for the copilot and each agent, but all must be from the same provider (e.g., LiteLLM).
- Rowboat is provider-agnostic — any backend implementing the OpenAI messages format should work.
- OpenAI-specific tools (like <code>web_search</code>) will not function with non-OpenAI providers. Remove such tools to avoid errors.
</Step>
<Step title="Clone the repository and start Rowboat Docker">
Clone the Rowboat repo and spin it up locally:
```bash
git clone git@github.com:rowboatlabs/rowboat.git
cd rowboat
docker-compose up --build
```
</Step>
<Step title="Access the app">
Once Docker is running, navigate to:
[http://localhost:3000](http://localhost:3000)
</Step>
</Steps>

View file

@ -1,45 +0,0 @@
---
title: "Jobs"
description: "Monitor and inspect all your trigger executions and job runs"
icon: "message"
---
## Overview
The Jobs page in Rowboat provides a comprehensive view of all your automated job executions. Here you can monitor the status of your triggers, inspect what happened during each run, and troubleshoot any issues that may have occurred.
<Frame>
<img src="/docs/img/jobs-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Jobs page showing list of all job runs with status indicators" />
</Frame>
## What You'll See
The Jobs page displays a list of all job runs from your triggers, including:
- **External trigger executions** from webhook events
- **One-time trigger runs** from scheduled jobs
- **Recurring trigger executions** from cron-based schedules
Each job run displays the following key information:
- **Job ID**: Unique identifier for the job run
- **Status**: Indicates if the job succeeded, failed, or is in progress
- **Reason**: The trigger or cause for the job (e.g., external trigger, scheduled, cron)
- **Created Time**: When the job was executed
## Viewing Job Details
### Expand a Job Run
Click on any job run to expand it and see detailed information about what happened during execution:
<Frame>
<img src="/docs/img/jobs-inside-run.png" className="w-full max-w-[800px] rounded-xl" alt="Job run details showing expanded information for a specific job" />
</Frame>
**Basic job details**: Job ID, Status, creation time, Updated time, Conversation ID and Turn ID. By clicking on the Conversation ID, you can view more in-depth details about the run.
**Job Reason**: Why the job triggered - either external trigger, scheduled, or cron.
**Job Input**: The input data sent to your assistant.
**Job Output**: The final output produced by your agents.

View file

@ -1,68 +0,0 @@
---
title: "RAG (Data)"
description: "How to use our inbuilt RAG"
icon: "database"
---
# Using RAG in Rowboat
Rowboat provides multiple ways to enhance your agents' context with Retrieval-Augmented Generation (RAG). This guide will help you set up and use each RAG feature.
<Info>RAG is called "Data" on the build view in the Rowboat UI. </Info>
---
## Types of RAG
| RAG Type | Description | Configuration Required |
|----------|-------------|------------------------|
| **Text RAG** | Process and reason over text content directly | No configuration needed |
| **File Uploads** | Upload PDF files directly from your device | No configuration needed |
| **URL Scraping** | Scrape content from web URLs using Firecrawl | Requires API key setup |
<Note> URL Scraping does not require any setup in the managed version of Rowboat. </Note>
<Frame>
<img src="/docs/img/rag-adding-data.png" className="w-full max-w-[800px] rounded-xl" alt="Adding data sources for RAG in Rowboat" />
</Frame>
## RAG Features
### 1. Text RAG
Process and reason over text content directly
### 2. File Uploads
- Upload PDF files directly from your device
- **Open Source Version**: Files are stored locally on your machine
- **Managed Version**: Files are stored in cloud S3 storage
- Files are parsed using OpenAI by default
<Note> You can also use Google's Gemini model for parsing as it is better at parsing larger files. </Note>
#### 2.1 Using Gemini for File Parsing
To use Google's Gemini model for parsing uploaded PDFs, set the following variables:
```bash
# Enable Gemini for file parsing
export USE_GEMINI_FILE_PARSING=true
export GOOGLE_API_KEY=your_google_api_key
```
### 3. URL Scraping
Rowboat uses Firecrawl for URL scraping. You can have a maximum of 100 URLs.
**Open Source Version**: To enable URL scraping, set the following variables:
```bash
export USE_RAG_SCRAPING=true
export FIRECRAWL_API_KEY=your_firecrawl_api_key
```
**Managed Version**: No configuration required - URL scraping is handled automatically.

View file

@ -1,60 +0,0 @@
---
title: "Rowboat Studio"
description: "Visual interface to build, test, and deploy multi-agent AI assistants using plain language"
icon: "puzzle-piece"
---
## Overview
**Rowboat Studio** is your visual interface for building AI assistants — powered by agents, tools, and workflows — using plain language and minimal setup. It brings the process of creating multi-agent systems down to just a few clicks.
Workflows created within Rowboat are known as **assistants**, and each assistant is composed of:
- One or more **agents**
- Attached **tools** and **MCP servers**
Once built, assistants can be tested live in the **playground** and deployed in real-world products using the [API](/docs/api-sdk/using_the_api) or [SDK](/docs/api-sdk//using_the_sdk).
---
## Key Components
Heres what youll interact with in Studio:
| Component | Description | Highlights |
|------------|-------------|------------|
| **Agent** | Core building blocks of your assistant.<br />Each agent handles a specific part of the conversation and performs tasks using tools and instructions. | • Define behavior in plain language<br />• Connect agents into a graph<br />• Attach tools and RAG sources |
| **Playground** | Interactive testbed for conversations.<br />Lets you simulate end-user chats with your assistant and inspect agent behavior in real time. | • Real-time feedback and debugging<br />• See tool calls and agent handoffs<br />• Test individual agents or the whole system |
| **Copilot** | Your AI assistant for building assistants.<br />Copilot creates and updates agents, tools, and instructions based on your plain-English prompts. | • Understands full system context<br />• Improves agents based on playground chat<br />• Builds workflows intelligently and fast |
> **Agents are the heart of every assistant.** Learn more about how they work in the Agents page.
<Card title="Agents" icon="robot" horizontal href="/docs/using-rowboat/agents">
Learn about creating and configuring individual agents within your multi-agent system
</Card>
---
## Building in Rowboat
<Steps>
<Step title="Describe your assistant to Copilot">
Use plain language to tell Copilot what you want your assistant to do. Copilot will auto-generate the agents, instructions, and tools that form the base of your assistant.
</Step>
<Step title="Review and apply Copilot suggestions">
Inspect the created agents — especially their instructions and examples — and refine or approve them before moving forward.
</Step>
<Step title="Connect MCP servers and tools">
Integrate external services, tools, and backend logic into your agents using Rowboat's modular system. Tools are tied to agents and triggered through instructions.
</Step>
<Step title="Test in the Playground">
Use the chat playground to simulate real-world conversations. Youll see which agent takes control, what tools are triggered, and how your assistant flows.
</Step>
<Step title="Deploy via API or SDK">
Assistants can be deployed into production using the **Rowboat Chat API** or the **Rowboat Chat SDK**. Both support stateless and stateful conversation flows.
</Step>
</Steps>

View file

@ -1,56 +0,0 @@
---
title: "Tools"
description: "Add and configure tools for your agents to interact with external services"
icon: "wrench"
---
## Overview
The Tools page in Rowboat lets you add and configure tools that your agents can use to interact with external services, APIs, and systems. Tools enable your agents to perform real-world actions like sending emails, managing calendars, or processing payments.
<Frame>
<img src="/docs/img/tools-ui.png" alt="Screenshot of the Tools UI in Rowboat" className="w-full max-w-[800px] rounded-xl shadow" />
</Frame>
## Tool Types
| Tool Type | Description | Use Case | Availability |
|-----------|-------------|----------|--------------|
| **Library Tools** | Pre-built integrations with popular services | Quick setup, no configuration needed | Managed and open source |
| **MCP Tools** | Custom tools from MCP servers | Custom functionality, specialized APIs | Managed and open source |
| **Webhook Tools** | HTTP endpoints for custom integrations | Your own systems, custom workflows | Open source only |
## Library (Composio Tools)
- Browse a library of 500+ toolkits from popular services
- With 3000+ tools to choose from!
- Click on a service to see available tools and add them to your workflow
- Users must create a [Composio](https://composio.dev/) account and add their API key
- Tools require authorization to work properly
### Setting up Composio API Key
To use Composio tools, get a Composio key and export it as an environment variable:
```bash
export COMPOSIO_API_KEY=your_api_key_here
```
<Note>Users can visit [Composio's toolkit documentation](https://docs.composio.dev/toolkits/introduction) for a deep dive into all the tools available.</Note>
## Custom MCP Servers
- Add your own MCP (Model Context Protocol) servers
- Connect to custom tools and APIs you've built
- Configure server URLs and authentication
- Import tools from your MCP servers
## Webhook
<Note>Webhook tools are only available in the open source (local) version of Rowboat.</Note>
- Create custom webhook tools
- Configure HTTP endpoints for your agents to call
- Set up custom authentication and parameters
- Build integrations with your own systems

View file

@ -1,131 +0,0 @@
---
title: "Triggers"
description: "Learn about setting up automated triggers for your Rowboat agents"
icon: "bolt"
---
## Overview
Triggers in Rowboat are automated mechanisms that activate your agents when specific events occur or conditions are met. They form the foundation of your automated workflow system, enabling your agents to respond to external events, scheduled times, and system conditions without manual intervention.
## Trigger Types
Rowboat supports three main categories of triggers, each designed for different automation scenarios:
| Trigger Type | Purpose | Execution | Use Cases |
|--------------|---------|-----------|-----------|
| **External Triggers** | Connect to external services and events | Real-time via webhooks | Slack messages, GitHub events, email processing |
| **One-Time Triggers** | Execute at specific predetermined times | Single execution at set time | Delayed responses, time-sensitive actions |
| **Recurring Triggers** | Execute on repeating schedules | Continuous via cron expressions | Daily reports, periodic maintenance, regular syncs |
---
## External Triggers (Composio Integration)
External triggers are powered by **Composio** and allow users to use triggers from across 30+ services including Slack, GitHub, Gmail, Notion, Google Calendar, and more.
<Frame>
<img src="/docs/img/triggers-external-ui.png" className="w-full max-w-[800px] rounded-xl" alt="External Triggers UI showing available toolkits and services" />
</Frame>
### Creating External Triggers
1. **Click New External Trigger**: Start the trigger creation process
2. **Select a Toolkit**: Browse available toolkits or search for specific services
3. **Choose Trigger Type**: Select the specific trigger from available options, click configure
4. **Authenticate**: Complete OAuth2 flow or enter API keys for the selected service (your preferred method)
5. **Configure**: Set up event filters and data mapping if required
6. **Deploy**: Activate the trigger to start listening for events
### Local Setup
If you're running the open source version of Rowboat, you'll need to set up external triggers manually. In the managed version, this is all handled automatically for you.
<Steps>
<Step title="Create a Composio project">
Sign into [Composio](https://composio.dev/) and create a new project for your Rowboat instance.
</Step>
<Step title="Get your Composio API key">
Go to your project settings and copy the project API key. Export it in your Rowboat environment:
```bash
export COMPOSIO_API_KEY=your-composio-api-key
```
</Step>
<Step title="Expose your Rowboat port">
Use ngrok to expose your local Rowboat instance:
```bash
ngrok http 3000
```
Copy the generated ngrok URL (e.g., `https://a5fe8c0d45b8.ngrok-free.app`).
</Step>
<Step title="Configure webhook URL">
In Composio, go to Events & Triggers section and set the Trigger Webhook URL to:
```
{ngrok_url}/api/composio/webhook
```
Example: `https://a5fe8c0d45b8.ngrok-free.app/api/composio/webhook`
</Step>
<Step title="Set webhook secret">
Copy the Webhook Secret from Composio and export it in Rowboat:
```bash
export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
```
</Step>
<Step title="Restart Rowboat">
Restart your Rowboat instance to load the new environment variables. You're now ready to use external triggers!
</Step>
</Steps>
<Note>Make sure your Rowboat assistant is deployed before receiving trigger calls</Note>
---
## One-Time Triggers (Scheduled Jobs)
One-time triggers execute your agents at a specific, predetermined time. They're useful for delayed responses, batch processing, time-sensitive actions, or coordinating with external schedules.
<Frame>
<img src="/docs/img/triggers-onetime-ui.png" className="w-full max-w-[800px] rounded-xl" alt="One-Time Triggers UI showing scheduled job configuration" />
</Frame>
### Creating One-Time Triggers
1. Set the exact execution time (date and time)
2. Configure the input messages for your agents
3. Deploy to schedule the execution
---
## Recurring Triggers (Cron-based Jobs)
Recurring triggers execute your agents on a repeating schedule using cron expressions. They're ideal for daily reports, periodic maintenance, regular data syncs, and continuous monitoring tasks.
<Frame>
<img src="/docs/img/triggers-recurring-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Recurring Triggers UI showing cron-based job configuration" />
</Frame>
### Creating Recurring Triggers
1. Define the cron expression (e.g., `0 9 * * *` for daily at 9 AM)
2. Configure the recurring message structure
3. Enable the trigger to start the recurring schedule
### Common Cron Patterns
```cron
0 9 * * * # Daily at 9:00 AM
0 8 * * 1 # Every Monday at 8:00 AM
*/15 * * * * # Every 15 minutes
0 0 1 * * # First day of month at midnight
```

View file

@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "npx next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",

View file

@ -30,6 +30,8 @@ import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js';
import { versionHistory } from '@x/core';
type InvokeChannels = ipc.InvokeChannels; type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels; type IPCChannels = ipc.IPCChannels;
@ -104,6 +106,18 @@ let watcher: FSWatcher | null = null;
const changeQueue = new Set<string>(); const changeQueue = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Emit knowledge commit event to all renderer windows
*/
function emitKnowledgeCommitEvent(): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('knowledge:didCommit', {});
}
}
}
/** /**
* Emit workspace change event to all renderer windows * Emit workspace change event to all renderer windows
*/ */
@ -282,6 +296,9 @@ export function stopServicesWatcher(): void {
* Add new handlers here as you add channels to IPCChannels * Add new handlers here as you add channels to IPCChannels
*/ */
export function setupIpcHandlers() { export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
registerIpcHandlers({ registerIpcHandlers({
'app:getVersions': async () => { 'app:getVersions': async () => {
// args is null for this channel (no request payload) // args is null for this channel (no request payload)
@ -497,5 +514,22 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream'; const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size }; return { data: buffer.toString('base64'), mimeType, size: stat.size };
}, },
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
return { commits };
},
'knowledge:fileAtCommit': async (_event, args) => {
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
return { content };
},
'knowledge:restore': async (_event, args) => {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
// Search handler
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);
},
}); });
} }

View file

@ -17,7 +17,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup"; import started from "electron-squirrel-startup";
@ -171,9 +170,6 @@ app.whenReady().then(async () => {
// start knowledge graph builder // start knowledge graph builder
initGraphBuilder(); initGraphBuilder();
// start pre-built agent runner
initPreBuiltRunner();
// start background agent runner (scheduled agents) // start background agent runner (scheduled agents)
initAgentRunner(); initAgentRunner();

View file

@ -84,7 +84,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
return clientIdOverride; return clientIdOverride;
} }
const oauthRepo = getOAuthRepo(); const oauthRepo = getOAuthRepo();
const clientId = await oauthRepo.getClientId(provider); const { clientId } = await oauthRepo.read(provider);
if (clientId) { if (clientId) {
return clientId; return clientId;
} }
@ -179,9 +179,9 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Build authorization URL // Build authorization URL
const authUrl = oauthClient.buildAuthorizationUrl(config, { const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirectUri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
scope: scopes.join(' '), scope: scopes.join(' '),
codeChallenge, code_challenge: codeChallenge,
state, state,
}); });
@ -212,11 +212,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Save tokens // Save tokens
console.log(`[OAuth] Token exchange successful for ${provider}`); console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.saveTokens(provider, tokens); await oauthRepo.upsert(provider, { tokens });
if (provider === 'google' && clientId) { if (provider === 'google' && clientId) {
await oauthRepo.setClientId(provider, clientId); await oauthRepo.upsert(provider, { clientId });
} }
await oauthRepo.clearError(provider); await oauthRepo.upsert(provider, { error: null });
// Trigger immediate sync for relevant providers // Trigger immediate sync for relevant providers
if (provider === 'google') { if (provider === 'google') {
@ -281,7 +281,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> { export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
try { try {
const oauthRepo = getOAuthRepo(); const oauthRepo = getOAuthRepo();
await oauthRepo.clearTokens(provider); await oauthRepo.delete(provider);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('OAuth disconnect failed:', error); console.error('OAuth disconnect failed:', error);
@ -297,7 +297,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
try { try {
const oauthRepo = getOAuthRepo(); const oauthRepo = getOAuthRepo();
let tokens = await oauthRepo.getTokens(provider); const { tokens } = await oauthRepo.read(provider);
if (!tokens) { if (!tokens) {
return null; return null;
} }
@ -306,7 +306,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
if (oauthClient.isTokenExpired(tokens)) { if (oauthClient.isTokenExpired(tokens)) {
if (!tokens.refresh_token) { if (!tokens.refresh_token) {
// No refresh token, need to reconnect // No refresh token, need to reconnect
await oauthRepo.setError(provider, 'Missing refresh token. Please reconnect.'); await oauthRepo.upsert(provider, { error: 'Missing refresh token. Please reconnect.' });
return null; return null;
} }
@ -316,11 +316,11 @@ export async function getAccessToken(provider: string): Promise<string | null> {
// Refresh token, preserving existing scopes // Refresh token, preserving existing scopes
const existingScopes = tokens.scopes; const existingScopes = tokens.scopes;
tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes); const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
await oauthRepo.saveTokens(provider, tokens); await oauthRepo.upsert(provider, { tokens });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Token refresh failed'; const message = error instanceof Error ? error.message : 'Token refresh failed';
await oauthRepo.setError(provider, message); await oauthRepo.upsert(provider, { error: message });
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
return null; return null;
} }

File diff suppressed because it is too large Load diff

View file

@ -102,7 +102,7 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
* Must be used inside Conversation component. * Must be used inside Conversation component.
*/ */
export const ScrollPositionPreserver = () => { export const ScrollPositionPreserver = () => {
const { isAtBottom } = useStickToBottomContext(); const { isAtBottom, scrollRef } = useStickToBottomContext();
const preservationContext = useContext(ScrollPreservationContext); const preservationContext = useContext(ScrollPreservationContext);
const containerFoundRef = useRef(false); const containerFoundRef = useRef(false);
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
useLayoutEffect(() => { useLayoutEffect(() => {
if (containerFoundRef.current || !preservationContext) return; if (containerFoundRef.current || !preservationContext) return;
// Find the scroll container (StickToBottom creates one) // Use the local StickToBottom scroll container for this conversation instance.
// It's the first parent with overflow-y scroll/auto const container = scrollRef.current;
const findScrollContainer = (): HTMLElement | null => {
const candidates = document.querySelectorAll('[role="log"]');
for (const candidate of candidates) {
// The scroll container is a direct child of the role="log" element
const children = candidate.children;
for (const child of children) {
const style = window.getComputedStyle(child);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
return child as HTMLElement;
}
}
}
return null;
};
const container = findScrollContainer();
if (container) { if (container) {
preservationContext.registerScrollContainer(container); preservationContext.registerScrollContainer(container);
containerFoundRef.current = true; containerFoundRef.current = true;
} }
}, [preservationContext]); }, [preservationContext, scrollRef]);
// Track engagement based on scroll position // Track engagement based on scroll position
useEffect(() => { useEffect(() => {

View file

@ -2,8 +2,14 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react"; import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolCallPart } from "@x/shared/dist/message.js";
import z from "zod"; import z from "zod";
@ -11,6 +17,8 @@ import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & { export type PermissionRequestProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>; toolCall: z.infer<typeof ToolCallPart>;
onApprove?: () => void; onApprove?: () => void;
onApproveSession?: () => void;
onApproveAlways?: () => void;
onDeny?: () => void; onDeny?: () => void;
isProcessing?: boolean; isProcessing?: boolean;
response?: 'approve' | 'deny' | null; response?: 'approve' | 'deny' | null;
@ -20,6 +28,8 @@ export const PermissionRequest = ({
className, className,
toolCall, toolCall,
onApprove, onApprove,
onApproveSession,
onApproveAlways,
onDeny, onDeny,
isProcessing = false, isProcessing = false,
response = null, response = null,
@ -117,16 +127,40 @@ export const PermissionRequest = ({
</div> </div>
{!isResponded && ( {!isResponded && (
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button <div className="flex flex-1 items-center">
variant="default" <Button
size="sm" variant="default"
onClick={onApprove} size="sm"
disabled={isProcessing} onClick={onApprove}
className="flex-1" disabled={isProcessing}
> className={cn("flex-1", command && "rounded-r-none")}
<CheckIcon className="size-4" /> >
Approve <CheckIcon className="size-4" />
</Button> Approve
</Button>
{command && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
disabled={isProcessing}
className="rounded-l-none border-l border-l-primary-foreground/20 px-1.5"
>
<ChevronDownIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onApproveSession}>
Allow for Session
</DropdownMenuItem>
<DropdownMenuItem onClick={onApproveAlways}>
Always Allow
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"

View file

@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
if (autoFocus || focusTrigger !== undefined) { if (autoFocus || focusTrigger !== undefined) {
// Small delay to ensure the element is fully mounted and visible // Small delay to ensure the element is fully mounted and visible
const timer = setTimeout(() => { const timer = setTimeout(() => {
textareaRef.current?.focus(); const textarea = textareaRef.current;
if (!textarea) return;
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
}, 50); }, 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }

View file

@ -0,0 +1,360 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ArrowUp,
AudioLines,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
LoaderIcon,
Plus,
Square,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
getAttachmentIconKind,
getAttachmentToneClass,
getAttachmentTypeLabel,
} from '@/lib/attachment-presentation'
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
import { cn } from '@/lib/utils'
import {
type FileMention,
type PromptInputMessage,
PromptInputProvider,
PromptInputTextarea,
usePromptInputController,
} from '@/components/ai-elements/prompt-input'
import { toast } from 'sonner'
export type StagedAttachment = {
id: string
path: string
filename: string
mimeType: string
isImage: boolean
size: number
thumbnailUrl?: string
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
return AudioLines
case 'video':
return FileVideo
case 'spreadsheet':
return FileSpreadsheet
case 'archive':
return FileArchive
case 'code':
return FileCode2
case 'text':
return FileText
default:
return FileIcon
}
}
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
}
function ChatInputInner({
onSubmit,
onStop,
isProcessing,
isStopping,
isActive,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
}: ChatInputInnerProps) {
const controller = usePromptInputController()
const message = controller.textInput.value
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
const [focusNonce, setFocusNonce] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
// Restore the tab draft when this input mounts.
useEffect(() => {
if (initialDraft) {
controller.textInput.setInput(initialDraft)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
onDraftChange?.(message)
}, [message, onDraftChange])
useEffect(() => {
if (presetMessage) {
controller.textInput.setInput(presetMessage)
onPresetMessageConsumed?.()
}
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
const addFiles = useCallback(async (paths: string[]) => {
const newAttachments: StagedAttachment[] = []
for (const filePath of paths) {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
if (result.size > MAX_ATTACHMENT_SIZE) {
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
continue
}
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
const image = isImageMime(mime)
newAttachments.push({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
path: filePath,
filename: getFileDisplayName(filePath),
mimeType: mime,
isImage: image,
size: result.size,
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
})
} catch (err) {
console.error('Failed to read file:', filePath, err)
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments])
setFocusNonce((value) => value + 1)
}
}, [])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}, [])
const handleSubmit = useCallback(() => {
if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
controller.textInput.clear()
controller.mentions.clearMentions()
setAttachments([])
}, [attachments, canSubmit, controller, message, onSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}, [handleSubmit])
useEffect(() => {
if (!isActive) return
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
}
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
if (paths.length > 0) {
void addFiles(paths)
}
}
}
document.addEventListener('dragover', onDragOver)
document.addEventListener('drop', onDrop)
return () => {
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop)
}
}, [addFiles, isActive])
return (
<div className="rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((attachment) => {
const attachmentType = getAttachmentTypeLabel(attachment)
const attachmentName = getAttachmentDisplayName(attachment)
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
return (
<span
key={attachment.id}
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
>
<span
className={cn(
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
attachment.isImage && attachment.thumbnailUrl
? 'bg-muted'
: getAttachmentToneClass(attachmentType)
)}
>
{attachment.isImage && attachment.thumbnailUrl ? (
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
) : (
<Icon className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
>
<X className="size-3.5" />
</button>
</span>
)
})}
</div>
)}
<div className="flex items-center gap-2 px-4 py-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (!files || files.length === 0) return
const paths = Array.from(files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
if (paths.length > 0) {
void addFiles(paths)
}
e.target.value = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div>
)
}
export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
isActive?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
initialDraft?: string
onDraftChange?: (text: string) => void
}
export function ChatInputWithMentions({
knowledgeFiles,
recentFiles,
visibleFiles,
onSubmit,
onStop,
isProcessing,
isStopping,
isActive = true,
presetMessage,
onPresetMessageConsumed,
runId,
initialDraft,
onDraftChange,
}: ChatInputWithMentionsProps) {
return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
onStop={onStop}
isProcessing={isProcessing}
isStopping={isStopping}
isActive={isActive}
presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
initialDraft={initialDraft}
onDraftChange={onDraftChange}
/>
</PromptInputProvider>
)
}

View file

@ -0,0 +1,137 @@
import {
AudioLines,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import type { MessageAttachment } from '@/lib/chat-conversation'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
getAttachmentIconKind,
getAttachmentToneClass,
getAttachmentTypeLabel,
} from '@/lib/attachment-presentation'
import { isImageMime, toFileUrl } from '@/lib/file-utils'
import { cn } from '@/lib/utils'
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
return AudioLines
case 'video':
return FileVideo
case 'spreadsheet':
return FileSpreadsheet
case 'archive':
return FileArchive
case 'code':
return FileCode2
case 'text':
return FileText
default:
return FileIcon
}
}
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
useEffect(() => {
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
setSrc(nextSrc)
setTriedBase64(Boolean(attachment.thumbnailUrl))
}, [attachment.thumbnailUrl, fallbackFileUrl])
const loadBase64 = useMemo(
() => async () => {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
const mimeType = result.mimeType || attachment.mimeType || 'image/*'
setSrc(`data:${mimeType};base64,${result.data}`)
} catch {
// Keep current src; fallback rendering (broken image icon) is better than crashing.
}
},
[attachment.mimeType, attachment.path]
)
useEffect(() => {
if (attachment.thumbnailUrl || triedBase64) return
setTriedBase64(true)
void loadBase64()
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
return (
<img
src={src}
alt="Image attachment"
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
onError={() => {
if (triedBase64) return
setTriedBase64(true)
void loadBase64()
}}
/>
)
}
interface ChatMessageAttachmentsProps {
attachments: MessageAttachment[]
className?: string
}
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
if (attachments.length === 0) return null
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
return (
<div className={cn('flex flex-col items-end gap-2', className)}>
{imageAttachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => (
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{fileAttachments.map((attachment, index) => {
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
const attachmentName = getAttachmentDisplayName(attachment)
const attachmentType = getAttachmentTypeLabel(attachment)
return (
<span
key={`${attachment.path}-${index}`}
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
title={attachmentName}
>
<span
className={cn(
'flex size-12 shrink-0 items-center justify-center rounded-xl',
getAttachmentToneClass(attachmentType)
)}
>
<Icon className="size-6 shrink-0" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
</span>
</span>
)
})}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -52,7 +52,16 @@ function getMarkdownWithBlankLines(editor: Editor): string {
const blocks: string[] = [] const blocks: string[] = []
// Helper to convert a node to markdown text // Helper to convert a node to markdown text
const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }): string => { const nodeToText = (node: {
type?: string
content?: Array<{
type?: string
text?: string
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
attrs?: Record<string, unknown>
}>
attrs?: Record<string, unknown>
}): string => {
if (!node.content) return '' if (!node.content) return ''
return node.content.map(child => { return node.content.map(child => {
if (child.type === 'text') { if (child.type === 'text') {
@ -67,6 +76,9 @@ function getMarkdownWithBlankLines(editor: Editor): string {
} }
} }
return text return text
} else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || ''
return path ? `[[${path}]]` : ''
} else if (child.type === 'hardBreak') { } else if (child.type === 'hardBreak') {
return '\n' return '\n'
} }
@ -183,6 +195,9 @@ interface MarkdownEditorProps {
placeholder?: string placeholder?: string
wikiLinks?: WikiLinkConfig wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null> onImageUpload?: (file: File) => Promise<string | null>
editorSessionKey?: number
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
editable?: boolean
} }
type WikiLinkMatch = { type WikiLinkMatch = {
@ -266,6 +281,9 @@ export function MarkdownEditor({
placeholder = 'Start writing...', placeholder = 'Start writing...',
wikiLinks, wikiLinks,
onImageUpload, onImageUpload,
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@ -287,6 +305,7 @@ export function MarkdownEditor({
) )
const editor = useEditor({ const editor = useEditor({
editable,
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
heading: { heading: {
@ -388,7 +407,7 @@ export function MarkdownEditor({
return false return false
}, },
}, },
}) }, [editorSessionKey])
const orderedFiles = useMemo(() => { const orderedFiles = useMemo(() => {
if (!wikiLinks) return [] if (!wikiLinks) return []
@ -477,12 +496,37 @@ export function MarkdownEditor({
isInternalUpdate.current = true isInternalUpdate.current = true
// Pre-process to preserve blank lines // Pre-process to preserve blank lines
const preprocessed = preprocessMarkdown(content) const preprocessed = preprocessMarkdown(content)
editor.commands.setContent(preprocessed) // Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false isInternalUpdate.current = false
} }
} }
}, [editor, content]) }, [editor, content])
useEffect(() => {
if (!onHistoryHandlersChange) return
if (!editor) {
onHistoryHandlersChange(null)
return
}
onHistoryHandlersChange({
undo: () => editor.chain().focus().undo().run(),
redo: () => editor.chain().focus().redo().run(),
})
return () => {
onHistoryHandlersChange(null)
}
}, [editor, onHistoryHandlersChange])
// Update editable state when prop changes
useEffect(() => {
if (editor) {
editor.setEditable(editable)
}
}, [editor, editable])
// Force re-render decorations when selection highlight changes // Force re-render decorations when selection highlight changes
useEffect(() => { useEffect(() => {
if (editor) { if (editor) {

View file

@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null) const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "" }, openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "" }, anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "" }, google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "" }, openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "" }, aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
}) })
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle", status: "idle",
@ -87,7 +87,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [slackConnecting, setSlackConnecting] = useState(false) const [slackConnecting, setSlackConnecting] = useState(false)
const updateProviderConfig = useCallback( const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[provider]: { ...prev[provider], ...updates }, [provider]: { ...prev[provider], ...updates },
@ -287,6 +287,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const apiKey = activeConfig.apiKey.trim() || undefined const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim() const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const providerConfig = { const providerConfig = {
provider: { provider: {
flavor: llmProvider, flavor: llmProvider,
@ -294,6 +295,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
baseURL, baseURL,
}, },
model, model,
knowledgeGraphModel,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -657,39 +659,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span> <div className="space-y-2">
{modelsLoading ? ( <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {modelsLoading ? (
<Loader2 className="size-4 animate-spin" /> <div className="flex items-center gap-2 text-sm text-muted-foreground">
Loading models... <Loader2 className="size-4 animate-spin" />
</div> Loading...
) : showModelInput ? ( </div>
<Input ) : showModelInput ? (
value={activeConfig.model} <Input
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })} value={activeConfig.model}
placeholder="Enter model" onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
/> placeholder="Enter model"
) : ( />
<Select ) : (
value={activeConfig.model} <Select
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })} value={activeConfig.model}
> onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
<SelectTrigger> >
<SelectValue placeholder="Select a model" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Select a model" />
<SelectContent> </SelectTrigger>
{modelsForProvider.map((model) => ( <SelectContent>
<SelectItem key={model.id} value={model.id}> {modelsForProvider.map((model) => (
{model.name || model.id} <SelectItem key={model.id} value={model.id}>
</SelectItem> {model.name || model.id}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
)} </Select>
{modelsError && ( )}
<div className="text-xs text-destructive">{modelsError}</div> {modelsError && (
)} <div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
{showApiKey && ( {showApiKey && (

View file

@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command'
import { useDebounce } from '@/hooks/use-debounce'
import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
import { cn } from '@/lib/utils'
interface SearchResult {
type: 'knowledge' | 'chat'
title: string
preview: string
path: string
}
type SearchType = 'knowledge' | 'chat'
function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge']
return ['chat'] // "tasks" tab maps to chat
}
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelectFile: (path: string) => void
onSelectRun: (runId: string) => void
}
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
const { activeSection } = useSidebarSection()
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
() => new Set(activeTabToTypes(activeSection))
)
const debouncedQuery = useDebounce(query, 250)
// Sync filter preselection when dialog opens
useEffect(() => {
if (open) {
setActiveTypes(new Set(activeTabToTypes(activeSection)))
}
}, [open, activeSection])
const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type]))
}, [])
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([])
return
}
let cancelled = false
setIsSearching(true)
const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
.then((res) => {
if (!cancelled) {
setResults(res.results)
}
})
.catch((err) => {
console.error('Search failed:', err)
if (!cancelled) {
setResults([])
}
})
.finally(() => {
if (!cancelled) {
setIsSearching(false)
}
})
return () => { cancelled = true }
}, [debouncedQuery, activeTypes])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
}
}, [open])
const handleSelect = useCallback((result: SearchResult) => {
onOpenChange(false)
if (result.type === 'knowledge') {
onSelectFile(result.path)
} else {
onSelectRun(result.path)
}
}, [onOpenChange, onSelectFile, onSelectRun])
const knowledgeResults = results.filter(r => r.type === 'knowledge')
const chatResults = results.filter(r => r.type === 'chat')
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Search across knowledge and chats"
showCloseButton={false}
className="top-[20%] translate-y-0"
>
<CommandInput
placeholder="Search..."
value={query}
onValueChange={setQuery}
/>
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<FilterToggle
active={activeTypes.has('knowledge')}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Knowledge"
/>
<FilterToggle
active={activeTypes.has('chat')}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
</div>
<CommandList>
{!query.trim() && (
<CommandEmpty>Type to search...</CommandEmpty>
)}
{query.trim() && !isSearching && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{knowledgeResults.length > 0 && (
<CommandGroup heading="Knowledge">
{knowledgeResults.map((result) => (
<CommandItem
key={`knowledge-${result.path}`}
value={`knowledge-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{chatResults.length > 0 && (
<CommandGroup heading="Chats">
{chatResults.map((result) => (
<CommandItem
key={`chat-${result.path}`}
value={`chat-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
)
}
function FilterToggle({
active,
onClick,
icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
{label}
</button>
)
}

View file

@ -167,14 +167,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "" }, openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "" }, anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "" }, google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "" }, openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "" }, aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
}) })
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0) (!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback( const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { ...prev[prov], ...updates }, [prov]: { ...prev[prov], ...updates },
@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: parsed.provider.apiKey || "", apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
model: parsed.model, model: parsed.model,
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
}, },
})) }))
} }
@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
baseURL: activeConfig.baseURL.trim() || undefined, baseURL: activeConfig.baseURL.trim() || undefined,
}, },
model: activeConfig.model.trim(), model: activeConfig.model.trim(),
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)} )}
</div> </div>
{/* Model selection */} {/* Model selection - side by side */}
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span> <div className="space-y-2">
{modelsLoading ? ( <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {modelsLoading ? (
<Loader2 className="size-4 animate-spin" /> <div className="flex items-center gap-2 text-sm text-muted-foreground">
Loading models... <Loader2 className="size-4 animate-spin" />
</div> Loading...
) : showModelInput ? ( </div>
<Input ) : showModelInput ? (
value={activeConfig.model} <Input
onChange={(e) => updateConfig(provider, { model: e.target.value })} value={activeConfig.model}
placeholder="Enter model" onChange={(e) => updateConfig(provider, { model: e.target.value })}
/> placeholder="Enter model"
) : ( />
<Select ) : (
value={activeConfig.model} <Select
onValueChange={(value) => updateConfig(provider, { model: value })} value={activeConfig.model}
> onValueChange={(value) => updateConfig(provider, { model: value })}
<SelectTrigger> >
<SelectValue placeholder="Select a model" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Select a model" />
<SelectContent> </SelectTrigger>
{modelsForProvider.map((model) => ( <SelectContent>
<SelectItem key={model.id} value={model.id}> {modelsForProvider.map((model) => (
{model.name || model.id} <SelectItem key={model.id} value={model.id}>
</SelectItem> {model.name || model.id}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
)} </Select>
{modelsError && ( )}
<div className="text-xs text-destructive">{modelsError}</div> {modelsError && (
)} <div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
{/* API Key */} {/* API Key */}

View file

@ -8,6 +8,7 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
Copy, Copy,
ExternalLink,
FilePlus, FilePlus,
FolderPlus, FolderPlus,
AlertTriangle, AlertTriangle,
@ -105,6 +106,7 @@ type KnowledgeActions = {
rename: (path: string, newName: string, isDir: boolean) => Promise<void> rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void> remove: (path: string) => Promise<void>
copyPath: (path: string) => void copyPath: (path: string) => void
onOpenInNewTab?: (path: string) => void
} }
type RunListItem = { type RunListItem = {
@ -149,6 +151,7 @@ type TasksActions = {
onNewChat: () => void onNewChat: () => void
onSelectRun: (runId: string) => void onSelectRun: (runId: string) => void
onDeleteRun: (runId: string) => void onDeleteRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void onSelectBackgroundTask?: (taskName: string) => void
} }
@ -817,6 +820,36 @@ function KnowledgeSection({
onVoiceNoteCreated?: (path: string) => void onVoiceNoteCreated?: (path: string) => void
}) { }) {
const isExpanded = expandedPaths.size > 0 const isExpanded = expandedPaths.size > 0
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!selectedPath) return
let cancelled = false
let rafId: number | null = null
let attempts = 0
const maxAttempts = 20
const revealActiveFile = () => {
if (cancelled) return
const container = treeContainerRef.current
if (!container) return
const activeRow = container.querySelector<HTMLElement>('[data-knowledge-active="true"]')
if (activeRow) {
activeRow.scrollIntoView({ block: "nearest", inline: "nearest" })
return
}
if (attempts >= maxAttempts) return
attempts += 1
rafId = requestAnimationFrame(revealActiveFile)
}
rafId = requestAnimationFrame(revealActiveFile)
return () => {
cancelled = true
if (rafId !== null) cancelAnimationFrame(rafId)
}
}, [selectedPath, expandedPaths, tree])
const quickActions = [ const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FilePlus, label: "New Note", action: () => actions.createNote() },
@ -862,18 +895,20 @@ function KnowledgeSection({
</Tooltip> </Tooltip>
</div> </div>
<SidebarGroupContent className="flex-1 overflow-y-auto"> <SidebarGroupContent className="flex-1 overflow-y-auto">
<SidebarMenu> <div ref={treeContainerRef}>
{tree.map((item, index) => ( <SidebarMenu>
<Tree {tree.map((item, index) => (
key={index} <Tree
item={item} key={index}
selectedPath={selectedPath} item={item}
expandedPaths={expandedPaths} selectedPath={selectedPath}
onSelect={onSelectFile} expandedPaths={expandedPaths}
actions={actions} onSelect={onSelectFile}
/> actions={actions}
))} />
</SidebarMenu> ))}
</SidebarMenu>
</div>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -932,7 +967,7 @@ function Tree({
try { try {
await actions.rename(item.path, trimmedName, isDir) await actions.rename(item.path, trimmedName, isDir)
toast('Renamed successfully', 'success') toast('Renamed successfully', 'success')
} catch (err) { } catch {
toast('Failed to rename', 'error') toast('Failed to rename', 'error')
} }
} }
@ -947,7 +982,7 @@ function Tree({
try { try {
await actions.remove(item.path) await actions.remove(item.path)
toast('Moved to trash', 'success') toast('Moved to trash', 'success')
} catch (err) { } catch {
toast('Failed to delete', 'error') toast('Failed to delete', 'error')
} }
} }
@ -981,6 +1016,15 @@ function Tree({
<ContextMenuSeparator /> <ContextMenuSeparator />
</> </>
)} )}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={handleCopyPath}> <ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" /> <Copy className="mr-2 size-4" />
Copy Path Copy Path
@ -1033,12 +1077,24 @@ function Tree({
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem> <SidebarMenuItem
className="group/file-item"
data-knowledge-file-path={item.path}
data-knowledge-active={isSelected ? "true" : "false"}
>
<SidebarMenuButton <SidebarMenuButton
isActive={isSelected} isActive={isSelected}
onClick={() => onSelect(item.path, item.kind)} onClick={(e) => {
if (e.metaKey && actions.onOpenInNewTab) {
actions.onOpenInNewTab(item.path)
} else {
onSelect(item.path, item.kind)
}
}}
> >
<span>{item.name}</span> <div className="flex w-full items-center gap-1 min-w-0">
<span className="min-w-0 flex-1 truncate">{item.name}</span>
</div>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -1162,37 +1218,54 @@ function TasksSection({
</div> </div>
<SidebarMenu> <SidebarMenu>
{runs.map((run) => ( {runs.map((run) => (
<SidebarMenuItem key={run.id} className="group/chat-item"> <ContextMenu key={run.id}>
<SidebarMenuButton <ContextMenuTrigger asChild>
isActive={currentRunId === run.id} <SidebarMenuItem className="group/chat-item">
onClick={() => actions?.onSelectRun(run.id)} <SidebarMenuButton
> isActive={currentRunId === run.id}
<div className="flex w-full items-center gap-2 min-w-0"> onClick={(e) => {
{processingRunIds?.has(run.id) ? ( if (e.metaKey && actions?.onOpenInNewTab) {
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" /> actions.onOpenInNewTab(run.id)
) : null} } else {
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span> actions?.onSelectRun(run.id)
{run.createdAt ? ( }
<span className={`shrink-0 text-[10px] text-muted-foreground${processingRunIds?.has(run.id) ? '' : ' group-hover/chat-item:hidden'}`}> }}
{formatRunTime(run.createdAt)} >
</span> <div className="flex w-full items-center gap-2 min-w-0">
) : null} {processingRunIds?.has(run.id) ? (
{!processingRunIds?.has(run.id) && ( <span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
<button ) : null}
type="button" <span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
className="shrink-0 hidden group-hover/chat-item:flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors" {run.createdAt ? (
onClick={(e) => { <span className="shrink-0 text-[10px] text-muted-foreground">
e.stopPropagation() {formatRunTime(run.createdAt)}
setPendingDeleteRunId(run.id) </span>
}} ) : null}
aria-label="Delete chat" </div>
</SidebarMenuButton>
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{actions?.onOpenInNewTab && (
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
)}
{!processingRunIds?.has(run.id) && (
<>
{actions?.onOpenInNewTab && <ContextMenuSeparator />}
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteRunId(run.id)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="mr-2 size-4" />
</button> Delete
)} </ContextMenuItem>
</div> </>
</SidebarMenuButton> )}
</SidebarMenuItem> </ContextMenuContent>
</ContextMenu>
))} ))}
</SidebarMenu> </SidebarMenu>
</> </>

View file

@ -0,0 +1,97 @@
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
export type ChatTab = {
id: string
runId: string | null
}
export type FileTab = {
id: string
path: string
}
interface TabBarProps<T> {
tabs: T[]
activeTabId: string
getTabTitle: (tab: T) => string
getTabId: (tab: T) => string
isProcessing?: (tab: T) => boolean
onSwitchTab: (tabId: string) => void
onCloseTab: (tabId: string) => void
layout?: 'fill' | 'scroll'
allowSingleTabClose?: boolean
}
export function TabBar<T>({
tabs,
activeTabId,
getTabTitle,
getTabId,
isProcessing,
onSwitchTab,
onCloseTab,
layout = 'fill',
allowSingleTabClose = false,
}: TabBarProps<T>) {
return (
<div
className={cn(
'flex flex-1 self-stretch min-w-0',
layout === 'scroll'
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'overflow-hidden'
)}
>
{tabs.map((tab, index) => {
const tabId = getTabId(tab)
const isActive = tabId === activeTabId
const processing = isProcessing?.(tab) ?? false
const title = getTabTitle(tab)
return (
<React.Fragment key={tabId}>
{index > 0 && (
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
)}
<button
type="button"
onClick={() => onSwitchTab(tabId)}
className={cn(
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
isActive
? 'bg-background text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
>
{processing && (
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
)}
<span className="truncate flex-1 text-left">{title}</span>
{(allowSingleTabClose || tabs.length > 1) && (
<span
role="button"
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"
onClick={(e) => {
e.stopPropagation()
onCloseTab(tabId)
}}
aria-label="Close tab"
>
<X className="size-3" />
</span>
)}
</button>
{/* Right edge divider after last tab to close off the section */}
{index === tabs.length - 1 && (
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
)}
</React.Fragment>
)
})}
</div>
)
}

View file

@ -0,0 +1,177 @@
import { useEffect, useState, useCallback } from 'react'
import { X, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface CommitInfo {
oid: string
message: string
timestamp: number
author: string
}
interface VersionHistoryPanelProps {
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
onClose: () => void
onSelectVersion: (oid: string | null, content: string) => void // null = current
onRestore: (oid: string) => void
}
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
const d = new Date(unixSeconds * 1000)
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
return { date, time }
}
export function VersionHistoryPanel({
path,
onClose,
onSelectVersion,
onRestore,
}: VersionHistoryPanelProps) {
const [commits, setCommits] = useState<CommitInfo[]>([])
const [loading, setLoading] = useState(true)
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
const [error, setError] = useState<string | null>(null)
// Strip "knowledge/" prefix for IPC calls
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
const loadHistory = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
setCommits(result.commits)
} catch (err) {
console.error('Failed to load version history:', err)
setError('Failed to load history')
} finally {
setLoading(false)
}
}, [relPath])
useEffect(() => {
loadHistory()
}, [loadHistory])
// Refresh when new commits land
useEffect(() => {
return window.ipc.on('knowledge:didCommit', () => {
loadHistory()
})
}, [loadHistory])
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
if (isLatest) {
setSelectedOid(null)
// Read current file content
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
onSelectVersion(null, result.data)
} catch (err) {
console.error('Failed to read current file:', err)
}
return
}
setSelectedOid(oid)
try {
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
onSelectVersion(oid, result.content)
} catch (err) {
console.error('Failed to load file at commit:', err)
}
}, [path, relPath, onSelectVersion])
const handleRestore = useCallback(() => {
if (selectedOid) {
onRestore(selectedOid)
}
}, [selectedOid, onRestore])
return (
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">Version history</span>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Close version history"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Commit list */}
<div className="flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
{error}
</div>
) : commits.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No history available
</div>
) : (
<div className="py-1">
{commits.map((commit, index) => {
const isLatest = index === 0
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
const { date, time } = formatTimestamp(commit.timestamp)
return (
<button
key={commit.oid}
type="button"
onClick={() => handleSelectCommit(commit.oid, isLatest)}
className={cn(
'w-full text-left px-3 py-2 transition-colors',
isSelected
? 'bg-accent'
: 'hover:bg-accent/50'
)}
>
<div className="flex items-center gap-1.5">
{!isLatest && (
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="text-sm text-foreground">
{date} &middot; {time}
</span>
</div>
{isLatest && (
<div className="text-xs text-muted-foreground mt-0.5">
Current version
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Footer */}
{selectedOid && (
<div className="shrink-0 border-t border-border p-3">
<Button
variant="default"
size="sm"
className="w-full"
onClick={handleRestore}
>
Restore this version
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,107 @@
import { getExtension } from '@/lib/file-utils'
export type AttachmentLike = {
filename?: string
path: string
mimeType: string
}
export type AttachmentIconKind =
| 'audio'
| 'video'
| 'spreadsheet'
| 'archive'
| 'code'
| 'text'
| 'file'
const ARCHIVE_EXTENSIONS = new Set([
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
])
const SPREADSHEET_EXTENSIONS = new Set([
'csv', 'tsv', 'xls', 'xlsx',
])
const CODE_EXTENSIONS = new Set([
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
])
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
if (attachment.filename) return attachment.filename
const fromPath = attachment.path.split(/[\\/]/).pop()
return fromPath || attachment.path
}
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
const ext = getExtension(getAttachmentDisplayName(attachment))
if (ext) return ext.toUpperCase()
const mediaType = attachment.mimeType.toLowerCase()
if (mediaType.startsWith('audio/')) return 'AUDIO'
if (mediaType.startsWith('video/')) return 'VIDEO'
if (mediaType.startsWith('text/')) return 'TEXT'
if (mediaType.startsWith('image/')) return 'IMAGE'
const [, subtypeRaw = 'file'] = mediaType.split('/')
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
return cleaned ? cleaned.toUpperCase() : 'FILE'
}
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
const mediaType = attachment.mimeType.toLowerCase()
const ext = getExtension(attachment.filename || attachment.path)
if (mediaType.startsWith('audio/')) return 'audio'
if (mediaType.startsWith('video/')) return 'video'
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
if (
mediaType.includes('json')
|| mediaType.includes('javascript')
|| mediaType.includes('typescript')
|| mediaType.includes('xml')
|| CODE_EXTENSIONS.has(ext)
) {
return 'code'
}
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
return 'text'
}
return 'file'
}
export function getAttachmentToneClass(typeLabel: string): string {
switch (typeLabel) {
case 'PDF':
return 'bg-red-500 text-white'
case 'CSV':
case 'XLS':
case 'XLSX':
case 'TSV':
return 'bg-emerald-500 text-white'
case 'ZIP':
case 'RAR':
case '7Z':
case 'TAR':
case 'GZ':
return 'bg-amber-500 text-white'
case 'MP3':
case 'WAV':
case 'M4A':
case 'FLAC':
case 'AAC':
return 'bg-fuchsia-500 text-white'
case 'MP4':
case 'MOV':
case 'AVI':
case 'WEBM':
return 'bg-violet-500 text-white'
default:
return 'bg-primary/85 text-primary-foreground'
}
}

View file

@ -0,0 +1,186 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
export interface MessageAttachment {
path: string
filename: string
mimeType: string
size?: number
thumbnailUrl?: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
attachments?: MessageAttachment[]
timestamp: number
}
export interface ToolCall {
id: string
name: string
input: ToolUIPart['input']
result?: ToolUIPart['output']
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
}
export interface ErrorMessage {
id: string
kind: 'error'
message: string
timestamp: number
}
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
export type PermissionResponse = 'approve' | 'deny'
export type ChatTabViewState = {
runId: string | null
conversation: ConversationItem[]
currentAssistantMessage: string
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
}
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],
currentAssistantMessage: '',
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
})
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
'kind' in item && item.kind === 'error'
export const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
case 'pending':
return 'input-streaming'
case 'running':
return 'input-available'
case 'completed':
return 'output-available'
case 'error':
return 'output-error'
default:
return 'input-available'
}
}
export const normalizeToolInput = (
input: ToolCall['input'] | string | undefined
): ToolCall['input'] => {
if (input === undefined || input === null) return {}
if (typeof input === 'string') {
const trimmed = input.trim()
if (!trimmed) return {}
try {
return JSON.parse(trimmed)
} catch {
return input
}
}
return input
}
export const normalizeToolOutput = (
output: ToolCall['result'] | undefined,
status: ToolCall['status']
) => {
if (output === undefined || output === null) {
return status === 'completed' ? 'No output returned.' : null
}
if (output === '') return '(empty output)'
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
return output
}
export type WebSearchCardResult = { title: string; url: string; description: string }
export type WebSearchCardData = {
query: string
results: WebSearchCardResult[]
title?: string
}
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
if (tool.name === 'web-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
return {
query: (input?.query as string) || '',
results: (result?.results as WebSearchCardResult[]) || [],
}
}
if (tool.name === 'research-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const rawResults = (result?.results as Array<{
title: string
url: string
highlights?: string[]
text?: string
}>) || []
const mapped = rawResults.map((entry) => ({
title: entry.title,
url: entry.url,
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
}))
const category = input?.category as string | undefined
return {
query: (input?.query as string) || '',
results: mapped,
title: category
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
: 'Researched the web',
}
}
return null
}
// Parse attached files from message content and return clean message + file paths.
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
const match = content.match(attachedFilesRegex)
if (!match) {
return { message: content, files: [] }
}
const filesXml = match[1]
const filePathRegex = /<file path="([^"]+)">/g
const files: string[] = []
let fileMatch
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
files.push(fileMatch[1])
}
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
for (const filePath of files) {
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
if (!fileName) continue
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
cleanMessage = cleanMessage.replace(mentionRegex, '')
}
return { message: cleanMessage.trim(), files }
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()
if (!normalized) return undefined
return normalized.length > 100 ? normalized.substring(0, 100) : normalized
}

View file

@ -0,0 +1,61 @@
const IMAGE_MIMES = new Set([
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
]);
const EXTENSION_TO_MIME: Record<string, string> = {
// Images
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
avif: 'image/avif', tiff: 'image/tiff',
// Text / code
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
csv: 'text/csv', xml: 'text/xml',
js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
yml: 'text/yaml', toml: 'text/toml',
py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
// Documents
pdf: 'application/pdf',
// Archives
zip: 'application/zip',
};
export function isImageMime(mimeType: string): boolean {
return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
}
export function getMimeFromExtension(ext: string): string {
const normalized = ext.toLowerCase().replace(/^\./, '');
return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
}
export function getFileDisplayName(filePath: string): string {
return filePath.split('/').pop() || filePath;
}
export function getExtension(filePath: string): string {
const name = filePath.split('/').pop() || '';
const dotIndex = name.lastIndexOf('.');
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
}
export function toFileUrl(filePath: string): string {
if (!filePath) return filePath;
if (
filePath.startsWith('data:') ||
filePath.startsWith('file://') ||
filePath.startsWith('http://') ||
filePath.startsWith('https://')
) {
return filePath;
}
const normalized = filePath.replace(/\\/g, '/');
const encoded = encodeURI(normalized);
if (/^[A-Za-z]:\//.test(normalized)) {
return `file:///${encoded}`;
}
return `file://${encoded}`;
}

View file

@ -9,8 +9,7 @@ export const normalizeWikiPath = (input: string) => {
} }
export const ensureMarkdownExtension = (path: string) => { export const ensureMarkdownExtension = (path: string) => {
const lastSegment = path.split('/').pop() ?? path if (path.toLowerCase().endsWith('.md')) return path
if (lastSegment.includes('.')) return path
return `${path}.md` return `${path}.md`
} }

View file

@ -16,8 +16,17 @@
position: relative; position: relative;
} }
/* Notion-like base typography */
.tiptap-editor .ProseMirror { .tiptap-editor .ProseMirror {
padding: 1rem; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
"Segoe UI Emoji", "Segoe UI Symbol";
font-size: 16px;
line-height: 1.5;
color: rgb(55, 53, 47);
max-width: 720px;
margin: 0 auto;
padding: 2rem 4rem;
outline: none; outline: none;
} }
@ -27,47 +36,45 @@
/* Placeholder */ /* Placeholder */
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before { .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--muted-foreground); color: rgba(55, 53, 47, 0.4);
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;
height: 0; height: 0;
pointer-events: none; pointer-events: none;
} }
/* Typography */ /* Paragraphs */
.tiptap-editor .ProseMirror { .tiptap-editor .ProseMirror p {
font-size: 1rem; margin: 1px 0;
line-height: 1.75; padding: 3px 2px;
color: var(--foreground);
}
.tiptap-editor .ProseMirror > * + * {
margin-top: 0.75em;
} }
/* Headings */ /* Headings */
.tiptap-editor .ProseMirror h1 { .tiptap-editor .ProseMirror h1 {
font-size: 2em; font-size: 1.875em;
font-weight: 700; font-weight: 600;
line-height: 1.2; line-height: 1.3;
margin-top: 1.5em; margin-top: 2em;
margin-bottom: 0.5em; margin-bottom: 4px;
padding: 3px 2px;
} }
.tiptap-editor .ProseMirror h2 { .tiptap-editor .ProseMirror h2 {
font-size: 1.5em; font-size: 1.5em;
font-weight: 600; font-weight: 600;
line-height: 1.3; line-height: 1.3;
margin-top: 1.25em; margin-top: 1.1em;
margin-bottom: 0.5em; margin-bottom: 1px;
padding: 3px 2px;
} }
.tiptap-editor .ProseMirror h3 { .tiptap-editor .ProseMirror h3 {
font-size: 1.25em; font-size: 1.25em;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.3;
margin-top: 1em; margin-top: 1em;
margin-bottom: 0.5em; margin-bottom: 1px;
padding: 3px 2px;
} }
.tiptap-editor .ProseMirror h1:first-child, .tiptap-editor .ProseMirror h1:first-child,
@ -76,16 +83,11 @@
margin-top: 0; margin-top: 0;
} }
/* Paragraphs */
.tiptap-editor .ProseMirror p {
margin: 0;
}
/* Lists */ /* Lists */
.tiptap-editor .ProseMirror ul, .tiptap-editor .ProseMirror ul,
.tiptap-editor .ProseMirror ol { .tiptap-editor .ProseMirror ol {
padding-left: 1.5em; padding-left: 1.625em;
margin: 0.5em 0; margin: 1px 0;
} }
.tiptap-editor .ProseMirror ul { .tiptap-editor .ProseMirror ul {
@ -97,7 +99,7 @@
} }
.tiptap-editor .ProseMirror li { .tiptap-editor .ProseMirror li {
margin: 0.25em 0; padding: 3px 0;
} }
.tiptap-editor .ProseMirror li p { .tiptap-editor .ProseMirror li p {
@ -106,50 +108,56 @@
/* Blockquote */ /* Blockquote */
.tiptap-editor .ProseMirror blockquote { .tiptap-editor .ProseMirror blockquote {
border-left: 3px solid var(--border); border-left: 3px solid rgb(55, 53, 47);
padding-left: 1em; padding-left: 14px;
margin: 4px 0;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
color: var(--muted-foreground);
font-style: italic;
} }
/* Code */ /* Code blocks */
.tiptap-editor .ProseMirror code {
background-color: var(--muted);
border-radius: 0.25em;
padding: 0.15em 0.3em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
/* Code Block */
.tiptap-editor .ProseMirror pre { .tiptap-editor .ProseMirror pre {
background-color: var(--muted); background: rgb(247, 246, 243);
border-radius: 0.5em; border-radius: 4px;
padding: 1em; padding: 2rem;
font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono",
"Liberation Mono", Courier, monospace;
font-size: 0.85em;
line-height: 1.5;
margin: 8px 0;
overflow-x: auto; overflow-x: auto;
margin: 0.75em 0;
} }
.tiptap-editor .ProseMirror pre code { .tiptap-editor .ProseMirror pre code {
background: none; background: none;
padding: 0; padding: 0;
font-size: 0.875em; font-size: inherit;
color: inherit; color: inherit;
border-radius: 0;
} }
/* Horizontal Rule */ /* Inline code */
.tiptap-editor .ProseMirror code {
background: rgba(135, 131, 120, 0.15);
border-radius: 3px;
padding: 0.2em 0.4em;
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 0.85em;
color: #eb5757;
}
/* Divider */
.tiptap-editor .ProseMirror hr { .tiptap-editor .ProseMirror hr {
border: none; border: none;
border-top: 1px solid var(--border); border-top: 1px solid rgba(55, 53, 47, 0.16);
margin: 1.5em 0; margin: 8px 0;
} }
/* Links */ /* Links */
.tiptap-editor .ProseMirror a { .tiptap-editor .ProseMirror a {
color: var(--primary); color: inherit;
text-decoration: underline; text-decoration: underline;
text-decoration-color: rgba(55, 53, 47, 0.4);
text-underline-offset: 2px; text-underline-offset: 2px;
cursor: pointer; cursor: pointer;
} }
@ -175,14 +183,13 @@
.tiptap-editor .ProseMirror ul[data-type="taskList"] { .tiptap-editor .ProseMirror ul[data-type="taskList"] {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;
margin: 0.5em 0; margin: 1px 0;
} }
.tiptap-editor .ProseMirror ul[data-type="taskList"] li { .tiptap-editor .ProseMirror ul[data-type="taskList"] li {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.5em; gap: 0.5em;
margin: 0.25em 0;
} }
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label { .tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
@ -238,14 +245,16 @@
align-self: center; align-self: center;
} }
/* Content area centering */ /* Keep knowledge text width readable while margins collapse on narrow panes. */
.tiptap-editor .ProseMirror { .tiptap-editor .ProseMirror {
margin-left: 20%; width: 100%;
margin-right: 20%; max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
padding-left: 1rem; margin-left: auto;
padding-right: 1rem; margin-right: auto;
box-sizing: border-box;
padding-left: clamp(0.5rem, 1.5vw, 1rem);
padding-right: clamp(0.5rem, 1.5vw, 1rem);
} }
.wiki-link-anchor { .wiki-link-anchor {
position: absolute; position: absolute;
height: 0; height: 0;
@ -327,3 +336,33 @@
background-color: var(--primary); background-color: var(--primary);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
/* Dark mode overrides */
.dark .tiptap-editor .ProseMirror {
color: rgba(255, 255, 255, 0.9);
}
.dark .tiptap-editor .ProseMirror a {
text-decoration-color: rgba(255, 255, 255, 0.4);
}
.dark .tiptap-editor .ProseMirror blockquote {
border-left-color: rgba(255, 255, 255, 0.4);
}
.dark .tiptap-editor .ProseMirror hr {
border-top-color: rgba(255, 255, 255, 0.16);
}
.dark .tiptap-editor .ProseMirror pre {
background: rgba(255, 255, 255, 0.05);
}
.dark .tiptap-editor .ProseMirror code {
background: rgba(255, 255, 255, 0.1);
color: #ff7b72;
}
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: rgba(255, 255, 255, 0.3);
}

View file

@ -27,6 +27,7 @@
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"google-auth-library": "^10.5.0", "google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
"googleapis": "^169.0.0", "googleapis": "^169.0.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0", "node-html-markdown": "^2.0.0",

View file

@ -12,7 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js"; import { CopilotAgent } from "../application/assistant/agent.js";
import { isBlocked } from "../application/lib/command-executor.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js"; import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js"; import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js"; import { createProvider } from "../models/models.js";
@ -226,13 +226,14 @@ export class StreamStepMessageBuilder {
private textBuffer: string = ""; private textBuffer: string = "";
private reasoningBuffer: string = ""; private reasoningBuffer: string = "";
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined; private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
private reasoningProviderOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
flushBuffers() { flushBuffers() {
// skip reasoning if (this.reasoningBuffer || this.reasoningProviderOptions) {
// if (this.reasoningBuffer) { this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions });
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); this.reasoningBuffer = "";
// this.reasoningBuffer = ""; this.reasoningProviderOptions = undefined;
// } }
if (this.textBuffer) { if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer }); this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = ""; this.textBuffer = "";
@ -242,7 +243,11 @@ export class StreamStepMessageBuilder {
ingest(event: z.infer<typeof LlmStepStreamEvent>) { ingest(event: z.infer<typeof LlmStepStreamEvent>) {
switch (event.type) { switch (event.type) {
case "reasoning-start": case "reasoning-start":
break;
case "reasoning-end": case "reasoning-end":
this.reasoningProviderOptions = event.providerOptions;
this.flushBuffers();
break;
case "text-start": case "text-start":
case "text-end": case "text-end":
this.flushBuffers(); this.flushBuffers();
@ -352,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return await repo.fetch(id); return await repo.fetch(id);
} }
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] { export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = []; const result: ModelMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
@ -395,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
}); });
break; break;
case "user": case "user":
result.push({ if (typeof msg.content === 'string') {
role: "user", // Legacy string — pass through unchanged
content: msg.content, result.push({
providerOptions, role: "user",
}); content: msg.content,
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
const textSegments: string[] = [];
const attachmentLines: string[] = [];
for (const part of msg.content) {
if (part.type === "attachment") {
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
} else {
textSegments.push(part.text);
}
}
if (attachmentLines.length > 0) {
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
}
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
}
break; break;
case "tool": case "tool":
result.push({ result.push({
@ -457,6 +494,7 @@ export class AgentState {
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {}; pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
allowedToolCallIds: Record<string, true> = {}; allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {}; deniedToolCallIds: Record<string, true> = {};
sessionAllowedCommands: Set<string> = new Set();
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] { getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = []; const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
@ -593,6 +631,16 @@ export class AgentState {
switch (event.response) { switch (event.response) {
case "approve": case "approve":
this.allowedToolCallIds[event.toolCallId] = true; this.allowedToolCallIds[event.toolCallId] = true;
// For session scope, extract command names and add to session allowlist
if (event.scope === "session") {
const toolCall = this.toolCallIdMap[event.toolCallId];
if (toolCall && typeof toolCall.arguments === 'object' && toolCall.arguments !== null && 'command' in toolCall.arguments) {
const names = extractCommandNames(String(toolCall.arguments.command));
for (const name of names) {
this.sessionAllowedCommands.add(name);
}
}
}
break; break;
case "deny": case "deny":
this.deniedToolCallIds[event.toolCallId] = true; this.deniedToolCallIds[event.toolCallId] = true;
@ -658,7 +706,12 @@ export async function* streamAgent({
// set up provider + model // set up provider + model
const provider = createProvider(modelConfig.provider); const provider = createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model); const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: modelConfig.model;
const model = provider.languageModel(modelId);
logger.log(`using model: ${modelId}`);
let loopCounter = 0; let loopCounter = 0;
while (true) { while (true) {
@ -827,10 +880,6 @@ export async function* streamAgent({
tools, tools,
signal, signal,
)) { )) {
// Only log significant events (not text-delta to reduce noise)
if (event.type !== 'text-delta') {
loopLogger.log('got llm-stream-event:', event.type);
}
messageBuilder.ingest(event); messageBuilder.ingest(event);
yield* processEvent({ yield* processEvent({
runId, runId,
@ -881,7 +930,7 @@ export async function* streamAgent({
} }
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") { if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission // if command is blocked, then seek permission
if (isBlocked(part.arguments.command)) { if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({ yield* processEvent({
runId, runId,
@ -924,9 +973,11 @@ async function* streamLlm(
tools: ToolSet, tools: ToolSet,
signal?: AbortSignal, signal?: AbortSignal,
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> { ): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
const converted = convertFromMessages(messages);
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
const { fullStream } = streamText({ const { fullStream } = streamText({
model, model,
messages: convertFromMessages(messages), messages: converted,
system: instructions, system: instructions,
tools, tools,
stopWhen: stepCountIs(1), stopWhen: stepCountIs(1),
@ -935,7 +986,7 @@ async function* streamLlm(
for await (const event of fullStream) { for await (const event of fullStream) {
// Check abort on every chunk for responsiveness // Check abort on every chunk for responsiveness
signal?.throwIfAborted(); signal?.throwIfAborted();
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); console.log("-> \t\tstream event", JSON.stringify(event));
switch (event.type) { switch (event.type) {
case "error": case "error":
yield { yield {
@ -968,6 +1019,12 @@ async function* streamLlm(
providerOptions: event.providerMetadata, providerOptions: event.providerMetadata,
}; };
break; break;
case "text-end":
yield {
type: "text-end",
providerOptions: event.providerMetadata,
};
break;
case "text-delta": case "text-delta":
yield { yield {
type: "text-delta", type: "text-delta",

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js"; import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.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 Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything. export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
@ -125,6 +128,18 @@ Always consult this catalog first so you load the right skills before taking act
- Summarize completed work and suggest logical next steps at the end of a task. - Summarize completed work and suggest logical next steps at the end of a task.
- Always ask for confirmation before taking destructive actions. - Always ask for confirmation before taking destructive actions.
## Output Formatting
- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 they're too large for chat.
- Use **bold** for key terms, names, or concepts the user should notice.
- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose.
- Use numbered lists only when order matters (steps, rankings).
- For short answers (1-3 sentences), just use plain prose. No headers, no bullets.
- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config.
- Use inline \`code\` for file names, commands, variable names, or short technical references.
- Add a blank line between sections for breathing room.
- Never start a response with a heading. Lead with a sentence or two of context first.
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
## MCP Tool Discovery (CRITICAL) ## MCP Tool Discovery (CRITICAL)
**ALWAYS check for MCP tools BEFORE saying you can't do something.** **ALWAYS check for MCP tools BEFORE saying you can't do something.**
@ -138,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files. - Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
- Keep user data safedouble-check before editing or deleting important resources. - Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## Workspace Access & Scope ## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. - **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. - **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). - **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:** **CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command). - Follow the detected runtime platform above for shell syntax and filesystem path style.
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths. - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`. - NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder"). - NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
- NEVER ask what OS the user is on - they are on macOS. - NEVER ask what OS the user is on if runtime platform is already available.
- Load the \`organize-files\` skill for guidance on file organization tasks. - Load the \`organize-files\` skill for guidance on file organization tasks.
## Builtin Tools vs Shell Commands ## Builtin Tools vs Shell Commands

View file

@ -0,0 +1,69 @@
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.`;
}

View file

@ -1,17 +1,19 @@
import { exec, execSync, spawn, ChildProcess } from 'child_process'; import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js'; import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec); const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string { function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, ''); return token.trim().replace(/^['"()]+|['"()]+$/g, '');
} }
function extractCommandNames(command: string): string[] { export function extractCommandNames(command: string): string[] {
const discovered = new Set<string>(); const discovered = new Set<string>();
const segments = command.split(COMMAND_SPLIT_REGEX); const segments = command.split(COMMAND_SPLIT_REGEX);
@ -42,27 +44,21 @@ function extractCommandNames(command: string): string[] {
return Array.from(discovered); return Array.from(discovered);
} }
function findBlockedCommands(command: string): string[] { function findBlockedCommands(command: string, sessionAllowedCommands?: Set<string>): string[] {
const invoked = extractCommandNames(command); const invoked = extractCommandNames(command);
if (!invoked.length) return []; if (!invoked.length) return [];
const allowList = getSecurityAllowList(); const allowList = getSecurityAllowList();
if (!allowList.length) return invoked; if (!allowList.length && (!sessionAllowedCommands || sessionAllowedCommands.size === 0)) return invoked;
const allowSet = new Set(allowList); const allowSet = new Set(allowList);
if (allowSet.has('*')) return []; if (allowSet.has('*')) return [];
return invoked.filter((cmd) => !allowSet.has(cmd)); return invoked.filter((cmd) => !allowSet.has(cmd) && !sessionAllowedCommands?.has(cmd));
} }
// export const BlockedResult = { export function isBlocked(command: string, sessionAllowedCommands?: Set<string>): boolean {
// stdout: '', const blocked = findBlockedCommands(command, sessionAllowedCommands);
// 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; return blocked.length > 0;
} }
@ -91,7 +87,7 @@ export async function executeCommand(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility shell: EXECUTION_SHELL,
}); });
return { return {
@ -151,7 +147,7 @@ export function executeCommandAbortable(
// Check if already aborted before spawning // Check if already aborted before spawning
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
// Return a dummy process and a resolved result // Return a dummy process and a resolved result
const dummyProc = spawn('true', { shell: true }); const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
dummyProc.kill(); dummyProc.kill();
return { return {
process: dummyProc, process: dummyProc,
@ -165,7 +161,7 @@ export function executeCommandAbortable(
} }
const proc = spawn(command, [], { const proc = spawn(command, [], {
shell: '/bin/sh', shell: EXECUTION_SHELL,
cwd: options?.cwd, cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@ -279,7 +275,7 @@ export function executeCommandSync(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
encoding: 'utf-8', encoding: 'utf-8',
shell: '/bin/sh', shell: EXECUTION_SHELL,
}); });
return { return {

View file

@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
import { UserMessageContent } from "@x/shared/dist/message.js";
import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
type EnqueuedMessage = { type EnqueuedMessage = {
messageId: string; messageId: string;
message: string; message: UserMessageContentType;
}; };
export interface IMessageQueue { export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>; enqueue(runId: string, message: UserMessageContentType): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>; dequeue(runId: string): Promise<EnqueuedMessage | null>;
} }
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator; this.idGenerator = idGenerator;
} }
async enqueue(runId: string, message: string): Promise<string> { async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
if (!this.store[runId]) { if (!this.store[runId]) {
this.store[runId] = []; this.store[runId] = [];
} }

View file

@ -152,19 +152,11 @@ export function generateState(): string {
*/ */
export function buildAuthorizationUrl( export function buildAuthorizationUrl(
config: client.Configuration, config: client.Configuration,
params: { params: Record<string, string>
redirectUri: string;
scope: string;
codeChallenge: string;
state: string;
}
): URL { ): URL {
return client.buildAuthorizationUrl(config, { return client.buildAuthorizationUrl(config, {
redirect_uri: params.redirectUri,
scope: params.scope,
code_challenge: params.codeChallenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
state: params.state, ...params,
}); });
} }
@ -208,6 +200,11 @@ export async function refreshTokens(
tokens.scopes = existingScopes; tokens.scopes = existingScopes;
} }
// Preserve existing refresh token if server didn't return it
if (!tokens.refresh_token) {
tokens.refresh_token = refreshToken;
}
console.log(`[OAuth] Token refresh successful`); console.log(`[OAuth] Token refresh successful`);
return tokens; return tokens;
} }

View file

@ -5,9 +5,9 @@ import { OAuthTokens } from './types.js';
import z from 'zod'; import z from 'zod';
const ProviderConnectionSchema = z.object({ const ProviderConnectionSchema = z.object({
tokens: OAuthTokens, tokens: OAuthTokens.nullable().optional(),
clientId: z.string().optional(), clientId: z.string().nullable().optional(),
error: z.string().optional(), error: z.string().nullable().optional(),
}); });
const OAuthConfigSchema = z.object({ const OAuthConfigSchema = z.object({
@ -17,7 +17,7 @@ const OAuthConfigSchema = z.object({
const ClientFacingConfigSchema = z.record(z.string(), z.object({ const ClientFacingConfigSchema = z.record(z.string(), z.object({
connected: z.boolean(), connected: z.boolean(),
error: z.string().optional(), error: z.string().nullable().optional(),
})); }));
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens); const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
@ -28,13 +28,9 @@ const DEFAULT_CONFIG: z.infer<typeof OAuthConfigSchema> = {
}; };
export interface IOAuthRepo { export interface IOAuthRepo {
getTokens(provider: string): Promise<OAuthTokens | null>; read(provider: string): Promise<z.infer<typeof ProviderConnectionSchema>>;
saveTokens(provider: string, tokens: OAuthTokens): Promise<void>; upsert(provider: string, connection: Partial<z.infer<typeof ProviderConnectionSchema>>): Promise<void>;
clearTokens(provider: string): Promise<void>; delete(provider: string): Promise<void>;
getClientId(provider: string): Promise<string | null>;
setClientId(provider: string, clientId: string): Promise<void>;
setError(provider: string, errorMessage: string): Promise<void>;
clearError(provider: string): Promise<void>;
getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>>; getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>>;
} }
@ -92,71 +88,22 @@ export class FSOAuthRepo implements IOAuthRepo {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
} }
async getTokens(provider: string): Promise<OAuthTokens | null> { async read(provider: string): Promise<z.infer<typeof ProviderConnectionSchema>> {
const config = await this.readConfig(); const config = await this.readConfig();
const tokens = config.providers[provider]?.tokens; return config.providers[provider] ?? {};
return tokens ?? null;
} }
async upsert(provider: string, connection: Partial<z.infer<typeof ProviderConnectionSchema>>): Promise<void> {
async saveTokens(provider: string, tokens: OAuthTokens): Promise<void> {
const config = await this.readConfig(); const config = await this.readConfig();
if (config.providers[provider]) { config.providers[provider] = { ...config.providers[provider] ?? {}, ...connection };
delete config.providers[provider];
}
config.providers[provider] = {
tokens,
};
await this.writeConfig(config); await this.writeConfig(config);
} }
async clearTokens(provider: string): Promise<void> { async delete(provider: string): Promise<void> {
const config = await this.readConfig(); const config = await this.readConfig();
delete config.providers[provider]; delete config.providers[provider];
await this.writeConfig(config); await this.writeConfig(config);
} }
async getClientId(provider: string): Promise<string | null> {
const config = await this.readConfig();
const clientId = config.providers[provider]?.clientId;
return clientId ?? null;
}
async setClientId(provider: string, clientId: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
config.providers[provider].clientId = clientId;
await this.writeConfig(config);
}
async clearClientId(provider: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
delete config.providers[provider].clientId;
await this.writeConfig(config);
}
async setError(provider: string, errorMessage: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
config.providers[provider].error = errorMessage;
await this.writeConfig(config);
}
async clearError(provider: string): Promise<void> {
const config = await this.readConfig();
if (!config.providers[provider]) {
throw new Error(`Provider ${provider} not found`);
}
delete config.providers[provider].error;
await this.writeConfig(config);
}
async getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>> { async getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>> {
const config = await this.readConfig(); const config = await this.readConfig();
const clientFacingConfig: z.infer<typeof ClientFacingConfigSchema> = {}; const clientFacingConfig: z.infer<typeof ClientFacingConfigSchema> = {};

View file

@ -343,7 +343,7 @@ export async function executeAction(
try { try {
const client = getComposioClient(); const client = getComposioClient();
const result = await client.tools.execute(actionSlug, { const result = await client.tools.execute(actionSlug, {
userId: connectedAccountId, userId: 'rowboat-user',
arguments: input, arguments: input,
connectedAccountId, connectedAccountId,
dangerouslySkipVersionCheck: true, dangerouslySkipVersionCheck: true,
@ -352,8 +352,8 @@ export async function executeAction(
console.log(`[Composio] Action completed successfully`); console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data }; return { success: true, data: result.data };
} catch (error) { } catch (error) {
console.error(`[Composio] Action execution failed:`, error); console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
return { success: false, data: null, error: message }; return { success: false, data: null, error: message };
} }
} }

View file

@ -91,4 +91,9 @@ function ensureWelcomeFile() {
ensureDirs(); ensureDirs();
ensureDefaultConfigs(); ensureDefaultConfigs();
ensureWelcomeFile(); ensureWelcomeFile();
// Initialize version history repo (async, fire-and-forget on startup)
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
console.error('[VersionHistory] Failed to init repo:', err);
});

View file

@ -20,6 +20,23 @@ const DEFAULT_ALLOW_LIST = [
let cachedAllowList: string[] | null = null; let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null; let cachedMtimeMs: number | null = null;
export async function addToSecurityConfig(commands: string[]): Promise<void> {
ensureSecurityConfigSync();
const current = readAllowList();
const merged = new Set(current);
for (const cmd of commands) {
const normalized = cmd.trim().toLowerCase();
if (normalized) merged.add(normalized);
}
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
"utf8",
);
// Reset cache so next read picks up the new file
resetSecurityAllowListCache();
}
/** /**
* Async function to ensure security config file exists. * Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs(). * Called explicitly at app startup via initConfigs().
@ -102,11 +119,9 @@ export function getSecurityAllowList(): string[] {
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedAllowList; return cachedAllowList;
} }
cachedAllowList = readAllowList();
const allowList = readAllowList();
cachedAllowList = allowList;
cachedMtimeMs = stats.mtimeMs; cachedMtimeMs = stats.mtimeMs;
return allowList; return cachedAllowList;
} catch { } catch {
cachedAllowList = null; cachedAllowList = null;
cachedMtimeMs = null; cachedMtimeMs = null;

View file

@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
export * as watcher from './workspace/watcher.js'; export * as watcher from './workspace/watcher.js';
// Config initialization // Config initialization
export { initConfigs } from './config/initConfigs.js'; export { initConfigs } from './config/initConfigs.js';
// Knowledge version history
export * as versionHistory from './knowledge/version_history.js';

View file

@ -15,6 +15,7 @@ import {
} from './graph_state.js'; } from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'; import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
/** /**
* Build obsidian-style knowledge graph by running topic extraction * Build obsidian-style knowledge graph by running topic extraction
@ -320,6 +321,13 @@ async function buildGraphWithFiles(
// Save state after each successful batch // Save state after each successful batch
// This ensures partial progress is saved even if later batches fail // This ensures partial progress is saved even if later batches fail
saveState(state); saveState(state);
// Commit knowledge changes to version history
try {
await commitAll('Knowledge update', 'Rowboat');
} catch (err) {
console.error(`[GraphBuilder] Failed to commit version history:`, err);
}
} catch (error) { } catch (error) {
hadError = true; hadError = true;
console.error(`Error processing batch ${batchNumber}:`, error); console.error(`Error processing batch ${batchNumber}:`, error);
@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
// Save state after each batch // Save state after each batch
saveState(state); saveState(state);
// Commit knowledge changes to version history
try {
await commitAll('Knowledge update', 'Rowboat');
} catch (err) {
console.error(`[GraphBuilder] Failed to commit version history:`, err);
}
} catch (error) { } catch (error) {
hadError = true; hadError = true;
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error); console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);

View file

@ -31,7 +31,7 @@ export class FirefliesClientFactory {
*/ */
static async getClient(): Promise<Client | null> { static async getClient(): Promise<Client | null> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) { if (!tokens) {
this.clearCache(); this.clearCache();
@ -49,7 +49,7 @@ export class FirefliesClientFactory {
// Token expired, try to refresh // Token expired, try to refresh
if (!tokens.refresh_token) { if (!tokens.refresh_token) {
console.log("[Fireflies] Token expired and no refresh token available."); console.log("[Fireflies] Token expired and no refresh token available.");
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.'); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
this.clearCache(); this.clearCache();
return null; return null;
} }
@ -62,7 +62,7 @@ export class FirefliesClientFactory {
tokens.refresh_token, tokens.refresh_token,
existingScopes existingScopes
); );
await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens); await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
// Update cached tokens and recreate client // Update cached tokens and recreate client
this.cache.tokens = refreshedTokens; this.cache.tokens = refreshedTokens;
@ -77,7 +77,7 @@ export class FirefliesClientFactory {
return this.cache.client; return this.cache.client;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies'; const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies';
await oauthRepo.setError(this.PROVIDER_NAME, message); await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
console.error("[Fireflies] Failed to refresh token:", error); console.error("[Fireflies] Failed to refresh token:", error);
this.clearCache(); this.clearCache();
return null; return null;
@ -107,7 +107,7 @@ export class FirefliesClientFactory {
*/ */
static async hasValidCredentials(): Promise<boolean> { static async hasValidCredentials(): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
return tokens !== null; return tokens !== null;
} }

View file

@ -27,9 +27,9 @@ export class GoogleClientFactory {
private static async resolveClientId(): Promise<string> { private static async resolveClientId(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const clientId = await oauthRepo.getClientId(this.PROVIDER_NAME); const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
if (!clientId) { if (!clientId) {
await oauthRepo.setError(this.PROVIDER_NAME, 'Google client ID missing. Please reconnect.'); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
throw new Error('Google client ID missing. Please reconnect.'); throw new Error('Google client ID missing. Please reconnect.');
} }
return clientId; return clientId;
@ -40,7 +40,7 @@ export class GoogleClientFactory {
*/ */
static async getClient(): Promise<OAuth2Client | null> { static async getClient(): Promise<OAuth2Client | null> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) { if (!tokens) {
this.clearCache(); this.clearCache();
@ -64,7 +64,7 @@ export class GoogleClientFactory {
// Token expired, try to refresh // Token expired, try to refresh
if (!tokens.refresh_token) { if (!tokens.refresh_token) {
console.log("[OAuth] Token expired and no refresh token available for Google."); console.log("[OAuth] Token expired and no refresh token available for Google.");
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.'); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
this.clearCache(); this.clearCache();
return null; return null;
} }
@ -77,7 +77,7 @@ export class GoogleClientFactory {
tokens.refresh_token, tokens.refresh_token,
existingScopes existingScopes
); );
await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens); await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
// Update cached tokens and recreate client // Update cached tokens and recreate client
this.cache.tokens = refreshedTokens; this.cache.tokens = refreshedTokens;
@ -89,7 +89,7 @@ export class GoogleClientFactory {
return this.cache.client; return this.cache.client;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google'; const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
await oauthRepo.setError(this.PROVIDER_NAME, message); await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
console.error("[OAuth] Failed to refresh token for Google:", error); console.error("[OAuth] Failed to refresh token for Google:", error);
this.clearCache(); this.clearCache();
return null; return null;
@ -116,7 +116,7 @@ export class GoogleClientFactory {
*/ */
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> { static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) { if (!tokens) {
return false; return false;
} }

View file

@ -0,0 +1,243 @@
import fs from 'node:fs';
import path from 'node:path';
import git from 'isomorphic-git';
import { WorkDir } from '../config/config.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
// Simple promise-based mutex to serialize commits
let commitLock: Promise<void> = Promise.resolve();
// Commit listeners for notifying other layers (e.g. renderer refresh)
type CommitListener = () => void;
const commitListeners: CommitListener[] = [];
export function onCommit(listener: CommitListener): () => void {
commitListeners.push(listener);
return () => {
const idx = commitListeners.indexOf(listener);
if (idx >= 0) commitListeners.splice(idx, 1);
};
}
/**
* Initialize a git repo in the knowledge directory if one doesn't exist.
* Stages all existing .md files and makes an initial commit.
*/
export async function initRepo(): Promise<void> {
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
if (fs.existsSync(gitDir)) {
return;
}
// Ensure knowledge dir exists
if (!fs.existsSync(KNOWLEDGE_DIR)) {
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
}
await git.init({ fs, dir: KNOWLEDGE_DIR });
// Stage all existing .md files
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
for (const file of files) {
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
}
if (files.length > 0) {
await git.commit({
fs,
dir: KNOWLEDGE_DIR,
message: 'Initial snapshot',
author: { name: 'Rowboat', email: 'local' },
});
}
}
/**
* Recursively find all .md files relative to the knowledge dir.
*/
function getAllMdFiles(baseDir: string, relDir: string): string[] {
const results: string[] = [];
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
let entries: string[];
try {
entries = fs.readdirSync(absDir);
} catch {
return results;
}
for (const entry of entries) {
if (entry === '.git' || entry.startsWith('.')) continue;
const fullPath = path.join(absDir, entry);
const relPath = relDir ? `${relDir}/${entry}` : entry;
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
results.push(...getAllMdFiles(baseDir, relPath));
} else if (entry.endsWith('.md')) {
results.push(relPath);
}
}
return results;
}
/**
* Stage all changes to .md files and commit. No-op if nothing changed.
* Serialized via a promise lock to prevent concurrent git index corruption.
*/
export async function commitAll(message: string, authorName: string): Promise<void> {
const prev = commitLock;
let resolve: () => void;
commitLock = new Promise(r => { resolve = r; });
await prev;
try {
await commitAllInner(message, authorName);
} finally {
resolve!();
}
}
async function commitAllInner(message: string, authorName: string): Promise<void> {
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
let hasChanges = false;
for (const [filepath, head, workdir, stage] of matrix) {
// Skip non-md files
if (!filepath.endsWith('.md')) continue;
// [filepath, HEAD, WORKDIR, STAGE]
// Unchanged: [f, 1, 1, 1]
if (head === 1 && workdir === 1 && stage === 1) continue;
hasChanges = true;
if (workdir === 0) {
// File deleted from workdir
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
} else {
// File added or modified
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
}
}
if (!hasChanges) return;
await git.commit({
fs,
dir: KNOWLEDGE_DIR,
message,
author: { name: authorName, email: 'local' },
});
for (const listener of commitListeners) {
try { listener(); } catch { /* ignore */ }
}
}
export interface CommitInfo {
oid: string;
message: string;
timestamp: number;
author: string;
}
const MAX_FILE_HISTORY = 50;
/**
* Get commit history for a specific file.
* Returns commits where the file content changed, most recent first.
* Capped at MAX_FILE_HISTORY entries.
*/
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
// Normalize path separators for git (always forward slashes)
const filepath = knowledgeRelPath.replace(/\\/g, '/');
let commits: Awaited<ReturnType<typeof git.log>>;
try {
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
} catch {
return [];
}
if (commits.length === 0) return [];
const result: CommitInfo[] = [];
// Walk through commits and check if file changed between consecutive commits
for (let i = 0; i < commits.length; i++) {
if (result.length >= MAX_FILE_HISTORY) break;
const commit = commits[i]!;
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
const parentOid = parentCommit
? await getBlobOidAtCommit(parentCommit.oid, filepath)
: null;
// Include this commit if:
// - The file existed and changed from parent
// - The file was added (parentOid is null but currentOid exists)
// - The file was deleted (currentOid is null but parentOid exists)
if (currentOid !== parentOid) {
result.push({
oid: commit.oid,
message: commit.commit.message.trim(),
timestamp: commit.commit.author.timestamp,
author: commit.commit.author.name,
});
}
}
return result;
}
/**
* Get the blob OID for a file at a specific commit, or null if not found.
*/
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
try {
const result = await git.readBlob({
fs,
dir: KNOWLEDGE_DIR,
oid: commitOid,
filepath,
});
// Compute a content hash from the blob to compare
return result.oid;
} catch {
return null;
}
}
/**
* Read file content at a specific commit.
*/
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
const filepath = knowledgeRelPath.replace(/\\/g, '/');
const result = await git.readBlob({
fs,
dir: KNOWLEDGE_DIR,
oid,
filepath,
});
return Buffer.from(result.blob).toString('utf-8');
}
/**
* Restore a file to its content at a given commit, then commit the restoration.
*/
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
const content = await getFileAtCommit(knowledgeRelPath, oid);
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
// Ensure parent directory exists
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(absPath, content, 'utf-8');
const filename = path.basename(knowledgeRelPath);
await commitAll(`Restored ${filename}`, 'You');
}

View file

@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer<typeof MessageEvent>; const messageEvent = event as z.infer<typeof MessageEvent>;
if (messageEvent.message.role === 'user') { if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content; const content = messageEvent.message.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate to 100 chars if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
if (!cleaned) continue; // Skip if only attached files/mentions } else {
textContent = content
.filter(p => p.type === 'text')
.map(p => p.text)
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }
} }
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') { if (msg.role === 'user') {
// Found first user message - use as title // Found first user message - use as title
const content = msg.content; const content = msg.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
} else {
textContent = content
.filter(p => p.type === 'text')
.map(p => p.text)
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (cleaned) { if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }

View file

@ -1,13 +1,15 @@
import z from "zod"; import z from "zod";
import container from "../di/container.js"; import container from "../di/container.js";
import { IMessageQueue } from "../application/lib/message-queue.js"; import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js"; import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js"; import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js"; import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js"; import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js"; import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js"; import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSecurityConfig } from "../config/security.js";
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> { export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo'); const repo = container.resolve<IRunsRepo>('runsRepo');
@ -17,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run; return run;
} }
export async function createMessage(runId: string, message: string): Promise<string> { export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue'); const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message); const id = await queue.enqueue(runId, message);
const runtime = container.resolve<IAgentRuntime>('agentRuntime'); const runtime = container.resolve<IAgentRuntime>('agentRuntime');
@ -26,11 +28,32 @@ export async function createMessage(runId: string, message: string): Promise<str
} }
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> { export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
const { scope, ...rest } = ev;
// For "always" scope, derive command from the run log and persist to security config
if (rest.response === "approve" && scope === "always") {
const repo = container.resolve<IRunsRepo>('runsRepo');
const run = await repo.fetch(runId);
const permReqEvent = run.log.find(
(e): e is z.infer<typeof ToolPermissionRequestEvent> =>
e.type === "tool-permission-request"
&& e.toolCall.toolCallId === rest.toolCallId
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
);
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
if (commandNames.length > 0) {
await addToSecurityConfig(commandNames);
}
}
}
const repo = container.resolve<IRunsRepo>('runsRepo'); const repo = container.resolve<IRunsRepo>('runsRepo');
const event: z.infer<typeof ToolPermissionResponseEvent> = { const event: z.infer<typeof ToolPermissionResponseEvent> = {
...ev, ...rest,
runId, runId,
type: "tool-permission-response", type: "tool-permission-response",
scope,
}; };
await repo.appendEvents(runId, [event]); await repo.appendEvents(runId, [event]);
const runtime = container.resolve<IAgentRuntime>('agentRuntime'); const runtime = container.resolve<IAgentRuntime>('agentRuntime');

View file

@ -0,0 +1,375 @@
import path from 'path';
import fs from 'fs';
import fsp from 'fs/promises';
import readline from 'readline';
import { execFile } from 'child_process';
import { WorkDir } from '../config/config.js';
interface SearchResult {
type: 'knowledge' | 'chat';
title: string;
preview: string;
path: string;
}
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const RUNS_DIR = path.join(WorkDir, 'runs');
type SearchType = 'knowledge' | 'chat';
/**
* Search across knowledge files and chat history.
* @param types - optional filter to search only specific types (default: both)
*/
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
const trimmed = query.trim();
if (!trimmed) {
return { results: [] };
}
const searchKnowledgeEnabled = !types || types.includes('knowledge');
const searchChatsEnabled = !types || types.includes('chat');
const [knowledgeResults, chatResults] = await Promise.all([
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
]);
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
return { results };
}
/**
* Search knowledge markdown files by content and filename.
*/
async function searchKnowledge(query: string, limit: number): Promise<SearchResult[]> {
if (!fs.existsSync(KNOWLEDGE_DIR)) {
return [];
}
const results: SearchResult[] = [];
const seenPaths = new Set<string>();
const lowerQuery = query.toLowerCase();
// Content search via grep
try {
const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
for (const match of grepMatches) {
if (results.length >= limit) break;
const relPath = path.relative(WorkDir, match.file);
if (seenPaths.has(relPath)) continue;
seenPaths.add(relPath);
const title = path.basename(match.file, '.md');
results.push({
type: 'knowledge',
title,
preview: match.line.trim().substring(0, 150),
path: relPath,
});
}
} catch {
// grep failed (no matches or dir issue) — continue
}
// Filename search — check files whose name matches the query
try {
const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
for (const file of allFiles) {
if (results.length >= limit) break;
const relPath = path.relative(WorkDir, file);
if (seenPaths.has(relPath)) continue;
const basename = path.basename(file, '.md');
if (basename.toLowerCase().includes(lowerQuery)) {
seenPaths.add(relPath);
const preview = await readFirstLines(file, 2);
results.push({
type: 'knowledge',
title: basename,
preview,
path: relPath,
});
}
}
} catch {
// ignore errors
}
return results;
}
/**
* Search chat history by title and message content.
*/
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
if (!fs.existsSync(RUNS_DIR)) {
return [];
}
const results: SearchResult[] = [];
const seenIds = new Set<string>();
const lowerQuery = query.toLowerCase();
// Content search via grep on JSONL files
try {
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
for (const match of grepMatches) {
if (results.length >= limit) break;
const runId = path.basename(match.file, '.jsonl');
if (seenIds.has(runId)) continue;
const meta = await readRunMetadata(match.file);
if (meta.agentName !== 'copilot') {
seenIds.add(runId);
continue;
}
seenIds.add(runId);
// Extract a content preview from the matching line
let preview = '';
try {
const parsed = JSON.parse(match.line);
if (parsed.message?.content && typeof parsed.message.content === 'string') {
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
}
} catch {
preview = match.line.substring(0, 150);
}
results.push({
type: 'chat',
title: meta.title || runId,
preview,
path: runId,
});
}
} catch {
// grep failed — continue
}
// Title search — scan run files for matching titles
try {
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
const jsonlFiles = entries
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
.map(e => e.name)
.sort()
.reverse(); // newest first
for (const name of jsonlFiles) {
if (results.length >= limit) break;
const runId = path.basename(name, '.jsonl');
if (seenIds.has(runId)) continue;
const filePath = path.join(RUNS_DIR, name);
const meta = await readRunMetadata(filePath);
if (meta.agentName !== 'copilot') {
seenIds.add(runId);
continue;
}
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
seenIds.add(runId);
results.push({
type: 'chat',
title: meta.title,
preview: meta.title,
path: runId,
});
}
}
} catch {
// ignore errors
}
return results;
}
/**
* Use grep to find files matching a query.
*/
function grepFiles(query: string, dir: string, includeGlob: string): Promise<Array<{ file: string; line: string }>> {
return new Promise((resolve, reject) => {
execFile(
'grep',
['-ril', '--include=' + includeGlob, query, dir],
{ maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
// Exit code 1 = no matches
if (error.code === 1) {
resolve([]);
return;
}
reject(error);
return;
}
const files = stdout.trim().split('\n').filter(Boolean);
// For each matching file, get the first matching line
const promises = files.map(file =>
getFirstMatchingLine(file, query).then(line => ({ file, line }))
);
Promise.all(promises).then(resolve).catch(reject);
}
);
});
}
/**
* Get the first line in a file that matches the query (case-insensitive).
*/
function getFirstMatchingLine(filePath: string, query: string): Promise<string> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: string) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const lowerQuery = query.toLowerCase();
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on('line', (line) => {
if (line.toLowerCase().includes(lowerQuery)) {
done(line);
rl.close();
stream.destroy();
}
});
rl.on('close', () => done(''));
stream.on('error', () => done(''));
});
}
interface RunMetadata {
title: string | undefined;
agentName: string | undefined;
}
/**
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
*/
function readRunMetadata(filePath: string): Promise<RunMetadata> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: RunMetadata) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let lineIndex = 0;
let agentName: string | undefined;
rl.on('line', (line) => {
if (resolved) return;
const trimmed = line.trim();
if (!trimmed) return;
try {
if (lineIndex === 0) {
// Start event — extract agentName
const start = JSON.parse(trimmed);
agentName = start.agentName;
lineIndex++;
return;
}
const event = JSON.parse(trimmed);
if (event.type === 'message') {
const msg = event.message;
if (msg?.role === 'user') {
const content = msg.content;
if (typeof content === 'string' && content.trim()) {
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (cleaned) {
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
rl.close();
stream.destroy();
return;
}
}
done({ title: undefined, agentName });
rl.close();
stream.destroy();
return;
} else if (msg?.role === 'assistant') {
done({ title: undefined, agentName });
rl.close();
stream.destroy();
return;
}
}
lineIndex++;
} catch {
lineIndex++;
}
});
rl.on('close', () => done({ title: undefined, agentName }));
rl.on('error', () => done({ title: undefined, agentName: undefined }));
stream.on('error', () => {
rl.close();
done({ title: undefined, agentName: undefined });
});
});
}
/**
* Recursively list all .md files in a directory.
*/
async function listMarkdownFiles(dir: string): Promise<string[]> {
const results: string[] = [];
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nested = await listMarkdownFiles(fullPath);
results.push(...nested);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(fullPath);
}
}
} catch {
// ignore
}
return results;
}
/**
* Read the first N non-empty lines of a file for preview.
*/
async function readFirstLines(filePath: string, n: number): Promise<string> {
return new Promise((resolve) => {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
const lines: string[] = [];
rl.on('line', (line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
lines.push(trimmed);
}
if (lines.length >= n) {
rl.close();
stream.destroy();
}
});
rl.on('close', () => {
resolve(lines.join(' ').substring(0, 150));
});
stream.on('error', () => {
resolve('');
});
});
}

View file

@ -0,0 +1,170 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const WIKI_LINK_REGEX = /\[\[([^[\]]+)\]\]/g;
const KNOWLEDGE_PREFIX = 'knowledge/';
const MARKDOWN_EXTENSION = '.md';
function normalizeRelPath(relPath: string): string {
return relPath.replace(/\\/g, '/');
}
function isKnowledgeMarkdownPath(relPath: string): boolean {
const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
const lower = normalized.toLowerCase();
return lower.startsWith(KNOWLEDGE_PREFIX) && lower.endsWith(MARKDOWN_EXTENSION);
}
function stripKnowledgePrefix(relPath: string): string {
const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
if (!normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)) return normalized;
return normalized.slice(KNOWLEDGE_PREFIX.length);
}
function stripMarkdownExtension(wikiPath: string): string {
return wikiPath.toLowerCase().endsWith(MARKDOWN_EXTENSION)
? wikiPath.slice(0, -MARKDOWN_EXTENSION.length)
: wikiPath;
}
function toWikiPathCompareKey(wikiPath: string): string {
return stripMarkdownExtension(wikiPath).toLowerCase();
}
function splitWikiPathPrefix(rawPath: string): { pathWithoutPrefix: string; hadKnowledgePrefix: boolean } {
let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, '');
const hadKnowledgePrefix = /^knowledge\//i.test(normalized);
if (hadKnowledgePrefix) {
normalized = normalized.slice(KNOWLEDGE_PREFIX.length);
}
return { pathWithoutPrefix: normalized, hadKnowledgePrefix };
}
function rewriteWikiLinksInMarkdown(
markdown: string,
fromWikiPath: string,
toWikiPath: string,
opts?: { allowBareSelfNameMatch?: boolean }
): string {
const fromCompareKey = toWikiPathCompareKey(fromWikiPath);
const fromBaseName = stripMarkdownExtension(fromWikiPath).split('/').pop()?.toLowerCase() ?? null;
const toWikiPathWithoutExtension = stripMarkdownExtension(toWikiPath);
const toBaseName = toWikiPathWithoutExtension.split('/').pop() ?? toWikiPathWithoutExtension;
return markdown.replace(WIKI_LINK_REGEX, (fullMatch, innerRaw: string) => {
const pipeIndex = innerRaw.indexOf('|');
const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw;
const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : '';
const hashIndex = pathAndAnchor.indexOf('#');
const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor;
const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : '';
const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? '';
const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? '';
const rawPath = pathPart.trim();
if (!rawPath) return fullMatch;
const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath);
if (!pathWithoutPrefix) return fullMatch;
const matchesFullPath = toWikiPathCompareKey(pathWithoutPrefix) === fromCompareKey;
const isBareTarget = !pathWithoutPrefix.includes('/');
const targetBaseName = stripMarkdownExtension(pathWithoutPrefix).toLowerCase();
const matchesBareSelfName = Boolean(
opts?.allowBareSelfNameMatch
&& fromBaseName
&& isBareTarget
&& targetBaseName === fromBaseName
);
if (!matchesFullPath && !matchesBareSelfName) {
return fullMatch;
}
const preserveMarkdownExtension = rawPath.toLowerCase().endsWith(MARKDOWN_EXTENSION);
const rewrittenPath = matchesBareSelfName
? (preserveMarkdownExtension ? `${toBaseName}.md` : toBaseName)
: (preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension);
const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenPath}` : rewrittenPath;
return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`;
});
}
async function collectKnowledgeMarkdownFiles(workspaceRoot: string): Promise<string[]> {
const knowledgeRoot = path.join(workspaceRoot, 'knowledge');
try {
const stat = await fs.lstat(knowledgeRoot);
if (!stat.isDirectory()) return [];
} catch {
return [];
}
const markdownFiles: string[] = [];
const pendingDirectories: string[] = [knowledgeRoot];
while (pendingDirectories.length > 0) {
const currentDirectory = pendingDirectories.pop();
if (!currentDirectory) continue;
const entries = await fs.readdir(currentDirectory, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const absolutePath = path.join(currentDirectory, entry.name);
if (entry.isDirectory()) {
pendingDirectories.push(absolutePath);
continue;
}
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith(MARKDOWN_EXTENSION)) continue;
const relativePath = normalizeRelPath(path.relative(workspaceRoot, absolutePath));
markdownFiles.push(relativePath);
}
}
return markdownFiles;
}
export async function rewriteWikiLinksForRenamedKnowledgeFile(
workspaceRoot: string,
fromRelPath: string,
toRelPath: string
): Promise<number> {
const normalizedFrom = normalizeRelPath(fromRelPath);
const normalizedTo = normalizeRelPath(toRelPath);
if (!isKnowledgeMarkdownPath(normalizedFrom) || !isKnowledgeMarkdownPath(normalizedTo)) {
return 0;
}
const fromWikiPath = stripKnowledgePrefix(normalizedFrom);
const toWikiPath = stripKnowledgePrefix(normalizedTo);
if (toWikiPathCompareKey(fromWikiPath) === toWikiPathCompareKey(toWikiPath)) return 0;
const markdownFiles = await collectKnowledgeMarkdownFiles(workspaceRoot);
let rewrittenFiles = 0;
const normalizedToLower = normalizedTo.toLowerCase();
for (const relativePath of markdownFiles) {
const absolutePath = path.join(workspaceRoot, ...relativePath.split('/'));
try {
const markdown = await fs.readFile(absolutePath, 'utf8');
if (!markdown.includes('[[')) continue;
const isRenamedFile = normalizeRelPath(relativePath).toLowerCase() === normalizedToLower;
const rewritten = rewriteWikiLinksInMarkdown(markdown, fromWikiPath, toWikiPath, {
allowBareSelfNameMatch: isRenamedFile,
});
if (rewritten === markdown) continue;
await fs.writeFile(absolutePath, rewritten, 'utf8');
rewrittenFiles += 1;
} catch (error) {
console.error('Failed to rewrite wiki links in file:', relativePath, error);
}
}
return rewrittenFiles;
}

View file

@ -5,6 +5,8 @@ import { workspace } from '@x/shared';
import { z } from 'zod'; import { z } from 'zod';
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
import { commitAll } from '../knowledge/version_history.js';
// ============================================================================ // ============================================================================
// Path Utilities // Path Utilities
@ -58,6 +60,11 @@ export function absToRelPosix(absPath: string): string | null {
return relPath.split(path.sep).join('/'); return relPath.split(path.sep).join('/');
} }
function isKnowledgeMarkdownRelPath(relPath: string): boolean {
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
return normalized.startsWith('knowledge/') && normalized.endsWith('.md');
}
// ============================================================================ // ============================================================================
// File System Utilities // File System Utilities
// ============================================================================ // ============================================================================
@ -212,6 +219,21 @@ export async function readFile(
}; };
} }
// Debounced commit for knowledge file edits
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleKnowledgeCommit(filename: string): void {
if (knowledgeCommitTimer) {
clearTimeout(knowledgeCommitTimer);
}
knowledgeCommitTimer = setTimeout(() => {
knowledgeCommitTimer = null;
commitAll(`Edit ${filename}`, 'You').catch(err => {
console.error('[VersionHistory] Failed to commit after edit:', err);
});
}, 3 * 60 * 1000);
}
export async function writeFile( export async function writeFile(
relPath: string, relPath: string,
data: string, data: string,
@ -260,6 +282,11 @@ export async function writeFile(
const stat = statToSchema(stats, 'file'); const stat = statToSchema(stats, 'file');
const etag = computeEtag(stats.size, stats.mtimeMs); const etag = computeEtag(stats.size, stats.mtimeMs);
// Schedule a debounced version history commit for knowledge files
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
scheduleKnowledgeCommit(path.basename(relPath));
}
return { return {
path: relPath, path: relPath,
stat, stat,
@ -286,6 +313,7 @@ export async function rename(
// Check if source exists // Check if source exists
await fs.access(fromPath); await fs.access(fromPath);
const fromStats = await fs.lstat(fromPath);
// Check if destination exists (only if overwrite is false) // Check if destination exists (only if overwrite is false)
if (!overwrite) { if (!overwrite) {
@ -309,6 +337,19 @@ export async function rename(
await fs.mkdir(path.dirname(toPath), { recursive: true }); await fs.mkdir(path.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath); await fs.rename(fromPath, toPath);
if (
fromStats.isFile()
&& isKnowledgeMarkdownRelPath(from)
&& isKnowledgeMarkdownRelPath(to)
) {
try {
await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, from, to);
} catch (error) {
console.error('Failed to rewrite wiki backlinks after file rename:', error);
}
}
return { ok: true as const }; return { ok: true as const };
} }
@ -383,4 +424,4 @@ export async function remove(
} }
return { ok: true as const }; return { ok: true as const };
} }

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js'; import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js'; import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': { 'runs:createMessage': {
req: z.object({ req: z.object({
runId: z.string(), runId: z.string(),
message: z.string(), message: UserMessageContent,
}), }),
res: z.object({ res: z.object({
messageId: z.string(), messageId: z.string(),
@ -244,7 +245,7 @@ const ipcSchemas = {
res: z.object({ res: z.object({
config: z.record(z.string(), z.object({ config: z.record(z.string(), z.object({
connected: z.boolean(), connected: z.boolean(),
error: z.string().optional(), error: z.string().nullable().optional(),
})), })),
}), }),
}, },
@ -396,6 +397,46 @@ const ipcSchemas = {
req: z.object({ path: z.string() }), req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
}, },
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),
res: z.object({
commits: z.array(z.object({
oid: z.string(),
message: z.string(),
timestamp: z.number(),
author: z.string(),
})),
}),
},
'knowledge:fileAtCommit': {
req: z.object({ path: RelPath, oid: z.string() }),
res: z.object({ content: z.string() }),
},
'knowledge:restore': {
req: z.object({ path: RelPath, oid: z.string() }),
res: z.object({ ok: z.literal(true) }),
},
'knowledge:didCommit': {
req: z.object({}),
res: z.null(),
},
// Search channels
'search:query': {
req: z.object({
query: z.string(),
limit: z.number().optional(),
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
}),
res: z.object({
results: z.array(z.object({
type: z.enum(['knowledge', 'chat']),
title: z.string(),
preview: z.string(),
path: z.string(),
})),
}),
},
} as const; } as const;
// ============================================================================ // ============================================================================

View file

@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
ToolCallPart, ToolCallPart,
]); ]);
// A piece of user-typed text within a content array
export const UserTextPart = z.object({
type: z.literal("text"),
text: z.string(),
});
// An attachment within a content array
export const UserAttachmentPart = z.object({
type: z.literal("attachment"),
path: z.string(), // absolute file path
filename: z.string(), // display name ("photo.png")
mimeType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
});
// Any single part of a user message (text or attachment)
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
// Named type for user message content — used everywhere instead of repeating the union
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
export const UserMessage = z.object({ export const UserMessage = z.object({
role: z.literal("user"), role: z.literal("user"),
content: z.string(), content: UserMessageContent,
providerOptions: ProviderOptions.optional(), providerOptions: ProviderOptions.optional(),
}); });

View file

@ -10,4 +10,5 @@ export const LlmProvider = z.object({
export const LlmModelConfig = z.object({ export const LlmModelConfig = z.object({
provider: LlmProvider, provider: LlmProvider,
model: z.string(), model: z.string(),
knowledgeGraphModel: z.string().optional(),
}); });

View file

@ -73,6 +73,7 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-response"), type: z.literal("tool-permission-response"),
toolCallId: z.string(), toolCallId: z.string(),
response: z.enum(["approve", "deny"]), response: z.enum(["approve", "deny"]),
scope: z.enum(["once", "session", "always"]).optional(),
}); });
export const RunErrorEvent = BaseRunEvent.extend({ export const RunErrorEvent = BaseRunEvent.extend({
@ -106,6 +107,7 @@ export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
subflow: true, subflow: true,
toolCallId: true, toolCallId: true,
response: true, response: true,
scope: true,
}); });
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({

216
apps/x/pnpm-lock.yaml generated
View file

@ -359,6 +359,9 @@ importers:
googleapis: googleapis:
specifier: ^169.0.0 specifier: ^169.0.0
version: 169.0.0 version: 169.0.0
isomorphic-git:
specifier: ^1.29.0
version: 1.37.2
mammoth: mammoth:
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.11.0 version: 1.11.0
@ -3501,6 +3504,10 @@ packages:
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abs-svg-path@0.1.1: abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
@ -3627,6 +3634,9 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'} engines: {node: '>=8'}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async@1.5.2: async@1.5.2:
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
@ -3641,6 +3651,10 @@ packages:
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
awilix@12.0.5: awilix@12.0.5:
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==} resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
engines: {node: '>=16.3.0'} engines: {node: '>=16.3.0'}
@ -3742,6 +3756,9 @@ packages:
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3762,6 +3779,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.4: call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3825,6 +3846,9 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clean-git-ref@2.0.1:
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
clean-stack@2.2.0: clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -4256,6 +4280,9 @@ packages:
dfa@1.2.0: dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
diff3@0.0.3:
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
dingbat-to-unicode@1.0.1: dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
@ -4496,6 +4523,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@5.0.1: eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -4640,6 +4671,10 @@ packages:
fontkit@2.0.4: fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1: foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -5104,6 +5139,10 @@ packages:
is-arrayish@0.3.4: is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
is-core-module@2.16.1: is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5170,6 +5209,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-typed-array@1.1.15:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'}
is-unicode-supported@0.1.0: is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5184,6 +5227,9 @@ packages:
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isbinaryfile@4.0.10: isbinaryfile@4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'} engines: {node: '>= 8.0.0'}
@ -5191,6 +5237,11 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-git@1.37.2:
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
engines: {node: '>=14.17'}
hasBin: true
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@ -5762,6 +5813,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minimisted@2.0.1:
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
minipass-collect@1.0.2: minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -6169,6 +6223,10 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pkce-challenge@5.0.1: pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
@ -6186,6 +6244,10 @@ packages:
points-on-path@0.2.1: points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -6220,6 +6282,10 @@ packages:
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
progress@2.0.3: progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@ -6434,6 +6500,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -6649,12 +6719,21 @@ packages:
server-destroy@1.0.1: server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5: setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sha.js@2.4.12:
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
engines: {node: '>= 0.10'}
hasBin: true
shebang-command@1.2.0: shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6701,6 +6780,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@ -6928,6 +7013,10 @@ packages:
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==} resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
to-data-view@1.1.0: to-data-view@1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
@ -6998,6 +7087,10 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
typescript-eslint@8.50.1: typescript-eslint@8.50.1:
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -7272,6 +7365,10 @@ packages:
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
which@1.3.1: which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true hasBin: true
@ -11316,6 +11413,10 @@ snapshots:
abbrev@1.1.1: {} abbrev@1.1.1: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abs-svg-path@0.1.1: {} abs-svg-path@0.1.1: {}
accepts@2.0.0: accepts@2.0.0:
@ -11440,6 +11541,8 @@ snapshots:
arrify@2.0.1: {} arrify@2.0.1: {}
async-lock@1.4.1: {}
async@1.5.2: async@1.5.2:
optional: true optional: true
@ -11449,6 +11552,10 @@ snapshots:
author-regex@1.0.0: {} author-regex@1.0.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
awilix@12.0.5: awilix@12.0.5:
dependencies: dependencies:
camel-case: 4.1.2 camel-case: 4.1.2
@ -11568,6 +11675,11 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {} bytes@3.1.2: {}
cacache@16.1.3: cacache@16.1.3:
@ -11610,6 +11722,13 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 function-bind: 1.1.2
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4: call-bound@1.0.4:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -11672,6 +11791,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
clean-git-ref@2.0.1: {}
clean-stack@2.2.0: {} clean-stack@2.2.0: {}
cli-cursor@3.1.0: cli-cursor@3.1.0:
@ -12061,7 +12182,6 @@ snapshots:
es-define-property: 1.0.1 es-define-property: 1.0.1
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
optional: true
define-properties@1.2.1: define-properties@1.2.1:
dependencies: dependencies:
@ -12095,6 +12215,8 @@ snapshots:
dfa@1.2.0: {} dfa@1.2.0: {}
diff3@0.0.3: {}
dingbat-to-unicode@1.0.1: {} dingbat-to-unicode@1.0.1: {}
dir-compare@4.2.0: dir-compare@4.2.0:
@ -12451,6 +12573,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
events@3.3.0: {} events@3.3.0: {}
@ -12638,6 +12762,10 @@ snapshots:
unicode-properties: 1.4.1 unicode-properties: 1.4.1
unicode-trie: 2.0.0 unicode-trie: 2.0.0
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1: foreground-child@3.3.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -12983,7 +13111,6 @@ snapshots:
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
optional: true
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
@ -13251,6 +13378,8 @@ snapshots:
is-arrayish@0.3.4: {} is-arrayish@0.3.4: {}
is-callable@1.2.7: {}
is-core-module@2.16.1: is-core-module@2.16.1:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
@ -13300,6 +13429,10 @@ snapshots:
is-stream@2.0.1: {} is-stream@2.0.1: {}
is-typed-array@1.1.15:
dependencies:
which-typed-array: 1.1.20
is-unicode-supported@0.1.0: {} is-unicode-supported@0.1.0: {}
is-url@1.2.4: {} is-url@1.2.4: {}
@ -13310,10 +13443,26 @@ snapshots:
isarray@1.0.0: {} isarray@1.0.0: {}
isarray@2.0.5: {}
isbinaryfile@4.0.10: {} isbinaryfile@4.0.10: {}
isexe@2.0.0: {} isexe@2.0.0: {}
isomorphic-git@1.37.2:
dependencies:
async-lock: 1.4.1
clean-git-ref: 2.0.1
crc-32: 1.2.2
diff3: 0.0.3
ignore: 5.3.2
minimisted: 2.0.1
pako: 1.0.11
pify: 4.0.1
readable-stream: 4.7.0
sha.js: 2.4.12
simple-get: 4.0.1
jackspeak@3.4.3: jackspeak@3.4.3:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@ -14139,6 +14288,10 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minimisted@2.0.1:
dependencies:
minimist: 1.2.8
minipass-collect@1.0.2: minipass-collect@1.0.2:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
@ -14506,6 +14659,8 @@ snapshots:
pify@2.3.0: {} pify@2.3.0: {}
pify@4.0.1: {}
pkce-challenge@5.0.1: {} pkce-challenge@5.0.1: {}
pkg-types@1.3.1: pkg-types@1.3.1:
@ -14527,6 +14682,8 @@ snapshots:
path-data-parser: 0.1.0 path-data-parser: 0.1.0
points-on-curve: 0.2.0 points-on-curve: 0.2.0
possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.6: postcss@8.5.6:
@ -14565,6 +14722,8 @@ snapshots:
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
process@0.11.10: {}
progress@2.0.3: {} progress@2.0.3: {}
promise-inflight@1.0.1: {} promise-inflight@1.0.1: {}
@ -14887,6 +15046,14 @@ snapshots:
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdirp@4.1.2: {} readdirp@4.1.2: {}
rechoir@0.8.0: rechoir@0.8.0:
@ -15175,10 +15342,25 @@ snapshots:
server-destroy@1.0.1: {} server-destroy@1.0.1: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.3.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
setimmediate@1.0.5: {} setimmediate@1.0.5: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sha.js@2.4.12:
dependencies:
inherits: 2.0.4
safe-buffer: 5.2.1
to-buffer: 1.2.2
shebang-command@1.2.0: shebang-command@1.2.0:
dependencies: dependencies:
shebang-regex: 1.0.0 shebang-regex: 1.0.0
@ -15236,6 +15418,14 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
dependencies: dependencies:
is-arrayish: 0.3.4 is-arrayish: 0.3.4
@ -15493,6 +15683,12 @@ snapshots:
unorm: 1.6.0 unorm: 1.6.0
optional: true optional: true
to-buffer@1.2.2:
dependencies:
isarray: 2.0.5
safe-buffer: 5.2.1
typed-array-buffer: 1.0.3
to-data-view@1.1.0: to-data-view@1.1.0:
optional: true optional: true
@ -15550,6 +15746,12 @@ snapshots:
media-typer: 1.1.0 media-typer: 1.1.0
mime-types: 3.0.2 mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
is-typed-array: 1.1.15
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@ -15827,6 +16029,16 @@ snapshots:
tr46: 0.0.3 tr46: 0.0.3
webidl-conversions: 3.0.1 webidl-conversions: 3.0.1
which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.8
call-bound: 1.0.4
for-each: 0.3.5
get-proto: 1.0.1
gopd: 1.2.0
has-tostringtag: 1.0.2
which@1.3.1: which@1.3.1:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0