mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
bootstrap new electron app
This commit is contained in:
parent
2491bacea1
commit
505e3ea620
89 changed files with 12397 additions and 8435 deletions
2
apps/.gitignore
vendored
2
apps/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
.DS_Store
|
||||
.vscode/
|
||||
|
|
@ -19,474 +19,6 @@ import { cors } from 'hono/cors';
|
|||
let id = 0;
|
||||
|
||||
const routes = new Hono()
|
||||
.get(
|
||||
'/health',
|
||||
describeRoute({
|
||||
summary: 'Health check',
|
||||
description: 'Check if the server is running',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Server is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
status: z.literal("ok"),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ status: 'ok' });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/mcp',
|
||||
describeRoute({
|
||||
summary: 'List MCP servers',
|
||||
description: 'List the MCP servers',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Server list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(McpServerList),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listServers());
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/mcp/:serverName',
|
||||
describeRoute({
|
||||
summary: 'Upsert MCP server',
|
||||
description: 'Add or edit MCP server',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP server added / updated',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
validator('json', McpServerDefinition),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.upsert(c.req.valid('param').serverName, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/mcp/:serverName',
|
||||
describeRoute({
|
||||
summary: 'Delete MCP server',
|
||||
description: 'Delete a MCP server',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP server deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.delete(c.req.valid('param').serverName);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/mcp/:serverName/tools',
|
||||
describeRoute({
|
||||
summary: 'Get MCP tools',
|
||||
description: 'Get the MCP tools',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP tools',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ListToolsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('query', z.object({
|
||||
cursor: z.string().optional(),
|
||||
})),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const result = await listTools(c.req.valid('param').serverName, c.req.valid('query').cursor);
|
||||
return c.json(result);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/mcp/:serverName/tools/:toolName/execute',
|
||||
describeRoute({
|
||||
summary: 'Execute MCP tool',
|
||||
description: 'Execute a MCP tool',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Tool executed',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
result: z.any(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
toolName: z.string(),
|
||||
})),
|
||||
validator('json', z.object({
|
||||
input: z.any(),
|
||||
})),
|
||||
async (c) => {
|
||||
const result = await executeTool(
|
||||
c.req.valid('param').serverName,
|
||||
c.req.valid('param').toolName,
|
||||
c.req.valid('json').input
|
||||
);
|
||||
return c.json(result);
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/models',
|
||||
describeRoute({
|
||||
summary: 'Get model config',
|
||||
description: 'Get the current model and provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Model config',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ModelConfig),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return c.json(config);
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/models/providers/:providerName',
|
||||
describeRoute({
|
||||
summary: 'Upsert provider config',
|
||||
description: 'Add or update a provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Provider upserted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
providerName: z.string(),
|
||||
})),
|
||||
validator('json', Provider),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.upsert(c.req.valid('param').providerName, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/models/providers/:providerName',
|
||||
describeRoute({
|
||||
summary: 'Delete provider config',
|
||||
description: 'Delete a provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Provider deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
providerName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.delete(c.req.valid('param').providerName);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/models/default',
|
||||
describeRoute({
|
||||
summary: 'Set default model',
|
||||
description: 'Set the default provider and model',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Default set',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const body = c.req.valid('json');
|
||||
await repo.setDefault(body.provider, body.model);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// GET /agents
|
||||
.get(
|
||||
'/agents',
|
||||
describeRoute({
|
||||
summary: 'List agents',
|
||||
description: 'List all configured agents',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agents list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.array(Agent)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
const agents = await repo.list();
|
||||
return c.json(agents);
|
||||
}
|
||||
)
|
||||
// POST /agents/new
|
||||
.post(
|
||||
'/agents/new',
|
||||
describeRoute({
|
||||
summary: 'Create agent',
|
||||
description: 'Create a new agent',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', Agent),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.create(c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// GET /agents/<id>
|
||||
.get(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Get agent',
|
||||
description: 'Fetch a specific agent by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Agent),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
const agent = await repo.fetch(c.req.valid('param').id);
|
||||
return c.json(agent);
|
||||
}
|
||||
)
|
||||
// PUT /agents/<id>
|
||||
.put(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Update agent',
|
||||
description: 'Update an existing agent',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent updated',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
validator('json', Agent),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.update(c.req.valid('param').id, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// DELETE /agents/<id>
|
||||
.delete(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Delete agent',
|
||||
description: 'Delete an agent by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.delete(c.req.valid('param').id);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/runs/:runId',
|
||||
describeRoute({
|
||||
summary: 'Get run',
|
||||
description: 'Get a run by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Run),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const run = await repo.fetch(c.req.valid('param').runId);
|
||||
return c.json(run);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/new',
|
||||
describeRoute({
|
||||
summary: 'Create run',
|
||||
description: 'Create a new run',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Run),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', CreateRunOptions),
|
||||
async (c) => {
|
||||
const run = await createRun(c.req.valid('json'));
|
||||
return c.json(run);
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/runs',
|
||||
describeRoute({
|
||||
summary: 'List runs',
|
||||
description: 'List all runs',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Runs list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ListRunsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('query', z.object({
|
||||
cursor: z.string().optional(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const runs = await repo.list(c.req.valid('query').cursor);
|
||||
return c.json(runs);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/messages/new',
|
||||
describeRoute({
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('node:path');
|
||||
|
||||
const createWindow = () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
},
|
||||
});
|
||||
|
||||
win.loadURL('http://localhost:8080');
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
});
|
||||
7869
apps/electron/package-lock.json
generated
7869
apps/electron/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"name": "electron",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-deb": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"electron": "^39.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
const { contextBridge } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('config', {
|
||||
apiBase: process.env.API_BASE,
|
||||
});
|
||||
1
apps/electron/.gitignore → apps/x/.gitignore
vendored
1
apps/electron/.gitignore → apps/x/.gitignore
vendored
|
|
@ -1,2 +1 @@
|
|||
node_modules/
|
||||
out/
|
||||
2
apps/x/apps/main/.gitignore
vendored
Normal file
2
apps/x/apps/main/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
19
apps/x/apps/main/package.json
Normal file
19
apps/x/apps/main/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@x/main",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "rm -rf dist && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"electron": "^39.2.7"
|
||||
}
|
||||
}
|
||||
282
apps/x/apps/main/src/ipc.ts
Normal file
282
apps/x/apps/main/src/ipc.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { ipc } from '@x/shared';
|
||||
import { watcher as watcherCore, workspace } from '@x/core';
|
||||
import { workspace as workspaceShared } from '@x/shared';
|
||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||
import { bus } from '@x/core/dist/runs/bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import z from 'zod';
|
||||
import { RunEvent } from 'packages/shared/dist/runs.js';
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
/**
|
||||
* Type-safe handler function for invoke channels
|
||||
*/
|
||||
type InvokeHandler<K extends InvokeChannels> = (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
args: IPCChannels[K]['req']
|
||||
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
|
||||
|
||||
/**
|
||||
* Type-safe handler registration map
|
||||
* Ensures all invoke channels have handlers
|
||||
*/
|
||||
type InvokeHandlers = {
|
||||
[K in InvokeChannels]: InvokeHandler<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register all IPC handlers with type safety and runtime validation
|
||||
*
|
||||
* This function ensures:
|
||||
* 1. All invoke channels have handlers (exhaustiveness checking)
|
||||
* 2. Handler signatures match channel definitions
|
||||
* 3. Request/response payloads are validated at runtime
|
||||
*/
|
||||
export function registerIpcHandlers(handlers: InvokeHandlers) {
|
||||
// Register each handler with runtime validation
|
||||
for (const [channel, handler] of Object.entries(handlers) as [
|
||||
InvokeChannels,
|
||||
InvokeHandler<InvokeChannels>
|
||||
][]) {
|
||||
ipcMain.handle(channel, async (event, rawArgs) => {
|
||||
// Validate request payload
|
||||
const args = ipc.validateRequest(channel, rawArgs);
|
||||
|
||||
// Call handler
|
||||
const result = await handler(event, args);
|
||||
|
||||
// Validate response payload
|
||||
return ipc.validateResponse(channel, result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Electron-Specific Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get application versions (Electron-specific)
|
||||
*/
|
||||
function getVersions(): {
|
||||
chrome: string;
|
||||
node: string;
|
||||
electron: string;
|
||||
} {
|
||||
return {
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Watcher (with debouncing and lifecycle management)
|
||||
// ============================================================================
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
const changeQueue = new Set<string>();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Emit workspace change event to all renderer windows
|
||||
*/
|
||||
function emitWorkspaceChangeEvent(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('workspace:didChange', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queued changes and emit events (debounced)
|
||||
*/
|
||||
function processChangeQueue(): void {
|
||||
if (changeQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = Array.from(changeQueue);
|
||||
changeQueue.clear();
|
||||
|
||||
if (paths.length === 1) {
|
||||
// For single path, try to determine kind from file stats
|
||||
const relPath = paths[0]!;
|
||||
try {
|
||||
const absPath = workspace.resolveWorkspacePath(relPath);
|
||||
fs.lstat(absPath)
|
||||
.then((stats) => {
|
||||
const kind = stats.isDirectory() ? 'dir' : 'file';
|
||||
emitWorkspaceChangeEvent({ type: 'changed', path: relPath, kind });
|
||||
})
|
||||
.catch(() => {
|
||||
// File no longer exists (edge case), emit without kind
|
||||
emitWorkspaceChangeEvent({ type: 'changed', path: relPath });
|
||||
});
|
||||
} catch {
|
||||
// Invalid path, ignore
|
||||
}
|
||||
} else {
|
||||
// Emit bulkChanged for multiple paths
|
||||
emitWorkspaceChangeEvent({ type: 'bulkChanged', paths });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a path change for debounced emission
|
||||
*/
|
||||
function queueChange(relPath: string): void {
|
||||
changeQueue.add(relPath);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
processChangeQueue();
|
||||
debounceTimer = null;
|
||||
}, 150); // 150ms debounce
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workspace change event from core watcher
|
||||
*/
|
||||
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||
// Debounce 'changed' events, emit others immediately
|
||||
if (event.type === 'changed' && event.path) {
|
||||
queueChange(event.path);
|
||||
} else {
|
||||
emitWorkspaceChangeEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start workspace watcher
|
||||
* Watches ~/.rowboat recursively and emits change events to renderer
|
||||
*
|
||||
* This should be called once when the app starts (from main.ts).
|
||||
* The watcher runs as a main-process service and catches ALL filesystem changes
|
||||
* (both from IPC handlers and external changes like terminal/git).
|
||||
*
|
||||
* Safe to call multiple times - guards against duplicate watchers.
|
||||
*/
|
||||
export async function startWorkspaceWatcher(): Promise<void> {
|
||||
if (watcher) {
|
||||
// Watcher already running - safe to ignore subsequent calls
|
||||
return;
|
||||
}
|
||||
|
||||
watcher = await watcherCore.createWorkspaceWatcher(handleWorkspaceChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop workspace watcher
|
||||
*/
|
||||
export function stopWorkspaceWatcher(): void {
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
watcher = null;
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
changeQueue.clear();
|
||||
}
|
||||
|
||||
function emitRunEvent(event: z.infer<typeof RunEvent>): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('runs:events', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let runsWatcher: (() => void) | null = null;
|
||||
export async function startRunsWatcher(): Promise<void> {
|
||||
if (runsWatcher) {
|
||||
return;
|
||||
}
|
||||
runsWatcher = await bus.subscribe('*', async (event) => {
|
||||
emitRunEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler Implementations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
* Add new handlers here as you add channels to IPCChannels
|
||||
*/
|
||||
export function setupIpcHandlers() {
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
return getVersions();
|
||||
},
|
||||
'workspace:getRoot': async () => {
|
||||
return workspace.getRoot();
|
||||
},
|
||||
'workspace:exists': async (_, args) => {
|
||||
return workspace.exists(args.path);
|
||||
},
|
||||
'workspace:stat': async (_event, args) => {
|
||||
return workspace.stat(args.path);
|
||||
},
|
||||
'workspace:readdir': async (_event, args) => {
|
||||
return workspace.readdir(args.path, args.opts);
|
||||
},
|
||||
'workspace:readFile': async (_event, args) => {
|
||||
return workspace.readFile(args.path, args.encoding);
|
||||
},
|
||||
'workspace:writeFile': async (_event, args) => {
|
||||
return workspace.writeFile(args.path, args.data, args.opts);
|
||||
},
|
||||
'workspace:mkdir': async (_event, args) => {
|
||||
return workspace.mkdir(args.path, args.recursive);
|
||||
},
|
||||
'workspace:rename': async (_event, args) => {
|
||||
return workspace.rename(args.from, args.to, args.overwrite);
|
||||
},
|
||||
'workspace:copy': async (_event, args) => {
|
||||
return workspace.copy(args.from, args.to, args.overwrite);
|
||||
},
|
||||
'workspace:remove': async (_event, args) => {
|
||||
return workspace.remove(args.path, args.opts);
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
'mcp:executeTool': async (_event, args) => {
|
||||
return { result: await mcpCore.executeTool(args.serverName, args.toolName, args.input) };
|
||||
},
|
||||
'runs:create': async (_event, args) => {
|
||||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:provideHumanInput': async (_event, args) => {
|
||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:stop': async (_event, args) => {
|
||||
await runsCore.stop(args.runId);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
61
apps/x/apps/main/src/main.ts
Normal file
61
apps/x/apps/main/src/main.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { app, BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const preloadPath = path.join(__dirname, "../../preload/dist/preload.js");
|
||||
console.log("preloadPath", preloadPath);
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
// IMPORTANT: keep Node out of renderer
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
preload: preloadPath,
|
||||
},
|
||||
});
|
||||
|
||||
win.loadURL("http://localhost:5173"); // load the dev server
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
setupIpcHandlers();
|
||||
|
||||
createWindow();
|
||||
|
||||
// Start workspace watcher as a main-process service
|
||||
// Watcher runs independently and catches ALL filesystem changes:
|
||||
// - Changes made via IPC handlers (workspace:writeFile, etc.)
|
||||
// - External changes (terminal, git, other editors)
|
||||
// Only starts once (guarded in startWorkspaceWatcher)
|
||||
startWorkspaceWatcher();
|
||||
|
||||
|
||||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
// Clean up watcher on app quit
|
||||
stopWorkspaceWatcher();
|
||||
});
|
||||
14
apps/x/apps/main/tsconfig.json
Normal file
14
apps/x/apps/main/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"node",
|
||||
"electron"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
2
apps/x/apps/preload/.gitignore
vendored
Normal file
2
apps/x/apps/preload/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
16
apps/x/apps/preload/package.json
Normal file
16
apps/x/apps/preload/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@x/preload",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/preload.js",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc && esbuild dist/preload.js --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload.bundle.js && mv dist/preload.bundle.js dist/preload.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^39.2.7",
|
||||
"esbuild": "^0.24.2"
|
||||
}
|
||||
}
|
||||
54
apps/x/apps/preload/src/preload.ts
Normal file
54
apps/x/apps/preload/src/preload.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { ipc as ipcShared } from '@x/shared';
|
||||
|
||||
type InvokeChannels = ipcShared.InvokeChannels;
|
||||
type IPCChannels = ipcShared.IPCChannels;
|
||||
type SendChannels = ipcShared.SendChannels;
|
||||
const { validateRequest } = ipcShared;
|
||||
|
||||
const ipc = {
|
||||
/**
|
||||
* Invoke a channel that expects a response (request/response pattern)
|
||||
* Only channels with non-null responses can be invoked
|
||||
*/
|
||||
invoke<K extends InvokeChannels>(
|
||||
channel: K,
|
||||
args: IPCChannels[K]['req']
|
||||
): Promise<IPCChannels[K]['res']> {
|
||||
// Runtime validation of request payload
|
||||
const validatedArgs = validateRequest(channel, args);
|
||||
return ipcRenderer.invoke(channel, validatedArgs);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a message to a channel without expecting a response (fire-and-forget)
|
||||
* Only channels with null responses can be sent
|
||||
*/
|
||||
send<K extends SendChannels>(
|
||||
channel: K,
|
||||
args: IPCChannels[K]['req']
|
||||
): void {
|
||||
// Runtime validation of request payload
|
||||
const validatedArgs = validateRequest(channel, args);
|
||||
ipcRenderer.send(channel, validatedArgs);
|
||||
},
|
||||
|
||||
/**
|
||||
* Listen to a send channel event
|
||||
* Returns a cleanup function to remove the listener
|
||||
*/
|
||||
on<K extends SendChannels>(
|
||||
channel: K,
|
||||
handler: (event: IPCChannels[K]['req']) => void
|
||||
): () => void {
|
||||
const listener = (_event: unknown, data: IPCChannels[K]['req']) => {
|
||||
handler(data);
|
||||
};
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('ipc', ipc);
|
||||
9
apps/x/apps/preload/tsconfig.json
Normal file
9
apps/x/apps/preload/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["electron"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
24
apps/x/apps/renderer/.gitignore
vendored
Normal file
24
apps/x/apps/renderer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
apps/x/apps/renderer/README.md
Normal file
73
apps/x/apps/renderer/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
apps/x/apps/renderer/eslint.config.js
Normal file
23
apps/x/apps/renderer/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
apps/x/apps/renderer/index.html
Normal file
13
apps/x/apps/renderer/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RowboatX</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
apps/x/apps/renderer/package.json
Normal file
38
apps/x/apps/renderer/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@x/renderer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
apps/x/apps/renderer/public/vite.svg
Normal file
1
apps/x/apps/renderer/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
44
apps/x/apps/renderer/src/App.css
Normal file
44
apps/x/apps/renderer/src/App.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
621
apps/x/apps/renderer/src/App.tsx
Normal file
621
apps/x/apps/renderer/src/App.tsx
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { workspace } from '@x/shared';
|
||||
import { RunEvent } from '@x/shared/src/runs.js';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Textarea } from './components/ui/textarea';
|
||||
import { Send, Loader2, MessageSquare } from 'lucide-react';
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
||||
interface TreeNode extends DirEntry {
|
||||
children?: TreeNode[]
|
||||
loaded?: boolean
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ReasoningBlock {
|
||||
id: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type ConversationItem = ChatMessage | ToolCall | ReasoningBlock;
|
||||
|
||||
// Sort nodes (dirs first, then alphabetically)
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}).map(node => {
|
||||
if (node.children) {
|
||||
node.children = sortNodes(node.children)
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
// Build tree structure from flat entries
|
||||
function buildTree(entries: DirEntry[]): TreeNode[] {
|
||||
const treeMap = new Map<string, TreeNode>()
|
||||
const roots: TreeNode[] = []
|
||||
|
||||
// Create nodes
|
||||
entries.forEach(entry => {
|
||||
const node: TreeNode = { ...entry, children: [], loaded: false }
|
||||
treeMap.set(entry.path, node)
|
||||
})
|
||||
|
||||
// Build hierarchy
|
||||
entries.forEach(entry => {
|
||||
const node = treeMap.get(entry.path)!
|
||||
const parts = entry.path.split('/')
|
||||
if (parts.length === 1) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parentPath = parts.slice(0, -1).join('/')
|
||||
const parent = treeMap.get(parentPath)
|
||||
if (parent) {
|
||||
if (!parent.children) parent.children = []
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return sortNodes(roots)
|
||||
}
|
||||
|
||||
function App() {
|
||||
// File browser state
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
const [fileLoading, setFileLoading] = useState(true)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
// Chat state
|
||||
const [message, setMessage] = useState<string>('')
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([])
|
||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>('')
|
||||
const [currentReasoning, setCurrentReasoning] = useState<string>('')
|
||||
const [runId, setRunId] = useState<string | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [agentId] = useState<string>('copilot')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Scroll to bottom when conversation updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [conversation, currentAssistantMessage, currentReasoning])
|
||||
|
||||
// Load directory and merge into tree
|
||||
const loadDirectory = useCallback(async (path: string = '') => {
|
||||
try {
|
||||
setFileError(null)
|
||||
const result = await window.ipc.invoke('workspace:readdir', {
|
||||
path,
|
||||
opts: { recursive: true, includeHidden: false }
|
||||
})
|
||||
const tree = buildTree(result)
|
||||
return tree
|
||||
} catch (err) {
|
||||
setFileError(String(err))
|
||||
return []
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load initial tree
|
||||
useEffect(() => {
|
||||
async function process() {
|
||||
const tree = await loadDirectory();
|
||||
setTree(tree)
|
||||
setFileLoading(false)
|
||||
}
|
||||
process();
|
||||
}, [loadDirectory])
|
||||
|
||||
// Listen to workspace change events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('workspace:didChange', () => {
|
||||
// Reload tree on any change
|
||||
loadDirectory().then(result => setTree(result))
|
||||
})
|
||||
return cleanup
|
||||
}, [loadDirectory])
|
||||
|
||||
// Load file content when selected
|
||||
useEffect(() => {
|
||||
async function process() {
|
||||
if (!selectedPath) {
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path: selectedPath })
|
||||
if (stat.kind === 'file') {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath })
|
||||
setFileContent(result.data)
|
||||
} else {
|
||||
setFileContent('')
|
||||
}
|
||||
} catch (err) {
|
||||
setFileError(String(err))
|
||||
}
|
||||
}
|
||||
process();
|
||||
}, [selectedPath])
|
||||
|
||||
// Listen to run events
|
||||
useEffect(() => {
|
||||
// Note: runs:events sends RunEvent data, but IPC contract types it as null
|
||||
// We need to cast the handler to accept the actual event type
|
||||
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
||||
handleRunEvent(event as RunEventType)
|
||||
}) as (event: null) => void)
|
||||
return cleanup
|
||||
}, [runId])
|
||||
|
||||
const handleRunEvent = (event: RunEventType) => {
|
||||
// Only process events for the current run
|
||||
if (event.runId !== runId) return
|
||||
|
||||
console.log('Run event:', event.type, event)
|
||||
|
||||
switch (event.type) {
|
||||
case 'run-processing-start':
|
||||
setIsProcessing(true)
|
||||
break
|
||||
|
||||
case 'run-processing-end':
|
||||
setIsProcessing(false)
|
||||
break
|
||||
|
||||
case 'start':
|
||||
setCurrentAssistantMessage('')
|
||||
setCurrentReasoning('')
|
||||
break
|
||||
|
||||
case 'llm-stream-event':
|
||||
{
|
||||
const llmEvent = event.event
|
||||
if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) {
|
||||
setCurrentReasoning(prev => prev + llmEvent.delta)
|
||||
} else if (llmEvent.type === 'reasoning-end') {
|
||||
// Commit reasoning block if we have content
|
||||
setCurrentReasoning(reasoning => {
|
||||
if (reasoning) {
|
||||
setConversation(prev => [...prev, {
|
||||
id: `reasoning-${Date.now()}`,
|
||||
content: reasoning,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
} else if (llmEvent.type === 'text-delta' && llmEvent.delta) {
|
||||
setCurrentAssistantMessage(prev => prev + llmEvent.delta)
|
||||
} else if (llmEvent.type === 'tool-call') {
|
||||
// Add tool call to conversation
|
||||
setConversation(prev => [...prev, {
|
||||
id: llmEvent.toolCallId || `tool-${Date.now()}`,
|
||||
name: llmEvent.toolName || 'tool',
|
||||
input: llmEvent.input,
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'message':
|
||||
{
|
||||
const msg = event.message
|
||||
if (msg.role === 'assistant') {
|
||||
// Commit current assistant message
|
||||
setCurrentAssistantMessage(currentMsg => {
|
||||
if (currentMsg) {
|
||||
setConversation(prev => {
|
||||
// Avoid duplicates
|
||||
const exists = prev.some(m =>
|
||||
m.id === event.messageId && 'role' in m && m.role === 'assistant'
|
||||
)
|
||||
if (exists) return prev
|
||||
return [...prev, {
|
||||
id: event.messageId,
|
||||
role: 'assistant',
|
||||
content: currentMsg,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool-invocation':
|
||||
setConversation(prev => prev.map(item =>
|
||||
item.id === event.toolCallId || ('name' in item && item.name === event.toolName)
|
||||
? { ...item, status: 'running' as const }
|
||||
: item
|
||||
))
|
||||
break
|
||||
|
||||
case 'tool-result':
|
||||
setConversation(prev => prev.map(item =>
|
||||
item.id === event.toolCallId || ('name' in item && item.name === event.toolName)
|
||||
? { ...item, result: event.result, status: 'completed' as const }
|
||||
: item
|
||||
))
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setIsProcessing(false)
|
||||
console.error('Run error:', event.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!message.trim() || isProcessing) return
|
||||
|
||||
const userMessage = message.trim()
|
||||
setMessage('')
|
||||
|
||||
// Add user message immediately
|
||||
const userMessageId = `user-${Date.now()}`
|
||||
setConversation(prev => [...prev, {
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
|
||||
try {
|
||||
// Create run if needed
|
||||
let currentRunId = runId
|
||||
if (!currentRunId) {
|
||||
const run = await window.ipc.invoke('runs:create', {
|
||||
agentId,
|
||||
})
|
||||
currentRunId = run.id
|
||||
setRunId(currentRunId)
|
||||
}
|
||||
|
||||
// Send message
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: userMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
setSelectedPath(path)
|
||||
return
|
||||
}
|
||||
|
||||
const newExpanded = new Set(expandedPaths)
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path)
|
||||
} else {
|
||||
newExpanded.add(path)
|
||||
}
|
||||
setExpandedPaths(newExpanded)
|
||||
}
|
||||
|
||||
const handleCreateFile = async (parentPath: string = '') => {
|
||||
const name = prompt('Enter file name:')
|
||||
if (!name) return
|
||||
|
||||
const filePath = parentPath ? `${parentPath}/${name}` : name
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: filePath,
|
||||
data: '',
|
||||
opts: {
|
||||
encoding: 'utf8'
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
setFileError(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateDir = async (parentPath: string = '') => {
|
||||
const name = prompt('Enter directory name:')
|
||||
if (!name) return
|
||||
|
||||
const dirPath = parentPath ? `${parentPath}/${name}` : name
|
||||
try {
|
||||
await window.ipc.invoke('workspace:mkdir', {
|
||||
path: dirPath,
|
||||
recursive: false
|
||||
})
|
||||
if (parentPath) {
|
||||
setExpandedPaths(prev => new Set(prev).add(parentPath))
|
||||
}
|
||||
} catch (err) {
|
||||
setFileError(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (path: string) => {
|
||||
if (!confirm(`Delete ${path}?`)) return
|
||||
|
||||
try {
|
||||
await window.ipc.invoke('workspace:remove', {
|
||||
path,
|
||||
opts: {
|
||||
recursive: true,
|
||||
trash: true,
|
||||
},
|
||||
})
|
||||
if (selectedPath === path) {
|
||||
setSelectedPath(null)
|
||||
}
|
||||
} catch (err) {
|
||||
setFileError(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const renderTreeNode = (node: TreeNode, depth: number = 0) => {
|
||||
const isExpanded = expandedPaths.has(node.path)
|
||||
const isSelected = selectedPath === node.path
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`flex items-center gap-2 py-1 px-2 hover:bg-gray-700 cursor-pointer ${isSelected ? 'bg-gray-600' : ''
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
onClick={() => toggleExpand(node.path, node.kind)}
|
||||
>
|
||||
<span className="text-gray-400 w-4">
|
||||
{node.kind === 'dir' ? (isExpanded ? '📂' : '📁') : '📄'}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-gray-200">{node.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(node.path)
|
||||
}}
|
||||
className="text-xs text-red-400 hover:text-red-300 px-2"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{node.kind === 'dir' && isExpanded && hasChildren && (
|
||||
<div>
|
||||
{node.children!.map(child => renderTreeNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
if ('role' in item) {
|
||||
// ChatMessage
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex ${item.role === 'user' ? 'justify-end' : 'justify-start'} mb-4`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
item.role === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if ('name' in item) {
|
||||
// ToolCall
|
||||
return (
|
||||
<div key={item.id} className="mb-4 bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-1">
|
||||
🔧 {item.name}
|
||||
</div>
|
||||
{item.input !== undefined && item.input !== null && (
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
Input: {JSON.stringify(item.input, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
{item.result !== undefined && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Result: {typeof item.result === 'string' ? item.result : String(JSON.stringify(item.result, null, 2)).substring(0, 1000)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Status: {item.status}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// ReasoningBlock
|
||||
return (
|
||||
<div key={item.id} className="mb-4 bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-1">💭 Reasoning</div>
|
||||
<div className="text-sm text-gray-400 whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-screen bg-[#1e1e1e] text-gray-200">
|
||||
{/* Sidebar - File Browser */}
|
||||
<div className="w-64 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-3 border-b border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCreateFile()}
|
||||
className="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 rounded"
|
||||
>
|
||||
+ File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCreateDir()}
|
||||
className="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 rounded"
|
||||
>
|
||||
+ Dir
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{fileLoading && <div className="text-sm text-gray-400 p-2">Loading...</div>}
|
||||
{fileError && <div className="text-sm text-red-400 p-2">{fileError}</div>}
|
||||
{tree.map(node => renderTreeNode(node))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* File viewer or chat */}
|
||||
{selectedPath ? (
|
||||
<>
|
||||
<div className="border-b border-gray-700 p-2 bg-gray-800 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-400">{selectedPath}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPath(null)}
|
||||
className="text-gray-300 hover:text-white"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Back to Chat
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-gray-200 whitespace-pre-wrap">
|
||||
{fileContent || 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{conversation.length === 0 && !currentAssistantMessage && !currentReasoning ? (
|
||||
<div className="flex items-center justify-center h-full text-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-300 mb-2">
|
||||
Start a conversation
|
||||
</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Type a message below to begin chatting with the agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{conversation.map(item => renderConversationItem(item))}
|
||||
|
||||
{/* Current reasoning */}
|
||||
{currentReasoning && (
|
||||
<div className="mb-4 bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-300 mb-1">💭 Reasoning</div>
|
||||
<div className="text-sm text-gray-400 whitespace-pre-wrap">
|
||||
{currentReasoning}
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-gray-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current streaming message */}
|
||||
{currentAssistantMessage && (
|
||||
<div className="flex justify-start mb-4">
|
||||
<div className="max-w-[80%] rounded-lg px-4 py-2 bg-gray-700 text-gray-100">
|
||||
<div className="text-sm whitespace-pre-wrap">
|
||||
{currentAssistantMessage}
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-gray-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-gray-700 p-4 bg-gray-800">
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
className="resize-none"
|
||||
rows={3}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="self-end"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
apps/x/apps/renderer/src/assets/react.svg
Normal file
1
apps/x/apps/renderer/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
45
apps/x/apps/renderer/src/components/ui/button.tsx
Normal file
45
apps/x/apps/renderer/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
outline: "border border-gray-600 bg-transparent hover:bg-gray-700",
|
||||
ghost: "hover:bg-gray-700",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3",
|
||||
lg: "h-10 px-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
24
apps/x/apps/renderer/src/components/ui/textarea.tsx
Normal file
24
apps/x/apps/renderer/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
||||
39
apps/x/apps/renderer/src/global.d.ts
vendored
Normal file
39
apps/x/apps/renderer/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { ipc } from '@x/shared';
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type SendChannels = ipc.SendChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ipc: {
|
||||
/**
|
||||
* Invoke a channel that expects a response (request/response pattern)
|
||||
* Only channels with non-null responses can be invoked
|
||||
*/
|
||||
invoke<K extends InvokeChannels>(
|
||||
channel: K,
|
||||
args: IPCChannels[K]['req']
|
||||
): Promise<IPCChannels[K]['res']>;
|
||||
|
||||
/**
|
||||
* Send a message to a channel without expecting a response (fire-and-forget)
|
||||
* Only channels with null responses can be sent
|
||||
*/
|
||||
send<K extends SendChannels>(
|
||||
channel: K,
|
||||
args: IPCChannels[K]['req']
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Listen to a send channel event
|
||||
* Returns a cleanup function to remove the listener
|
||||
*/
|
||||
on<K extends SendChannels>(
|
||||
channel: K,
|
||||
handler: (event: IPCChannels[K]['req']) => void
|
||||
): () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
68
apps/x/apps/renderer/src/index.css
Normal file
68
apps/x/apps/renderer/src/index.css
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
7
apps/x/apps/renderer/src/lib/utils.ts
Normal file
7
apps/x/apps/renderer/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
10
apps/x/apps/renderer/src/main.tsx
Normal file
10
apps/x/apps/renderer/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
28
apps/x/apps/renderer/tsconfig.app.json
Normal file
28
apps/x/apps/renderer/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/x/apps/renderer/tsconfig.json
Normal file
7
apps/x/apps/renderer/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/x/apps/renderer/tsconfig.node.json
Normal file
26
apps/x/apps/renderer/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
apps/x/apps/renderer/vite.config.ts
Normal file
11
apps/x/apps/renderer/vite.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
56
apps/x/eslint.config.mts
Normal file
56
apps/x/eslint.config.mts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["**/dist"]),
|
||||
|
||||
// node runtime
|
||||
{
|
||||
files: ["apps/main/**/*.ts", "packages/**/*.ts"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// browser runtime (renderer)
|
||||
{
|
||||
files: ["apps/renderer/**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// preload
|
||||
{
|
||||
files: ["apps/preload/**/*.ts"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node, ...globals.browser },
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
28
apps/x/package.json
Normal file
28
apps/x/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "x",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run deps && concurrently -k \"npm:renderer\" \"npm:main\"",
|
||||
"renderer": "cd apps/renderer && npm run dev",
|
||||
"shared": "cd packages/shared && npm run build",
|
||||
"core": "cd packages/core && npm run build",
|
||||
"preload": "cd apps/preload && npm run build",
|
||||
"deps": "npm run shared && npm run core && npm run preload",
|
||||
"main": "wait-on http://localhost:5173 && cd apps/main && npm run build && npm run start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
2
apps/x/packages/core/.gitignore
vendored
Normal file
2
apps/x/packages/core/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
34
apps/x/packages/core/package.json
Normal file
34
apps/x/packages/core/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@x/core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc",
|
||||
"dev": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.44",
|
||||
"@ai-sdk/google": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@ai-sdk/openai-compatible": "^1.0.27",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@google-cloud/local-auth": "^3.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.6",
|
||||
"@x/shared": "workspace:*",
|
||||
"ai": "^5.0.102",
|
||||
"awilix": "^12.0.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"googleapis": "^169.0.0",
|
||||
"node-html-markdown": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3"
|
||||
}
|
||||
}
|
||||
100
apps/x/packages/core/src/agents/repo.ts
Normal file
100
apps/x/packages/core/src/agents/repo.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import { glob } from "node:fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { Agent } from "@x/shared/dist/agent.js";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const UpdateAgentSchema = Agent.omit({ name: true });
|
||||
|
||||
export interface IAgentsRepo {
|
||||
list(): Promise<z.infer<typeof Agent>[]>;
|
||||
fetch(id: string): Promise<z.infer<typeof Agent>>;
|
||||
create(agent: z.infer<typeof Agent>): Promise<void>;
|
||||
update(id: string, agent: z.infer<typeof Agent>): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSAgentsRepo implements IAgentsRepo {
|
||||
private readonly agentsDir = path.join(WorkDir, "agents");
|
||||
|
||||
async list(): Promise<z.infer<typeof Agent>[]> {
|
||||
const result: z.infer<typeof Agent>[] = [];
|
||||
|
||||
// list all md files in workdir/agents/
|
||||
// const matches = await Array.fromAsync(glob("**/*.md", { cwd: this.agentsDir }));
|
||||
const matches: string[] = [];
|
||||
const results = glob("**/*.md", { cwd: this.agentsDir });
|
||||
for await (const file of results) {
|
||||
matches.push(file);
|
||||
}
|
||||
for (const file of matches) {
|
||||
try {
|
||||
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
|
||||
result.push(agent);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async parseAgentMd(filePath: string): Promise<z.infer<typeof Agent>> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
|
||||
// strip the path prefix from the file name
|
||||
// and the .md extension
|
||||
const agentName = filePath
|
||||
.replace(this.agentsDir + "/", "")
|
||||
.replace(/\.md$/, "");
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: agentName,
|
||||
instructions: raw,
|
||||
};
|
||||
let content = raw;
|
||||
|
||||
// check for frontmatter markers at start
|
||||
if (raw.startsWith("---")) {
|
||||
const end = raw.indexOf("\n---", 3);
|
||||
|
||||
if (end !== -1) {
|
||||
const fm = raw.slice(3, end).trim(); // YAML text
|
||||
content = raw.slice(end + 4).trim(); // body after frontmatter
|
||||
const yaml = parse(fm);
|
||||
const parsed = Agent
|
||||
.omit({ name: true, instructions: true })
|
||||
.parse(yaml);
|
||||
agent = {
|
||||
...agent,
|
||||
...parsed,
|
||||
instructions: content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Agent>> {
|
||||
return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
|
||||
}
|
||||
|
||||
async create(agent: z.infer<typeof Agent>): Promise<void> {
|
||||
const { instructions, ...rest } = agent;
|
||||
const contents = `---\n${stringify(rest)}\n---\n${instructions}`;
|
||||
await fs.writeFile(path.join(this.agentsDir, `${agent.name}.md`), contents);
|
||||
}
|
||||
|
||||
async update(id: string, agent: z.infer<typeof UpdateAgentSchema>): Promise<void> {
|
||||
const { instructions, ...rest } = agent;
|
||||
const contents = `---\n${stringify(rest)}\n---\n${instructions}`;
|
||||
await fs.writeFile(path.join(this.agentsDir, `${id}.md`), contents);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await fs.unlink(path.join(this.agentsDir, `${id}.md`));
|
||||
}
|
||||
}
|
||||
806
apps/x/packages/core/src/agents/runtime.ts
Normal file
806
apps/x/packages/core/src/agents/runtime.ts
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
import { jsonSchema, ModelMessage } from "ai";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||
import { isBlocked } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { getProvider } from "../models/models.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class AgentRuntime implements IAgentRuntime {
|
||||
private runsRepo: IRunsRepo;
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
private bus: IBus;
|
||||
private messageQueue: IMessageQueue;
|
||||
private modelConfigRepo: IModelConfigRepo;
|
||||
private runsLock: IRunsLock;
|
||||
|
||||
constructor({
|
||||
runsRepo,
|
||||
idGenerator,
|
||||
bus,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
runsLock,
|
||||
}: {
|
||||
runsRepo: IRunsRepo;
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
bus: IBus;
|
||||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
runsLock: IRunsLock;
|
||||
}) {
|
||||
this.runsRepo = runsRepo;
|
||||
this.idGenerator = idGenerator;
|
||||
this.bus = bus;
|
||||
this.messageQueue = messageQueue;
|
||||
this.modelConfigRepo = modelConfigRepo;
|
||||
this.runsLock = runsLock;
|
||||
}
|
||||
|
||||
async trigger(runId: string): Promise<void> {
|
||||
if (!await this.runsLock.lock(runId)) {
|
||||
console.log(`unable to acquire lock on run ${runId}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.bus.publish({
|
||||
runId,
|
||||
type: "run-processing-start",
|
||||
subflow: [],
|
||||
});
|
||||
while (true) {
|
||||
let eventCount = 0;
|
||||
const run = await this.runsRepo.fetch(runId);
|
||||
if (!run) {
|
||||
throw new Error(`Run ${runId} not found`);
|
||||
}
|
||||
const state = new AgentState();
|
||||
for (const event of run.log) {
|
||||
state.ingest(event);
|
||||
}
|
||||
for await (const event of streamAgent({
|
||||
state,
|
||||
idGenerator: this.idGenerator,
|
||||
runId,
|
||||
messageQueue: this.messageQueue,
|
||||
modelConfigRepo: this.modelConfigRepo,
|
||||
})) {
|
||||
eventCount++;
|
||||
if (event.type !== "llm-stream-event") {
|
||||
await this.runsRepo.appendEvents(runId, [event]);
|
||||
}
|
||||
await this.bus.publish(event);
|
||||
}
|
||||
|
||||
// if no events, break
|
||||
if (!eventCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.runsLock.release(runId);
|
||||
await this.bus.publish({
|
||||
runId,
|
||||
type: "run-processing-end",
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
|
||||
switch (t.type) {
|
||||
case "mcp":
|
||||
return tool({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: jsonSchema(t.inputSchema),
|
||||
});
|
||||
case "agent": {
|
||||
const agent = await loadAgent(t.name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${t.name} not found`);
|
||||
}
|
||||
return tool({
|
||||
name: t.name,
|
||||
description: agent.description,
|
||||
inputSchema: z.object({
|
||||
message: z.string().describe("The message to send to the workflow"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "builtin": {
|
||||
if (t.name === "ask-human") {
|
||||
return tool({
|
||||
description: "Ask a human before proceeding",
|
||||
inputSchema: z.object({
|
||||
question: z.string().describe("The question to ask the human"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
const match = BuiltinTools[t.name];
|
||||
if (!match) {
|
||||
throw new Error(`Unknown builtin tool: ${t.name}`);
|
||||
}
|
||||
return tool({
|
||||
description: match.description,
|
||||
inputSchema: match.inputSchema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RunLogger {
|
||||
private logFile: string;
|
||||
private fileHandle: fs.WriteStream;
|
||||
|
||||
ensureRunsDir() {
|
||||
const runsDir = path.join(WorkDir, "runs");
|
||||
if (!fs.existsSync(runsDir)) {
|
||||
fs.mkdirSync(runsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
constructor(runId: string) {
|
||||
this.ensureRunsDir();
|
||||
this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
|
||||
this.fileHandle = fs.createWriteStream(this.logFile, {
|
||||
flags: "a",
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
log(event: z.infer<typeof RunEvent>) {
|
||||
if (event.type !== "llm-stream-event") {
|
||||
this.fileHandle.write(JSON.stringify(event) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamStepMessageBuilder {
|
||||
private parts: z.infer<typeof AssistantContentPart>[] = [];
|
||||
private textBuffer: string = "";
|
||||
private reasoningBuffer: string = "";
|
||||
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
||||
|
||||
flushBuffers() {
|
||||
// skip reasoning
|
||||
// if (this.reasoningBuffer) {
|
||||
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
|
||||
// this.reasoningBuffer = "";
|
||||
// }
|
||||
if (this.textBuffer) {
|
||||
this.parts.push({ type: "text", text: this.textBuffer });
|
||||
this.textBuffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
|
||||
switch (event.type) {
|
||||
case "reasoning-start":
|
||||
case "reasoning-end":
|
||||
case "text-start":
|
||||
case "text-end":
|
||||
this.flushBuffers();
|
||||
break;
|
||||
case "reasoning-delta":
|
||||
this.reasoningBuffer += event.delta;
|
||||
break;
|
||||
case "text-delta":
|
||||
this.textBuffer += event.delta;
|
||||
break;
|
||||
case "tool-call":
|
||||
this.parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
arguments: event.input,
|
||||
providerOptions: event.providerOptions,
|
||||
});
|
||||
break;
|
||||
case "finish-step":
|
||||
this.providerOptions = event.providerOptions;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get(): z.infer<typeof AssistantMessage> {
|
||||
this.flushBuffers();
|
||||
return {
|
||||
role: "assistant",
|
||||
content: this.parts,
|
||||
providerOptions: this.providerOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||
if (id === "copilot" || id === "rowboatx") {
|
||||
return CopilotAgent;
|
||||
}
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const { providerOptions } = msg;
|
||||
switch (msg.role) {
|
||||
case "assistant":
|
||||
if (typeof msg.content === 'string') {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content.map(part => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return part;
|
||||
case 'reasoning':
|
||||
return part;
|
||||
case 'tool-call':
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
input: part.arguments,
|
||||
providerOptions: part.providerOptions,
|
||||
};
|
||||
}
|
||||
}),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "system":
|
||||
result.push({
|
||||
role: "system",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "user":
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
output: {
|
||||
type: "text",
|
||||
value: msg.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
providerOptions,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262
|
||||
return JSON.parse(JSON.stringify(result));
|
||||
}
|
||||
|
||||
async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
|
||||
try {
|
||||
tools[name] = await mapAgentTool(tool);
|
||||
} catch (error) {
|
||||
console.error(`Error mapping tool ${name}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export class AgentState {
|
||||
runId: string | null = null;
|
||||
agent: z.infer<typeof Agent> | null = null;
|
||||
agentName: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
toolCallIdMap: Record<string, z.infer<typeof ToolCallPart>> = {};
|
||||
pendingToolCalls: Record<string, true> = {};
|
||||
pendingToolPermissionRequests: Record<string, z.infer<typeof ToolPermissionRequestEvent>> = {};
|
||||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
|
||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
|
||||
for (const perm of subflowState.getPendingPermissions()) {
|
||||
response.push({
|
||||
...perm,
|
||||
subflow: [id, ...perm.subflow],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const perm of Object.values(this.pendingToolPermissionRequests)) {
|
||||
response.push({
|
||||
...perm,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
getPendingAskHumans(): z.infer<typeof AskHumanRequestEvent>[] {
|
||||
const response: z.infer<typeof AskHumanRequestEvent>[] = [];
|
||||
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
|
||||
for (const ask of subflowState.getPendingAskHumans()) {
|
||||
response.push({
|
||||
...ask,
|
||||
subflow: [id, ...ask.subflow],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const ask of Object.values(this.pendingAskHumanRequests)) {
|
||||
response.push({
|
||||
...ask,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
finalResponse(): string {
|
||||
if (!this.lastAssistantMsg) {
|
||||
return '';
|
||||
}
|
||||
if (typeof this.lastAssistantMsg.content === "string") {
|
||||
return this.lastAssistantMsg.content;
|
||||
}
|
||||
return this.lastAssistantMsg.content.reduce((acc, part) => {
|
||||
if (part.type === "text") {
|
||||
return acc + part.text;
|
||||
}
|
||||
return acc;
|
||||
}, "");
|
||||
}
|
||||
|
||||
ingest(event: z.infer<typeof RunEvent>) {
|
||||
if (event.subflow.length > 0) {
|
||||
const { subflow, ...rest } = event;
|
||||
if (!this.subflowStates[subflow[0]]) {
|
||||
this.subflowStates[subflow[0]] = new AgentState();
|
||||
}
|
||||
this.subflowStates[subflow[0]].ingest({
|
||||
...rest,
|
||||
subflow: subflow.slice(1),
|
||||
});
|
||||
return;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
this.runId = event.runId;
|
||||
this.agentName = event.agentName;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
if (!this.subflowStates[event.toolCallId]) {
|
||||
this.subflowStates[event.toolCallId] = new AgentState();
|
||||
}
|
||||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
if (event.message.content instanceof Array) {
|
||||
for (const part of event.message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
this.toolCallIdMap[part.toolCallId] = part;
|
||||
this.pendingToolCalls[part.toolCallId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.message.role === "tool") {
|
||||
const message = event.message as z.infer<typeof ToolMessage>;
|
||||
delete this.pendingToolCalls[message.toolCallId];
|
||||
}
|
||||
if (event.message.role === "assistant") {
|
||||
this.lastAssistantMsg = event.message;
|
||||
}
|
||||
break;
|
||||
case "tool-permission-request":
|
||||
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
|
||||
break;
|
||||
case "tool-permission-response":
|
||||
switch (event.response) {
|
||||
case "approve":
|
||||
this.allowedToolCallIds[event.toolCallId] = true;
|
||||
break;
|
||||
case "deny":
|
||||
this.deniedToolCallIds[event.toolCallId] = true;
|
||||
break;
|
||||
}
|
||||
delete this.pendingToolPermissionRequests[event.toolCallId];
|
||||
break;
|
||||
case "ask-human-request":
|
||||
this.pendingAskHumanRequests[event.toolCallId] = event;
|
||||
break;
|
||||
case "ask-human-response": {
|
||||
// console.error('im here', this.agentName, this.runId, event.subflow);
|
||||
const ogEvent = this.pendingAskHumanRequests[event.toolCallId];
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
userResponse: event.response,
|
||||
}),
|
||||
toolCallId: ogEvent.toolCallId,
|
||||
toolName: this.toolCallIdMap[ogEvent.toolCallId]!.toolName,
|
||||
});
|
||||
delete this.pendingAskHumanRequests[ogEvent.toolCallId];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamAgent({
|
||||
state,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
}: {
|
||||
state: AgentState,
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
runId: string;
|
||||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
|
||||
|
||||
async function* processEvent(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
state.ingest(event);
|
||||
yield event;
|
||||
}
|
||||
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
|
||||
// set up agent
|
||||
const agent = await loadAgent(state.agentName!);
|
||||
|
||||
// set up tools
|
||||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const provider = await getProvider(agent.provider);
|
||||
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
|
||||
|
||||
let loopCounter = 0;
|
||||
while (true) {
|
||||
loopCounter++;
|
||||
const loopLogger = logger.child(`iter-${loopCounter}`);
|
||||
loopLogger.log('starting loop iteration');
|
||||
|
||||
// execute any pending tool calls
|
||||
for (const toolCallId of Object.keys(state.pendingToolCalls)) {
|
||||
const toolCall = state.toolCallIdMap[toolCallId];
|
||||
const _logger = loopLogger.child(`tc-${toolCallId}-${toolCall.toolName}`);
|
||||
_logger.log('processing');
|
||||
|
||||
// if ask-human, skip
|
||||
if (toolCall.toolName === "ask-human") {
|
||||
_logger.log('skipping, reason: ask-human');
|
||||
continue;
|
||||
}
|
||||
|
||||
// if tool has been denied, deny
|
||||
if (state.deniedToolCallIds[toolCallId]) {
|
||||
_logger.log('returning denied tool message, reason: tool has been denied');
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "tool",
|
||||
content: "Unable to execute this tool: Permission was denied.",
|
||||
toolCallId: toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// if permission is pending on this tool call, skip execution
|
||||
if (state.pendingToolPermissionRequests[toolCallId]) {
|
||||
_logger.log('skipping, reason: permission is pending');
|
||||
continue;
|
||||
}
|
||||
|
||||
// execute approved tool
|
||||
_logger.log('executing tool');
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-invocation",
|
||||
toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
input: JSON.stringify(toolCall.arguments),
|
||||
subflow: [],
|
||||
});
|
||||
let result: unknown = null;
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
const subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
})) {
|
||||
yield* processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
}
|
||||
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
|
||||
result = subflowState.finalResponse();
|
||||
}
|
||||
} else {
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments);
|
||||
}
|
||||
if (result) {
|
||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||
role: "tool",
|
||||
content: JSON.stringify(result),
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
};
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-result",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
result: result,
|
||||
subflow: [],
|
||||
});
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: resultMsg,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// if waiting on user permission or ask-human, exit
|
||||
if (state.getPendingAskHumans().length || state.getPendingPermissions().length) {
|
||||
loopLogger.log('exiting loop, reason: pending asks or permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
// get any queued user messages
|
||||
while (true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
break;
|
||||
}
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "message",
|
||||
messageId: msg.messageId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: msg.message,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
|
||||
// if last response is from assistant and text, exit
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage
|
||||
&& lastMessage.role === "assistant"
|
||||
&& (typeof lastMessage.content === "string"
|
||||
|| !lastMessage.content.some(part => part.type === "tool-call")
|
||||
)
|
||||
) {
|
||||
loopLogger.log('exiting loop, reason: last message is from assistant and text');
|
||||
return;
|
||||
}
|
||||
|
||||
// run one LLM turn.
|
||||
loopLogger.log('running llm turn');
|
||||
// stream agent response and build message
|
||||
const messageBuilder = new StreamStepMessageBuilder();
|
||||
for await (const event of streamLlm(
|
||||
model,
|
||||
state.messages,
|
||||
agent.instructions,
|
||||
tools,
|
||||
)) {
|
||||
loopLogger.log('got llm-stream-event:', event.type)
|
||||
messageBuilder.ingest(event);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "llm-stream-event",
|
||||
event: event,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
|
||||
// build and emit final message from agent response
|
||||
const message = messageBuilder.get();
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message,
|
||||
subflow: [],
|
||||
});
|
||||
|
||||
// if there were any ask-human calls, emit those events
|
||||
if (message.content instanceof Array) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
const underlyingTool = agent.tools![part.toolName];
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "ask-human-request",
|
||||
toolCallId: part.toolCallId,
|
||||
query: part.arguments.question,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||
// if command is blocked, then seek permission
|
||||
if (isBlocked(part.arguments.command)) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: part,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "spawn-subflow",
|
||||
agentName: underlyingTool.name,
|
||||
toolCallId: part.toolCallId,
|
||||
subflow: [],
|
||||
});
|
||||
yield* processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: part.arguments.message,
|
||||
},
|
||||
subflow: [part.toolCallId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamLlm(
|
||||
model: LanguageModel,
|
||||
messages: z.infer<typeof MessageList>,
|
||||
instructions: string,
|
||||
tools: ToolSet,
|
||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||
const { fullStream } = streamText({
|
||||
model,
|
||||
messages: convertFromMessages(messages),
|
||||
system: instructions,
|
||||
tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
});
|
||||
for await (const event of fullStream) {
|
||||
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
|
||||
switch (event.type) {
|
||||
case "reasoning-start":
|
||||
yield {
|
||||
type: "reasoning-start",
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "reasoning-delta":
|
||||
yield {
|
||||
type: "reasoning-delta",
|
||||
delta: event.text,
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "reasoning-end":
|
||||
yield {
|
||||
type: "reasoning-end",
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "text-start":
|
||||
yield {
|
||||
type: "text-start",
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "text-delta":
|
||||
yield {
|
||||
type: "text-delta",
|
||||
delta: event.text,
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "tool-call":
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
input: event.input,
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
case "finish-step":
|
||||
yield {
|
||||
type: "finish-step",
|
||||
usage: event.usage,
|
||||
finishReason: event.finishReason,
|
||||
providerOptions: event.providerMetadata,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
// console.warn("Unknown event type", event);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
export const MappedToolCall = z.object({
|
||||
toolCall: ToolCallPart,
|
||||
agentTool: ToolAttachment,
|
||||
});
|
||||
19
apps/x/packages/core/src/application/assistant/agent.ts
Normal file
19
apps/x/packages/core/src/application/assistant/agent.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import z from "zod";
|
||||
import { CopilotInstructions } from "./instructions.js";
|
||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
||||
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
tools[name] = {
|
||||
type: "builtin",
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export const CopilotAgent: z.infer<typeof Agent> = {
|
||||
name: "rowboatx",
|
||||
description: "Rowboatx copilot",
|
||||
instructions: CopilotInstructions,
|
||||
tools,
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { skillCatalog } from "./skills/index.js";
|
||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||
|
||||
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
||||
|
||||
## General Capabilities
|
||||
|
||||
In addition to Rowboat-specific workflow management, you can help users with general tasks like answering questions, explaining concepts, brainstorming ideas, solving problems, writing and debugging code, analyzing information, and providing explanations on a wide range of topics. Be conversational, helpful, and engaging. For tasks requiring external capabilities (web search, APIs, etc.), use MCP tools as described below.
|
||||
|
||||
Use the catalog below to decide which skills to load for each user request. Before acting:
|
||||
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
|
||||
- Apply the instructions from every loaded skill while working on the request.
|
||||
|
||||
${skillCatalog}
|
||||
|
||||
Always consult this catalog first so you load the right skills before taking action.
|
||||
|
||||
# Communication & Execution Style
|
||||
|
||||
## Communication principles
|
||||
- Be concise and direct. Avoid verbose explanations unless the user asks for details.
|
||||
- Only show JSON output when explicitly requested by the user. Otherwise, summarize results in plain language.
|
||||
- Break complex efforts into clear, sequential steps the user can follow.
|
||||
- Explain reasoning briefly as you work, and confirm outcomes before moving on.
|
||||
- Be proactive about understanding missing context; ask clarifying questions when needed.
|
||||
- Summarize completed work and suggest logical next steps at the end of a task.
|
||||
- Always ask for confirmation before taking destructive actions.
|
||||
|
||||
## MCP Tool Discovery (CRITICAL)
|
||||
|
||||
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
||||
|
||||
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
|
||||
|
||||
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
|
||||
|
||||
## Execution reminders
|
||||
- Explore existing files and structure before creating new assets.
|
||||
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
||||
- Keep user data safe—double-check before editing or deleting important resources.
|
||||
|
||||
## Workspace access & scope
|
||||
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
||||
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
||||
- Prefer builtin file tools (\`createFile\`, \`updateFile\`, \`deleteFile\`, \`exploreDirectory\`) for workspace changes. Reserve refusal or "you do it" responses for cases that are truly outside the Rowboat sandbox.
|
||||
|
||||
## Builtin Tools vs Shell Commands
|
||||
|
||||
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries:
|
||||
- \`deleteFile\`, \`createFile\`, \`updateFile\`, \`readFile\` - File operations
|
||||
- \`listFiles\`, \`exploreDirectory\` - Directory exploration
|
||||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
|
||||
These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`.
|
||||
|
||||
**CRITICAL: MCP Server Configuration**
|
||||
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
|
||||
- NEVER manually edit \`config/mcp.json\` using \`createFile\` or \`updateFile\` for MCP servers
|
||||
- Invalid MCP configs will prevent the agent from starting with validation errors
|
||||
|
||||
**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`deleteFile\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`createFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||
|
||||
The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools.
|
||||
`;
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
export const skill = String.raw`
|
||||
# Builtin Tools Reference
|
||||
|
||||
Load this skill when creating or modifying agents that need access to Rowboat's builtin tools (shell execution, file operations, etc.).
|
||||
|
||||
## Available Builtin Tools
|
||||
|
||||
Agents can use builtin tools by declaring them in the \`"tools"\` object with \`"type": "builtin"\` and the appropriate \`"name"\`.
|
||||
|
||||
### executeCommand
|
||||
**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output.
|
||||
|
||||
**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
|
||||
|
||||
**Agent tool declaration:**
|
||||
\`\`\`json
|
||||
"tools": {
|
||||
"bash": {
|
||||
"type": "builtin",
|
||||
"name": "executeCommand"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**What it can do:**
|
||||
- Run package managers (npm, pip, apt, brew, cargo, go get, etc.)
|
||||
- Git operations (clone, commit, push, pull, status, diff, log, etc.)
|
||||
- System operations (ps, top, df, du, find, grep, kill, etc.)
|
||||
- Build and compilation (make, cargo build, go build, npm run build, etc.)
|
||||
- Network operations (curl, wget, ping, ssh, netstat, etc.)
|
||||
- Text processing (awk, sed, grep, jq, yq, cut, sort, uniq, etc.)
|
||||
- Database operations (psql, mysql, mongo, redis-cli, etc.)
|
||||
- Container operations (docker, kubectl, podman, etc.)
|
||||
- Testing and debugging (pytest, jest, cargo test, etc.)
|
||||
- File operations (cat, head, tail, wc, diff, patch, etc.)
|
||||
- Any CLI tool or script execution
|
||||
|
||||
**Agent instruction examples:**
|
||||
- "Use the bash tool to run git commands for version control operations"
|
||||
- "Execute curl commands using the bash tool to fetch data from APIs"
|
||||
- "Use bash to run 'npm install' and 'npm test' commands"
|
||||
- "Run Python scripts using the bash tool with 'python script.py'"
|
||||
- "Use bash to execute 'docker ps' and inspect container status"
|
||||
- "Run database queries using 'psql' or 'mysql' commands via bash"
|
||||
- "Use bash to execute system monitoring commands like 'top' or 'ps aux'"
|
||||
|
||||
**Pro tips for agent instructions:**
|
||||
- Commands can be chained with && for sequential execution
|
||||
- Use pipes (|) to combine Unix tools (e.g., "cat file.txt | grep pattern | wc -l")
|
||||
- Redirect output with > or >> when needed
|
||||
- Full bash shell features are available (variables, loops, conditionals, etc.)
|
||||
- Tools like jq, yq, awk, sed can parse and transform data
|
||||
|
||||
**Example agent with executeCommand:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "arxiv-feed-reader",
|
||||
"description": "A feed reader for the arXiv",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Extract latest papers from the arXiv feed and summarize them. Use curl to fetch the RSS feed, then parse it with yq and jq:\n\ncurl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"'\n\nThis will give you papers containing 'agent' in the title.",
|
||||
"tools": {
|
||||
"bash": {
|
||||
"type": "builtin",
|
||||
"name": "executeCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Another example - System monitoring agent:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "system-monitor",
|
||||
"description": "Monitor system resources and processes",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Monitor system resources using bash commands. Use 'df -h' for disk usage, 'free -h' for memory, 'top -bn1' for processes, 'ps aux' for process list. Parse the output and report any issues.",
|
||||
"tools": {
|
||||
"bash": {
|
||||
"type": "builtin",
|
||||
"name": "executeCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Another example - Git automation agent:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "git-helper",
|
||||
"description": "Automate git operations",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Help with git operations. Use commands like 'git status', 'git log --oneline -10', 'git diff', 'git branch -a' to inspect the repository. Can also run 'git add', 'git commit', 'git push' when instructed.",
|
||||
"tools": {
|
||||
"bash": {
|
||||
"type": "builtin",
|
||||
"name": "executeCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Agent-to-Agent Calling
|
||||
|
||||
Agents can call other agents as tools to create complex multi-step workflows. This is the core mechanism for building multi-agent systems in the CLI.
|
||||
|
||||
**Tool declaration:**
|
||||
\`\`\`json
|
||||
"tools": {
|
||||
"summariser": {
|
||||
"type": "agent",
|
||||
"name": "summariser_agent"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**When to use:**
|
||||
- Breaking complex tasks into specialized sub-agents
|
||||
- Creating reusable agent components
|
||||
- Orchestrating multi-step workflows
|
||||
- Delegating specialized tasks (e.g., summarization, data processing, audio generation)
|
||||
|
||||
**How it works:**
|
||||
- The agent calls the tool like any other tool
|
||||
- The target agent receives the input and processes it
|
||||
- Results are returned as tool output
|
||||
- The calling agent can then continue processing or delegate further
|
||||
|
||||
**Example - Agent that delegates to a summarizer:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "paper_analyzer",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the summariser. Don't ask for human input.",
|
||||
"tools": {
|
||||
"summariser": {
|
||||
"type": "agent",
|
||||
"name": "summariser_agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Tips for agent chaining:**
|
||||
- Make instructions explicit about when to call other agents
|
||||
- Pass clear, structured data between agents
|
||||
- Add "Don't ask for human input" for autonomous workflows
|
||||
- Keep each agent focused on a single responsibility
|
||||
|
||||
## Additional Builtin Tools
|
||||
|
||||
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`.
|
||||
|
||||
### Copilot-Specific Builtin Tools
|
||||
|
||||
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration:
|
||||
|
||||
#### File & Directory Operations
|
||||
- \`exploreDirectory\` - Recursively explore directory structure
|
||||
- \`readFile\` - Read and parse file contents
|
||||
- \`createFile\` - Create a new file with content
|
||||
- \`updateFile\` - Update or overwrite existing file contents
|
||||
- \`deleteFile\` - Delete a file
|
||||
- \`listFiles\` - List all files and directories
|
||||
|
||||
#### Agent Operations
|
||||
- \`analyzeAgent\` - Read and analyze an agent file structure
|
||||
- \`loadSkill\` - Load a Rowboat skill definition into context
|
||||
|
||||
#### MCP Operations
|
||||
- \`addMcpServer\` - Add or update an MCP server configuration (with validation)
|
||||
- \`listMcpServers\` - List all available MCP servers
|
||||
- \`listMcpTools\` - List all available tools from a specific MCP server
|
||||
- \`executeMcpTool\` - **Execute a specific MCP tool on behalf of the user**
|
||||
|
||||
#### Using executeMcpTool as Copilot
|
||||
|
||||
The \`executeMcpTool\` builtin allows the copilot to directly execute MCP tools without creating an agent. Load the "mcp-integration" skill for complete guidance on discovering and executing MCP tools, including workflows, schema matching, and examples.
|
||||
|
||||
**When to use executeMcpTool vs creating an agent:**
|
||||
- Use \`executeMcpTool\` for immediate, one-time tasks
|
||||
- Create an agent when the user needs repeated use or autonomous operation
|
||||
- Create an agent for complex multi-step workflows involving multiple tools
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Give agents clear examples** in their instructions showing exact bash commands to run
|
||||
2. **Explain output parsing** - show how to use jq, yq, grep, awk to extract data
|
||||
3. **Chain commands efficiently** - use && for sequences, | for pipes
|
||||
4. **Handle errors** - remind agents to check exit codes and stderr
|
||||
5. **Be specific** - provide example commands rather than generic descriptions
|
||||
6. **Security** - remind agents to validate inputs and avoid dangerous operations
|
||||
|
||||
## When to Use Builtin Tools vs MCP Tools vs Agent Tools
|
||||
|
||||
- **Use builtin executeCommand** when you need: CLI tools, system operations, data processing, git operations, any shell command
|
||||
- **Use MCP tools** when you need: Web scraping (firecrawl), text-to-speech (elevenlabs), specialized APIs, external service integrations
|
||||
- **Use agent tools (\`"type": "agent"\`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning
|
||||
|
||||
Many tasks can be accomplished with just \`executeCommand\` and common Unix tools - it's incredibly powerful!
|
||||
|
||||
## Key Insight: Multi-Agent Workflows
|
||||
|
||||
In the CLI, multi-agent workflows are built by:
|
||||
1. Creating specialized agents for specific tasks (in \`agents/\` directory)
|
||||
2. Creating an orchestrator agent that has other agents in its \`tools\`
|
||||
3. Running the orchestrator with \`rowboatx --agent orchestrator_name\`
|
||||
|
||||
There are no separate "workflow" files - everything is an agent!
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export const skill = String.raw`
|
||||
# Deletion Guardrails
|
||||
|
||||
Load this skill when a user asks to delete agents or workflows so you follow the required confirmation steps.
|
||||
|
||||
## Workflow deletion protocol
|
||||
1. Read the workflow file to identify every agent it references.
|
||||
2. Report those agents to the user and ask whether they should be deleted too.
|
||||
3. Wait for explicit confirmation before deleting anything.
|
||||
4. Only remove the workflow and/or agents the user authorizes.
|
||||
|
||||
## Agent deletion protocol
|
||||
1. Inspect the agent file to discover which workflows reference it.
|
||||
2. List those workflows to the user and ask whether they should be updated or deleted.
|
||||
3. Pause for confirmation before modifying workflows or removing the agent.
|
||||
4. Perform only the deletions the user approves.
|
||||
|
||||
## Safety checklist
|
||||
- Never delete cascaded resources automatically.
|
||||
- Keep a clear audit trail in your responses describing what was removed.
|
||||
- If the user’s instructions are ambiguous, ask clarifying questions before taking action.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
151
apps/x/packages/core/src/application/assistant/skills/index.ts
Normal file
151
apps/x/packages/core/src/application/assistant/skills/index.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import builtinToolsSkill from "./builtin-tools/skill.js";
|
||||
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
||||
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
|
||||
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
|
||||
|
||||
const CURRENT_FILE = fileURLToPath(import.meta.url);
|
||||
const CURRENT_DIR = path.dirname(CURRENT_FILE);
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
||||
type SkillDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type ResolvedSkill = {
|
||||
id: string;
|
||||
catalogPath: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const definitions: SkillDefinition[] = [
|
||||
{
|
||||
id: "workflow-authoring",
|
||||
title: "Workflow Authoring",
|
||||
folder: "workflow-authoring",
|
||||
summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.",
|
||||
content: workflowAuthoringSkill,
|
||||
},
|
||||
{
|
||||
id: "builtin-tools",
|
||||
title: "Builtin Tools Reference",
|
||||
folder: "builtin-tools",
|
||||
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
|
||||
content: builtinToolsSkill,
|
||||
},
|
||||
{
|
||||
id: "mcp-integration",
|
||||
title: "MCP Integration Guidance",
|
||||
folder: "mcp-integration",
|
||||
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
||||
content: mcpIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "deletion-guardrails",
|
||||
title: "Deletion Guardrails",
|
||||
folder: "deletion-guardrails",
|
||||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||
content: deletionGuardrailsSkill,
|
||||
},
|
||||
{
|
||||
id: "workflow-run-ops",
|
||||
title: "Workflow Run Operations",
|
||||
folder: "workflow-run-ops",
|
||||
summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.",
|
||||
content: workflowRunOpsSkill,
|
||||
},
|
||||
];
|
||||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
...definition,
|
||||
catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`,
|
||||
}));
|
||||
|
||||
const catalogSections = skillEntries.map((entry) => [
|
||||
`## ${entry.title}`,
|
||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||
`- **Use it for:** ${entry.summary}`,
|
||||
].join("\n"));
|
||||
|
||||
export const skillCatalog = [
|
||||
"# Rowboat Skill Catalog",
|
||||
"",
|
||||
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
|
||||
"",
|
||||
catalogSections.join("\n\n"),
|
||||
].join("\n");
|
||||
|
||||
const normalizeIdentifier = (value: string) =>
|
||||
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
||||
|
||||
const aliasMap = new Map<string, ResolvedSkill>();
|
||||
|
||||
const registerAlias = (alias: string, entry: ResolvedSkill) => {
|
||||
const normalized = normalizeIdentifier(alias);
|
||||
if (!normalized) return;
|
||||
aliasMap.set(normalized, entry);
|
||||
};
|
||||
|
||||
const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
|
||||
const normalized = normalizeIdentifier(alias);
|
||||
if (!normalized) return;
|
||||
|
||||
const variants = new Set<string>([normalized]);
|
||||
|
||||
if (/\.(ts|js)$/i.test(normalized)) {
|
||||
variants.add(normalized.replace(/\.(ts|js)$/i, ""));
|
||||
variants.add(
|
||||
normalized.endsWith(".ts") ? normalized.replace(/\.ts$/i, ".js") : normalized.replace(/\.js$/i, ".ts"),
|
||||
);
|
||||
} else {
|
||||
variants.add(`${normalized}.ts`);
|
||||
variants.add(`${normalized}.js`);
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
registerAlias(variant, entry);
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of skillEntries) {
|
||||
const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts");
|
||||
const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js");
|
||||
const resolvedEntry: ResolvedSkill = {
|
||||
id: entry.id,
|
||||
catalogPath: entry.catalogPath,
|
||||
content: entry.content,
|
||||
};
|
||||
|
||||
const baseAliases = [
|
||||
entry.id,
|
||||
entry.folder,
|
||||
`${entry.folder}/skill`,
|
||||
`${entry.folder}/skill.ts`,
|
||||
`${entry.folder}/skill.js`,
|
||||
`skills/${entry.folder}/skill.ts`,
|
||||
`skills/${entry.folder}/skill.js`,
|
||||
`${CATALOG_PREFIX}/${entry.folder}/skill.ts`,
|
||||
`${CATALOG_PREFIX}/${entry.folder}/skill.js`,
|
||||
absoluteTs,
|
||||
absoluteJs,
|
||||
];
|
||||
|
||||
for (const alias of baseAliases) {
|
||||
registerAliasVariants(alias, resolvedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
export const availableSkills = skillEntries.map((entry) => entry.id);
|
||||
|
||||
export function resolveSkill(identifier: string): ResolvedSkill | null {
|
||||
const normalized = normalizeIdentifier(identifier);
|
||||
if (!normalized) return null;
|
||||
|
||||
return aliasMap.get(normalized) ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
export const skill = String.raw`
|
||||
# MCP Integration Guidance
|
||||
|
||||
**Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools.
|
||||
|
||||
## CRITICAL: Always Check MCP Tools First
|
||||
|
||||
**IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS:
|
||||
|
||||
1. **First check**: Call \`listMcpServers\` to see what's available
|
||||
2. **Then list tools**: Call \`listMcpTools\` on relevant servers
|
||||
3. **Execute if possible**: Use \`executeMcpTool\` if a tool matches the need
|
||||
4. **Only then decline**: If no MCP tool can help, explain what's not possible
|
||||
|
||||
**DO NOT** immediately say "I can't do that" or "I don't have internet access" without checking MCP tools first!
|
||||
|
||||
### Common User Requests and MCP Tools
|
||||
|
||||
| User Request | Check For | Likely Tool |
|
||||
|--------------|-----------|-------------|
|
||||
| "Search the web/internet" | firecrawl, composio, fetch | \`firecrawl_search\`, \`COMPOSIO_SEARCH_WEB\` |
|
||||
| "Scrape this website" | firecrawl | \`firecrawl_scrape\` |
|
||||
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
|
||||
| "Get current time/date" | time | \`get_current_time\` |
|
||||
| "Make HTTP request" | fetch | \`fetch\`, \`post\` |
|
||||
| "GitHub operations" | github | \`create_issue\`, \`search_repos\` |
|
||||
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
|
||||
| "Tweet/social media" | twitter, composio | Various social tools |
|
||||
|
||||
## Key concepts
|
||||
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.
|
||||
- Agents reference MCP tools through the \`"tools"\` block by specifying \`type\`, \`name\`, \`description\`, \`mcpServerName\`, and a full \`inputSchema\`.
|
||||
- Tool schemas can include optional property descriptions; only include \`"required"\` when parameters are mandatory.
|
||||
|
||||
## CRITICAL: Adding MCP Servers
|
||||
|
||||
**ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
|
||||
|
||||
**NEVER manually create or edit \`config/mcp.json\`** using \`createFile\` or \`updateFile\` for MCP servers—this bypasses validation and will cause errors.
|
||||
|
||||
### MCP Server Configuration Schema
|
||||
|
||||
There are TWO types of MCP servers:
|
||||
|
||||
#### 1. STDIO (Command-based) Servers
|
||||
For servers that run as local processes (Node.js, Python, etc.):
|
||||
|
||||
**Required fields:**
|
||||
- \`command\`: string (e.g., "npx", "node", "python", "uvx")
|
||||
|
||||
**Optional fields:**
|
||||
- \`args\`: array of strings (command arguments)
|
||||
- \`env\`: object with string key-value pairs (environment variables)
|
||||
- \`type\`: "stdio" (optional, inferred from presence of \`command\`)
|
||||
|
||||
**Schema:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "stdio",
|
||||
"command": "string (REQUIRED)",
|
||||
"args": ["string", "..."],
|
||||
"env": {
|
||||
"KEY": "value"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Valid STDIO examples:**
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/data"]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server_git"],
|
||||
"env": {
|
||||
"GIT_REPO_PATH": "/path/to/repo"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
#### 2. HTTP/SSE Servers
|
||||
For servers that expose HTTP or Server-Sent Events endpoints:
|
||||
|
||||
**Required fields:**
|
||||
- \`url\`: string (complete URL including protocol and path)
|
||||
|
||||
**Optional fields:**
|
||||
- \`headers\`: object with string key-value pairs (HTTP headers)
|
||||
- \`type\`: "http" (optional, inferred from presence of \`url\`)
|
||||
|
||||
**Schema:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "http",
|
||||
"url": "string (REQUIRED)",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token",
|
||||
"Custom-Header": "value"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Valid HTTP examples:**
|
||||
\`\`\`json
|
||||
{
|
||||
"url": "http://localhost:3000/sse"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer sk-1234567890"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Common Validation Errors to Avoid
|
||||
|
||||
❌ **WRONG - Missing required field:**
|
||||
\`\`\`json
|
||||
{
|
||||
"args": ["some-arg"]
|
||||
}
|
||||
\`\`\`
|
||||
Error: Missing \`command\` for stdio OR \`url\` for http
|
||||
|
||||
❌ **WRONG - Empty object:**
|
||||
\`\`\`json
|
||||
{}
|
||||
\`\`\`
|
||||
Error: Must have either \`command\` (stdio) or \`url\` (http)
|
||||
|
||||
❌ **WRONG - Mixed types:**
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "npx",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
\`\`\`
|
||||
Error: Cannot have both \`command\` and \`url\`
|
||||
|
||||
✅ **CORRECT - Minimal stdio:**
|
||||
\`\`\`json
|
||||
{
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-time"]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
✅ **CORRECT - Minimal http:**
|
||||
\`\`\`json
|
||||
{
|
||||
"url": "http://localhost:3000/sse"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Using addMcpServer Tool
|
||||
|
||||
**Example 1: Add stdio server**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "filesystem",
|
||||
"serverType": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/data"]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Example 2: Add HTTP server**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "custom-api",
|
||||
"serverType": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token123"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Example 3: Add Python MCP server**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "github",
|
||||
"serverType": "stdio",
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server_github"],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "ghp_xxxxx"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Operator actions
|
||||
1. Use \`listMcpServers\` to enumerate configured servers.
|
||||
2. Use \`addMcpServer\` to add or update MCP server configurations (with validation).
|
||||
3. Use \`listMcpTools\` for a server to understand the available operations and schemas.
|
||||
4. Use \`executeMcpTool\` to run MCP tools directly on behalf of the user.
|
||||
5. Explain which MCP tools match the user's needs before editing agent definitions.
|
||||
6. When adding a tool to an agent, document what it does and ensure the schema mirrors the MCP definition.
|
||||
|
||||
## Executing MCP Tools Directly (Copilot)
|
||||
|
||||
As the copilot, you can execute MCP tools directly on behalf of the user using the \`executeMcpTool\` builtin. This allows you to use MCP tools without creating an agent.
|
||||
|
||||
### When to Execute MCP Tools Directly
|
||||
- User asks you to perform a task that an MCP tool can handle (web search, file operations, API calls, etc.)
|
||||
- User wants immediate results from an MCP tool without setting up an agent
|
||||
- You need to test or demonstrate an MCP tool's functionality
|
||||
- You're helping the user accomplish a one-time task
|
||||
|
||||
### Workflow for Executing MCP Tools
|
||||
1. **Discover available servers**: Use \`listMcpServers\` to see what MCP servers are configured
|
||||
2. **List tools from a server**: Use \`listMcpTools\` with the server name to see available tools and their schemas
|
||||
3. **CAREFULLY EXAMINE THE SCHEMA**: Look at the \`inputSchema\` to understand exactly what parameters are required
|
||||
4. **Execute the tool**: Use \`executeMcpTool\` with the server name, tool name, and required arguments (matching the schema exactly)
|
||||
5. **Return results**: Present the results to the user in a helpful format
|
||||
|
||||
### CRITICAL: Schema Matching
|
||||
|
||||
**ALWAYS** examine the \`inputSchema\` from \`listMcpTools\` before calling \`executeMcpTool\`.
|
||||
|
||||
The schema tells you:
|
||||
- What parameters are required (check the \`"required"\` array)
|
||||
- What type each parameter should be (string, number, boolean, object, array)
|
||||
- Parameter descriptions and examples
|
||||
|
||||
**Example schema from listMcpTools:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "COMPOSIO_SEARCH_WEB",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Number of results"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Correct executeMcpTool call:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB",
|
||||
"arguments": {
|
||||
"query": "elon musk latest news"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**WRONG - Missing arguments:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**WRONG - Wrong parameter name:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "composio",
|
||||
"toolName": "COMPOSIO_SEARCH_WEB",
|
||||
"arguments": {
|
||||
"search": "elon musk" // Wrong! Should be "query"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Example: Using Firecrawl to Search the Web
|
||||
|
||||
**Step 1: List servers**
|
||||
\`\`\`json
|
||||
// Call: listMcpServers
|
||||
// Response: { "servers": [{"name": "firecrawl", "type": "stdio", ...}] }
|
||||
\`\`\`
|
||||
|
||||
**Step 2: List tools**
|
||||
\`\`\`json
|
||||
// Call: listMcpTools with serverName: "firecrawl"
|
||||
// Response: { "tools": [{"name": "firecrawl_search", "description": "Search the web", "inputSchema": {...}}] }
|
||||
\`\`\`
|
||||
|
||||
**Step 3: Execute the tool**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "firecrawl",
|
||||
"toolName": "firecrawl_search",
|
||||
"arguments": {
|
||||
"query": "latest AI news",
|
||||
"limit": 5
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Example: Using Filesystem Tool
|
||||
|
||||
**Execute a filesystem read operation:**
|
||||
\`\`\`json
|
||||
{
|
||||
"serverName": "filesystem",
|
||||
"toolName": "read_file",
|
||||
"arguments": {
|
||||
"path": "/path/to/file.txt"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Tips for Executing MCP Tools
|
||||
- Always check the \`inputSchema\` from \`listMcpTools\` to know what arguments are required
|
||||
- Match argument types exactly (string, number, boolean, object, array)
|
||||
- Provide helpful context to the user about what the tool is doing
|
||||
- Handle errors gracefully and suggest alternatives if a tool fails
|
||||
- For complex tasks, consider creating an agent instead of one-off tool calls
|
||||
|
||||
### Discovery Pattern (Recommended)
|
||||
|
||||
When a user asks for something that might be accomplished with an MCP tool:
|
||||
|
||||
1. **Identify the need**: "You want to search the web? Let me check what MCP tools are available..."
|
||||
2. **List servers**: Call \`listMcpServers\`
|
||||
3. **Check for relevant tools**: If you find a relevant server (e.g., "firecrawl" for web search), call \`listMcpTools\`
|
||||
4. **Execute the tool**: Once you find the right tool and understand its schema, call \`executeMcpTool\`
|
||||
5. **Present results**: Format and explain the results to the user
|
||||
|
||||
### Common MCP Servers and Their Tools
|
||||
|
||||
Based on typical configurations, you might find:
|
||||
- **firecrawl**: Web scraping, search, crawling (\`firecrawl_search\`, \`firecrawl_scrape\`, \`firecrawl_crawl\`)
|
||||
- **filesystem**: File operations (\`read_file\`, \`write_file\`, \`list_directory\`)
|
||||
- **github**: GitHub operations (\`create_issue\`, \`create_pr\`, \`search_repositories\`)
|
||||
- **fetch**: HTTP requests (\`fetch\`, \`post\`)
|
||||
- **time**: Time/date operations (\`get_current_time\`, \`convert_timezone\`)
|
||||
|
||||
Always use \`listMcpServers\` and \`listMcpTools\` to discover what's actually available rather than assuming.
|
||||
|
||||
## Adding MCP Tools to Agents
|
||||
|
||||
Once an MCP server is configured, add its tools to agent definitions:
|
||||
|
||||
### MCP Tool Format in Agent
|
||||
\`\`\`json
|
||||
"tools": {
|
||||
"descriptive_key": {
|
||||
"type": "mcp",
|
||||
"name": "actual_tool_name_from_server",
|
||||
"description": "What the tool does",
|
||||
"mcpServerName": "server_name_from_config",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param1": {"type": "string", "description": "What param1 means"}
|
||||
},
|
||||
"required": ["param1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Tool Schema Rules
|
||||
- Use \`listMcpTools\` to get the exact \`inputSchema\` from the server
|
||||
- Copy the schema exactly as provided by the MCP server
|
||||
- Only include \`"required"\` array if parameters are truly mandatory
|
||||
- Add descriptions to help the agent understand parameter usage
|
||||
|
||||
### Example snippets to reference
|
||||
- Firecrawl search (required param):
|
||||
\`\`\`json
|
||||
"tools": {
|
||||
"search": {
|
||||
"type": "mcp",
|
||||
"name": "firecrawl_search",
|
||||
"description": "Search the web",
|
||||
"mcpServerName": "firecrawl",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {"type": "number", "description": "Number of results"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- ElevenLabs text-to-speech (no required array):
|
||||
\`\`\`json
|
||||
"tools": {
|
||||
"text_to_speech": {
|
||||
"type": "mcp",
|
||||
"name": "text_to_speech",
|
||||
"description": "Generate audio from text",
|
||||
"mcpServerName": "elevenLabs",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
|
||||
## Safety reminders
|
||||
- ALWAYS use \`addMcpServer\` to configure MCP servers—never manually edit config files
|
||||
- Only recommend MCP tools that are actually configured (use \`listMcpServers\` first)
|
||||
- Clarify any missing details (required parameters, server names) before modifying files
|
||||
- Test server connection with \`listMcpTools\` after adding a new server
|
||||
- Invalid MCP configs prevent agents from starting—validation is critical
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
export const skill = String.raw`
|
||||
# Agent and Workflow Authoring
|
||||
|
||||
Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
|
||||
|
||||
- **All definitions live in \`agents/*.json\`** - there is no separate workflows folder
|
||||
- Agents configure a model, instructions, and the tools they can use
|
||||
- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents**
|
||||
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
|
||||
|
||||
## How multi-agent workflows work
|
||||
|
||||
1. **Create an orchestrator agent** that has other agents in its \`tools\`
|
||||
2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\`
|
||||
3. The orchestrator calls other agents as tools when needed
|
||||
4. Data flows through tool call parameters and responses
|
||||
|
||||
## Agent File Schema
|
||||
|
||||
Agent files MUST conform to this exact schema. Invalid agents will fail to load.
|
||||
|
||||
### Complete Agent Schema
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "string (REQUIRED, must match filename without .json)",
|
||||
"description": "string (REQUIRED, what this agent does)",
|
||||
"instructions": "string (REQUIRED, detailed instructions for the agent)",
|
||||
"model": "string (OPTIONAL, e.g., 'gpt-5.1', 'claude-sonnet-4-5')",
|
||||
"provider": "string (OPTIONAL, provider alias from models.json)",
|
||||
"tools": {
|
||||
"descriptive_key": {
|
||||
"type": "builtin | mcp | agent (REQUIRED)",
|
||||
"name": "string (REQUIRED)",
|
||||
// Additional fields depend on type - see below
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Required Fields
|
||||
- \`name\`: Agent identifier (must exactly match the filename without .json)
|
||||
- \`description\`: Brief description of agent's purpose
|
||||
- \`instructions\`: Detailed instructions for how the agent should behave
|
||||
|
||||
### Optional Fields
|
||||
- \`model\`: Model to use (defaults to model config if not specified)
|
||||
- \`provider\`: Provider alias from models.json (optional)
|
||||
- \`tools\`: Object containing tool definitions (can be empty or omitted)
|
||||
|
||||
### Naming Rules
|
||||
- Agent filename MUST match the \`name\` field exactly
|
||||
- Example: If \`name\` is "summariser_agent", file must be "summariser_agent.json"
|
||||
- Use lowercase with underscores for multi-word names
|
||||
- No spaces or special characters in names
|
||||
|
||||
### Agent Format Example
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "agent_name",
|
||||
"description": "Description of the agent",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Instructions for the agent",
|
||||
"tools": {
|
||||
"descriptive_tool_key": {
|
||||
"type": "mcp",
|
||||
"name": "actual_mcp_tool_name",
|
||||
"description": "What the tool does",
|
||||
"mcpServerName": "server_name_from_config",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param1": {"type": "string", "description": "What the parameter means"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Tool Types & Schemas
|
||||
|
||||
Tools in agents must follow one of three types. Each has specific required fields.
|
||||
|
||||
### 1. Builtin Tools
|
||||
Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.)
|
||||
|
||||
**Schema:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "tool_name"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Required fields:**
|
||||
- \`type\`: Must be "builtin"
|
||||
- \`name\`: Builtin tool name (e.g., "executeCommand", "readFile")
|
||||
|
||||
**Example:**
|
||||
\`\`\`json
|
||||
"bash": {
|
||||
"type": "builtin",
|
||||
"name": "executeCommand"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Available builtin tools:**
|
||||
- \`executeCommand\` - Execute shell commands
|
||||
- \`readFile\`, \`createFile\`, \`updateFile\`, \`deleteFile\` - File operations
|
||||
- \`listFiles\`, \`exploreDirectory\` - Directory operations
|
||||
- \`analyzeAgent\` - Analyze agent structure
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\` - MCP management
|
||||
- \`loadSkill\` - Load skill guidance
|
||||
|
||||
### 2. MCP Tools
|
||||
Tools from external MCP servers (APIs, databases, web scraping, etc.)
|
||||
|
||||
**Schema:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "mcp",
|
||||
"name": "tool_name_from_server",
|
||||
"description": "What the tool does",
|
||||
"mcpServerName": "server_name_from_config",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": {"type": "string", "description": "Parameter description"}
|
||||
},
|
||||
"required": ["param"]
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Required fields:**
|
||||
- \`type\`: Must be "mcp"
|
||||
- \`name\`: Exact tool name from MCP server
|
||||
- \`description\`: What the tool does (helps agent understand when to use it)
|
||||
- \`mcpServerName\`: Server name from config/mcp.json
|
||||
- \`inputSchema\`: Full JSON Schema object for tool parameters
|
||||
|
||||
**Example:**
|
||||
\`\`\`json
|
||||
"search": {
|
||||
"type": "mcp",
|
||||
"name": "firecrawl_search",
|
||||
"description": "Search the web",
|
||||
"mcpServerName": "firecrawl",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Important:**
|
||||
- Use \`listMcpTools\` to get the exact inputSchema from the server
|
||||
- Copy the schema exactly—don't modify property types or structure
|
||||
- Only include \`"required"\` array if parameters are mandatory
|
||||
|
||||
### 3. Agent Tools (for chaining agents)
|
||||
Reference other agents as tools to build multi-agent workflows
|
||||
|
||||
**Schema:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "agent",
|
||||
"name": "target_agent_name"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Required fields:**
|
||||
- \`type\`: Must be "agent"
|
||||
- \`name\`: Name of the target agent (must exist in agents/ directory)
|
||||
|
||||
**Example:**
|
||||
\`\`\`json
|
||||
"summariser": {
|
||||
"type": "agent",
|
||||
"name": "summariser_agent"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**How it works:**
|
||||
- Use \`"type": "agent"\` to call other agents as tools
|
||||
- The target agent will be invoked with the parameters you pass
|
||||
- Results are returned as tool output
|
||||
- This is how you build multi-agent workflows
|
||||
- The referenced agent file must exist (e.g., agents/summariser_agent.json)
|
||||
|
||||
## Complete Multi-Agent Workflow Example
|
||||
|
||||
**Podcast creation workflow** - This is all done through agents calling other agents:
|
||||
|
||||
**1. Task-specific agent** (does one thing):
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "summariser_agent",
|
||||
"description": "Summarises an arxiv paper",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Download and summarise an arxiv paper. Use curl to fetch the PDF. Output just the GIST in two lines. Don't ask for human input.",
|
||||
"tools": {
|
||||
"bash": {"type": "builtin", "name": "executeCommand"}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**2. Agent that delegates to other agents**:
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "summarise-a-few",
|
||||
"description": "Summarises multiple arxiv papers",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the tool. Don't ask for human input.",
|
||||
"tools": {
|
||||
"summariser": {
|
||||
"type": "agent",
|
||||
"name": "summariser_agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**3. Orchestrator agent** (coordinates the whole workflow):
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "podcast_workflow",
|
||||
"description": "Create a podcast from arXiv papers",
|
||||
"model": "gpt-5.1",
|
||||
"instructions": "1. Fetch arXiv papers about agents using bash\n2. Pick papers and summarise them using summarise_papers\n3. Create a podcast transcript\n4. Generate audio using text_to_speech\n\nExecute these steps in sequence.",
|
||||
"tools": {
|
||||
"bash": {"type": "builtin", "name": "executeCommand"},
|
||||
"summarise_papers": {
|
||||
"type": "agent",
|
||||
"name": "summarise-a-few"
|
||||
},
|
||||
"text_to_speech": {
|
||||
"type": "mcp",
|
||||
"name": "text_to_speech",
|
||||
"mcpServerName": "elevenLabs",
|
||||
"description": "Generate audio",
|
||||
"inputSchema": { "type": "object", "properties": {...}}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**To run this workflow**: \`rowboatx --agent podcast_workflow\`
|
||||
|
||||
## Naming and organization rules
|
||||
- **All agents live in \`agents/*.json\`** - no other location
|
||||
- Agent filenames must match the \`"name"\` field exactly
|
||||
- When referencing an agent as a tool, use its \`"name"\` value
|
||||
- Always keep filenames and \`"name"\` fields perfectly aligned
|
||||
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
|
||||
|
||||
## Best practices for multi-agent design
|
||||
1. **Single responsibility**: Each agent should do one specific thing well
|
||||
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
|
||||
3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows
|
||||
4. **Data passing**: Make it clear what data to extract and pass between agents
|
||||
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
|
||||
6. **Orchestration**: Create a top-level agent that coordinates the workflow
|
||||
|
||||
## Validation & Best Practices
|
||||
|
||||
### CRITICAL: Schema Compliance
|
||||
- Agent files MUST have \`name\`, \`description\`, and \`instructions\` fields
|
||||
- Agent filename MUST exactly match the \`name\` field
|
||||
- Tools MUST have valid \`type\` ("builtin", "mcp", or "agent")
|
||||
- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema
|
||||
- Agent tools MUST reference existing agent files
|
||||
- Invalid agents will fail to load and prevent workflow execution
|
||||
|
||||
### File Creation/Update Process
|
||||
1. When creating an agent, use \`createFile\` with complete, valid JSON
|
||||
2. When updating an agent, read it first with \`readFile\`, modify, then use \`updateFile\`
|
||||
3. Validate JSON syntax before writing—malformed JSON breaks the agent
|
||||
4. Test agent loading after creation/update by using \`analyzeAgent\`
|
||||
|
||||
### Common Validation Errors to Avoid
|
||||
|
||||
❌ **WRONG - Missing required fields:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "my_agent"
|
||||
// Missing description and instructions
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
❌ **WRONG - Filename mismatch:**
|
||||
- File: agents/my_agent.json
|
||||
- Content: {"name": "myagent", ...}
|
||||
|
||||
❌ **WRONG - Invalid tool type:**
|
||||
\`\`\`json
|
||||
"tool1": {
|
||||
"type": "custom", // Invalid type
|
||||
"name": "something"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
❌ **WRONG - MCP tool missing required fields:**
|
||||
\`\`\`json
|
||||
"search": {
|
||||
"type": "mcp",
|
||||
"name": "firecrawl_search"
|
||||
// Missing: description, mcpServerName, inputSchema
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
✅ **CORRECT - Minimal valid agent:**
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "simple_agent",
|
||||
"description": "A simple agent",
|
||||
"instructions": "Do simple tasks"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
✅ **CORRECT - Complete MCP tool:**
|
||||
\`\`\`json
|
||||
"search": {
|
||||
"type": "mcp",
|
||||
"name": "firecrawl_search",
|
||||
"description": "Search the web",
|
||||
"mcpServerName": "firecrawl",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Capabilities checklist
|
||||
1. Explore \`agents/\` directory to understand existing agents before editing
|
||||
2. Read existing agents with \`readFile\` before making changes
|
||||
3. Validate all required fields are present before creating/updating agents
|
||||
4. Ensure filename matches the \`name\` field exactly
|
||||
5. Use \`analyzeAgent\` to verify agent structure after creation/update
|
||||
6. When creating multi-agent workflows, create an orchestrator agent
|
||||
7. Add other agents as tools with \`"type": "agent"\` for chaining
|
||||
8. Use \`listMcpServers\` and \`listMcpTools\` when adding MCP integrations
|
||||
9. Confirm work done and outline next steps once changes are complete
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
export const skill = String.raw`
|
||||
# Agent Run Operations
|
||||
|
||||
Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling.
|
||||
|
||||
## When to use
|
||||
- User wants to run an agent (including multi-agent workflows)
|
||||
- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input)
|
||||
- User wants to inspect cron jobs or change agent schedules
|
||||
- User asks how to set up monitoring for waiting runs
|
||||
|
||||
## Running Agents
|
||||
|
||||
**To run any agent**:
|
||||
\`\`\`bash
|
||||
rowboatx --agent <agent-name>
|
||||
\`\`\`
|
||||
|
||||
**With input**:
|
||||
\`\`\`bash
|
||||
rowboatx --agent <agent-name> --input "your input here"
|
||||
\`\`\`
|
||||
|
||||
**Non-interactive** (for automation/cron):
|
||||
\`\`\`bash
|
||||
rowboatx --agent <agent-name> --input "input" --no-interactive
|
||||
\`\`\`
|
||||
|
||||
**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow.
|
||||
|
||||
## Run monitoring examples
|
||||
Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed.
|
||||
|
||||
Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges.
|
||||
|
||||
Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}'
|
||||
|
||||
If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below.
|
||||
|
||||
1. **List all runs**
|
||||
|
||||
ls ~/.rowboat/runs
|
||||
|
||||
|
||||
2. **Filter by agent**
|
||||
|
||||
grep -rl '"agent":"<agent-name>"' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r
|
||||
|
||||
Replace <agent-name> with the desired agent name.
|
||||
|
||||
3. **Filter by time window**
|
||||
To the previous commands add the below through unix pipe
|
||||
|
||||
awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"'
|
||||
|
||||
Use the correct timestamps.
|
||||
|
||||
4. **Show runs waiting for human input**
|
||||
|
||||
awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}'
|
||||
|
||||
Prints the files whose last line equals 'pause-for-human-input'.
|
||||
|
||||
## Cron management examples
|
||||
|
||||
For scheduling agents to run automatically at specific times.
|
||||
|
||||
1. **View current cron schedule**
|
||||
\`\`\`bash
|
||||
crontab -l 2>/dev/null || echo 'No crontab entries configured.'
|
||||
\`\`\`
|
||||
|
||||
2. **Schedule an agent to run periodically**
|
||||
\`\`\`bash
|
||||
(crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent <agent-name> --input "input" --no-interactive >> ~/.rowboat/logs/<agent-name>.log 2>&1') | crontab -
|
||||
\`\`\`
|
||||
|
||||
Example (runs daily at 10 AM):
|
||||
\`\`\`bash
|
||||
(crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab -
|
||||
\`\`\`
|
||||
|
||||
3. **Unschedule/remove an agent**
|
||||
\`\`\`bash
|
||||
crontab -l | grep -v '<agent-name>' | crontab -
|
||||
\`\`\`
|
||||
|
||||
## Common cron schedule patterns
|
||||
- \`0 10 * * *\` - Daily at 10 AM
|
||||
- \`0 */6 * * *\` - Every 6 hours
|
||||
- \`0 9 * * 1\` - Every Monday at 9 AM
|
||||
- \`*/30 * * * *\` - Every 30 minutes
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
436
apps/x/packages/core/src/application/lib/builtin-tools.ts
Normal file
436
apps/x/packages/core/src/application/lib/builtin-tools.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import { executeCommand } from "./command-executor.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
||||
import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
||||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const BuiltinToolsSchema = z.record(z.string(), z.object({
|
||||
description: z.string(),
|
||||
inputSchema: z.custom<ZodType>(),
|
||||
execute: z.function({
|
||||
input: z.any(),
|
||||
output: z.promise(z.any()),
|
||||
}),
|
||||
}));
|
||||
|
||||
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||
loadSkill: {
|
||||
description: "Load a Rowboat skill definition into context by fetching its guidance string",
|
||||
inputSchema: z.object({
|
||||
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
|
||||
}),
|
||||
execute: async ({ skillName }: { skillName: string }) => {
|
||||
const resolved = resolveSkill(skillName);
|
||||
|
||||
if (!resolved) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
skillName: resolved.id,
|
||||
path: resolved.catalogPath,
|
||||
content: resolved.content,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:getRoot': {
|
||||
description: 'Get the workspace root directory path',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
return await workspace.getRoot();
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:exists': {
|
||||
description: 'Check if a file or directory exists in the workspace',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative path to check'),
|
||||
}),
|
||||
execute: async ({ path: relPath }: { path: string }) => {
|
||||
try {
|
||||
return await workspace.exists(relPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:stat': {
|
||||
description: 'Get file or directory statistics (size, modification time, etc.)',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative path to stat'),
|
||||
}),
|
||||
execute: async ({ path: relPath }: { path: string }) => {
|
||||
try {
|
||||
return await workspace.stat(relPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:readdir': {
|
||||
description: 'List directory contents. Can recursively explore directory structure with options.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe('Workspace-relative directory path (empty string for root)'),
|
||||
recursive: z.boolean().optional().describe('Recursively list all subdirectories (default: false)'),
|
||||
includeStats: z.boolean().optional().describe('Include file stats like size and modification time (default: false)'),
|
||||
includeHidden: z.boolean().optional().describe('Include hidden files starting with . (default: false)'),
|
||||
allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions
|
||||
}: {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
includeStats?: boolean;
|
||||
includeHidden?: boolean;
|
||||
allowedExtensions?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const entries = await workspace.readdir(relPath || '', {
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions,
|
||||
});
|
||||
return entries;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:readFile': {
|
||||
description: 'Read file contents from the workspace. Supports utf8, base64, and binary encodings.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
|
||||
}),
|
||||
execute: async ({ path: relPath, encoding = 'utf8' }: { path: string; encoding?: 'utf8' | 'base64' | 'binary' }) => {
|
||||
try {
|
||||
return await workspace.readFile(relPath, encoding);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:writeFile': {
|
||||
description: 'Write or update file contents in the workspace. Automatically creates parent directories and supports atomic writes.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
data: z.string().describe('File content to write'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('Data encoding (default: utf8)'),
|
||||
atomic: z.boolean().optional().describe('Use atomic write (default: true)'),
|
||||
mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
|
||||
expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
data,
|
||||
encoding,
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag
|
||||
}: {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
atomic?: boolean;
|
||||
mkdirp?: boolean;
|
||||
expectedEtag?: string;
|
||||
}) => {
|
||||
try {
|
||||
return await workspace.writeFile(relPath, data, {
|
||||
encoding,
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:mkdir': {
|
||||
description: 'Create a directory in the workspace',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative directory path'),
|
||||
recursive: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
|
||||
}),
|
||||
execute: async ({ path: relPath, recursive = true }: { path: string; recursive?: boolean }) => {
|
||||
try {
|
||||
return await workspace.mkdir(relPath, recursive);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:rename': {
|
||||
description: 'Rename or move a file or directory in the workspace',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source workspace-relative path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await workspace.rename(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:copy': {
|
||||
description: 'Copy a file in the workspace (directories not supported)',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source workspace-relative file path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative file path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await workspace.copy(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace:remove': {
|
||||
description: 'Remove a file or directory from the workspace. Files are moved to trash by default for safety.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative path to remove'),
|
||||
recursive: z.boolean().optional().describe('Required for directories (default: false)'),
|
||||
trash: z.boolean().optional().describe('Move to trash instead of permanent delete (default: true)'),
|
||||
}),
|
||||
execute: async ({ path: relPath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
|
||||
try {
|
||||
return await workspace.remove(relPath, {
|
||||
recursive,
|
||||
trash,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
analyzeAgent: {
|
||||
description: 'Read and analyze an agent file to understand its structure, tools, and configuration',
|
||||
inputSchema: z.object({
|
||||
agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'),
|
||||
}),
|
||||
execute: async ({ agentName }: { agentName: string }) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
try {
|
||||
const agent = await repo.fetch(agentName);
|
||||
|
||||
// Extract key information
|
||||
const toolsList = agent.tools ? Object.keys(agent.tools) : [];
|
||||
const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]) => ({
|
||||
key,
|
||||
type: tool.type,
|
||||
name: tool.name,
|
||||
})) : [];
|
||||
|
||||
const analysis = {
|
||||
name: agent.name,
|
||||
description: agent.description || 'No description',
|
||||
model: agent.model || 'Not specified',
|
||||
toolCount: toolsList.length,
|
||||
tools: agentTools,
|
||||
hasOtherAgents: agentTools.some(t => t.type === 'agent'),
|
||||
structure: agent,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
analysis,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
addMcpServer: {
|
||||
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
|
||||
inputSchema: z.object({
|
||||
serverName: z.string().describe('Name/alias for the MCP server'),
|
||||
config: McpServerDefinition,
|
||||
}),
|
||||
execute: async ({ serverName, config }: {
|
||||
serverName: string;
|
||||
config: z.infer<typeof McpServerDefinition>;
|
||||
}) => {
|
||||
try {
|
||||
const validationResult = McpServerDefinition.safeParse(config);
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Server definition failed validation. Check the errors below.',
|
||||
validationErrors: validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`),
|
||||
providedDefinition: config,
|
||||
};
|
||||
}
|
||||
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.upsert(serverName, config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
serverName,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
listMcpServers: {
|
||||
description: 'List all available MCP servers from the configuration',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const result = await listServers();
|
||||
|
||||
return {
|
||||
result,
|
||||
count: Object.keys(result.mcpServers).length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
listMcpTools: {
|
||||
description: 'List all available tools from a specific MCP server',
|
||||
inputSchema: z.object({
|
||||
serverName: z.string().describe('Name of the MCP server to query'),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ serverName, cursor }: { serverName: string, cursor?: string }) => {
|
||||
try {
|
||||
const result = await listTools(serverName, cursor);
|
||||
return {
|
||||
serverName,
|
||||
result,
|
||||
count: result.tools.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
executeMcpTool: {
|
||||
description: 'Execute a specific tool from an MCP server. Use this to run MCP tools on behalf of the user. IMPORTANT: Always use listMcpTools first to get the tool\'s inputSchema, then match the required parameters exactly in the arguments field.',
|
||||
inputSchema: z.object({
|
||||
serverName: z.string().describe('Name of the MCP server that provides the tool'),
|
||||
toolName: z.string().describe('Name of the tool to execute'),
|
||||
arguments: z.record(z.string(), z.any()).optional().describe('Arguments to pass to the tool (as key-value pairs matching the tool\'s input schema). MUST include all required parameters from the tool\'s inputSchema.'),
|
||||
}),
|
||||
execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record<string, unknown> }) => {
|
||||
try {
|
||||
const result = await executeTool(serverName, toolName, args);
|
||||
return {
|
||||
success: true,
|
||||
serverName,
|
||||
toolName,
|
||||
result,
|
||||
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
executeCommand: {
|
||||
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
|
||||
inputSchema: z.object({
|
||||
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
|
||||
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root)'),
|
||||
}),
|
||||
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
|
||||
try {
|
||||
const workingDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
|
||||
const result = await executeCommand(command, { cwd: workingDir });
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
command,
|
||||
workingDir,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
command,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
35
apps/x/packages/core/src/application/lib/bus.ts
Normal file
35
apps/x/packages/core/src/application/lib/bus.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { RunEvent } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export interface IBus {
|
||||
publish(event: z.infer<typeof RunEvent>): Promise<void>;
|
||||
|
||||
// subscribe accepts a handler to handle events
|
||||
// and returns a function to unsubscribe
|
||||
subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
|
||||
export class InMemoryBus implements IBus {
|
||||
private subscribers: Map<string, ((event: z.infer<typeof RunEvent>) => Promise<void>)[]> = new Map();
|
||||
|
||||
async publish(event: z.infer<typeof RunEvent>): Promise<void> {
|
||||
const pending: Promise<void>[] = [];
|
||||
for (const subscriber of this.subscribers.get(event.runId) || []) {
|
||||
pending.push(subscriber(event));
|
||||
}
|
||||
for (const subscriber of this.subscribers.get('*') || []) {
|
||||
pending.push(subscriber(event));
|
||||
}
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void> {
|
||||
if (!this.subscribers.has(runId)) {
|
||||
this.subscribers.set(runId, []);
|
||||
}
|
||||
this.subscribers.get(runId)!.push(handler);
|
||||
return () => {
|
||||
this.subscribers.get(runId)!.splice(this.subscribers.get(runId)!.indexOf(handler), 1);
|
||||
};
|
||||
}
|
||||
}
|
||||
145
apps/x/packages/core/src/application/lib/command-executor.ts
Normal file
145
apps/x/packages/core/src/application/lib/command-executor.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSecurityAllowList } from '../../config/security.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
|
||||
function sanitizeToken(token: string): string {
|
||||
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
||||
}
|
||||
|
||||
function extractCommandNames(command: string): string[] {
|
||||
const discovered = new Set<string>();
|
||||
const segments = command.split(COMMAND_SPLIT_REGEX);
|
||||
|
||||
for (const segment of segments) {
|
||||
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
||||
if (!tokens.length) continue;
|
||||
|
||||
let index = 0;
|
||||
while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index])) {
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index >= tokens.length) continue;
|
||||
|
||||
const primary = sanitizeToken(tokens[index]).toLowerCase();
|
||||
if (!primary) continue;
|
||||
|
||||
discovered.add(primary);
|
||||
|
||||
if (WRAPPER_COMMANDS.has(primary) && index + 1 < tokens.length) {
|
||||
const wrapped = sanitizeToken(tokens[index + 1]).toLowerCase();
|
||||
if (wrapped) {
|
||||
discovered.add(wrapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(discovered);
|
||||
}
|
||||
|
||||
function findBlockedCommands(command: string): string[] {
|
||||
const invoked = extractCommandNames(command);
|
||||
if (!invoked.length) return [];
|
||||
|
||||
const allowList = getSecurityAllowList();
|
||||
if (!allowList.length) return invoked;
|
||||
|
||||
const allowSet = new Set(allowList);
|
||||
if (allowSet.has('*')) return [];
|
||||
|
||||
return invoked.filter((cmd) => !allowSet.has(cmd));
|
||||
}
|
||||
|
||||
// export const BlockedResult = {
|
||||
// stdout: '',
|
||||
// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
|
||||
// exitCode: 126,
|
||||
// };
|
||||
|
||||
export function isBlocked(command: string): boolean {
|
||||
const blocked = findBlockedCommands(command);
|
||||
return blocked.length > 0;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an arbitrary shell command
|
||||
* @param command - The command to execute (e.g., "cat abc.txt | grep 'abc@gmail.com'")
|
||||
* @param options - Optional execution options
|
||||
* @returns Promise with stdout, stderr, and exit code
|
||||
*/
|
||||
export async function executeCommand(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
timeout?: number; // timeout in milliseconds
|
||||
maxBuffer?: number; // max buffer size in bytes
|
||||
}
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execPromise(command, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// exec throws an error if the command fails or times out
|
||||
const e = error as { stdout?: string; stderr?: string; code?: number; message?: string };
|
||||
return {
|
||||
stdout: e.stdout?.trim() || '',
|
||||
stderr: e.stderr?.trim() || e.message || '',
|
||||
exitCode: e.code || 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a command synchronously (blocking)
|
||||
* Use with caution - prefer executeCommand for async execution
|
||||
*/
|
||||
export function executeCommandSync(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
): CommandResult {
|
||||
try {
|
||||
const stdout = execSync(command, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
encoding: 'utf-8',
|
||||
shell: '/bin/sh',
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const e = error as { stdout?: string; stderr?: string; status?: number; message?: string };
|
||||
return {
|
||||
stdout: e.stdout?.toString().trim() || '',
|
||||
stderr: e.stderr?.toString().trim() || e.message || '',
|
||||
exitCode: e.status || 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
apps/x/packages/core/src/application/lib/exec-tool.ts
Normal file
23
apps/x/packages/core/src/application/lib/exec-tool.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { z } from "zod";
|
||||
import { BuiltinTools } from "./builtin-tools.js";
|
||||
import { executeTool } from "../../mcp/mcp.js";
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: Record<string, unknown>): Promise<unknown> {
|
||||
switch (agentTool.type) {
|
||||
case "mcp":
|
||||
return execMcpTool(agentTool, input);
|
||||
case "builtin": {
|
||||
const builtinTool = BuiltinTools[agentTool.name];
|
||||
if (!builtinTool || !builtinTool.execute) {
|
||||
throw new Error(`Unsupported builtin tool: ${agentTool.name}`);
|
||||
}
|
||||
return builtinTool.execute(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/x/packages/core/src/application/lib/id-gen.ts
Normal file
34
apps/x/packages/core/src/application/lib/id-gen.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export interface IMonotonicallyIncreasingIdGenerator {
|
||||
next(): Promise<string>;
|
||||
}
|
||||
|
||||
export class IdGen implements IMonotonicallyIncreasingIdGenerator {
|
||||
private lastMs = 0;
|
||||
private seq = 0;
|
||||
private readonly pid: string;
|
||||
private readonly hostTag: string;
|
||||
|
||||
constructor() {
|
||||
this.pid = String(process.pid).padStart(7, "0");
|
||||
this.hostTag = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ISO8601-based, lexicographically sortable id string.
|
||||
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
|
||||
*/
|
||||
async next(): Promise<string> {
|
||||
const now = Date.now();
|
||||
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
|
||||
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
|
||||
this.lastMs = ms;
|
||||
|
||||
// Build ISO string (UTC) and remove milliseconds for cleaner filenames
|
||||
const iso = new Date(ms).toISOString() // e.g. 2025-11-11T04:36:29.123Z
|
||||
.replace(/\.\d{3}Z$/, "Z") // drop .123 part
|
||||
.replace(/:/g, "-"); // safe for files: 2025-11-11T04-36-29Z
|
||||
|
||||
const seqStr = String(this.seq).padStart(3, "0");
|
||||
return `${iso}-${this.pid}${this.hostTag}-${seqStr}`;
|
||||
}
|
||||
}
|
||||
43
apps/x/packages/core/src/application/lib/message-queue.ts
Normal file
43
apps/x/packages/core/src/application/lib/message-queue.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: string): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
export class InMemoryMessageQueue implements IMessageQueue {
|
||||
private store: Record<string, EnqueuedMessage[]> = {};
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
|
||||
constructor({
|
||||
idGenerator,
|
||||
}: {
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
}) {
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: string): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
const id = await this.idGenerator.next();
|
||||
this.store[runId].push({
|
||||
messageId: id,
|
||||
message,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async dequeue(runId: string): Promise<EnqueuedMessage | null> {
|
||||
if (!this.store[runId]) {
|
||||
return null;
|
||||
}
|
||||
return this.store[runId].shift() ?? null;
|
||||
}
|
||||
}
|
||||
15
apps/x/packages/core/src/config/config.ts
Normal file
15
apps/x/packages/core/src/config/config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { homedir } from "os";
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
|
||||
function ensureDirs() {
|
||||
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
|
||||
ensure(WorkDir);
|
||||
ensure(path.join(WorkDir, "agents"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
101
apps/x/packages/core/src/config/security.ts
Normal file
101
apps/x/packages/core/src/config/security.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { WorkDir } from "./config.js";
|
||||
|
||||
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
|
||||
|
||||
const DEFAULT_ALLOW_LIST = [
|
||||
"cat",
|
||||
"curl",
|
||||
"date",
|
||||
"echo",
|
||||
"grep",
|
||||
"jq",
|
||||
"ls",
|
||||
"pwd",
|
||||
"yq",
|
||||
"whoami"
|
||||
]
|
||||
|
||||
let cachedAllowList: string[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function ensureSecurityConfig() {
|
||||
if (!fs.existsSync(SECURITY_CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
SECURITY_CONFIG_PATH,
|
||||
JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeList(commands: unknown[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const entry of commands) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const normalized = entry.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
function parseSecurityPayload(payload: unknown): string[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return normalizeList(payload);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const maybeObject = payload as Record<string, unknown>;
|
||||
if (Array.isArray(maybeObject.allowedCommands)) {
|
||||
return normalizeList(maybeObject.allowedCommands);
|
||||
}
|
||||
|
||||
const dynamicList = Object.entries(maybeObject)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
return normalizeList(dynamicList);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function readAllowList(): string[] {
|
||||
ensureSecurityConfig();
|
||||
|
||||
try {
|
||||
const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8");
|
||||
const parsed = JSON.parse(configContent);
|
||||
return parseSecurityPayload(parsed);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`);
|
||||
return DEFAULT_ALLOW_LIST;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSecurityAllowList(): string[] {
|
||||
ensureSecurityConfig();
|
||||
try {
|
||||
const stats = fs.statSync(SECURITY_CONFIG_PATH);
|
||||
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedAllowList;
|
||||
}
|
||||
|
||||
const allowList = readAllowList();
|
||||
cachedAllowList = allowList;
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return allowList;
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
return readAllowList();
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSecurityAllowListCache() {
|
||||
cachedAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
}
|
||||
30
apps/x/packages/core/src/di/container.ts
Normal file
30
apps/x/packages/core/src/di/container.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { asClass, createContainer, InjectionMode } from "awilix";
|
||||
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
|
||||
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
|
||||
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
|
||||
import { FSRunsRepo, IRunsRepo } from "../runs/repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator, IdGen } from "../application/lib/id-gen.js";
|
||||
import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IBus, InMemoryBus } from "../application/lib/bus.js";
|
||||
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
|
||||
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
container.register({
|
||||
idGenerator: asClass<IMonotonicallyIncreasingIdGenerator>(IdGen).singleton(),
|
||||
messageQueue: asClass<IMessageQueue>(InMemoryMessageQueue).singleton(),
|
||||
bus: asClass<IBus>(InMemoryBus).singleton(),
|
||||
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
|
||||
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
|
||||
|
||||
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
|
||||
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
|
||||
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
|
||||
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
5
apps/x/packages/core/src/index.ts
Normal file
5
apps/x/packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Workspace filesystem operations
|
||||
export * as workspace from './workspace/workspace.js';
|
||||
|
||||
// Workspace watcher
|
||||
export * as watcher from './workspace/watcher.js';
|
||||
287
apps/x/packages/core/src/knowledge/sync_calendar.ts
Normal file
287
apps/x/packages/core/src/knowledge/sync_calendar.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { google, calendar_v3 as cal, drive_v3 as drive } from 'googleapis';
|
||||
import { authenticate } from '@google-cloud/local-auth';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
|
||||
// Configuration
|
||||
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
|
||||
const TOKEN_PATH = path.join(process.cwd(), 'token_calendar_notes.json'); // Changed to force re-auth with new scopes
|
||||
const SYNC_INTERVAL_MS = 60 * 1000;
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
];
|
||||
|
||||
const nhm = new NodeHtmlMarkdown();
|
||||
|
||||
// --- Auth Functions ---
|
||||
|
||||
async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
|
||||
try {
|
||||
if (!fs.existsSync(TOKEN_PATH)) return null;
|
||||
const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8');
|
||||
const tokenData = JSON.parse(tokenContent);
|
||||
|
||||
const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
||||
const keys = JSON.parse(credsContent);
|
||||
const key = keys.installed || keys.web;
|
||||
|
||||
const client = new google.auth.OAuth2(
|
||||
key.client_id,
|
||||
key.client_secret,
|
||||
key.redirect_uris ? key.redirect_uris[0] : 'http://localhost'
|
||||
);
|
||||
|
||||
client.setCredentials({
|
||||
refresh_token: tokenData.refresh_token || tokenData.refreshToken,
|
||||
access_token: tokenData.token || tokenData.access_token,
|
||||
expiry_date: tokenData.expiry || tokenData.expiry_date,
|
||||
scope: tokenData.scope
|
||||
});
|
||||
|
||||
return client;
|
||||
} catch (err) {
|
||||
console.error("Error loading saved credentials:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredentials(client: OAuth2Client) {
|
||||
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
||||
const keys = JSON.parse(content);
|
||||
const key = keys.installed || keys.web;
|
||||
const payload = JSON.stringify({
|
||||
type: 'authorized_user',
|
||||
client_id: key.client_id,
|
||||
client_secret: key.client_secret,
|
||||
refresh_token: client.credentials.refresh_token,
|
||||
access_token: client.credentials.access_token,
|
||||
expiry_date: client.credentials.expiry_date,
|
||||
}, null, 2);
|
||||
fs.writeFileSync(TOKEN_PATH, payload);
|
||||
}
|
||||
|
||||
async function authorize(): Promise<OAuth2Client> {
|
||||
let client: OAuth2Client | null = await loadSavedCredentialsIfExist();
|
||||
if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) {
|
||||
console.log("Using existing valid token.");
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) {
|
||||
console.log("Refreshing expired token...");
|
||||
try {
|
||||
await client.refreshAccessToken();
|
||||
await saveCredentials(client);
|
||||
return client;
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh token:", e);
|
||||
if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Performing new OAuth authentication...");
|
||||
client = await authenticate({
|
||||
scopes: SCOPES,
|
||||
keyfilePath: CREDENTIALS_PATH,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
if (client && client.credentials) {
|
||||
await saveCredentials(client);
|
||||
}
|
||||
return client!;
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function cleanFilename(name: string): string {
|
||||
return name.replace(/[\\/*?:"<>|]/g, "").replace(/\s+/g, "_").substring(0, 100).trim();
|
||||
}
|
||||
|
||||
// --- Sync Logic ---
|
||||
|
||||
function cleanUpOldFiles(currentEventIds: Set<string>, syncDir: string) {
|
||||
if (!fs.existsSync(syncDir)) return;
|
||||
|
||||
const files = fs.readdirSync(syncDir);
|
||||
for (const filename of files) {
|
||||
if (filename === 'sync_state.json') continue;
|
||||
|
||||
// We expect files like:
|
||||
// {eventId}.json
|
||||
// {eventId}_doc_{docId}.md
|
||||
|
||||
let eventId: string | null = null;
|
||||
|
||||
if (filename.endsWith('.json')) {
|
||||
eventId = filename.replace('.json', '');
|
||||
} else if (filename.endsWith('.md')) {
|
||||
// Try to extract eventId from prefix
|
||||
// Assuming eventId doesn't contain underscores usually, but if it does, this split might be fragile.
|
||||
// Google Calendar IDs are usually alphanumeric.
|
||||
// Let's rely on the delimiter we use: "_doc_"
|
||||
const parts = filename.split('_doc_');
|
||||
if (parts.length > 1) {
|
||||
eventId = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (eventId && !currentEventIds.has(eventId)) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(syncDir, filename));
|
||||
console.log(`Removed old/out-of-window file: ${filename}`);
|
||||
} catch (e) {
|
||||
console.error(`Error deleting file ${filename}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEvent(event: cal.Schema$Event, syncDir: string): Promise<boolean> {
|
||||
const eventId = event.id;
|
||||
if (!eventId) return false;
|
||||
|
||||
const filePath = path.join(syncDir, `${eventId}.json`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(event, null, 2));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Error saving event ${eventId}:`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, syncDir: string) {
|
||||
if (!event.attachments || event.attachments.length === 0) return;
|
||||
|
||||
const eventId = event.id;
|
||||
const eventTitle = event.summary || 'Untitled';
|
||||
const eventDate = event.start?.dateTime || event.start?.date || 'Unknown';
|
||||
const organizer = event.organizer?.email || 'Unknown';
|
||||
|
||||
for (const att of event.attachments) {
|
||||
// We only care about Google Docs
|
||||
if (att.mimeType === 'application/vnd.google-apps.document') {
|
||||
const fileId = att.fileId;
|
||||
const safeTitle = cleanFilename(att.title || 'Untitled');
|
||||
// Unique filename linked to event
|
||||
const filename = `${eventId}_doc_${safeTitle}.md`;
|
||||
const filePath = path.join(syncDir, filename);
|
||||
|
||||
// Simple cache check: if file exists, skip.
|
||||
// Ideally we check modifiedTime, but that requires an extra API call per file.
|
||||
// Given the loop interval, we can just check existence to save quota.
|
||||
// If user updates notes, they might want them re-synced.
|
||||
// For now, let's just check existence. To be smarter, we'd need a state file or check API.
|
||||
if (fs.existsSync(filePath)) continue;
|
||||
|
||||
try {
|
||||
const res = await drive.files.export({
|
||||
fileId: fileId ?? '',
|
||||
mimeType: 'text/html'
|
||||
});
|
||||
|
||||
const html = res.data as string;
|
||||
const md = nhm.translate(html);
|
||||
|
||||
const frontmatter = [
|
||||
`# ${att.title}`,
|
||||
`**Event:** ${eventTitle}`,
|
||||
`**Date:** ${eventDate}`,
|
||||
`**Organizer:** ${organizer}`,
|
||||
`**Link:** ${att.fileUrl}`,
|
||||
`---`,
|
||||
``
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(filePath, frontmatter + md);
|
||||
console.log(`Synced Note: ${att.title} for event ${eventTitle}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to download note ${att.title}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) {
|
||||
// Calculate window
|
||||
const now = new Date();
|
||||
const lookbackMs = lookbackDays * 24 * 60 * 60 * 1000;
|
||||
const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const timeMin = new Date(now.getTime() - lookbackMs).toISOString();
|
||||
const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString();
|
||||
|
||||
console.log(`Syncing calendar from ${timeMin} to ${timeMax} (lookback: ${lookbackDays} days)...`);
|
||||
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
try {
|
||||
const res = await calendar.events.list({
|
||||
calendarId: 'primary',
|
||||
timeMin: timeMin,
|
||||
timeMax: timeMax,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime'
|
||||
});
|
||||
|
||||
const events = res.data.items || [];
|
||||
const currentEventIds = new Set<string>();
|
||||
|
||||
if (events.length === 0) {
|
||||
console.log("No events found in this window.");
|
||||
} else {
|
||||
console.log(`Found ${events.length} events.`);
|
||||
for (const event of events) {
|
||||
if (event.id) {
|
||||
await saveEvent(event, syncDir);
|
||||
await processAttachments(drive, event, syncDir);
|
||||
currentEventIds.add(event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanUpOldFiles(currentEventIds, syncDir);
|
||||
|
||||
} catch (error) {
|
||||
console.error("An error occurred during calendar sync:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Starting Google Calendar & Notes Sync (TS)...");
|
||||
|
||||
const syncDirArg = process.argv[2];
|
||||
const lookbackDaysArg = process.argv[3];
|
||||
|
||||
const SYNC_DIR = syncDirArg || 'synced_calendar_events';
|
||||
const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 14;
|
||||
|
||||
if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) {
|
||||
console.error("Error: Lookback days must be a positive number.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SYNC_DIR)) {
|
||||
fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await authorize();
|
||||
console.log("Authorization successful.");
|
||||
|
||||
while (true) {
|
||||
await syncCalendarWindow(auth, SYNC_DIR, LOOKBACK_DAYS);
|
||||
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fatal error in main loop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
372
apps/x/packages/core/src/knowledge/sync_gmail.ts
Normal file
372
apps/x/packages/core/src/knowledge/sync_gmail.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { google, gmail_v1 as gmail } from 'googleapis';
|
||||
import { authenticate } from '@google-cloud/local-auth';
|
||||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_SYNC_DIR = 'synced_emails_ts';
|
||||
const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json');
|
||||
const TOKEN_PATH = path.join(process.cwd(), 'token_api.json'); // Reuse Python's token
|
||||
const SYNC_INTERVAL_MS = 60 * 1000;
|
||||
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
|
||||
|
||||
const nhm = new NodeHtmlMarkdown();
|
||||
|
||||
// --- Auth Functions ---
|
||||
|
||||
async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
|
||||
try {
|
||||
const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8');
|
||||
const tokenData = JSON.parse(tokenContent);
|
||||
|
||||
const credsContent = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
||||
const keys = JSON.parse(credsContent);
|
||||
const key = keys.installed || keys.web;
|
||||
|
||||
// Manually construct credentials for google.auth.fromJSON
|
||||
const credentials = {
|
||||
type: 'authorized_user',
|
||||
client_id: key.client_id,
|
||||
client_secret: key.client_secret,
|
||||
refresh_token: tokenData.refresh_token || tokenData.refreshToken, // Handle both cases
|
||||
access_token: tokenData.token || tokenData.access_token, // Handle both cases
|
||||
expiry_date: tokenData.expiry || tokenData.expiry_date
|
||||
};
|
||||
return google.auth.fromJSON(credentials) as OAuth2Client;
|
||||
} catch (err) {
|
||||
console.error("Error loading saved credentials:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredentials(client: OAuth2Client) {
|
||||
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
||||
const keys = JSON.parse(content);
|
||||
const key = keys.installed || keys.web;
|
||||
const payload = JSON.stringify({
|
||||
type: 'authorized_user',
|
||||
client_id: key.client_id,
|
||||
client_secret: key.client_secret,
|
||||
refresh_token: client.credentials.refresh_token,
|
||||
access_token: client.credentials.access_token,
|
||||
expiry_date: client.credentials.expiry_date,
|
||||
}, null, 2);
|
||||
fs.writeFileSync(TOKEN_PATH, payload);
|
||||
}
|
||||
|
||||
async function authorize(): Promise<OAuth2Client> {
|
||||
let client = await loadSavedCredentialsIfExist();
|
||||
if (client && client.credentials && client.credentials.expiry_date && client.credentials.expiry_date > Date.now()) {
|
||||
console.log("Using existing valid token.");
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client && client.credentials && (!client.credentials.expiry_date || client.credentials.expiry_date <= Date.now()) && client.credentials.refresh_token) {
|
||||
console.log("Refreshing expired token...");
|
||||
try {
|
||||
await client.refreshAccessToken();
|
||||
await saveCredentials(client); // Save refreshed token
|
||||
return client;
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh token:", e);
|
||||
// Fall through to full re-auth if refresh fails
|
||||
if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Performing new OAuth authentication...");
|
||||
client = await authenticate({
|
||||
scopes: SCOPES,
|
||||
keyfilePath: CREDENTIALS_PATH,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
if (client && client.credentials) {
|
||||
await saveCredentials(client);
|
||||
}
|
||||
return client!;
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function cleanFilename(name: string): string {
|
||||
return name.replace(/[\\/*?:":<>|]/g, "").substring(0, 100).trim();
|
||||
}
|
||||
|
||||
function decodeBase64(data: string): string {
|
||||
return Buffer.from(data, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
function getBody(payload: gmail.Schema$MessagePart): string {
|
||||
let body = "";
|
||||
if (payload.parts) {
|
||||
for (const part of payload.parts) {
|
||||
if (part.mimeType === 'text/plain' && part.body && part.body.data) {
|
||||
const text = decodeBase64(part.body.data);
|
||||
// Strip quoted lines
|
||||
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>'));
|
||||
body += cleanLines.join('\n');
|
||||
} else if (part.mimeType === 'text/html' && part.body && part.body.data) {
|
||||
const html = decodeBase64(part.body.data);
|
||||
const md = nhm.translate(html);
|
||||
// Simple quote stripping for MD
|
||||
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>'));
|
||||
body += cleanLines.join('\n');
|
||||
} else if (part.parts) {
|
||||
body += getBody(part);
|
||||
}
|
||||
}
|
||||
} else if (payload.body && payload.body.data) {
|
||||
const data = decodeBase64(payload.body.data);
|
||||
if (payload.mimeType === 'text/html') {
|
||||
const md = nhm.translate(data);
|
||||
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
} else {
|
||||
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
|
||||
const filename = part.filename;
|
||||
const attId = part.body?.attachmentId;
|
||||
if (!filename || !attId) return null;
|
||||
|
||||
const safeName = `${msgId}_${cleanFilename(filename)}`;
|
||||
const filePath = path.join(attachmentsDir, safeName);
|
||||
|
||||
if (fs.existsSync(filePath)) return safeName;
|
||||
|
||||
try {
|
||||
const res = await gmail.users.messages.attachments.get({
|
||||
userId,
|
||||
messageId: msgId,
|
||||
id: attId
|
||||
});
|
||||
|
||||
const data = res.data.data;
|
||||
if (data) {
|
||||
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
||||
console.log(`Saved attachment: ${safeName}`);
|
||||
return safeName;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error saving attachment ${filename}:`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Sync Logic ---
|
||||
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) {
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
try {
|
||||
const res = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
||||
const thread = res.data;
|
||||
const messages = thread.messages;
|
||||
|
||||
if (!messages || messages.length === 0) return;
|
||||
|
||||
// Subject from first message
|
||||
const firstHeader = messages[0].payload?.headers;
|
||||
const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)';
|
||||
|
||||
let mdContent = `# ${subject}\n\n`;
|
||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||
|
||||
for (const msg of messages) {
|
||||
const msgId = msg.id!;
|
||||
const headers = msg.payload?.headers || [];
|
||||
const from = headers.find(h => h.name === 'From')?.value || 'Unknown';
|
||||
const date = headers.find(h => h.name === 'Date')?.value || 'Unknown';
|
||||
|
||||
mdContent += `### From: ${from}\n`;
|
||||
mdContent += `**Date:** ${date}\n\n`;
|
||||
|
||||
if (msg.payload) {
|
||||
const body = getBody(msg.payload);
|
||||
mdContent += `${body}\n\n`;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
const parts: gmail.Schema$MessagePart[] = [];
|
||||
const traverseParts = (pList: gmail.Schema$MessagePart[]) => {
|
||||
for (const p of pList) {
|
||||
parts.push(p);
|
||||
if (p.parts) traverseParts(p.parts);
|
||||
}
|
||||
};
|
||||
if (msg.payload?.parts) traverseParts(msg.payload.parts);
|
||||
|
||||
let attachmentsFound = false;
|
||||
for (const part of parts) {
|
||||
if (part.filename && part.body?.attachmentId) {
|
||||
const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir);
|
||||
if (savedName) {
|
||||
if (!attachmentsFound) {
|
||||
mdContent += "**Attachments:**\n";
|
||||
attachmentsFound = true;
|
||||
}
|
||||
mdContent += `- [${part.filename}](attachments/${savedName})\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
mdContent += "\n---\n\n";
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
||||
console.log(`Synced Thread: ${subject} (${threadId})`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing thread ${threadId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(stateFile: string): { historyId?: string } {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveState(historyId: string, stateFile: string) {
|
||||
fs.writeFileSync(stateFile, JSON.stringify({
|
||||
historyId,
|
||||
last_sync: new Date().toISOString()
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
||||
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
|
||||
|
||||
// Get History ID
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
const currentHistoryId = profile.data.historyId!;
|
||||
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const res = await gmail.users.threads.list({
|
||||
userId: 'me',
|
||||
q: `after:${dateQuery}`,
|
||||
pageToken
|
||||
});
|
||||
|
||||
const threads = res.data.threads;
|
||||
if (threads) {
|
||||
for (const thread of threads) {
|
||||
await processThread(auth, thread.id!, syncDir, attachmentsDir);
|
||||
}
|
||||
}
|
||||
pageToken = res.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
saveState(currentHistoryId, stateFile);
|
||||
console.log("Full sync complete.");
|
||||
}
|
||||
|
||||
async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
||||
console.log(`Checking updates since historyId ${startHistoryId}...`);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
try {
|
||||
const res = await gmail.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId,
|
||||
historyTypes: ['messageAdded']
|
||||
});
|
||||
|
||||
const changes = res.data.history;
|
||||
if (!changes || changes.length === 0) {
|
||||
console.log("No new changes.");
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
saveState(profile.data.historyId!, stateFile);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${changes.length} history records.`);
|
||||
const threadIds = new Set<string>();
|
||||
|
||||
for (const record of changes) {
|
||||
if (record.messagesAdded) {
|
||||
for (const item of record.messagesAdded) {
|
||||
if (item.message?.threadId) {
|
||||
threadIds.add(item.message.threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tid of threadIds) {
|
||||
await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
}
|
||||
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
saveState(profile.data.historyId!, stateFile);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const e = error as { response?: { status?: number } };
|
||||
if (e.response?.status === 404) {
|
||||
console.log("History ID expired. Falling back to full sync.");
|
||||
await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
||||
} else {
|
||||
console.error("Error during partial sync:", error);
|
||||
// If 401, remove token to force re-auth next run
|
||||
if (e.response?.status === 401 && fs.existsSync(TOKEN_PATH)) {
|
||||
console.log("401 Unauthorized. Deleting token to force re-authentication.");
|
||||
fs.unlinkSync(TOKEN_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Starting Gmail Sync (TS)...");
|
||||
const syncDirArg = process.argv[2];
|
||||
const lookbackDaysArg = process.argv[3];
|
||||
|
||||
const SYNC_DIR = syncDirArg || DEFAULT_SYNC_DIR;
|
||||
const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 7; // Default to 7 days
|
||||
|
||||
if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) {
|
||||
console.error("Error: Lookback days must be a positive number.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||
|
||||
// Ensure directories exist
|
||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
|
||||
try {
|
||||
const auth = await authorize();
|
||||
console.log("Authorization successful.");
|
||||
|
||||
while (true) {
|
||||
const state = loadState(STATE_FILE);
|
||||
if (!state.historyId) {
|
||||
console.log("No history ID found, starting full sync...");
|
||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
} else {
|
||||
console.log("History ID found, starting partial sync...");
|
||||
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
}
|
||||
|
||||
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fatal error in main loop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
122
apps/x/packages/core/src/mcp/mcp.ts
Normal file
122
apps/x/packages/core/src/mcp/mcp.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import container from "../di/container.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import z from "zod";
|
||||
import { IMcpConfigRepo } from "./repo.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import {
|
||||
connectionState,
|
||||
ListToolsResponse,
|
||||
McpServerList,
|
||||
} from "@x/shared/dist/mcp.js";
|
||||
|
||||
type mcpState = {
|
||||
state: z.infer<typeof connectionState>,
|
||||
client: Client | null,
|
||||
error: string | null,
|
||||
};
|
||||
const clients: Record<string, mcpState> = {};
|
||||
|
||||
async function getClient(serverName: string): Promise<Client> {
|
||||
if (clients[serverName] && clients[serverName].state === "connected") {
|
||||
return clients[serverName].client!;
|
||||
}
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
const { mcpServers } = await repo.getConfig();
|
||||
const config = mcpServers[serverName];
|
||||
if (!config) {
|
||||
throw new Error(`MCP server ${serverName} not found`);
|
||||
}
|
||||
let transport: Transport | undefined = undefined;
|
||||
try {
|
||||
// create transport
|
||||
if ("command" in config) {
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
transport = new StreamableHTTPClientTransport(new URL(config.url));
|
||||
} catch {
|
||||
// if that fails, try sse transport
|
||||
transport = new SSEClientTransport(new URL(config.url));
|
||||
}
|
||||
}
|
||||
|
||||
if (!transport) {
|
||||
throw new Error(`No transport found for ${serverName}`);
|
||||
}
|
||||
|
||||
// create client
|
||||
const client = new Client({
|
||||
name: 'rowboatx',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
// store
|
||||
clients[serverName] = {
|
||||
state: "connected",
|
||||
client,
|
||||
error: null,
|
||||
};
|
||||
return client;
|
||||
} catch (error) {
|
||||
clients[serverName] = {
|
||||
state: "error",
|
||||
client: null,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
transport?.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
for (const [serverName, { client }] of Object.entries(clients)) {
|
||||
await client?.transport?.close();
|
||||
await client?.close();
|
||||
delete clients[serverName];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServers(): Promise<z.infer<typeof McpServerList>> {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
const { mcpServers } = await repo.getConfig();
|
||||
const result: z.infer<typeof McpServerList> = {
|
||||
mcpServers: {},
|
||||
};
|
||||
for (const [serverName, config] of Object.entries(mcpServers)) {
|
||||
const state = clients[serverName];
|
||||
result.mcpServers[serverName] = {
|
||||
config,
|
||||
state: state ? state.state : "disconnected",
|
||||
error: state ? state.error : null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listTools(serverName: string, cursor?: string): Promise<z.infer<typeof ListToolsResponse>> {
|
||||
const client = await getClient(serverName);
|
||||
const { tools, nextCursor } = await client.listTools({
|
||||
cursor,
|
||||
});
|
||||
return {
|
||||
tools,
|
||||
nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeTool(serverName: string, toolName: string, input: Record<string, unknown>): Promise<unknown> {
|
||||
const client = await getClient(serverName);
|
||||
const result = await client.callTool({
|
||||
name: toolName,
|
||||
arguments: input,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
44
apps/x/packages/core/src/mcp/repo.ts
Normal file
44
apps/x/packages/core/src/mcp/repo.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import { McpServerConfig, McpServerDefinition } from "@x/shared/dist/mcp.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export interface IMcpConfigRepo {
|
||||
getConfig(): Promise<z.infer<typeof McpServerConfig>>;
|
||||
upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void>;
|
||||
delete(serverName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSMcpConfigRepo implements IMcpConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof McpServerConfig>> {
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
async upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.mcpServers[serverName] = config;
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
|
||||
async delete(serverName: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
delete conf.mcpServers[serverName];
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
}
|
||||
119
apps/x/packages/core/src/models/models.ts
Normal file
119
apps/x/packages/core/src/models/models.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { ProviderV2 } from "@ai-sdk/provider";
|
||||
import { createGateway } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import container from "../di/container.js";
|
||||
import z from "zod";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const providerMap: Record<string, ProviderV2> = {};
|
||||
|
||||
export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
||||
// get model conf
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const modelConfig = await repo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
if (!name) {
|
||||
name = modelConfig.defaults.provider;
|
||||
}
|
||||
if (providerMap[name]) {
|
||||
return providerMap[name];
|
||||
}
|
||||
const providerConfig = modelConfig.providers[name];
|
||||
if (!providerConfig) {
|
||||
throw new Error(`Provider ${name} not found`);
|
||||
}
|
||||
const { apiKey, baseURL, headers } = providerConfig;
|
||||
switch (providerConfig.flavor) {
|
||||
case "rowboat [free]":
|
||||
providerMap[name] = createGateway({
|
||||
apiKey: "rowboatx",
|
||||
baseURL: "https://ai-gateway.rowboatlabs.com/v1/ai",
|
||||
});
|
||||
break;
|
||||
case "openai":
|
||||
providerMap[name] = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
break;
|
||||
case "aigateway":
|
||||
providerMap[name] = createGateway({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "anthropic":
|
||||
providerMap[name] = createAnthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "google":
|
||||
providerMap[name] = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "ollama":
|
||||
providerMap[name] = createOllama({
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "openai-compatible":
|
||||
providerMap[name] = createOpenAICompatible({
|
||||
name,
|
||||
apiKey,
|
||||
baseURL : baseURL || "",
|
||||
headers,
|
||||
});
|
||||
break;
|
||||
case "openrouter":
|
||||
providerMap[name] = createOpenRouter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Provider ${name} not found`);
|
||||
}
|
||||
return providerMap[name];
|
||||
}
|
||||
70
apps/x/packages/core/src/models/repo.ts
Normal file
70
apps/x/packages/core/src/models/repo.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { ModelConfig, Provider } from "./models.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export interface IModelConfigRepo {
|
||||
getConfig(): Promise<z.infer<typeof ModelConfig>>;
|
||||
upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void>;
|
||||
delete(providerName: string): Promise<void>;
|
||||
setDefault(providerName: string, model: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultConfig: z.infer<typeof ModelConfig> = {
|
||||
providers: {
|
||||
"openai": {
|
||||
flavor: "openai",
|
||||
}
|
||||
},
|
||||
defaults: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}
|
||||
};
|
||||
|
||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return ModelConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
async upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.providers[providerName] = config;
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async delete(providerName: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
delete conf.providers[providerName];
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async setDefault(providerName: string, model: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.defaults = {
|
||||
provider: providerName,
|
||||
model,
|
||||
};
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
}
|
||||
4
apps/x/packages/core/src/runs/bus.ts
Normal file
4
apps/x/packages/core/src/runs/bus.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import container from "../di/container.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
||||
export const bus = container.resolve<IBus>('bus');
|
||||
20
apps/x/packages/core/src/runs/lock.ts
Normal file
20
apps/x/packages/core/src/runs/lock.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface IRunsLock {
|
||||
lock(runId: string): Promise<boolean>;
|
||||
release(runId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class InMemoryRunsLock implements IRunsLock {
|
||||
private locks: Record<string, boolean> = {};
|
||||
|
||||
async lock(runId: string): Promise<boolean> {
|
||||
if (this.locks[runId]) {
|
||||
return false;
|
||||
}
|
||||
this.locks[runId] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async release(runId: string): Promise<void> {
|
||||
delete this.locks[runId];
|
||||
}
|
||||
}
|
||||
131
apps/x/packages/core/src/runs/repo.ts
Normal file
131
apps/x/packages/core/src/runs/repo.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import z from "zod";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import path from "path";
|
||||
import fsp from "fs/promises";
|
||||
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse } from "@x/shared/dist/runs.js";
|
||||
|
||||
export interface IRunsRepo {
|
||||
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
|
||||
fetch(id: string): Promise<z.infer<typeof Run>>;
|
||||
list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>>;
|
||||
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSRunsRepo implements IRunsRepo {
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
constructor({
|
||||
idGenerator,
|
||||
}: {
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
}) {
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
|
||||
await fsp.appendFile(
|
||||
path.join(WorkDir, 'runs', `${runId}.jsonl`),
|
||||
events.map(event => JSON.stringify(event)).join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const runId = await this.idGenerator.next();
|
||||
const ts = new Date().toISOString();
|
||||
const start: z.infer<typeof StartEvent> = {
|
||||
type: "start",
|
||||
runId,
|
||||
agentName: options.agentId,
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
await this.appendEvents(runId, [start]);
|
||||
return {
|
||||
id: runId,
|
||||
createdAt: ts,
|
||||
agentId: options.agentId,
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Run>> {
|
||||
const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8');
|
||||
const events = contents.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.map(line => RunEvent.parse(JSON.parse(line)));
|
||||
if (events.length === 0 || events[0].type !== 'start') {
|
||||
throw new Error('Corrupt run data');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
createdAt: events[0].ts!,
|
||||
agentId: events[0].agentName,
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
|
||||
async list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> {
|
||||
const runsDir = path.join(WorkDir, 'runs');
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
let files: string[] = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(runsDir, { withFileTypes: true });
|
||||
files = entries
|
||||
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
|
||||
.map(e => e.name);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ENOENT') {
|
||||
return { runs: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const cursorFile = cursor;
|
||||
let startIndex = 0;
|
||||
if (cursorFile) {
|
||||
const exact = files.indexOf(cursorFile);
|
||||
if (exact >= 0) {
|
||||
startIndex = exact + 1;
|
||||
} else {
|
||||
const firstOlder = files.findIndex(name => name.localeCompare(cursorFile) < 0);
|
||||
startIndex = firstOlder === -1 ? files.length : firstOlder;
|
||||
}
|
||||
}
|
||||
|
||||
const selected = files.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
const runs: z.infer<typeof ListRunsResponse>['runs'] = [];
|
||||
|
||||
for (const name of selected) {
|
||||
const runId = name.slice(0, -'.jsonl'.length);
|
||||
try {
|
||||
const contents = await fsp.readFile(path.join(runsDir, name), 'utf8');
|
||||
const firstLine = contents.split('\n').find(line => line.trim() !== '');
|
||||
if (!firstLine) {
|
||||
continue;
|
||||
}
|
||||
const start = StartEvent.parse(JSON.parse(firstLine));
|
||||
runs.push({
|
||||
id: runId,
|
||||
createdAt: start.ts!,
|
||||
agentId: start.agentName,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = startIndex + PAGE_SIZE < files.length;
|
||||
const nextCursor = hasMore && selected.length > 0
|
||||
? selected[selected.length - 1]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
runs,
|
||||
...(nextCursor ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
52
apps/x/packages/core/src/runs/runs.ts
Normal file
52
apps/x/packages/core/src/runs/runs.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
const run = await repo.create(opts);
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
||||
...ev,
|
||||
runId,
|
||||
type: "tool-permission-response",
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
}
|
||||
|
||||
export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof AskHumanResponsePayload>): Promise<void> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof AskHumanResponseEvent> = {
|
||||
...ev,
|
||||
runId,
|
||||
type: "ask-human-response",
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
}
|
||||
|
||||
export async function stop(runId: string): Promise<void> {
|
||||
console.log(`Stopping run ${runId}`);
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
77
apps/x/packages/core/src/workspace/watcher.ts
Normal file
77
apps/x/packages/core/src/workspace/watcher.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ensureWorkspaceRoot, absToRelPosix } from './workspace.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { WorkspaceChangeEvent } from 'packages/shared/dist/workspace.js';
|
||||
import z from 'zod';
|
||||
import { Stats } from 'node:fs';
|
||||
|
||||
export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEvent>) => void;
|
||||
|
||||
/**
|
||||
* Create a workspace watcher
|
||||
* Watches ~/.rowboat recursively and emits change events via callback
|
||||
*
|
||||
* Returns a watcher instance that can be closed.
|
||||
* The watcher emits events immediately without debouncing.
|
||||
* Debouncing and lifecycle management should be handled by the caller.
|
||||
*/
|
||||
export async function createWorkspaceWatcher(
|
||||
callback: WorkspaceChangeCallback
|
||||
): Promise<FSWatcher> {
|
||||
await ensureWorkspaceRoot();
|
||||
|
||||
const watcher = chokidar.watch(WorkDir, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 150,
|
||||
pollInterval: 50,
|
||||
},
|
||||
});
|
||||
|
||||
watcher
|
||||
.on('add', (absPath: string) => {
|
||||
const relPath = absToRelPosix(absPath);
|
||||
if (relPath) {
|
||||
fs.lstat(absPath)
|
||||
.then((stats: Stats) => {
|
||||
const kind = stats.isDirectory() ? 'dir' : 'file';
|
||||
callback({ type: 'created', path: relPath, kind });
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
}
|
||||
})
|
||||
.on('addDir', (absPath: string) => {
|
||||
const relPath = absToRelPosix(absPath);
|
||||
if (relPath) {
|
||||
callback({ type: 'created', path: relPath, kind: 'dir' });
|
||||
}
|
||||
})
|
||||
.on('change', (absPath: string) => {
|
||||
const relPath = absToRelPosix(absPath);
|
||||
if (relPath) {
|
||||
// Emit change event immediately - debouncing handled by caller
|
||||
callback({ type: 'changed', path: relPath });
|
||||
}
|
||||
})
|
||||
.on('unlink', (absPath: string) => {
|
||||
const relPath = absToRelPosix(absPath);
|
||||
if (relPath) {
|
||||
callback({ type: 'deleted', path: relPath, kind: 'file' });
|
||||
}
|
||||
})
|
||||
.on('unlinkDir', (absPath: string) => {
|
||||
const relPath = absToRelPosix(absPath);
|
||||
if (relPath) {
|
||||
callback({ type: 'deleted', path: relPath, kind: 'dir' });
|
||||
}
|
||||
})
|
||||
.on('error', (error: unknown) => {
|
||||
console.error('Workspace watcher error:', error);
|
||||
});
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
371
apps/x/packages/core/src/workspace/workspace.ts
Normal file
371
apps/x/packages/core/src/workspace/workspace.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import type { Stats } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { workspace } from '@x/shared';
|
||||
import { z } from 'zod';
|
||||
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert that a relative path is safe (no traversal, no absolute paths)
|
||||
*/
|
||||
export function assertSafeRelPath(relPath: string): void {
|
||||
if (path.isAbsolute(relPath)) {
|
||||
throw new Error('Absolute paths are not allowed');
|
||||
}
|
||||
if (relPath.includes('..')) {
|
||||
throw new Error('Path traversal (..) is not allowed');
|
||||
}
|
||||
// Normalize and check again after normalization
|
||||
const normalized = path.normalize(relPath);
|
||||
if (normalized.includes('..') || path.isAbsolute(normalized)) {
|
||||
throw new Error('Invalid path');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace-relative path to an absolute path
|
||||
* Ensures the resolved path stays within the workspace boundary
|
||||
* Empty string represents the root directory
|
||||
*/
|
||||
export function resolveWorkspacePath(relPath: string): string {
|
||||
// Empty string means root directory
|
||||
if (relPath === '') {
|
||||
return WorkDir;
|
||||
}
|
||||
assertSafeRelPath(relPath);
|
||||
const resolved = path.resolve(WorkDir, relPath);
|
||||
if (!resolved.startsWith(WorkDir + path.sep) && resolved !== WorkDir) {
|
||||
throw new Error('Path outside workspace boundary');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute path to workspace-relative POSIX path
|
||||
* Returns null if path is outside workspace boundary
|
||||
*/
|
||||
export function absToRelPosix(absPath: string): string | null {
|
||||
const normalized = path.normalize(absPath);
|
||||
if (!normalized.startsWith(WorkDir + path.sep) && normalized !== WorkDir) {
|
||||
return null;
|
||||
}
|
||||
const relPath = path.relative(WorkDir, normalized);
|
||||
return relPath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File System Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Compute ETag from file stats: `${size}:${mtimeMs}`
|
||||
*/
|
||||
export function computeEtag(size: number, mtimeMs: number): string {
|
||||
return `${size}:${mtimeMs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert fs.Stats to Stat schema
|
||||
*/
|
||||
export function statToSchema(stats: Stats, kind: z.infer<typeof workspace.NodeKind>): z.infer<typeof workspace.Stat> {
|
||||
return {
|
||||
kind,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
isSymlink: stats.isSymbolicLink() ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure workspace root exists
|
||||
*/
|
||||
export async function ensureWorkspaceRoot(): Promise<void> {
|
||||
await fs.mkdir(WorkDir, { recursive: true });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Operations
|
||||
// ============================================================================
|
||||
|
||||
export async function getRoot(): Promise<{ root: string }> {
|
||||
await ensureWorkspaceRoot();
|
||||
return { root: '' };
|
||||
}
|
||||
|
||||
export async function exists(relPath: string): Promise<{ exists: boolean }> {
|
||||
const filePath = resolveWorkspacePath(relPath);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return { exists: true };
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function stat(relPath: string): Promise<z.infer<typeof workspace.Stat>> {
|
||||
const filePath = resolveWorkspacePath(relPath);
|
||||
const stats = await fs.lstat(filePath);
|
||||
const kind = stats.isDirectory() ? 'dir' : 'file';
|
||||
return statToSchema(stats, kind);
|
||||
}
|
||||
|
||||
export async function readdir(
|
||||
relPath: string,
|
||||
opts?: z.infer<typeof workspace.ReaddirOptions>,
|
||||
): Promise<Array<z.infer<typeof workspace.DirEntry>>> {
|
||||
const dirPath = resolveWorkspacePath(relPath);
|
||||
const entries: Array<z.infer<typeof workspace.DirEntry>> = [];
|
||||
|
||||
async function readDir(currentPath: string, currentRelPath: string): Promise<void> {
|
||||
const items = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
// Skip hidden files unless includeHidden is true
|
||||
if (!opts?.includeHidden && item.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemPath = path.join(currentPath, item.name);
|
||||
const itemRelPath = path.posix.join(currentRelPath, item.name);
|
||||
|
||||
// Filter by extension if specified
|
||||
if (opts?.allowedExtensions && opts.allowedExtensions.length > 0) {
|
||||
const ext = path.extname(item.name);
|
||||
if (!opts.allowedExtensions.includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let itemKind: z.infer<typeof workspace.NodeKind>;
|
||||
let itemStat: { size: number; mtimeMs: number } | undefined;
|
||||
|
||||
if (item.isDirectory()) {
|
||||
itemKind = 'dir';
|
||||
if (opts?.includeStats) {
|
||||
const stats = await fs.lstat(itemPath);
|
||||
itemStat = { size: stats.size, mtimeMs: stats.mtimeMs };
|
||||
}
|
||||
entries.push({ name: item.name, path: itemRelPath, kind: itemKind, stat: itemStat });
|
||||
|
||||
// Recurse if recursive is true
|
||||
if (opts?.recursive) {
|
||||
await readDir(itemPath, itemRelPath);
|
||||
}
|
||||
} else if (item.isFile()) {
|
||||
itemKind = 'file';
|
||||
if (opts?.includeStats) {
|
||||
const stats = await fs.lstat(itemPath);
|
||||
itemStat = { size: stats.size, mtimeMs: stats.mtimeMs };
|
||||
}
|
||||
entries.push({ name: item.name, path: itemRelPath, kind: itemKind, stat: itemStat });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await readDir(dirPath, relPath);
|
||||
|
||||
// Sort: directories first, then by name (localeCompare)
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === 'dir' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
relPath: string,
|
||||
encoding: z.infer<typeof workspace.Encoding> = 'utf8'
|
||||
): Promise<z.infer<typeof workspace.ReadFileResult>> {
|
||||
const filePath = resolveWorkspacePath(relPath);
|
||||
const stats = await fs.lstat(filePath);
|
||||
|
||||
let data: string;
|
||||
if (encoding === 'utf8') {
|
||||
data = await fs.readFile(filePath, 'utf8');
|
||||
} else if (encoding === 'base64') {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
data = buffer.toString('base64');
|
||||
} else {
|
||||
// binary: return as base64-encoded binary data
|
||||
const buffer = await fs.readFile(filePath);
|
||||
data = buffer.toString('base64');
|
||||
}
|
||||
|
||||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
encoding,
|
||||
data,
|
||||
stat,
|
||||
etag,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
relPath: string,
|
||||
data: string,
|
||||
opts?: z.infer<typeof WriteFileOptions>
|
||||
): Promise<z.infer<typeof WriteFileResult>> {
|
||||
const filePath = resolveWorkspacePath(relPath);
|
||||
const encoding = opts?.encoding || 'utf8';
|
||||
const atomic = opts?.atomic !== false; // default true
|
||||
const mkdirp = opts?.mkdirp !== false; // default true
|
||||
|
||||
// Create parent directory if needed
|
||||
if (mkdirp) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
// Check expectedEtag if provided (conflict detection)
|
||||
if (opts?.expectedEtag) {
|
||||
const existingStats = await fs.lstat(filePath);
|
||||
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
||||
if (existingEtag !== opts.expectedEtag) {
|
||||
throw new Error('File was modified (ETag mismatch)');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert data to buffer based on encoding
|
||||
let buffer: Buffer;
|
||||
if (encoding === 'utf8') {
|
||||
buffer = Buffer.from(data, 'utf8');
|
||||
} else if (encoding === 'base64') {
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
// binary: assume data is base64-encoded
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
}
|
||||
|
||||
if (atomic) {
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
await fs.rename(tempPath, filePath);
|
||||
} else {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
}
|
||||
|
||||
const stats = await fs.lstat(filePath);
|
||||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
stat,
|
||||
etag,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mkdir(
|
||||
relPath: string,
|
||||
recursive: boolean = true
|
||||
): Promise<{ ok: true }> {
|
||||
const dirPath = resolveWorkspacePath(relPath);
|
||||
await fs.mkdir(dirPath, { recursive });
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function rename(
|
||||
from: string,
|
||||
to: string,
|
||||
overwrite: boolean = false
|
||||
): Promise<{ ok: true }> {
|
||||
const fromPath = resolveWorkspacePath(from);
|
||||
const toPath = resolveWorkspacePath(to);
|
||||
|
||||
// Check if destination exists
|
||||
if (!overwrite) {
|
||||
await fs.access(toPath);
|
||||
throw new Error('Destination already exists');
|
||||
}
|
||||
|
||||
// Create parent directory for destination
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
||||
|
||||
await fs.rename(fromPath, toPath);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function copy(
|
||||
from: string,
|
||||
to: string,
|
||||
overwrite: boolean = false
|
||||
): Promise<{ ok: true }> {
|
||||
const fromPath = resolveWorkspacePath(from);
|
||||
const toPath = resolveWorkspacePath(to);
|
||||
|
||||
// Check if source is a file (no recursive dir copy)
|
||||
const fromStats = await fs.lstat(fromPath);
|
||||
if (fromStats.isDirectory()) {
|
||||
throw new Error('Copying directories is not supported');
|
||||
}
|
||||
|
||||
// Check if destination exists
|
||||
if (!overwrite) {
|
||||
await fs.access(toPath);
|
||||
}
|
||||
|
||||
// Create parent directory for destination
|
||||
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
||||
|
||||
await fs.copyFile(fromPath, toPath);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function remove(
|
||||
relPath: string,
|
||||
opts?: z.infer<typeof RemoveOptions>
|
||||
): Promise<{ ok: true }> {
|
||||
const filePath = resolveWorkspacePath(relPath);
|
||||
const trash = opts?.trash !== false; // default true
|
||||
|
||||
const stats = await fs.lstat(filePath);
|
||||
|
||||
if (trash) {
|
||||
// Move to trash: ~/.workspace/.trash/<timestamp>-<name>
|
||||
const trashDir = path.join(WorkDir, '.trash');
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
|
||||
const timestamp = Date.now();
|
||||
const basename = path.basename(filePath);
|
||||
const trashPath = path.join(trashDir, `${timestamp}-${basename}`);
|
||||
|
||||
// Handle name conflicts in trash
|
||||
let finalTrashPath = trashPath;
|
||||
let counter = 1;
|
||||
while (true) {
|
||||
try {
|
||||
await fs.access(finalTrashPath);
|
||||
finalTrashPath = path.join(trashDir, `${timestamp}-${counter}-${basename}`);
|
||||
counter++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rename(filePath, finalTrashPath);
|
||||
} else {
|
||||
// Permanent delete
|
||||
if (stats.isDirectory()) {
|
||||
if (!opts?.recursive) {
|
||||
throw new Error('Cannot remove directory without recursive=true');
|
||||
}
|
||||
await fs.rm(filePath, { recursive: true });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
12
apps/x/packages/core/tsconfig.json
Normal file
12
apps/x/packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
2
apps/x/packages/shared/.gitignore
vendored
Normal file
2
apps/x/packages/shared/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
14
apps/x/packages/shared/package.json
Normal file
14
apps/x/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@x/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc",
|
||||
"dev": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
35
apps/x/packages/shared/src/agent.ts
Normal file
35
apps/x/packages/shared/src/agent.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const BaseTool = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const BuiltinTool = BaseTool.extend({
|
||||
type: z.literal("builtin"),
|
||||
});
|
||||
|
||||
export const McpTool = BaseTool.extend({
|
||||
type: z.literal("mcp"),
|
||||
description: z.string(),
|
||||
inputSchema: z.any(),
|
||||
mcpServerName: z.string(),
|
||||
});
|
||||
|
||||
export const AgentAsATool = BaseTool.extend({
|
||||
type: z.literal("agent"),
|
||||
});
|
||||
|
||||
export const ToolAttachment = z.discriminatedUnion("type", [
|
||||
BuiltinTool,
|
||||
McpTool,
|
||||
AgentAsATool,
|
||||
]);
|
||||
|
||||
export const Agent = z.object({
|
||||
name: z.string(),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
instructions: z.string(),
|
||||
tools: z.record(z.string(), ToolAttachment).optional(),
|
||||
});
|
||||
12
apps/x/packages/shared/src/example.ts
Normal file
12
apps/x/packages/shared/src/example.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import z from "zod"
|
||||
import { Agent } from "./agent.js"
|
||||
import { McpServerDefinition } from "./mcp.js";
|
||||
|
||||
export const Example = z.object({
|
||||
id: z.string(),
|
||||
instructions: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
entryAgent: z.string().optional(),
|
||||
agents: z.array(Agent).optional(),
|
||||
mcpServers: z.record(z.string(), McpServerDefinition).optional(),
|
||||
});
|
||||
6
apps/x/packages/shared/src/index.ts
Normal file
6
apps/x/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { PrefixLogger } from './prefix-logger.js';
|
||||
|
||||
export * as ipc from './ipc.js';
|
||||
export * as workspace from './workspace.js';
|
||||
export * as mcp from './mcp.js';
|
||||
export { PrefixLogger };
|
||||
212
apps/x/packages/shared/src/ipc.ts
Normal file
212
apps/x/packages/shared/src/ipc.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { z } from 'zod';
|
||||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||
import { ListToolsResponse } from './mcp.js';
|
||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ToolPermissionAuthorizePayload } from './runs.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
// ============================================================================
|
||||
|
||||
const ipcSchemas = {
|
||||
'app:getVersions': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
chrome: z.string(),
|
||||
node: z.string(),
|
||||
electron: z.string(),
|
||||
}),
|
||||
},
|
||||
'workspace:getRoot': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
root: z.string(),
|
||||
}),
|
||||
},
|
||||
'workspace:exists': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
}),
|
||||
res: z.object({
|
||||
exists: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'workspace:stat': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
}),
|
||||
res: Stat,
|
||||
},
|
||||
'workspace:readdir': {
|
||||
req: z.object({
|
||||
path: z.string(), // Empty string allowed for root directory
|
||||
opts: ReaddirOptions.optional(),
|
||||
}),
|
||||
res: z.array(DirEntry),
|
||||
},
|
||||
'workspace:readFile': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
encoding: Encoding.optional(),
|
||||
}),
|
||||
res: ReadFileResult,
|
||||
},
|
||||
'workspace:writeFile': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
data: z.string(),
|
||||
opts: WriteFileOptions.optional(),
|
||||
}),
|
||||
res: WriteFileResult,
|
||||
},
|
||||
'workspace:mkdir': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
recursive: z.boolean().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'workspace:rename': {
|
||||
req: z.object({
|
||||
from: RelPath,
|
||||
to: RelPath,
|
||||
overwrite: z.boolean().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'workspace:copy': {
|
||||
req: z.object({
|
||||
from: RelPath,
|
||||
to: RelPath,
|
||||
overwrite: z.boolean().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'workspace:remove': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
opts: RemoveOptions.optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'workspace:didChange': {
|
||||
req: WorkspaceChangeEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
'mcp:listTools': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
res: ListToolsResponse,
|
||||
},
|
||||
'mcp:executeTool': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
res: z.object({
|
||||
result: z.unknown(),
|
||||
}),
|
||||
},
|
||||
'runs:create': {
|
||||
req: CreateRunOptions,
|
||||
res: Run,
|
||||
},
|
||||
'runs:createMessage': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
messageId: z.string(),
|
||||
}),
|
||||
},
|
||||
'runs:authorizePermission': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
authorization: ToolPermissionAuthorizePayload,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'runs:provideHumanInput': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
reply: AskHumanResponsePayload,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'runs:stop': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'runs:events': {
|
||||
req: z.null(),
|
||||
res: z.null(),
|
||||
}
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Type Helpers
|
||||
// ============================================================================
|
||||
|
||||
export type IPCChannels = {
|
||||
[K in keyof typeof ipcSchemas]: {
|
||||
req: z.infer<typeof ipcSchemas[K]['req']>;
|
||||
res: z.infer<typeof ipcSchemas[K]['res']>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Channels that use invoke/handle (request/response pattern)
|
||||
* These are channels with non-null responses
|
||||
*/
|
||||
export type InvokeChannels = {
|
||||
[K in keyof IPCChannels]:
|
||||
IPCChannels[K]['res'] extends null ? never : K
|
||||
}[keyof IPCChannels];
|
||||
|
||||
/**
|
||||
* Channels that use send/on (fire-and-forget pattern)
|
||||
* These are channels with null responses (no response expected)
|
||||
*/
|
||||
export type SendChannels = {
|
||||
[K in keyof IPCChannels]:
|
||||
IPCChannels[K]['res'] extends null ? K : never
|
||||
}[keyof IPCChannels];
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
export function validateRequest<K extends keyof IPCChannels>(
|
||||
channel: K,
|
||||
data: unknown
|
||||
): IPCChannels[K]['req'] {
|
||||
const schema = ipcSchemas[channel].req;
|
||||
return schema.parse(data) as IPCChannels[K]['req'];
|
||||
}
|
||||
|
||||
export function validateResponse<K extends keyof IPCChannels>(
|
||||
channel: K,
|
||||
data: unknown
|
||||
): IPCChannels[K]['res'] {
|
||||
const schema = ipcSchemas[channel].res;
|
||||
return schema.parse(data) as IPCChannels[K]['res'];
|
||||
}
|
||||
63
apps/x/packages/shared/src/llm-step-events.ts
Normal file
63
apps/x/packages/shared/src/llm-step-events.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { z } from "zod";
|
||||
import { ProviderOptions } from "./message.js";
|
||||
|
||||
const BaseEvent = z.object({
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
})
|
||||
|
||||
export const LlmStepStreamReasoningStartEvent = BaseEvent.extend({
|
||||
type: z.literal("reasoning-start"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamReasoningDeltaEvent = BaseEvent.extend({
|
||||
type: z.literal("reasoning-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamReasoningEndEvent = BaseEvent.extend({
|
||||
type: z.literal("reasoning-end"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextStartEvent = BaseEvent.extend({
|
||||
type: z.literal("text-start"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextDeltaEvent = BaseEvent.extend({
|
||||
type: z.literal("text-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextEndEvent = BaseEvent.extend({
|
||||
type: z.literal("text-end"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamToolCallEvent = BaseEvent.extend({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.any(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamFinishStepEvent = z.object({
|
||||
type: z.literal("finish-step"),
|
||||
finishReason: z.enum(["stop", "tool-calls", "length", "content-filter", "error", "other", "unknown"]),
|
||||
usage: z.object({
|
||||
inputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalTokens: z.number().optional(),
|
||||
reasoningTokens: z.number().optional(),
|
||||
cachedInputTokens: z.number().optional(),
|
||||
}),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamEvent = z.union([
|
||||
LlmStepStreamReasoningStartEvent,
|
||||
LlmStepStreamReasoningDeltaEvent,
|
||||
LlmStepStreamReasoningEndEvent,
|
||||
LlmStepStreamTextStartEvent,
|
||||
LlmStepStreamTextDeltaEvent,
|
||||
LlmStepStreamTextEndEvent,
|
||||
LlmStepStreamToolCallEvent,
|
||||
LlmStepStreamFinishStepEvent,
|
||||
]);
|
||||
50
apps/x/packages/shared/src/mcp.ts
Normal file
50
apps/x/packages/shared/src/mcp.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import z from "zod";
|
||||
|
||||
export const StdioMcpServerConfig = z.object({
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const HttpMcpServerConfig = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerDefinition),
|
||||
});
|
||||
|
||||
export const connectionState = z.enum(["disconnected", "connected", "error"]);
|
||||
|
||||
export const McpServerList = z.object({
|
||||
mcpServers: z.record(z.string(), z.object({
|
||||
config: McpServerDefinition,
|
||||
state: connectionState,
|
||||
error: z.string().nullable(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const Tool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
outputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const ListToolsResponse = z.object({
|
||||
tools: z.array(Tool),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
67
apps/x/packages/shared/src/message.ts
Normal file
67
apps/x/packages/shared/src/message.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ProviderOptions = z.record(z.string(), z.record(z.string(), z.json()));
|
||||
|
||||
export const TextPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const ReasoningPart = z.object({
|
||||
type: z.literal("reasoning"),
|
||||
text: z.string(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const ToolCallPart = z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
arguments: z.any(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const AssistantContentPart = z.union([
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolCallPart,
|
||||
]);
|
||||
|
||||
export const UserMessage = z.object({
|
||||
role: z.literal("user"),
|
||||
content: z.string(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const AssistantMessage = z.object({
|
||||
role: z.literal("assistant"),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(AssistantContentPart),
|
||||
]),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const SystemMessage = z.object({
|
||||
role: z.literal("system"),
|
||||
content: z.string(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const ToolMessage = z.object({
|
||||
role: z.literal("tool"),
|
||||
content: z.string(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
export const Message = z.discriminatedUnion("role", [
|
||||
AssistantMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
]);
|
||||
|
||||
export const MessageList = z.array(Message);
|
||||
26
apps/x/packages/shared/src/prefix-logger.ts
Normal file
26
apps/x/packages/shared/src/prefix-logger.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// create a PrefixLogger class that wraps console.log with a prefix
|
||||
// and allows chaining with a parent logger
|
||||
export class PrefixLogger {
|
||||
private prefix: string;
|
||||
private parent: PrefixLogger | null;
|
||||
|
||||
constructor(prefix: string, parent: PrefixLogger | null = null) {
|
||||
this.prefix = prefix;
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
log(...args: unknown[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = '[' + this.prefix + ']';
|
||||
|
||||
if (this.parent) {
|
||||
this.parent.log(prefix, ...args);
|
||||
} else {
|
||||
console.log(timestamp, prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
child(childPrefix: string): PrefixLogger {
|
||||
return new PrefixLogger(childPrefix, this);
|
||||
}
|
||||
}
|
||||
129
apps/x/packages/shared/src/runs.ts
Normal file
129
apps/x/packages/shared/src/runs.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
||||
import { Message, ToolCallPart } from "./message.js";
|
||||
import z from "zod";
|
||||
|
||||
const BaseRunEvent = z.object({
|
||||
runId: z.string(),
|
||||
ts: z.iso.datetime().optional(),
|
||||
subflow: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const RunProcessingStartEvent = BaseRunEvent.extend({
|
||||
type: z.literal("run-processing-start"),
|
||||
});
|
||||
|
||||
export const RunProcessingEndEvent = BaseRunEvent.extend({
|
||||
type: z.literal("run-processing-end"),
|
||||
});
|
||||
|
||||
export const StartEvent = BaseRunEvent.extend({
|
||||
type: z.literal("start"),
|
||||
agentName: z.string(),
|
||||
});
|
||||
|
||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||
type: z.literal("spawn-subflow"),
|
||||
agentName: z.string(),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
export const LlmStreamEvent = BaseRunEvent.extend({
|
||||
type: z.literal("llm-stream-event"),
|
||||
event: LlmStepStreamEvent,
|
||||
});
|
||||
|
||||
export const MessageEvent = BaseRunEvent.extend({
|
||||
type: z.literal("message"),
|
||||
messageId: z.string(),
|
||||
message: Message,
|
||||
});
|
||||
|
||||
export const ToolInvocationEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-invocation"),
|
||||
toolCallId: z.string().optional(),
|
||||
toolName: z.string(),
|
||||
input: z.string(),
|
||||
});
|
||||
|
||||
export const ToolResultEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-result"),
|
||||
toolCallId: z.string().optional(),
|
||||
toolName: z.string(),
|
||||
result: z.any(),
|
||||
});
|
||||
|
||||
export const AskHumanRequestEvent = BaseRunEvent.extend({
|
||||
type: z.literal("ask-human-request"),
|
||||
toolCallId: z.string(),
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
export const AskHumanResponseEvent = BaseRunEvent.extend({
|
||||
type: z.literal("ask-human-response"),
|
||||
toolCallId: z.string(),
|
||||
response: z.string(),
|
||||
});
|
||||
|
||||
export const ToolPermissionRequestEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-permission-request"),
|
||||
toolCall: ToolCallPart,
|
||||
});
|
||||
|
||||
export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
||||
type: z.literal("tool-permission-response"),
|
||||
toolCallId: z.string(),
|
||||
response: z.enum(["approve", "deny"]),
|
||||
});
|
||||
|
||||
export const RunErrorEvent = BaseRunEvent.extend({
|
||||
type: z.literal("error"),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export const RunEvent = z.union([
|
||||
RunProcessingStartEvent,
|
||||
RunProcessingEndEvent,
|
||||
StartEvent,
|
||||
SpawnSubFlowEvent,
|
||||
LlmStreamEvent,
|
||||
MessageEvent,
|
||||
ToolInvocationEvent,
|
||||
ToolResultEvent,
|
||||
AskHumanRequestEvent,
|
||||
AskHumanResponseEvent,
|
||||
ToolPermissionRequestEvent,
|
||||
ToolPermissionResponseEvent,
|
||||
RunErrorEvent,
|
||||
]);
|
||||
|
||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||
subflow: true,
|
||||
toolCallId: true,
|
||||
response: true,
|
||||
});
|
||||
|
||||
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
||||
subflow: true,
|
||||
toolCallId: true,
|
||||
response: true,
|
||||
});
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
agentId: z.string(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
export const ListRunsResponse = z.object({
|
||||
runs: z.array(Run.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
agentId: true,
|
||||
})),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateRunOptions = Run.pick({
|
||||
agentId: true,
|
||||
});
|
||||
93
apps/x/packages/shared/src/workspace.ts
Normal file
93
apps/x/packages/shared/src/workspace.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Filesystem Schema Definitions
|
||||
// ============================================================================
|
||||
|
||||
// All paths are workspace-relative POSIX strings
|
||||
export const RelPath = z.string().min(1);
|
||||
|
||||
export const NodeKind = z.enum(['file', 'dir']);
|
||||
|
||||
export const Encoding = z.enum(['utf8', 'base64', 'binary']);
|
||||
|
||||
export const Stat = z.object({
|
||||
kind: NodeKind,
|
||||
size: z.number().min(0),
|
||||
mtimeMs: z.number().min(0),
|
||||
ctimeMs: z.number().min(0),
|
||||
isSymlink: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const DirEntry = z.object({
|
||||
name: z.string(),
|
||||
path: RelPath,
|
||||
kind: NodeKind,
|
||||
stat: z
|
||||
.object({
|
||||
size: z.number().min(0),
|
||||
mtimeMs: z.number().min(0),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReaddirOptions = z.object({
|
||||
recursive: z.boolean().optional(),
|
||||
includeStats: z.boolean().optional(),
|
||||
includeHidden: z.boolean().optional(),
|
||||
allowedExtensions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ReadFileResult = z.object({
|
||||
path: RelPath,
|
||||
encoding: Encoding,
|
||||
data: z.string(),
|
||||
stat: Stat,
|
||||
etag: z.string(),
|
||||
});
|
||||
|
||||
export const WriteFileOptions = z.object({
|
||||
encoding: Encoding.optional(),
|
||||
atomic: z.boolean().optional(),
|
||||
mkdirp: z.boolean().optional(),
|
||||
expectedEtag: z.string().optional(),
|
||||
});
|
||||
|
||||
export const WriteFileResult = ReadFileResult.pick({
|
||||
path: true,
|
||||
stat: true,
|
||||
etag: true,
|
||||
});
|
||||
|
||||
export const RemoveOptions = z.object({
|
||||
recursive: z.boolean().optional(),
|
||||
trash: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const WorkspaceChangeEvent = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('created'),
|
||||
path: RelPath,
|
||||
kind: NodeKind.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('deleted'),
|
||||
path: RelPath,
|
||||
kind: NodeKind.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('changed'),
|
||||
path: RelPath,
|
||||
kind: NodeKind.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('moved'),
|
||||
from: RelPath,
|
||||
to: RelPath,
|
||||
kind: NodeKind.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('bulkChanged'),
|
||||
paths: z.array(RelPath).optional(),
|
||||
}),
|
||||
]);
|
||||
11
apps/x/packages/shared/tsconfig.json
Normal file
11
apps/x/packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
5165
apps/x/pnpm-lock.yaml
generated
Normal file
5165
apps/x/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
7
apps/x/pnpm-workspace.yaml
Normal file
7
apps/x/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
10
apps/x/tsconfig.base.json
Normal file
10
apps/x/tsconfig.base.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue