Merge remote-tracking branch 'upstream/dev' into feat/byok-onboarding-simplify

# Conflicts:
#	apps/x/apps/main/src/ipc.ts
#	apps/x/packages/core/src/models/models.ts
#	apps/x/packages/shared/src/ipc.ts
This commit is contained in:
Prakhar Pandey 2026-06-19 01:37:25 +05:30
commit 36da053b8d
28 changed files with 3862 additions and 139 deletions

View file

@ -68,4 +68,32 @@ for (const dir of fs.readdirSync(prebuildsDir)) {
}
console.log('✅ node-pty staged in .package/node_modules');
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
// Bundle the vendored agent-slack CLI into a single self-contained script next
// to main.cjs. It runs as a child process (process.execPath with
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
// be inlined into main.cjs. Bundling here means the packaged app needs neither
// node_modules nor a global npm install.
const agentSlackPkg = JSON.parse(
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
);
await esbuild.build({
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
bundle: true,
platform: 'node',
target: 'node22',
outfile: './.package/dist/agent-slack.cjs',
format: 'cjs',
banner: { js: cjsBanner },
define: {
'import.meta.url': '__import_meta_url',
// Without this constant the CLI's --version walks up the directory tree
// for a package.json and would find Rowboat's instead of agent-slack's.
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
},
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
// back to node:sqlite; keep it external so the probe fails at runtime the
// same way it does under plain node.
external: ['bun:sqlite'],
});
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);

View file

@ -17,6 +17,7 @@
"@agentclientprotocol/codex-acp": "^0.0.44",
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"agent-slack": "0.9.3",
"chokidar": "^4.0.3",
"electron-squirrel-startup": "^1.0.1",
"html-to-docx": "^1.8.0",

View file

@ -16,16 +16,18 @@ import { bus } from '@x/core/dist/runs/bus.js';
import { serviceBus } from '@x/core/dist/services/service_bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import { exec } from 'node:child_process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import z from 'zod';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection, listModelsForProvider } from '@x/core/dist/models/models.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
@ -46,6 +48,10 @@ import type { CodeSession } from '@x/shared/dist/code-sessions.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import * as composioHandler from './composio-handler.js';
@ -62,7 +68,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
@ -85,6 +91,190 @@ import {
listTasks,
readRunIds as readTaskRunIds,
} from '@x/core/dist/background-tasks/fileops.js';
type SlackHomeChannel = {
id: string;
name: string;
workspaceUrl?: string;
workspaceName?: string;
};
type SlackHomeMessage = {
id: string;
workspaceName?: string;
workspaceUrl?: string;
channelId?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
url?: string;
};
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
return (parsed.workspaces || []).map((w) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
}
type SlackAuthResult = {
ok: boolean;
workspaces: Array<{ url: string; name: string }>;
error?: string;
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
};
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
// Shared by the plain and the quit-Slack-first import handlers.
async function importDesktopAndReadWorkspaces(): Promise<SlackAuthResult> {
const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
}
return { ok: true, workspaces };
}
// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
// Slack open. taskkill exits non-zero when nothing matches — that's fine.
async function quitSlackIfWindows(): Promise<void> {
if (process.platform !== 'win32') return;
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
} catch {
// No running Slack process to kill — nothing to do.
}
// Give Windows a moment to release the file handles before we copy them.
await new Promise(resolve => setTimeout(resolve, 800));
}
function extractArrayPayload(parsed: unknown): unknown[] {
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
for (const key of ['messages', 'channels', 'items', 'results', 'data']) {
if (Array.isArray(obj[key])) return obj[key] as unknown[];
}
}
return [];
}
function slackMessageText(message: Record<string, unknown>): string {
const value = message.text ?? message.body ?? message.content;
return typeof value === 'string' ? value.trim() : '';
}
function slackMessageAuthor(message: Record<string, unknown>): string | undefined {
const value = message.username ?? message.user ?? message.author;
return typeof value === 'string' ? value : undefined;
}
function extractSlackUserName(raw: unknown): string | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record<string, unknown> : undefined;
const user = obj.user && typeof obj.user === 'object' ? obj.user as Record<string, unknown> : undefined;
const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record<string, unknown> : undefined;
const candidates = [
profile?.display_name,
profile?.real_name,
userProfile?.display_name,
userProfile?.real_name,
obj.display_name,
obj.displayName,
obj.real_name,
obj.realName,
user?.display_name,
user?.displayName,
user?.real_name,
user?.realName,
obj.name,
user?.name,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
return null;
}
async function resolveSlackUserName(
userId: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | null> {
const key = `${workspaceUrl ?? ''}:${userId}`;
if (cache.has(key)) return cache.get(key) ?? null;
const args = ['user', 'get', userId];
if (workspaceUrl) {
args.push('--workspace', workspaceUrl);
}
const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
if (result.ok) {
const name = extractSlackUserName(result.data ?? {});
if (name) {
cache.set(key, name);
return name;
}
} else {
console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
}
cache.set(key, userId);
return null;
}
async function resolveSlackMessageText(
text: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string> {
const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g));
if (matches.length === 0) return text;
let resolved = text;
for (const match of matches) {
const userId = match[1] ?? match[3];
if (!userId) continue;
const fallback = match[2] ?? match[0];
const name = await resolveSlackUserName(userId, workspaceUrl, cache);
resolved = resolved.replaceAll(match[0], name ?? fallback);
}
return resolved;
}
async function resolveSlackAuthor(
author: string | undefined,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | undefined> {
if (!author) return undefined;
if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author;
return await resolveSlackUserName(author, workspaceUrl, cache) ?? author;
}
function slackMessageUrl(message: Record<string, unknown>, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined {
const direct = message.permalink ?? message.url;
if (typeof direct === 'string' && direct) return direct;
if (!workspaceUrl || !channelId) return undefined;
return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`;
}
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -553,6 +743,9 @@ export function setupIpcHandlers() {
'gmail:getAccountEmail': async () => {
return { email: await getAccountEmail() };
},
'gmail:getAccountName': async () => {
return { name: await getAccountName() };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
@ -668,6 +861,15 @@ export function setupIpcHandlers() {
return { success: false, error: message };
}
},
'llm:getDefaultModel': async () => {
return await getDefaultModelAndProvider();
},
'llm:generate': async (_event, args) => {
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
const result = await generateOneShot(args);
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
return result;
},
'models:saveConfig': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);
@ -861,21 +1063,191 @@ export function setupIpcHandlers() {
'slack:setConfig': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
// Connecting/disconnecting Slack changes the Copilot's routing (native
// `slack` skill vs. Composio), so rebuild its cached instructions.
invalidateCopilotInstructionsCache();
return { success: true };
},
'slack:cliStatus': async () => {
return await getAgentSlackCliStatus();
},
'slack:knowledgeStatus': async () => {
return {
cli: await getAgentSlackCliStatus(),
sources: getSlackKnowledgeSyncStatus(),
};
},
'slack:listWorkspaces': async () => {
try {
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
const parsed = JSON.parse(stdout);
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
return { workspaces };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
return { workspaces: [], error: message };
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!result.ok) {
return { workspaces: [], error: result.message, errorKind: result.kind };
}
const workspaces = parseWhoamiWorkspaces(result.data);
return { workspaces };
},
'slack:importDesktopAuth': async () => {
// Pull xoxc token(s) + cookie from the running/installed Slack desktop
// app into agent-slack's credential store, then read back the workspaces.
return await importDesktopAndReadWorkspaces();
},
'slack:quitAndImportDesktop': async () => {
// Windows-only convenience: kill Slack (which locks its Cookies DB) then
// run the normal desktop import in one click.
await quitSlackIfWindows();
return await importDesktopAndReadWorkspaces();
},
'slack:parseCurlAuth': async (_event, args) => {
// Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
// request from a signed-in Slack web tab; parse-curl reads it from stdin
// and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
const curl = (args.curl ?? '').trim();
if (!curl) {
return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
}
const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
}
return { ok: true, workspaces };
},
'slack:listChannels': async (_event, args) => {
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
if (!result.ok) {
return { channels: [], error: result.message };
}
const rawChannels = extractArrayPayload(result.data) as Array<{
id?: string;
name?: string;
is_private?: boolean;
isPrivate?: boolean;
is_member?: boolean;
isMember?: boolean;
}>;
const channels = rawChannels.map((ch) => ({
id: ch.id || ch.name || '',
name: ch.name || ch.id || '',
isPrivate: ch.is_private ?? ch.isPrivate,
isMember: ch.is_member ?? ch.isMember,
})).filter((ch) => ch.id && ch.name);
return { channels };
},
'slack:getRecentMessages': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();
if (!config.enabled || config.workspaces.length === 0) {
return { enabled: false, messages: [] };
}
const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
const messages: SlackHomeMessage[] = [];
const userNameCache = new Map<string, string>();
try {
const knowledgeConfig = knowledgeSourcesRepo.getConfig();
const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled);
let channels: SlackHomeChannel[] = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => ({
id: scope.id,
name: scope.name ?? scope.id,
workspaceUrl: scope.workspaceUrl,
workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name,
}));
if (channels.length === 0) {
for (const workspace of config.workspaces) {
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
if (!channelList.ok) {
throw new AgentSlackRunError(channelList.kind, channelList.message);
}
const rawChannels = extractArrayPayload(channelList.data);
for (const raw of rawChannels) {
if (!raw || typeof raw !== 'object') continue;
const channel = raw as Record<string, unknown>;
const id = typeof channel.id === 'string' ? channel.id : undefined;
const name = typeof channel.name === 'string' ? channel.name : id;
const isMember = channel.is_member ?? channel.isMember;
if (!id || !name || isMember === false) continue;
channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name });
}
}
}
channels = channels.slice(0, 8);
for (const channel of channels) {
const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500'];
if (channel.workspaceUrl) {
commandArgs.push('--workspace', channel.workspaceUrl);
}
const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
if (!messageList.ok) {
console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
continue;
}
const rawMessages = extractArrayPayload(messageList.data);
for (const raw of rawMessages) {
if (!raw || typeof raw !== 'object') continue;
const message = raw as Record<string, unknown>;
const ts = typeof message.ts === 'string' ? message.ts : undefined;
const text = slackMessageText(message);
if (!ts || !text) continue;
const channelId = typeof message.channel_id === 'string'
? message.channel_id
: typeof message.channel === 'string'
? message.channel
: channel.id;
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
messages.push({
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
workspaceName: channel.workspaceName,
workspaceUrl: channel.workspaceUrl,
channelId,
channelName: channel.name,
author: resolvedAuthor,
text: resolvedText,
ts,
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
});
}
}
const rankedIds = await rankSlackHomeMessages(messages, limit);
const byId = new Map(messages.map(message => [message.id, message]));
const rankedMessages = rankedIds
.map(id => byId.get(id))
.filter((message): message is SlackHomeMessage => Boolean(message));
return { enabled: true, messages: rankedMessages };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
return { enabled: true, messages: [], error: message, errorKind };
}
},
'knowledgeSources:getConfig': async () => {
return knowledgeSourcesRepo.getConfig();
},
'knowledgeSources:upsert': async (_event, args) => {
const config = knowledgeSourcesRepo.upsertSource(args);
if (args.provider === 'slack') {
// The Copilot prompt lists the selected Slack channels, so refresh it
// whenever the channel selection changes.
invalidateCopilotInstructionsCache();
triggerSlackKnowledgeSync();
void syncSlackKnowledgeSources().catch(error => {
console.error('[SlackKnowledge] Immediate sync after settings update failed:', error);
});
}
return config;
},
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet

View file

@ -37,10 +37,10 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
@ -56,8 +56,6 @@ import {
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -313,18 +311,13 @@ app.whenReady().then(async () => {
});
}
// Ensure agent-slack CLI is available
try {
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
} catch {
try {
console.log('agent-slack not found, installing...');
await execAsync('npm install -g agent-slack', { timeout: 60000 });
console.log('agent-slack installed successfully');
} catch (e) {
console.error('Failed to install agent-slack:', e);
}
}
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
// and is resolved per call by the shared executor in @x/core. Availability is
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
// diagnostics only.
getAgentSlackCliStatus().then((status) => {
console.log('[Slack] agent-slack CLI status:', status);
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
// Initialize all config files before UI can access them
await initConfigs();

View file

@ -160,6 +160,13 @@
border-bottom: 1px solid var(--gm-border);
}
.gmail-topbar-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.gmail-search {
display: flex;
align-items: center;
@ -707,6 +714,112 @@
border-color: var(--gm-border-strong);
}
/* Standalone "new email" composer — centered modal popup */
.gmail-compose-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: rgba(0, 0, 0, 0.32);
}
.gmail-compose-modal {
display: flex;
flex-direction: column;
width: min(840px, 100%);
height: min(720px, calc(100vh - 64px));
max-height: calc(100vh - 64px);
border: 1px solid var(--gm-border-strong);
border-radius: 10px;
overflow: hidden;
background: var(--gm-bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
}
.gmail-compose-modal-header {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 8px 0 14px;
background: var(--gm-bg-input);
color: var(--gm-text-body);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.gmail-compose-modal-header > span {
flex: 1;
}
.gmail-compose-modal .gmail-compose-editor {
flex: 1;
min-height: 160px;
max-height: none;
padding: 0 14px;
}
.gmail-compose-ai-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-input {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 6px;
outline: none;
background: var(--gm-bg-input);
color: var(--gm-text);
font: inherit;
font-size: 12px;
}
.gmail-compose-ai-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 10px;
border-bottom: 1px solid var(--gm-border);
}
.gmail-compose-ai-presets button {
height: 24px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 999px;
background: var(--gm-bg-pill);
color: var(--gm-text-muted);
font: inherit;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.gmail-compose-ai-presets button:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
border-color: var(--gm-accent);
color: var(--gm-accent);
}
.gmail-compose-ai-presets button:disabled,
.gmail-compose-ai-input:disabled {
opacity: 0.5;
cursor: default;
}
.gmail-compose-card {
max-width: 720px;
margin-left: 40px;
@ -987,7 +1100,10 @@
gap: 2px;
flex: 1;
min-width: 0;
justify-content: center;
justify-content: flex-start;
padding-left: 10px;
margin-left: 2px;
border-left: 1px solid var(--gm-border-strong);
}
.gmail-compose-link-popover {
@ -1059,11 +1175,16 @@
transition: background 120ms ease, color 120ms ease;
}
.gmail-compose-tool:hover {
.gmail-compose-tool:hover:not(:disabled) {
background: var(--gm-bg-pill-hover);
color: var(--gm-text);
}
.gmail-compose-tool:disabled {
opacity: 0.4;
cursor: default;
}
.gmail-compose-tool.is-active {
background: var(--gm-bg-pill-hover);
color: var(--gm-accent);
@ -1154,6 +1275,52 @@
pointer-events: none;
}
.gmail-compose-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
}
.gmail-compose-attachment {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 4px 8px;
border: 1px solid var(--gm-border);
border-radius: 6px;
background: var(--gm-bg-pill);
font-size: 12px;
color: var(--gm-text);
}
.gmail-compose-attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gmail-compose-attachment-size {
color: var(--gm-text-muted);
flex-shrink: 0;
}
.gmail-compose-attachment-remove {
border: none;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 0 0 0 2px;
flex-shrink: 0;
}
.gmail-compose-attachment-remove:hover {
color: var(--gm-text);
}
.gmail-compose-actions {
display: flex;
align-items: center;

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -258,6 +258,15 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;')
}
// Convert AI-generated plain text into the simple paragraph HTML the Tiptap
// editor expects (blank lines → paragraphs, single newlines → <br />).
function plainTextToHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para.trim()).replace(/\n/g, '<br />')}</p>`)
.join('')
}
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
const match = re.exec(text)
@ -514,7 +523,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
)
}
type ComposeMode = 'reply' | 'replyAll' | 'forward'
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
function ComposeToolbarButton({
editor,
@ -550,6 +559,29 @@ function ComposeToolbarButton({
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
return (
<div className="gmail-compose-toolbar">
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
aria-label="Undo"
title="Undo"
>
<Undo2 size={14} />
</button>
<button
type="button"
className="gmail-compose-tool"
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
aria-label="Redo"
title="Redo"
>
<Redo2 size={14} />
</button>
<span className="gmail-compose-tool-sep" />
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBold().run()}
@ -866,20 +898,76 @@ function RecipientField({
)
}
function ComposeBox({
const AI_GENERATE_SYSTEM =
'You write complete emails. Given an instruction, produce a subject line and a body. ' +
'Respond in EXACTLY this format and nothing else:\n' +
'Subject: <a concise, specific subject line>\n' +
'\n' +
'<the email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
'(never write a placeholder like "[Your Name]").'
const AI_REWRITE_SYSTEM =
'You rewrite emails. Given the current subject and body plus an edit instruction, ' +
'produce the revised subject line and body. Keep the subject if it still fits, or ' +
'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' +
'Subject: <the subject line>\n' +
'\n' +
'<the rewritten email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".'
// Split AI output of the form "Subject: …\n\n<body>" into its parts. If no
// subject line is present, the whole text is treated as the body.
function parseGeneratedEmail(text: string): { subject: string | null; body: string } {
const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i)
if (match) {
const subject = match[1].trim()
const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '')
return { subject, body }
}
return { subject: null, body: text }
}
// Guarantee the sender's name signs off the email. If the model already ended
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
function ensureSignature(body: string, name: string): string {
const signer = name.trim()
if (!signer) return body
const trimmed = body.replace(/\s+$/, '')
// Check the last couple of lines so we don't double up an existing sign-off.
const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase()
if (tail.includes(signer.toLowerCase())) return trimmed
return `${trimmed}\n\n${signer}`
}
const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [
{ key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' },
{ key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' },
{ key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' },
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
]
// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a
// thread it renders as an inline card under the thread; in 'new' mode it has no
// thread and renders as a centered modal with the AI writing bar.
const ComposeBox = memo(function ComposeBox({
mode,
thread,
selfEmail,
selfEmail = '',
onClose,
}: {
mode: ComposeMode
thread: GmailThread
selfEmail: string
thread?: GmailThread
selfEmail?: string
onClose: () => void
}) {
const latest = latestMessage(thread)
const isNew = mode === 'new'
const latest = thread ? latestMessage(thread) : undefined
const initialRecipients = useMemo(
() => buildRecipients(mode, thread, selfEmail),
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
[mode, thread, selfEmail],
)
@ -888,10 +976,11 @@ function ComposeBox({
const [bccList, setBccList] = useState<string[]>([])
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
const [showBcc, setShowBcc] = useState<boolean>(false)
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const initialContent = useMemo(() => {
if (!thread) return ''
if (mode === 'forward') return buildForwardedContent(thread)
// Gmail-side draft (user's own work) wins over the AI-generated draft.
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
@ -907,7 +996,7 @@ function ComposeBox({
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
}),
],
editorProps: {
@ -959,13 +1048,176 @@ function ComposeBox({
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
}
// The signed-in account's display name, used to sign off AI-generated emails.
const [selfName, setSelfName] = useState<string>('')
useEffect(() => {
if (!isNew) return
let cancelled = false
window.ipc.invoke('gmail:getAccountName', {})
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
.catch(() => {})
return () => { cancelled = true }
}, [isNew])
const [aiPrompt, setAiPrompt] = useState('')
const [generating, setGenerating] = useState(false)
// Once a draft has been generated, show a follow-up bar for iterative edits
// ("add a line about…", "remove the last paragraph", etc.). It hides again if
// the draft is emptied (e.g. undone), tracked via hasContent below.
const [hasGenerated, setHasGenerated] = useState(false)
const [hasContent, setHasContent] = useState(false)
// Keep hasContent in sync with the editor across typing, undo/redo, and clears.
useEffect(() => {
if (!editor) return
const sync = () => setHasContent(!editor.isEmpty)
sync()
editor.on('update', sync)
return () => { editor.off('update', sync) }
}, [editor])
// Clearing the body reverts the AI control to its "Write" state and drops the
// generated subject, so an emptied composer behaves like a fresh one. The
// hasGenerated guard avoids wiping a subject typed before any generation.
useEffect(() => {
if (hasGenerated && !hasContent) {
setHasGenerated(false)
setSubject('')
}
}, [hasGenerated, hasContent])
const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => {
if (!editor || generating) return
const current = editor.getText().trim()
let prompt: string
let system: string
if (aiMode === 'generate') {
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
system = AI_GENERATE_SYSTEM
const ctx: string[] = []
// Use the recipients' names (from the contacts picker) so the AI can
// address them naturally; fall back to the address when there's no name.
const recipientNames = toList
.map((token) => {
const name = extractName(token)
return name && name !== 'Unknown' ? name : extractAddress(token)
})
.filter(Boolean)
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
} else {
if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return }
if (!current) { toast('Write something first.', 'error'); return }
system = AI_REWRITE_SYSTEM
const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : ''
prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}`
}
setGenerating(true)
try {
// Draft through Copilot: no model override, so the backend resolves the
// same default model/provider the Copilot chat uses (models.json).
const res = await window.ipc.invoke('llm:generate', { prompt, system })
if (res.error || !res.text) {
toast(res.error || 'No text was generated.', 'error')
return
}
// Replace via a tracked transaction (selectAll + insertContent) so the AI
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
if (aiMode === 'generate') {
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
if (generatedSubject) setSubject(generatedSubject)
// Always sign off with the account name, even if the model omitted it.
const signed = ensureSignature(body, selfName)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
setHasGenerated(true)
} else {
// Rewrites also regenerate the subject so it stays in sync with the body.
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
if (rewrittenSubject) setSubject(rewrittenSubject)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
}
} catch (err) {
toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setGenerating(false)
}
}
// The single Write/Edit bar: generate a fresh draft until one exists, then
// switch to rewriting it. Clears the prompt after a run kicks off.
const runAiBar = async () => {
await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate')
setAiPrompt('')
}
// Attachments staged for this message. contentBase64 is the raw file bytes,
// read in the renderer; the main process wraps them into the MIME on send.
const [attachments, setAttachments] = useState<
Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }>
>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%.
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
// Read a file's bytes as raw base64 (the part after the data: URL prefix).
const readAsBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.onload = () => {
const result = String(reader.result)
const comma = result.indexOf(',')
resolve(comma >= 0 ? result.slice(comma + 1) : result)
}
reader.readAsDataURL(file)
})
const addFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return
const staged: typeof attachments = []
for (const file of Array.from(files)) {
try {
staged.push({
id: `${file.name}-${file.size}-${file.lastModified}`,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
contentBase64: await readAsBase64(file),
})
} catch {
toast(`Could not read ${file.name}.`, 'error')
}
}
setAttachments((prev) => {
const merged = [...prev]
for (const item of staged) {
if (!merged.some((a) => a.id === item.id)) merged.push(item)
}
const total = merged.reduce((sum, a) => sum + a.size, 0)
if (total > MAX_TOTAL_BYTES) {
toast('Attachments exceed the 25MB limit.', 'error')
return prev
}
return merged
})
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
}
const [sending, setSending] = useState(false)
const sendInGmail = async () => {
if (!editor || sending) return
const html = editor.getHTML()
const text = editor.getText().trim()
if (!text) {
toast('Draft is empty.', 'error')
toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error')
return
}
@ -975,25 +1227,29 @@ function ComposeBox({
}
// Build References chain from all known message ids (newest last).
const messageIds = thread.messages
const messageIds = (thread?.messages ?? [])
.map((m) => m.messageIdHeader)
.filter((v): v is string => Boolean(v))
const references = messageIds.join(' ')
const inReplyTo = latest?.messageIdHeader
const isForward = mode === 'forward'
// Only replies stay on the thread; forwards and new emails start fresh.
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
setSending(true)
try {
const result = await window.ipc.invoke('gmail:sendReply', {
threadId: isForward ? undefined : thread.threadId,
threadId: isThreaded ? thread?.threadId : undefined,
to: toList.join(', '),
cc: ccList.length ? ccList.join(', ') : undefined,
bcc: bccList.length ? bccList.join(', ') : undefined,
subject: subject.trim() || composeSubject(mode, thread.subject),
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
bodyHtml: html,
bodyText: text,
inReplyTo: isForward ? undefined : inReplyTo,
references: isForward ? undefined : references || undefined,
inReplyTo: isThreaded ? inReplyTo : undefined,
references: isThreaded ? references || undefined : undefined,
attachments: attachments.length
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
: undefined,
})
if (result.error) {
toast(`Send failed: ${result.error}`, 'error')
@ -1009,7 +1265,7 @@ function ComposeBox({
}
const refineWithCopilot = () => {
if (!editor) return
if (!editor || !thread) return
const currentDraft = editor.getText().trim()
const threadSubject = thread.subject || '(No subject)'
@ -1039,17 +1295,25 @@ function ComposeBox({
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}
return (
<div className="gmail-compose-card">
<div className="gmail-compose-header">
const card = (
<div
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
onClick={isNew ? (event) => event.stopPropagation() : undefined}
>
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
<span>{modeLabel}</span>
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
<button
type="button"
className={isNew ? 'gmail-icon-button' : undefined}
onClick={onClose}
aria-label="Close compose"
>×</button>
</div>
<RecipientField
label="To"
value={toList}
onChange={setToList}
autoFocus={mode === 'forward'}
autoFocus={isNew || mode === 'forward'}
trailing={
<div className="gmail-recipient-toggles">
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
@ -1059,18 +1323,83 @@ function ComposeBox({
/>
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
{mode === 'forward' && (
{isNew && (
<>
<div className="gmail-compose-ai-bar">
<input
className="gmail-compose-ai-input"
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
placeholder={hasGenerated
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
: 'Describe the email and let AI write it…'}
disabled={generating}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void runAiBar()
}
}}
/>
<button
type="button"
className="gmail-refine-button"
onClick={() => { void runAiBar() }}
disabled={generating}
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
>
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
{generating
? (hasGenerated ? 'Editing…' : 'Writing…')
: (hasGenerated ? 'Edit' : 'Write')}
</button>
</div>
<div className="gmail-compose-ai-presets">
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
{TONE_PRESETS.map((preset) => (
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
))}
</div>
</>
)}
{(isNew || mode === 'forward') && (
<div className="gmail-compose-line">
<span className="gmail-compose-label">Subject</span>
<input
className="gmail-compose-subject-input"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Subject"
/>
</div>
)}
<EditorContent editor={editor} className="gmail-compose-editor" />
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
void addFiles(event.target.value ? event.currentTarget.files : null)
event.currentTarget.value = ''
}}
/>
{attachments.length > 0 && (
<div className="gmail-compose-attachments">
{attachments.map((att) => (
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
<Paperclip size={13} />
<span className="gmail-compose-attachment-name">{att.filename}</span>
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
<button
type="button"
className="gmail-compose-attachment-remove"
onClick={() => removeAttachment(att.id)}
aria-label={`Remove ${att.filename}`}
>×</button>
</div>
))}
</div>
)}
{linkOpen && (
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
<input
@ -1099,7 +1428,7 @@ function ComposeBox({
className="gmail-send-button"
onClick={() => { void sendInGmail() }}
disabled={sending}
title="Send this reply via Gmail"
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
>
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
{sending ? 'Sending…' : 'Send'}
@ -1107,19 +1436,40 @@ function ComposeBox({
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach files"
>
<Sparkles size={15} />
Refine
<Paperclip size={15} />
Attach
</button>
{thread && (
<button
type="button"
className="gmail-refine-button"
onClick={refineWithCopilot}
title="Refine this draft with Copilot"
>
<Sparkles size={15} />
Refine
</button>
)}
</div>
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
</div>
</div>
)
}
if (isNew) {
return (
<div className="gmail-compose-overlay" onClick={onClose}>
{card}
</div>
)
}
return card
})
function ThreadDetail({
thread,
@ -1301,6 +1651,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [composeOpen, setComposeOpen] = useState(false)
// Stable so the open composer isn't re-rendered on every inbox sync tick.
const closeCompose = useCallback(() => setComposeOpen(false), [])
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@ -1526,12 +1879,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
// when files change. Throttled to at most one reload per ~3s so a burst of
// backend writes (sync processing many threads sequentially) coalesces into
// a small number of in-place updates rather than a flicker storm.
// Suppressed while a thread is open (composing/reading); deferred until close.
// Suppressed while a thread is open (reading/replying) or the compose-new
// modal is open; deferred until whichever is open closes. A reload replaces
// the threads array and re-renders the whole inbox list (and any mounted
// ThreadDetail iframes) on the main thread — that re-render janks an open
// composer even though ComposeBox itself is memoized, so we pause it.
const pendingReloadRef = useRef(false)
const reloadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastReloadAtRef = useRef(0)
const isSelectedRef = useRef<string | null>(null)
isSelectedRef.current = selectedThreadId
const composeOpenRef = useRef(false)
composeOpenRef.current = composeOpen
const isRefreshingRef = useRef(false)
isRefreshingRef.current = refreshing
const otherHasThreadsRef = useRef(false)
@ -1541,7 +1900,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const doReload = useCallback(() => {
if (isRefreshingRef.current) return
if (isSelectedRef.current !== null) {
if (isSelectedRef.current !== null || composeOpenRef.current) {
pendingReloadRef.current = true
return
}
@ -1596,9 +1955,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
}
}, [triggerLiveReload])
// When user closes a thread, if updates arrived while they were reading, flush now.
// When the user closes the open thread or the compose-new modal, if updates
// arrived while it was open, flush them now.
useEffect(() => {
if (selectedThreadId !== null) return
if (selectedThreadId !== null || composeOpen) return
if (!pendingReloadRef.current) return
pendingReloadRef.current = false
lastReloadAtRef.current = Date.now()
@ -1606,7 +1966,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
if (otherHasThreadsRef.current) {
void reloadFirstPage('other', { silent: true })
}
}, [selectedThreadId, reloadFirstPage])
}, [selectedThreadId, composeOpen, reloadFirstPage])
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
// the watcher fires, and triggerLiveReload picks up the changes. The
@ -1745,9 +2105,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
placeholder="Search loaded mail"
/>
</div>
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<div className="gmail-topbar-actions">
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
<SquarePen size={18} />
</button>
</div>
</div>
{error && !hasAny ? (
@ -1814,6 +2179,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
</div>
)}
</div>
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</div>
)

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
@ -54,6 +54,17 @@ type RawCalEvent = {
}
type EmailThread = { threadId: string; subject: string; from: string }
type SlackFeedMessage = {
id: string
workspaceName?: string
workspaceUrl?: string
channelId?: string
channelName?: string
author?: string
text: string
ts: string
url?: string
}
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
@ -94,6 +105,28 @@ function relativeAgo(iso?: string): string {
return `${d}d ago`
}
function relativeSlackTs(ts: string): string {
const seconds = Number(ts.split('.')[0])
if (!Number.isFinite(seconds)) return ''
const iso = new Date(seconds * 1000).toISOString()
return relativeAgo(iso)
}
// Short, non-actionable copy for the home feed — the actionable fix lives in
// Settings, so every failure routes the user there.
function homeSlackErrorCopy(kind: string | null): string {
switch (kind) {
case 'not_authed':
return 'Slack needs reconnecting — open Settings → Connected accounts.'
case 'network':
return "Couldn't reach Slack. Check your connection."
case 'rate_limited':
return 'Slack is rate-limiting requests — will retry shortly.'
default:
return "Couldn't load Slack right now — see Settings."
}
}
function parseAllDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
@ -218,6 +251,10 @@ export function HomeView({
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
const [slackError, setSlackError] = useState<string | null>(null)
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
@ -260,6 +297,22 @@ export function HomeView({
}
}, [])
const loadSlackMessages = useCallback(async () => {
try {
const result = await window.ipc.invoke('slack:getRecentMessages', { limit: 5 })
setSlackEnabled(result.enabled)
setSlackMessages(result.messages)
setSlackError(result.error ?? null)
setSlackErrorKind(result.errorKind ?? null)
} catch (err) {
console.error('Home: failed to load Slack messages', err)
setSlackEnabled(false)
setSlackMessages([])
setSlackError(null)
setSlackErrorKind(null)
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
@ -293,7 +346,7 @@ export function HomeView({
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadSlackMessages, loadConnectorLogos])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
@ -460,6 +513,53 @@ export function HomeView({
</div>
</div>
{/* Slack */}
{slackEnabled && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<MessageSquare className="size-[15px]" />
<span className="text-sm font-medium">Slack</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">Latest messages</span>
</div>
{slackError ? (
<div className="py-1 text-[12.5px] text-muted-foreground">{homeSlackErrorCopy(slackErrorKind)}</div>
) : slackMessages.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No messages worth surfacing right now.</div>
) : slackMessages.map((message, i) => (
<div
key={message.id}
className={`flex items-start gap-3 py-2 text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span className="truncate">{message.channelName ?? 'Slack'}</span>
{message.author && (
<>
<span className="shrink-0">·</span>
<span className="truncate">{message.author}</span>
</>
)}
<span className="shrink-0">·</span>
<span className="shrink-0">{relativeSlackTs(message.ts)}</span>
</div>
<div className="line-clamp-2 text-foreground">{message.text}</div>
</div>
{message.url && (
<button
type="button"
onClick={() => window.open(message.url, '_blank')}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-primary transition-colors hover:bg-accent"
>
Open
<ExternalLink className="size-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">

View file

@ -1,19 +1,37 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { useConnectors } from "@/hooks/useConnectors"
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
interface ConnectedAccountsSettingsProps {
dialogOpen: boolean
}
function relativeTime(iso?: string): string {
if (!iso) return "never"
const then = Date.parse(iso)
if (!Number.isFinite(then)) return "never"
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return "just now"
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.round(diffHr / 24)}d ago`
}
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
const c = useConnectors(dialogOpen)
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = c.providerStates[provider] || {
@ -237,6 +255,224 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
{/* Team Communication Section */}
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(workspace => workspace.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">Send messages and view channels</span>
)}
</div>
</div>
<div className="shrink-0">
{c.slackLoading || c.slackDiscovering ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.slackEnabled ? (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackDisable}
className="h-7 px-3 text-xs"
>
Disable
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-10 space-y-2">
{c.slackNeedsAuth ? (
<>
<p className="text-xs text-muted-foreground">
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<Button
size="sm"
onClick={c.handleSlackImportDesktop}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
>
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
</Button>
{isWindows && (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackQuitAndImport}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
title="Closes Slack so its data unlocks, then connects"
>
Quit Slack &amp; connect
</Button>
)}
<button
type="button"
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
className="text-xs text-primary underline-offset-2 hover:underline"
>
Paste from browser instead
</button>
</div>
{c.slackCurlOpen && (
<div className="space-y-1.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
In a browser signed in to Slack, open DevTools Network, click any
request to <code>app.slack.com</code>, right-click Copy Copy as cURL,
then paste it below.
</p>
<Textarea
value={c.slackCurlValue}
onChange={(event) => c.setSlackCurlValue(event.target.value)}
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
className="min-h-20 text-[11px] font-mono"
disabled={c.slackCurlSubmitting}
/>
<Button
size="sm"
onClick={c.handleSlackParseCurl}
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
className="h-7 px-3 text-xs"
>
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
</Button>
</div>
)}
</>
) : c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(workspace => (
<label key={workspace.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(workspace.url)}
onChange={(event) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (event.target.checked) next.add(workspace.url)
else next.delete(workspace.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{workspace.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
{/* Knowledge Sources Section */}
{c.slackEnabled && (
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Knowledge Sources
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack to knowledge</span>
<span className="text-xs text-muted-foreground truncate">
Sync selected channels into the knowledge graph
</span>
</div>
</div>
<Switch
checked={c.slackKnowledgeEnabled}
onCheckedChange={c.setSlackKnowledgeEnabled}
disabled={c.slackKnowledgeSaving}
/>
</div>
<div className="mt-2 space-y-2">
<Textarea
value={c.slackKnowledgeChannels}
onChange={(event) => c.setSlackKnowledgeChannels(event.target.value)}
placeholder={c.slackWorkspaces.length > 1 ? "https://team.slack.com #engineering" : "#engineering"}
className="min-h-20 text-xs"
disabled={!c.slackKnowledgeEnabled || c.slackKnowledgeSaving}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
One channel per line. Use channel names or IDs.
</span>
{(c.slackKnowledgeDirty || c.slackKnowledgeSaving) && (
<Button
size="sm"
onClick={c.handleSlackKnowledgeSave}
disabled={c.slackKnowledgeSaving || (c.slackKnowledgeEnabled && c.slackKnowledgeChannels.trim().length === 0)}
className="h-7 px-3 text-xs"
>
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
</Button>
)}
</div>
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
<div key={status.id} className="flex items-center gap-1.5 text-xs">
{status.lastStatus === 'error' ? (
<span className="text-amber-600 truncate">
Sync failing {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
</span>
) : status.lastSyncAt ? (
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
) : (
<span className="text-muted-foreground">Not synced yet first sync runs shortly</span>
)}
</div>
))}
</div>
</div>
</>
)}
</div>
</>
)

View file

@ -12,6 +12,58 @@ export interface ProviderStatus {
error?: string
}
type KnowledgeSourceConfig = {
id: string
provider: 'gmail' | 'meeting' | 'voice_memo' | 'slack' | 'github' | 'linear'
enabled: boolean
artifactDir: string
syncMode: 'file' | 'poll' | 'event' | 'manual'
intervalMs?: number
scopes: Array<{ type: string; id: string; name?: string; workspaceUrl?: string }>
instructions?: string
filters?: Record<string, unknown>
}
export type SlackSyncStatus = {
id: string
enabled: boolean
lastSyncAt?: string
lastStatus?: 'ok' | 'error'
lastError?: { kind: string; message: string }
nextDueAt?: string
}
/**
* Map a structured agent-slack failure to actionable user copy. The key
* distinction (raised by real usage): a missing Slack desktop app needs a
* different instruction than a signed-out one.
*/
export function actionableSlackError(kind?: string, message?: string): string {
// Windows locks Slack's Cookies/LevelDB files while it's running, so the
// desktop import copy fails with EBUSY. This can surface under any kind, so
// check the message first.
if (message && /EBUSY|resource busy|locked|copyfile/i.test(message)) {
return 'Slack is open and locking its data. Click "Quit Slack & connect" to close it automatically, or use "Paste from browser instead".'
}
switch (kind) {
case 'not_installed':
return 'The Slack helper is unavailable in this build. Please update or reinstall Rowboat.'
case 'network':
return "Couldn't reach Slack. Check your internet connection and try again."
case 'rate_limited':
return 'Slack is rate-limiting requests right now. Wait a minute and try again.'
case 'bad_channel':
return message || "A configured channel couldn't be found. Check the channel names in Settings."
case 'not_authed':
if (message && /Desktop data not found|not supported/i.test(message)) {
return 'No Slack desktop app was found. Install Slack, sign in to your workspace, then click Connect.'
}
return 'No signed-in Slack account found. Open the Slack desktop app, sign in, then click Connect.'
default:
return message || "Couldn't connect to Slack. Please try again."
}
}
export function useConnectors(active: boolean) {
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -37,6 +89,23 @@ export function useConnectors(active: boolean) {
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// True when discovery succeeded but no workspaces are connected yet, so the
// user needs to import auth from the Slack desktop app (fixes the silent
// "Enable" bounce-back where the button never progressed).
const [slackNeedsAuth, setSlackNeedsAuth] = useState(false)
const [slackAuthImporting, setSlackAuthImporting] = useState(false)
// Cross-OS "paste cURL from a browser tab" fallback when desktop import fails.
const [slackCurlOpen, setSlackCurlOpen] = useState(false)
const [slackCurlValue, setSlackCurlValue] = useState("")
const [slackCurlSubmitting, setSlackCurlSubmitting] = useState(false)
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
// Snapshot of the last-persisted knowledge config, used to detect unsaved
// edits so the Save button only appears when there's something to save.
const [slackKnowledgeSavedEnabled, setSlackKnowledgeSavedEnabled] = useState(false)
const [slackKnowledgeSavedChannels, setSlackKnowledgeSavedChannels] = useState("")
const [slackSyncStatuses, setSlackSyncStatuses] = useState<SlackSyncStatus[]>([])
// Composio Gmail/Calendar sync was removed. These flags are seeded false
// and never flipped — the IPC that used to set them is gone. The setters
@ -121,26 +190,105 @@ export function useConnectors(active: boolean) {
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
setSlackPickerOpen(true)
try {
const result = await window.ipc.invoke('slack:listWorkspaces', null)
if (result.error || result.workspaces.length === 0) {
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
setSlackAvailableWorkspaces([])
setSlackPickerOpen(true)
} else {
if (result.workspaces.length > 0) {
// Already-connected workspaces → straight to the picker.
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
} else {
// CLI ran but nothing is connected yet (or it errored): offer a
// concrete next step instead of a dead-end message.
setSlackAvailableWorkspaces([])
setSlackNeedsAuth(true)
setSlackDiscoverError(result.error ? actionableSlackError(result.errorKind, result.error) : null)
}
} catch (error) {
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
setSlackNeedsAuth(true)
setSlackDiscoverError("Couldn't start Slack discovery. Please try again.")
} finally {
setSlackDiscovering(false)
}
}, [])
// Shared success path for both auth methods: show the discovered workspaces
// in the picker, preselected. Returns true when workspaces were found.
const applyDiscoveredWorkspaces = useCallback((result: { ok: boolean; workspaces: Array<{ url: string; name: string }>; error?: string; errorKind?: string }) => {
if (result.ok && result.workspaces.length > 0) {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w) => w.url)))
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
return true
}
setSlackDiscoverError(actionableSlackError(result.errorKind, result.error))
return false
}, [])
// Import xoxc token + cookie from the signed-in Slack desktop app, then show
// the discovered workspaces in the picker.
const handleSlackImportDesktop = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:importDesktopAuth', null)
// Desktop import is best-effort: it fails when Slack is running and locks
// its Cookies DB (EBUSY on Windows), or on unsupported Slack builds. On
// any failure, reveal the browser-paste fallback so the user is never
// stuck — it has no file-lock dependency and works cross-OS.
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to import Slack desktop auth:', error)
setSlackDiscoverError("Couldn't import from the Slack desktop app. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Windows-only: force-quit Slack (releases its Cookies-DB lock) then import.
// One click instead of the manual taskkill dance.
const handleSlackQuitAndImport = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:quitAndImportDesktop', null)
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to quit Slack and import:', error)
setSlackDiscoverError("Couldn't import after closing Slack. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Fallback: parse a "Copy as cURL" request pasted from a signed-in Slack web
// tab. Works on every OS — no desktop app, leveldb, or keychain needed.
const handleSlackParseCurl = useCallback(async () => {
setSlackCurlSubmitting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:parseCurlAuth', { curl: slackCurlValue })
applyDiscoveredWorkspaces(result)
} catch (error) {
console.error('Failed to parse Slack cURL:', error)
setSlackDiscoverError("Couldn't read that cURL command. Please try again.")
} finally {
setSlackCurlSubmitting(false)
}
}, [applyDiscoveredWorkspaces, slackCurlValue])
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
try {
@ -149,6 +297,7 @@ export function useConnectors(active: boolean) {
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to save Slack config:', error)
@ -165,6 +314,22 @@ export function useConnectors(active: boolean) {
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: false,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes: [],
})
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
@ -174,6 +339,93 @@ export function useConnectors(active: boolean) {
}
}, [])
const refreshSlackKnowledgeStatus = useCallback(async () => {
try {
const result = await window.ipc.invoke('slack:knowledgeStatus', null)
setSlackSyncStatuses(result.sources)
} catch (error) {
console.error('Failed to load Slack knowledge status:', error)
setSlackSyncStatuses([])
}
}, [])
const refreshKnowledgeSources = useCallback(async () => {
try {
const result = await window.ipc.invoke('knowledgeSources:getConfig', null)
const slackSource = (result.sources as KnowledgeSourceConfig[]).find(source => source.id === 'slack')
const enabled = Boolean(slackSource?.enabled)
const channels = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => {
const channel = scope.name || scope.id
return scope.workspaceUrl ? `${scope.workspaceUrl} ${channel}` : channel
})
.join('\n')
setSlackKnowledgeEnabled(enabled)
setSlackKnowledgeChannels(channels)
setSlackKnowledgeSavedEnabled(enabled)
setSlackKnowledgeSavedChannels(channels)
} catch (error) {
console.error('Failed to load knowledge sources:', error)
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
}
}, [])
const parseSlackKnowledgeScopes = useCallback(() => {
const defaultWorkspaceUrl = slackWorkspaces.length === 1 ? slackWorkspaces[0]?.url : undefined
return slackKnowledgeChannels
.split(/\n+/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const parts = line.split(/\s+/)
const first = parts[0] ?? ''
const hasWorkspace = /^https?:\/\//.test(first)
const workspaceUrl = hasWorkspace ? first : defaultWorkspaceUrl
const channelRaw = hasWorkspace ? parts.slice(1).join(' ') : line
const channel = channelRaw.trim()
return {
type: 'channel',
id: channel.replace(/^#/, ''),
name: channel.startsWith('#') ? channel : `#${channel}`,
workspaceUrl,
}
})
.filter(scope => scope.id.length > 0)
}, [slackKnowledgeChannels, slackWorkspaces])
const handleSlackKnowledgeSave = useCallback(async () => {
try {
setSlackKnowledgeSaving(true)
const scopes = parseSlackKnowledgeScopes()
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: slackKnowledgeEnabled && scopes.length > 0,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes,
instructions: 'Use Slack messages to update durable knowledge about projects, people, decisions, blockers, owners, deadlines, and status changes.',
filters: {
limit: 100,
maxBodyChars: 4000,
recentBackfillSeconds: 6 * 60 * 60,
},
})
toast.success('Slack knowledge source saved')
await refreshKnowledgeSources()
} catch (error) {
console.error('Failed to save Slack knowledge source:', error)
toast.error('Failed to save Slack knowledge source')
} finally {
setSlackKnowledgeSaving(false)
}
}, [parseSlackKnowledgeScopes, refreshKnowledgeSources, slackKnowledgeEnabled])
// Gmail (Composio)
const refreshGmailStatus = useCallback(async () => {
try {
@ -417,6 +669,8 @@ export function useConnectors(active: boolean) {
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
refreshSlackConfig()
refreshKnowledgeSources()
refreshSlackKnowledgeStatus()
if (useComposioForGoogle) {
refreshGmailStatus()
@ -461,7 +715,7 @@ export function useConnectors(active: boolean) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshSlackKnowledgeStatus, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
// Refresh when active or providers change
useEffect(() => {
@ -545,6 +799,11 @@ export function useConnectors(active: boolean) {
(status) => Boolean(status?.error)
)
// Whether the knowledge config has unsaved edits — drives Save button visibility.
const slackKnowledgeDirty =
slackKnowledgeEnabled !== slackKnowledgeSavedEnabled ||
slackKnowledgeChannels !== slackKnowledgeSavedChannels
return {
// OAuth providers
providers,
@ -587,9 +846,27 @@ export function useConnectors(active: boolean) {
setSlackPickerOpen,
slackDiscovering,
slackDiscoverError,
slackNeedsAuth,
slackAuthImporting,
slackCurlOpen,
setSlackCurlOpen,
slackCurlValue,
setSlackCurlValue,
slackCurlSubmitting,
slackSyncStatuses,
slackKnowledgeEnabled,
setSlackKnowledgeEnabled,
slackKnowledgeChannels,
setSlackKnowledgeChannels,
slackKnowledgeSaving,
slackKnowledgeDirty,
handleSlackEnable,
handleSlackImportDesktop,
handleSlackQuitAndImport,
handleSlackParseCurl,
handleSlackSaveWorkspaces,
handleSlackDisable,
handleSlackKnowledgeSave,
// Gmail (Composio)
useComposioForGoogle,

View file

@ -5,6 +5,8 @@ import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ISlackConfigRepo } from "../../slack/repo.js";
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -12,7 +14,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
async function getComposioToolsPrompt(slackConnected: boolean = false): Promise<string> {
if (!(await isComposioConfigured())) {
return '';
}
@ -22,28 +24,54 @@ async function getComposioToolsPrompt(): Promise<string> {
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
// Slack is connected natively, so exclude it from the Composio catch-all.
const slackException = slackConnected
? ` Exception: **Slack is connected natively** — use the \`slack\` skill for Slack, not Composio.`
: '';
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.${slackException}
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, slackConnected: boolean = false, slackChannelsHint: string = ''): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
// When Slack is connected natively (desktop/cURL auth, not Composio), keep it
// out of the Composio routing examples so the Copilot doesn't try to connect
// it through Composio and wrongly report it as unavailable.
const composioServiceExamples = slackConnected
? 'Gmail, GitHub, LinkedIn, Notion, Google Sheets, Jira, etc.'
: 'Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.';
const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
? `\n**Third-Party Services:** When users ask to interact with any external service (${composioServiceExamples}) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: '';
// Slack is connected directly in Rowboat (agent-slack CLI), independent of
// Composio. Route every Slack request to the native \`slack\` skill so the
// Copilot never claims Slack isn't connected or sends it through Composio.
const slackChannelsLine = slackChannelsHint
? ` The user has selected these Slack channels to follow: ${slackChannelsHint}. For broad "what's on my Slack / catch me up / anything new" requests, query THESE channels directly with \`agent-slack message list "#channel" --workspace <url> --oldest <unix-seconds> --limit 100 --resolve-users\` (use \`--oldest\`/\`--latest\` to scope to today/yesterday). Do NOT rely on \`search messages\` or \`unreads\` to answer catch-up questions — they frequently return empty with desktop-imported auth even when channels have messages; direct \`message list\` is authoritative.`
: '';
const slackBlock = slackConnected
? `\n**Slack (connected):** Slack is connected directly in Rowboat (via the agent-slack CLI, not Composio). For ANY Slack request — summarizing or reading messages, catching up on channels or DMs, searching, listing users, or sending a message — your FIRST action MUST be \`loadSkill('slack')\`, then use the \`agent-slack\` commands it documents via \`executeCommand\` (the selected workspaces are in \`config/slack.json\`). NEVER tell the user Slack isn't connected, and NEVER route Slack through the \`composio-integration\` skill.${slackChannelsLine}\n`
: '';
const slackToolPriority = slackConnected
? ` For Slack specifically, load the \`slack\` skill and use the agent-slack CLI — Slack is connected natively, not via Composio.`
: '';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
? `For third-party services (GitHub, Gmail, etc.), load the \`composio-integration\` skill.${slackToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${slackToolPriority}`;
const slackToolsLine = composioEnabled
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
@ -76,7 +104,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}${slackBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@ -332,14 +360,41 @@ export async function buildCopilotInstructions(): Promise<string> {
} catch {
// repo unavailable — default to disabled
}
let slackConnected = false;
let slackChannelsHint = '';
try {
const slackRepo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const slackConfig = await slackRepo.getConfig();
slackConnected = slackConfig.enabled && slackConfig.workspaces.length > 0;
} catch {
// repo unavailable — default to not connected
}
if (slackConnected) {
try {
// Surface the channels the user selected for sync so the Copilot
// queries those directly instead of relying on workspace-wide search.
const slackSource = knowledgeSourcesRepo.getConfig().sources
.find(source => source.provider === 'slack' && source.enabled);
const channels = (slackSource?.scopes ?? []).filter(scope => scope.type === 'channel');
slackChannelsHint = channels
.map(scope => {
const raw = scope.name || scope.id;
const display = raw.startsWith('#') ? raw : `#${raw}`;
return scope.workspaceUrl ? `${display} (${scope.workspaceUrl})` : display;
})
.join(', ');
} catch {
// knowledge sources unavailable — fall back to no channel hint
}
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint);
const composioPrompt = await getComposioToolsPrompt(slackConnected);
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
: baseInstructions;

View file

@ -5,12 +5,27 @@ You interact with Slack by running **agent-slack** commands through \`executeCom
---
## 1. Check Connection
## 1. Check Connection & Selected Channels
Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
**Which channels the user follows:** The user selects specific channels to sync in \`config/knowledge_sources.json\`. Read that file and find the source with \`"provider": "slack"\`; its \`scopes\` array (entries with \`"type": "channel"\`) lists the selected channels (each has a \`name\` like \`#general\` and an optional \`workspaceUrl\`). For broad "what's on my Slack / catch me up / anything new" requests where the user did NOT name a channel, query these selected channels directly — do not guess or run workspace-wide search.
---
## 1b. Catching Up ("what's new", "today", "yesterday")
For catch-up questions, list recent messages from each selected channel and filter by time with \`--oldest\` / \`--latest\` (Unix-epoch seconds):
\`\`\`
# Everything in #general since the start of today (compute the epoch for 00:00 local)
agent-slack message list "#general" --workspace https://team.slack.com --oldest 1718668800 --limit 100 --resolve-users
\`\`\`
**Do NOT use \`agent-slack unreads\` or \`agent-slack search messages\` to answer catch-up questions.** With desktop-imported auth those endpoints frequently return empty even when channels clearly have messages. Direct \`message list\` against the selected channels is the authoritative source. Run one \`message list\` per selected channel (batch them in a single \`executeCommand\` with \`;\` separators), then summarize across channels. Always pass \`--resolve-users\` so author names are readable.
---
## 2. Core Commands
@ -41,6 +56,8 @@ agent-slack message react remove "<target>" <emoji> --ts <ts>
### Search
Note: search is best for finding a *specific* message by keyword. It can return empty under desktop-imported auth, so never conclude "there's nothing on Slack" from an empty search fall back to \`message list\` on the selected channels (see section 1b).
\`\`\`
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"

View file

@ -2,6 +2,7 @@ import { z, ZodType } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
@ -740,6 +741,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
try {
const rootDir = path.resolve(WorkDir);
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
// Make `agent-slack` resolvable for skill-authored shell
// commands; the shim forwards to the bundled CLI.
const env = agentSlackShimEnv(path.join(rootDir, 'bin'));
// TODO: Re-enable this check
// const rootPrefix = rootDir.endsWith(path.sep)
@ -758,6 +762,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
if (ctx?.signal) {
const { promise, process: proc } = executeCommandAbortable(command, {
cwd: workingDir,
env,
signal: ctx.signal,
onData: (chunk: string) => {
ctx.publish({
@ -788,7 +793,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
// Fallback to original for backward compatibility
const result = await executeCommand(command, { cwd: workingDir });
const result = await executeCommand(command, { cwd: workingDir, env });
return {
success: result.exitCode === 0,

View file

@ -189,7 +189,29 @@ function verifyIntegrity(file: string, integrity: string): void {
// contents land directly in destDir. Uses the system tar (bsdtar on macOS/Windows 10+,
// GNU tar on Linux) — all support -xzf and --strip-components.
function extractTarball(tarPath: string, destDir: string): void {
const r = spawnSync('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1'], { stdio: 'pipe' });
let tarCmd = 'tar';
let tarArgs = ['-xzf', tarPath, '-C', destDir, '--strip-components=1'];
let spawnOpts: Parameters<typeof spawnSync>[2] = { stdio: 'pipe' };
// Windows: PATH `tar` may resolve to a GNU tar from Git/MSYS2, which misreads the
// absolute archive path "C:\...\engine.tgz" as a remote "host:path" spec and fails with
// "tar (child): Cannot connect to C: resolve failed" (then "gzip: stdin: unexpected end
// of file"). Pin to the bsdtar shipped in System32, which handles drive-letter paths
// natively — this is the tar this code was always meant to use on Windows 10+.
if (process.platform === 'win32') {
const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
if (fs.existsSync(sysTar)) {
tarCmd = sysTar;
} else {
// No system bsdtar (very old/stripped Windows): fall back to PATH tar, but run
// from the archive's own directory and pass the bare filename so no drive-letter
// colon reaches tar's -f argument — works for both GNU tar and bsdtar.
tarArgs = ['-xzf', path.basename(tarPath), '-C', destDir, '--strip-components=1'];
spawnOpts = { stdio: 'pipe', cwd: path.dirname(tarPath) };
}
}
const r = spawnSync(tarCmd, tarArgs, spawnOpts);
if (r.status !== 0) {
const err = r.stderr?.toString().trim() || r.error?.message || `tar exited ${r.status}`;
throw new Error(`Code mode: failed to extract engine — ${err}`);

View file

@ -18,6 +18,9 @@ import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
import { knowledgeSourcesRepo } from './sources/repo.js';
import { syncSlackKnowledgeSources } from './sources/sync_slack.js';
import type { KnowledgeSourceConfig } from './sources/types.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -35,12 +38,11 @@ const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', '
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
path.join('knowledge', 'Meetings', 'fireflies'),
path.join('knowledge', 'Meetings', 'granola'),
path.join('knowledge', 'Meetings', 'rowboat'),
];
function getEnabledFileSources(): KnowledgeSourceConfig[] {
return knowledgeSourcesRepo
.listEnabledSources()
.filter(source => source.provider !== 'voice_memo');
}
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
@ -643,6 +645,15 @@ export async function processAllSources(): Promise<void> {
let anyFilesProcessed = false;
try {
const slackFiles = await syncSlackKnowledgeSources();
if (slackFiles.length > 0) {
console.log(`[GraphBuilder] Slack sync wrote ${slackFiles.length} artifact files`);
}
} catch (error) {
console.error('[GraphBuilder] Error syncing Slack knowledge sources:', error);
}
// Process voice memos first (they get moved to knowledge/)
try {
const voiceMemosProcessed = await processVoiceMemosForKnowledge();
@ -654,12 +665,13 @@ export async function processAllSources(): Promise<void> {
}
const state = loadState();
const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = [];
const folderChanges: { source: KnowledgeSourceConfig; sourceDir: string; files: string[] }[] = [];
const countsByFolder: Record<string, number> = {};
const allFiles: string[] = [];
const fileSources = getEnabledFileSources();
for (const folder of SOURCE_FOLDERS) {
const sourceDir = path.join(WorkDir, folder);
for (const source of fileSources) {
const sourceDir = path.join(WorkDir, source.artifactDir);
// Skip if folder doesn't exist
if (!fs.existsSync(sourceDir)) {
@ -671,7 +683,7 @@ export async function processAllSources(): Promise<void> {
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
if (source.provider === 'gmail') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
@ -690,13 +702,13 @@ export async function processAllSources(): Promise<void> {
}
if (filesToProcess.length > 0) {
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
folderChanges.push({ folder, sourceDir, files: filesToProcess });
countsByFolder[folder] = filesToProcess.length;
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${source.id}`);
folderChanges.push({ source, sourceDir, files: filesToProcess });
countsByFolder[source.id] = filesToProcess.length;
allFiles.push(...filesToProcess);
}
} catch (error) {
console.error(`[GraphBuilder] Error processing ${folder}:`, error);
console.error(`[GraphBuilder] Error processing ${source.id}:`, error);
// Continue with other folders even if one fails
}
}
@ -706,7 +718,7 @@ export async function processAllSources(): Promise<void> {
service: 'graph',
message: 'Syncing knowledge graph',
trigger: 'timer',
config: { sources: SOURCE_FOLDERS },
config: { sources: fileSources.map(source => source.id) },
});
const relativeFiles = allFiles.map(filePath => path.relative(WorkDir, filePath));
@ -770,7 +782,8 @@ export async function processAllSources(): Promise<void> {
*/
export async function init() {
console.log('[GraphBuilder] Starting Knowledge Graph Builder Service...');
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}, knowledge/Voice Memos`);
const sourceFolders = getEnabledFileSources().map(source => source.artifactDir);
console.log(`[GraphBuilder] Monitoring folders: ${sourceFolders.join(', ')}, knowledge/Voice Memos`);
console.log(`[GraphBuilder] Will check for new content every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run

View file

@ -30,16 +30,16 @@ tools:
**Current date and time:** ${new Date().toISOString()}
Sources (emails, meetings, voice memos) are processed in roughly chronological order. This means:
Sources (emails, meetings, voice memos, Slack messages, and connected-tool artifacts) are processed in roughly chronological order. This means:
- Earlier sources may reference events that have since occurred later sources will provide updates.
- If a source mentions a future meeting or deadline, it may already be in the past by now. Use the current date above to reason about what is past vs. upcoming.
- Don't treat old commitments as still "open" if later sources or the current date suggest they've likely been resolved.
# Task
You are a memory agent. You are given one or more source files (emails, meeting transcripts, or voice memos) to process. **The files in a request are independent of each other** they are batched together only for efficiency, not because they are related. Process each source file on its own terms (see "Source Scoping" below). For each source file you will:
You are a memory agent. You are given one or more source files (emails, meeting transcripts, voice memos, Slack messages, or other connected-tool artifacts) to process. **The files in a request are independent of each other** they are batched together only for efficiency, not because they are related. Process each source file on its own terms (see "Source Scoping" below). For each source file you will:
1. **Determine source type (meeting or email)**
1. **Determine source type (meeting, email, voice memo, Slack, or connected-tool artifact)**
2. **Evaluate if the source is worth processing**
3. **Search for all existing related notes**
4. **Resolve entities to canonical names**
@ -49,7 +49,7 @@ You are a memory agent. You are given one or more source files (emails, meeting
8. Create new notes or update existing notes
9. **Apply state changes to existing notes**
The core rule: **Both meetings and emails can create notes, but emails require personalized content and a new People/Organization note from an email also requires the user to have replied at least once in the thread (the Email Reply Gate). Emails can always update existing notes regardless.**
The core rule: **Meetings and voice memos can create notes freely. Emails require personalized content and a new People/Organization note from an email also requires the user to have replied at least once in the thread (the Email Reply Gate). Slack and connected-tool artifacts can update existing notes when they carry clear state changes, decisions, commitments, or project facts; they should create new notes only when the artifact clearly identifies a durable person, organization, project, or topic worth tracking.**
# Source Scoping (Batch Isolation) READ FIRST
@ -75,7 +75,7 @@ You have full read access to the existing knowledge directory. Use this extensiv
# Inputs
1. **source_file**: Path to a single file to process (email or meeting transcript)
1. **source_file**: Path to a single file to process (email, meeting transcript, voice memo, Slack message, or connected-tool artifact)
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
3. **user**: Information about the owner of this memory
- name: e.g., "Arj"
@ -170,7 +170,7 @@ ${renderNoteEffectRules()}
# Step 0: Determine Source Type
Read the source file and determine if it's a meeting or email.
Read the source file and determine its source type.
\`\`\`
file-readText({ path: "{source_file}" })
\`\`\`
@ -191,10 +191,22 @@ file-readText({ path: "{source_file}" })
- Has frontmatter \`path:\` field like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Slack indicators:**
- YAML frontmatter has \`source: slack\`
- Source file path is under \`knowledge_sources/slack/\`
- Contains fields like \`Workspace:\`, \`Channel:\`, \`Author:\`, \`Thread TS:\`, or a \`## Message\` section
**Connected-tool artifact indicators:**
- YAML frontmatter has \`source:\` set to a provider like \`github\`, \`linear\`, \`jira\`, \`notion\`, etc.
- Source file path is under \`knowledge_sources/<provider>/\`
- Contains issue, PR, task, ticket, comment, status, or project metadata
**Set processing mode:**
- \`source_type = "meeting"\` → Can create new notes
- \`source_type = "email"\` → Can create notes if personalized and relevant
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
- \`source_type = "slack"\` → Prefer updating existing project/person/topic notes; create new notes only for clear durable entities
- \`source_type = "connected_tool"\` → Prefer updating existing project/topic notes; create new notes only for durable projects, organizations, repositories, issues, or initiatives
---
@ -240,6 +252,22 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
## For Meetings and Voice Memos
Always process no filtering needed.
## For Slack Messages
Process Slack messages only when they contain durable knowledge:
- Decisions, approvals, changes in project status, blockers, owners, deadlines, handoffs, or commitments
- Facts about people, organizations, projects, customers, product areas, repositories, issues, or incidents
- Meaningful summaries in long threads
Skip Slack messages that are only acknowledgements, greetings, jokes, reactions, short coordination with no durable outcome, or vague statements that cannot be resolved to a known entity. For ambiguous updates like "x is done", update an existing note only if \`x\` resolves clearly from the message, channel, thread, or existing knowledge index. If it does not resolve clearly, skip rather than inventing a fact.
## For Connected-Tool Artifacts
Process artifacts from GitHub, Linear, Jira, and similar tools when they carry project or work-state changes:
- Issue/PR/task created, assigned, closed, merged, reopened, blocked, or reprioritized
- Status, owner, milestone, deadline, release, incident, customer, or decision changes
- Comments that clarify requirements, decisions, blockers, or commitments
Skip routine metadata churn and duplicated notifications unless they change durable knowledge.
## For Emails Read YAML Frontmatter
Emails have YAML frontmatter with labels prepended by the labeling agent:

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
filterSlackHomeCandidatesForRelevance,
rankSlackHomeMessages,
SlackHomeRankCandidate,
} from './rank_slack_home.js';
function slackTs(dateMs: number): string {
return `${Math.floor(dateMs / 1000)}.000000`;
}
const NOW = Date.parse('2026-06-04T18:00:00Z');
const recent = (text: string, id = text): SlackHomeRankCandidate => ({
id,
channelName: 'general',
text,
ts: slackTs(NOW - 5 * 60 * 1000),
});
function keptIds(candidates: SlackHomeRankCandidate[]): string[] {
return filterSlackHomeCandidatesForRelevance(candidates, NOW).map(c => c.id);
}
describe('filterSlackHomeCandidatesForRelevance', () => {
describe('routine standup logistics', () => {
it('drops stale standup logistics but keeps recent ones and durable updates', () => {
const nineHoursAgo = NOW - 9 * 60 * 60 * 1000;
const twelveHoursAgo = NOW - 12 * 60 * 60 * 1000;
const thirtyMinutesAgo = NOW - 30 * 60 * 1000;
const candidates: SlackHomeRankCandidate[] = [
{ id: 'stale-standup-schedule', channelName: 'general', text: 'standup at 4pm possible?', ts: slackTs(nineHoursAgo) },
{ id: 'stale-standup-sick', channelName: 'general', text: 'ill skip todays standup I am having stomach ache and not feeling well', ts: slackTs(twelveHoursAgo) },
{ id: 'durable-issue-update', channelName: 'general', text: 'is the icon issue fixed for windows?', ts: slackTs(twelveHoursAgo) },
{ id: 'recent-standup-schedule', channelName: 'general', text: 'standup at 4pm possible?', ts: slackTs(thirtyMinutesAgo) },
];
expect(keptIds(candidates)).toEqual(['durable-issue-update', 'recent-standup-schedule']);
});
});
describe('system / automated messages', () => {
it('drops channel join/leave, topic, rename and call notices', () => {
const candidates = [
recent('Alex has joined the channel', 'join'),
recent('Sam has left the channel', 'leave'),
recent('Alex set the channel topic: Q3 planning', 'topic'),
recent('Sam renamed the channel to design-team', 'rename'),
recent('Alex started a huddle', 'huddle'),
recent('Real question: can someone review my PR?', 'real'),
];
expect(keptIds(candidates)).toEqual(['real']);
});
it('keeps a system-shaped message that carries a durable signal', () => {
const candidates = [recent('Priya set the channel topic: incident response war room', 'topic-incident')];
expect(keptIds(candidates)).toEqual(['topic-incident']);
});
});
describe('emoji / reaction-only', () => {
it('drops emoji-only, shortcode-only and punctuation-only posts', () => {
const candidates = [
recent('👍', 'thumbs'),
recent('🎉🎉🎉', 'party'),
recent(':tada: :rocket:', 'shortcodes'),
recent('!!!', 'punct'),
recent('🚀 shipping the new pricing page today', 'real'),
];
expect(keptIds(candidates)).toEqual(['real']);
});
});
describe('greetings / acknowledgements', () => {
it('drops bare greetings and acks but keeps anything with content', () => {
const candidates = [
recent('thanks!', 'thanks'),
recent('gm', 'gm'),
recent('lgtm', 'lgtm'),
recent('+1', 'plus1'),
recent('sounds good', 'sg'),
recent('ok', 'ok'),
recent('ok, the deploy is blocked on the migration', 'ok-with-content'),
recent('thanks for fixing the outage', 'thanks-durable'),
];
// 'ok-with-content' kept (has content); 'thanks-durable' kept (durable signal).
expect(keptIds(candidates)).toEqual(['ok-with-content', 'thanks-durable']);
});
});
it('drops empty-text candidates', () => {
expect(keptIds([recent(' ', 'blank'), recent('a real message here', 'real')])).toEqual(['real']);
});
});
describe('rankSlackHomeMessages (deterministic)', () => {
it('orders surviving candidates newest-first and caps at the limit', async () => {
const mk = (id: string, minutesAgo: number): SlackHomeRankCandidate => ({
id, channelName: 'general', text: `update ${id}`, ts: slackTs(NOW - minutesAgo * 60 * 1000),
});
const candidates = [mk('old', 50), mk('newest', 1), mk('mid', 20)];
expect(await rankSlackHomeMessages(candidates, 5)).toEqual(['newest', 'mid', 'old']);
expect(await rankSlackHomeMessages(candidates, 2)).toEqual(['newest', 'mid']);
});
it('filters noise before ranking', async () => {
const candidates = [
recent('👍', 'emoji'),
recent('Alex has joined the channel', 'join'),
recent('can you review the pricing proposal?', 'real'),
];
expect(await rankSlackHomeMessages(candidates, 5)).toEqual(['real']);
});
it('handles a high-volume batch: caps output and preserves recency order', async () => {
const candidates: SlackHomeRankCandidate[] = Array.from({ length: 150 }, (_, i) => ({
id: `m${i}`,
channelName: 'general',
// i=0 is newest; larger i is older.
text: `status update number ${i}`,
ts: slackTs(NOW - i * 60 * 1000),
}));
const ranked = await rankSlackHomeMessages(candidates, 5);
expect(ranked).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
});
});

View file

@ -0,0 +1,92 @@
export type SlackHomeRankCandidate = {
id: string;
workspaceName?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
};
const EXPIRED_ROUTINE_AGE_MS = 2 * 60 * 60 * 1000;
const ROUTINE_EVENT_RE = /\b(stand[-\s]?up|daily\s+(sync|scrum|standup)|scrum|check[-\s]?in)\b/i;
const ROUTINE_LOGISTICS_RE = /\b(skip|skipping|miss|missing|can't|cannot|cant|won't|wont|join|attend|possible|move|reschedule|shift|late|running\s+late|stomach|sick|not\s+feeling|headache|doctor|appointment|today|todays|today's|tomorrow|at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/i;
// Durable signals always win: a message matching any of these is kept even if
// it would otherwise look like noise (a system message, a "done", etc.).
const DURABLE_SIGNAL_RE = /\b(blocker|blocked|decision|decided|owner|deadline|shipped|fixed|done|launched|deployed|merged|bug|issue|incident|outage|customer|contract|pricing|proposal|launch|release|handoff|review|approval|approved)\b/i;
// Slack system / automated messages render as plain narration like
// "<name> has joined the channel". They carry no human content, so drop them.
const SYSTEM_MESSAGE_RE = /\b(has joined the channel|has left the channel|was added to|has been added|set the channel (topic|purpose|description)|cleared the channel (topic|purpose)|renamed the channel|archived the channel|un-?archived the channel|pinned a message|joined the (call|huddle)|started a (call|huddle)|set up a call)\b/i;
// Greetings / acknowledgements with no informational content. Anchored to the
// whole (trimmed) message so "ok" drops but "ok, the deploy is blocked" stays.
const TRIVIAL_RE = /^(hi|hello+|hey+|yo|gm|gn|good\s*(morning|night|evening|afternoon)|morning|thanks?|thank\s*you|ty|thx|tysm|np|no\s*problem|ok(ay)?|k|got\s*it|gotcha|lgtm|\+1|nice|cool|great|awesome|perfect|done|yes+|yep|yup|no+|nope|sure|sounds?\s*good|sg|welcome|congrats?|congratulations)[\s.!?]*$/i;
const EMOJI_SHORTCODE_RE = /:[a-z0-9_+-]+:/gi;
function slackTsToMs(ts: string): number | null {
const seconds = Number(ts.split('.')[0]);
if (!Number.isFinite(seconds)) return null;
return seconds * 1000;
}
// Newest-first recency ordering, capped at limit. The Home card shows "latest
// messages", so recency is the ordering once noise is filtered out.
function timeRank(candidates: SlackHomeRankCandidate[], limit: number): string[] {
return [...candidates]
.sort((a, b) => Number(b.ts) - Number(a.ts))
.slice(0, limit)
.map(candidate => candidate.id);
}
// What remains after removing :shortcodes:, unicode emoji/symbols, punctuation
// and whitespace. Empty ⇒ the message was emoji/reaction-only.
function strippedToCore(text: string): string {
return text
.replace(EMOJI_SHORTCODE_RE, '')
.replace(/[\s\p{P}\p{S}]/gu, '')
.trim();
}
function isExpiredRoutineLogistics(candidate: SlackHomeRankCandidate, nowMs: number): boolean {
const sentAtMs = slackTsToMs(candidate.ts);
if (sentAtMs === null) return false;
if (nowMs - sentAtMs < EXPIRED_ROUTINE_AGE_MS) return false;
const text = candidate.text.replace(/\s+/g, ' ').trim();
if (!ROUTINE_EVENT_RE.test(text)) return false;
if (DURABLE_SIGNAL_RE.test(text)) return false;
return ROUTINE_LOGISTICS_RE.test(text);
}
// Low-value classes that never belong on Home: empty bodies, Slack system
// messages, emoji/reaction-only posts, and bare greetings/acks. A durable
// signal overrides all of these.
function isLowValueNoise(candidate: SlackHomeRankCandidate): boolean {
const text = candidate.text.replace(/\s+/g, ' ').trim();
if (!text) return true;
if (DURABLE_SIGNAL_RE.test(text)) return false;
if (SYSTEM_MESSAGE_RE.test(text)) return true;
if (TRIVIAL_RE.test(text)) return true;
return strippedToCore(text).length === 0;
}
export function filterSlackHomeCandidatesForRelevance(
candidates: SlackHomeRankCandidate[],
nowMs = Date.now(),
): SlackHomeRankCandidate[] {
return candidates.filter(candidate =>
!isExpiredRoutineLogistics(candidate, nowMs) && !isLowValueNoise(candidate));
}
// Deterministic Home feed: drop noise, then order by recency and cap. No LLM
// call — the filter does the de-noising and recency does the ordering.
// (kept async so the IPC caller's contract is unchanged.)
export async function rankSlackHomeMessages(
candidates: SlackHomeRankCandidate[],
limit: number,
): Promise<string[]> {
return timeRank(filterSlackHomeCandidatesForRelevance(candidates), limit);
}

View file

@ -0,0 +1,113 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import {
KnowledgeSourceConfig,
KnowledgeSourcesFile,
type KnowledgeSourcesFile as KnowledgeSourcesFileType,
} from './types.js';
const CONFIG_FILE = path.join(WorkDir, 'config', 'knowledge_sources.json');
const BUILTIN_SOURCES: KnowledgeSourceConfig[] = [
{
id: 'gmail',
provider: 'gmail',
enabled: true,
artifactDir: 'gmail_sync',
syncMode: 'file',
scopes: [],
},
{
id: 'fireflies-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'fireflies'),
syncMode: 'file',
scopes: [],
},
{
id: 'granola-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'granola'),
syncMode: 'file',
scopes: [],
},
{
id: 'rowboat-meetings',
provider: 'meeting',
enabled: true,
artifactDir: path.join('knowledge', 'Meetings', 'rowboat'),
syncMode: 'file',
scopes: [],
},
];
function ensureConfigDir(): void {
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
}
function mergeBuiltinSources(config: KnowledgeSourcesFileType): KnowledgeSourcesFileType {
const byId = new Map(config.sources.map(source => [source.id, source]));
for (const builtin of BUILTIN_SOURCES) {
if (!byId.has(builtin.id)) {
byId.set(builtin.id, builtin);
}
}
return { sources: Array.from(byId.values()) };
}
export interface IKnowledgeSourcesRepo {
getConfig(): KnowledgeSourcesFileType;
setConfig(config: KnowledgeSourcesFileType): void;
listEnabledSources(): KnowledgeSourceConfig[];
upsertSource(source: KnowledgeSourceConfig): KnowledgeSourcesFileType;
}
export class FSKnowledgeSourcesRepo implements IKnowledgeSourcesRepo {
getConfig(): KnowledgeSourcesFileType {
try {
if (!fs.existsSync(CONFIG_FILE)) {
const config = { sources: BUILTIN_SOURCES };
this.setConfig(config);
return config;
}
const parsed = KnowledgeSourcesFile.parse(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')));
const merged = mergeBuiltinSources(parsed);
if (merged.sources.length !== parsed.sources.length) {
this.setConfig(merged);
}
return merged;
} catch (error) {
console.error('[KnowledgeSources] Failed to load config:', error);
return { sources: BUILTIN_SOURCES };
}
}
setConfig(config: KnowledgeSourcesFileType): void {
const validated = KnowledgeSourcesFile.parse(mergeBuiltinSources(config));
ensureConfigDir();
fs.writeFileSync(CONFIG_FILE, JSON.stringify(validated, null, 2), 'utf-8');
}
listEnabledSources(): KnowledgeSourceConfig[] {
return this.getConfig().sources.filter(source => source.enabled);
}
upsertSource(source: KnowledgeSourceConfig): KnowledgeSourcesFileType {
const validated = KnowledgeSourceConfig.parse(source);
const config = this.getConfig();
const existingIndex = config.sources.findIndex(item => item.id === validated.id);
if (existingIndex >= 0) {
config.sources[existingIndex] = validated;
} else {
config.sources.push(validated);
}
this.setConfig(config);
return this.getConfig();
}
}
export const knowledgeSourcesRepo = new FSKnowledgeSourcesRepo();

View file

@ -0,0 +1,182 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { KnowledgeSourceConfig } from './types.js';
// WorkDir is resolved when config.js loads, so the env override must be in
// place before sync_slack.js (which imports it) is loaded — hence the
// dynamic imports in beforeAll.
const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'slack-sync-test-'));
process.env.ROWBOAT_WORKDIR = tmpWorkDir;
const sourceA: KnowledgeSourceConfig = {
id: 'slack-a',
provider: 'slack',
enabled: true,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes: [{ type: 'channel', id: 'C-AAA', name: '#alpha' }],
};
const sourceB: KnowledgeSourceConfig = {
...sourceA,
id: 'slack-b',
scopes: [{ type: 'channel', id: 'C-BBB', name: '#beta' }],
};
vi.mock('./repo.js', () => ({
knowledgeSourcesRepo: {
listEnabledSources: vi.fn(() => [sourceA, sourceB]),
getConfig: vi.fn(() => ({ sources: [sourceA, sourceB] })),
},
}));
vi.mock('../../services/service_logger.js', () => ({
serviceLogger: {
startRun: vi.fn(async () => ({ service: 'slack', runId: 'test-run', startedAt: Date.now() })),
log: vi.fn(async () => { }),
},
}));
vi.mock('../../events/producer.js', () => ({
createEvent: vi.fn(async () => { }),
}));
vi.mock('../../slack/agent-slack-exec.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../slack/agent-slack-exec.js')>();
return { ...actual, runAgentSlack: vi.fn() };
});
type SyncModule = typeof import('./sync_slack.js');
type ExecModule = typeof import('../../slack/agent-slack-exec.js');
let sync: SyncModule;
let execMock: ReturnType<typeof vi.mocked<ExecModule['runAgentSlack']>>;
const stateFile = path.join(tmpWorkDir, 'slack_knowledge_sync_state.json');
function readState() {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
/** Rewind a source's lastSyncAt so it counts as due again. */
function rewindSource(sourceId: string, ms: number) {
const state = readState();
state.sources[sourceId].lastSyncAt = new Date(Date.now() - ms).toISOString();
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf-8');
}
const okEmpty = { ok: true as const, stdout: '[]', data: [] };
const rateLimited = {
ok: false as const, kind: 'rate_limited' as const, stderr: 'ratelimited',
message: 'A rate-limit has been reached, you may retry this request in 30 seconds',
};
const badChannel = {
ok: false as const, kind: 'bad_channel' as const, stderr: '',
message: 'Could not resolve channel name: #alpha',
};
beforeAll(async () => {
sync = await import('./sync_slack.js');
const exec = await import('../../slack/agent-slack-exec.js');
execMock = vi.mocked(exec.runAgentSlack);
});
beforeEach(() => {
execMock.mockReset();
fs.rmSync(stateFile, { force: true });
});
afterAll(() => {
fs.rmSync(tmpWorkDir, { recursive: true, force: true });
});
describe('syncSlackKnowledgeSources status persistence', () => {
it('records ok status and lastSyncAt per source', async () => {
execMock.mockResolvedValue(okEmpty);
await sync.syncSlackKnowledgeSources();
const state = readState();
for (const id of ['slack-a', 'slack-b']) {
expect(state.sources[id].lastStatus).toBe('ok');
expect(Date.parse(state.sources[id].lastSyncAt)).toBeGreaterThan(Date.now() - 60_000);
expect(state.sources[id].lastError).toBeUndefined();
}
});
it('persists lastError and lets other sources continue past a bad one', async () => {
execMock.mockImplementation(async (args: string[]) =>
args.includes('C-AAA') ? badChannel : okEmpty);
await sync.syncSlackKnowledgeSources();
const state = readState();
expect(state.sources['slack-a']).toMatchObject({
lastStatus: 'error',
lastError: { kind: 'bad_channel', message: 'Could not resolve channel name: #alpha' },
});
// slack-b synced despite slack-a failing
expect(state.sources['slack-b'].lastStatus).toBe('ok');
expect(execMock.mock.calls.some(call => call[0].includes('C-BBB'))).toBe(true);
});
it('stops the run on rate limit without touching later sources', async () => {
execMock.mockResolvedValue(rateLimited);
await sync.syncSlackKnowledgeSources();
const state = readState();
expect(state.sources['slack-a'].lastError.kind).toBe('rate_limited');
expect(state.sources['slack-b']).toBeUndefined();
expect(execMock.mock.calls.every(call => !call[0].includes('C-BBB'))).toBe(true);
});
it('grows backoff on consecutive rate limits and resets it on success', async () => {
execMock.mockResolvedValue(rateLimited);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBe(2);
rewindSource('slack-a', 60 * 60 * 1000);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBe(4);
rewindSource('slack-a', 60 * 60 * 1000);
execMock.mockResolvedValue(okEmpty);
await sync.syncSlackKnowledgeSources();
expect(readState().sources['slack-a'].backoffMultiplier).toBeUndefined();
expect(readState().sources['slack-a'].lastStatus).toBe('ok');
});
it('does not re-sync a rate-limited source before its backed-off interval elapses', async () => {
execMock.mockResolvedValue(rateLimited);
// First run rate-limits slack-a and breaks before slack-b.
await sync.syncSlackKnowledgeSources();
execMock.mockClear();
// Second run: slack-a is backed off (not due) but slack-b never ran, so
// it's still due. slack-a must not be retried; slack-b may be.
await sync.syncSlackKnowledgeSources();
expect(execMock.mock.calls.every(call => !call[0].includes('C-AAA'))).toBe(true);
});
});
describe('effectiveIntervalMs', () => {
it('multiplies the base interval by the backoff and caps at 30 minutes', () => {
expect(sync.effectiveIntervalMs(sourceA, undefined)).toBe(5 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 2 })).toBe(10 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 4 })).toBe(20 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 8 })).toBe(30 * 60 * 1000);
expect(sync.effectiveIntervalMs(sourceA, { backoffMultiplier: 1024 })).toBe(30 * 60 * 1000);
});
});
describe('getSlackKnowledgeSyncStatus', () => {
it('reports per-source status with nextDueAt from interval + backoff', async () => {
execMock.mockImplementation(async (args: string[]) =>
args.includes('C-AAA') ? badChannel : okEmpty);
await sync.syncSlackKnowledgeSources();
const statuses = sync.getSlackKnowledgeSyncStatus();
const a = statuses.find(s => s.id === 'slack-a');
const b = statuses.find(s => s.id === 'slack-b');
expect(a).toMatchObject({ enabled: true, lastStatus: 'error', lastError: { kind: 'bad_channel' } });
expect(b).toMatchObject({ enabled: true, lastStatus: 'ok' });
// nextDueAt ≈ lastSyncAt + 5 min
expect(Date.parse(b!.nextDueAt!)).toBeCloseTo(Date.parse(b!.lastSyncAt!) + 5 * 60 * 1000, -3);
});
});

View file

@ -0,0 +1,479 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { AgentSlackRunError, runAgentSlack as execAgentSlack } from '../../slack/agent-slack-exec.js';
import type { AgentSlackErrorKind } from '../../slack/agent-slack-exec.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { createEvent } from '../../events/producer.js';
import { knowledgeSourcesRepo } from './repo.js';
import type { KnowledgeArtifact, KnowledgeSourceConfig, KnowledgeSourceScope } from './types.js';
const DEFAULT_LIMIT = 100;
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60 * 1000;
const DEFAULT_RECENT_BACKFILL_SECONDS = 6 * 60 * 60;
const STATE_FILE = path.join(WorkDir, 'slack_knowledge_sync_state.json');
const ARTIFACT_ROOT = path.join(WorkDir, 'knowledge_sources', 'slack');
export type SlackSourceSyncState = {
/** Time of the last sync attempt (success or failure). */
lastSyncAt?: string;
lastStatus?: 'ok' | 'error';
lastError?: { kind: AgentSlackErrorKind | 'unknown'; message: string };
/** Rate-limit backoff: multiplies the source interval; reset on success. */
backoffMultiplier?: number;
};
type SlackSyncState = {
lastSyncAt?: string;
sources?: Record<string, SlackSourceSyncState>;
channels: Record<string, { lastSeenTs?: string }>;
};
type SlackMessage = {
ts?: string;
thread_ts?: string;
user?: string;
username?: string;
text?: string;
body?: string;
content?: string;
channel?: string;
channel_id?: string;
channel_name?: string;
permalink?: string;
url?: string;
edited?: { ts?: string; user?: string };
reply_count?: number;
replies?: SlackMessage[];
};
function loadState(): SlackSyncState {
try {
if (fs.existsSync(STATE_FILE)) {
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as Partial<SlackSyncState>;
return { channels: {}, ...parsed };
}
} catch (error) {
console.error('[SlackKnowledge] Failed to load state:', error);
}
return { channels: {} };
}
function saveState(state: SlackSyncState): void {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
}
const MAX_SOURCE_SYNC_INTERVAL_MS = 30 * 60 * 1000;
/** Source interval with rate-limit backoff applied, capped at 30 minutes. */
export function effectiveIntervalMs(source: KnowledgeSourceConfig, sourceState?: SlackSourceSyncState): number {
const base = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
const multiplier = Math.max(1, sourceState?.backoffMultiplier ?? 1);
return Math.min(base * multiplier, MAX_SOURCE_SYNC_INTERVAL_MS);
}
function isSourceDue(source: KnowledgeSourceConfig, state: SlackSyncState): boolean {
const sourceState = state.sources?.[source.id];
if (!sourceState?.lastSyncAt) return true;
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
return !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= effectiveIntervalMs(source, sourceState);
}
function safeSegment(value: string): string {
return value
.replace(/^https?:\/\//, '')
.replace(/[\\/*?:"<>|#\s]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 120) || 'unknown';
}
function slackTsToDate(ts: string): string {
const seconds = Number(ts.split('.')[0]);
if (!Number.isFinite(seconds)) {
return new Date().toISOString();
}
return new Date(seconds * 1000).toISOString();
}
function subtractSlackTs(ts: string | undefined, seconds: number): string | undefined {
if (!ts) return undefined;
const value = Number(ts);
if (!Number.isFinite(value)) return undefined;
return Math.max(0, value - seconds).toFixed(6);
}
function compareSlackTs(a: string | undefined, b: string | undefined): number {
const an = Number(a);
const bn = Number(b);
if (!Number.isFinite(an) && !Number.isFinite(bn)) return 0;
if (!Number.isFinite(an)) return -1;
if (!Number.isFinite(bn)) return 1;
return an - bn;
}
function extractMessages(raw: unknown): SlackMessage[] {
if (Array.isArray(raw)) return raw as SlackMessage[];
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const candidates = [obj.messages, obj.items, obj.results, obj.data];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate as SlackMessage[];
}
}
return [];
}
function getMessageText(message: SlackMessage): string {
return message.text ?? message.body ?? message.content ?? '';
}
function getMessageAuthor(message: SlackMessage): string {
return message.username ?? message.user ?? 'unknown';
}
async function runAgentSlack(args: string[]): Promise<unknown> {
const result = await execAgentSlack(args, { timeoutMs: 30_000, maxBuffer: 2 * 1024 * 1024 });
if (!result.ok) {
throw new AgentSlackRunError(result.kind, result.message);
}
return result.data ?? [];
}
async function listMessages(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, oldest?: string): Promise<SlackMessage[]> {
const target = scope.id;
const args = [
'message',
'list',
target,
'--limit',
String(source.filters?.limit ?? DEFAULT_LIMIT),
'--max-body-chars',
String(source.filters?.maxBodyChars ?? 4000),
];
if (scope.workspaceUrl) {
args.push('--workspace', scope.workspaceUrl);
}
if (oldest) {
args.push('--oldest', oldest);
}
const raw = await runAgentSlack(args);
return extractMessages(raw)
.filter(message => message.ts && getMessageText(message).trim().length > 0)
.sort((a, b) => compareSlackTs(a.ts, b.ts));
}
function artifactForMessage(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, message: SlackMessage): KnowledgeArtifact | null {
if (!message.ts) return null;
const channelName = scope.name ?? message.channel_name ?? message.channel ?? message.channel_id ?? scope.id;
const workspaceName = scope.workspaceUrl ?? 'Slack';
const version = message.edited?.ts ?? message.ts;
const url = message.permalink ?? message.url;
const title = `Slack message in ${channelName}`;
const occurredAt = slackTsToDate(message.ts);
const author = getMessageAuthor(message);
const body = getMessageText(message).trim();
const bodyMarkdown = [
`# ${title}`,
``,
`**Workspace:** ${workspaceName}`,
`**Channel:** ${channelName}`,
`**Author:** ${author}`,
`**Timestamp:** ${occurredAt}`,
message.thread_ts ? `**Thread TS:** ${message.thread_ts}` : '',
url ? `**Link:** ${url}` : '',
``,
`## Message`,
``,
body,
].filter(line => line !== '').join('\n');
return {
sourceId: source.id,
provider: 'slack',
externalId: `${scope.workspaceUrl ?? 'workspace'}:${scope.id}:${message.ts}`,
version,
occurredAt,
title,
bodyMarkdown,
url,
metadata: {
workspaceUrl: scope.workspaceUrl,
channelId: scope.id,
channelName,
author,
ts: message.ts,
threadTs: message.thread_ts,
editedTs: message.edited?.ts,
},
};
}
function writeArtifact(source: KnowledgeSourceConfig, scope: KnowledgeSourceScope, artifact: KnowledgeArtifact): string | null {
const workspace = safeSegment(scope.workspaceUrl ?? 'workspace');
const channel = safeSegment(scope.name ?? scope.id);
const ts = safeSegment(artifact.metadata.ts as string);
const dir = path.join(WorkDir, source.artifactDir || path.join('knowledge_sources', 'slack'), workspace, channel);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${ts}.md`);
const frontmatter = [
'---',
`source: ${artifact.provider}`,
`source_id: ${artifact.sourceId}`,
`external_id: ${JSON.stringify(artifact.externalId)}`,
`version: ${JSON.stringify(artifact.version)}`,
`occurred_at: ${JSON.stringify(artifact.occurredAt)}`,
artifact.url ? `url: ${JSON.stringify(artifact.url)}` : '',
'---',
'',
].filter(Boolean).join('\n');
const content = `${frontmatter}${artifact.bodyMarkdown}\n`;
if (fs.existsSync(filePath)) {
try {
if (fs.readFileSync(filePath, 'utf-8') === content) {
return null;
}
} catch {
// Fall through and rewrite the artifact.
}
}
fs.writeFileSync(filePath, content, 'utf-8');
return filePath;
}
async function publishSlackSyncEvent(files: string[]): Promise<void> {
if (files.length === 0) return;
const relativeFiles = files.map(file => path.relative(WorkDir, file));
await createEvent({
source: 'slack',
type: 'slack.synced',
createdAt: new Date().toISOString(),
payload: [
'# Slack knowledge sync update',
'',
`${files.length} new/updated message artifact${files.length === 1 ? '' : 's'}.`,
'',
...relativeFiles.slice(0, 20).map(file => `- ${file}`),
].join('\n'),
});
}
/**
* Sync one source's channels into artifact files. Mutates state.channels as
* it goes; throws AgentSlackRunError on CLI failure (status bookkeeping is
* the caller's job).
*/
async function syncSource(source: KnowledgeSourceConfig, state: SlackSyncState): Promise<string[]> {
if (source.scopes.length === 0) {
console.log(`[SlackKnowledge] Source ${source.id} has no channel scopes; skipping`);
return [];
}
const writtenFiles: string[] = [];
for (const scope of source.scopes.filter(scope => scope.type === 'channel')) {
const key = `${source.id}:${scope.workspaceUrl ?? ''}:${scope.id}`;
const channelState = state.channels[key] ?? {};
const recentBackfillSeconds = Number(source.filters?.recentBackfillSeconds ?? DEFAULT_RECENT_BACKFILL_SECONDS);
const oldest = subtractSlackTs(channelState.lastSeenTs, recentBackfillSeconds);
const messages = await listMessages(source, scope, oldest);
let newestTs = channelState.lastSeenTs;
for (const message of messages) {
if (compareSlackTs(message.ts, channelState.lastSeenTs) <= 0 && !message.edited?.ts) {
continue;
}
const artifact = artifactForMessage(source, scope, message);
if (!artifact) continue;
const writtenFile = writeArtifact(source, scope, artifact);
if (writtenFile) {
writtenFiles.push(writtenFile);
}
if (compareSlackTs(message.ts, newestTs) > 0) {
newestTs = message.ts;
}
}
state.channels[key] = { lastSeenTs: newestTs };
}
return writtenFiles;
}
function recordSourceResult(state: SlackSyncState, sourceId: string, error?: { kind: AgentSlackErrorKind | 'unknown'; message: string }): void {
const previous = state.sources?.[sourceId];
const now = new Date().toISOString();
const next: SlackSourceSyncState = { lastSyncAt: now };
if (error) {
next.lastStatus = 'error';
next.lastError = error;
if (error.kind === 'rate_limited') {
// Doubles each consecutive rate limit; effectiveIntervalMs caps
// the resulting interval at 30 min, the clamp keeps the stored
// value sane in the state file.
next.backoffMultiplier = Math.min(Math.max(2, (previous?.backoffMultiplier ?? 1) * 2), 1024);
}
} else {
next.lastStatus = 'ok';
}
state.lastSyncAt = now;
state.sources = { ...(state.sources ?? {}), [sourceId]: next };
}
export async function syncSlackKnowledgeSources(): Promise<string[]> {
const state = loadState();
const sources = knowledgeSourcesRepo
.listEnabledSources()
.filter(source => source.provider === 'slack' && source.syncMode === 'poll')
.filter(source => isSourceDue(source, state));
if (sources.length === 0) return [];
const run = await serviceLogger.startRun({
service: 'slack',
message: 'Syncing Slack knowledge sources',
trigger: 'timer',
});
const writtenFiles: string[] = [];
let hadError = false;
for (const source of sources) {
let rateLimited = false;
try {
const files = await syncSource(source, state);
writtenFiles.push(...files);
recordSourceResult(state, source.id);
} catch (error) {
// One failing source must not abort the others.
hadError = true;
const kind = error instanceof AgentSlackRunError ? error.kind : 'unknown';
const message = error instanceof Error ? error.message : String(error);
recordSourceResult(state, source.id, { kind, message });
rateLimited = kind === 'rate_limited';
console.error(`[SlackKnowledge] Sync failed for source ${source.id} (${kind}):`, message);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Slack knowledge sync error for source ${source.id} (${kind})`,
error: message,
});
}
// Persist after every source so progress and status survive a crash.
saveState(state);
// Rate limits are per-token, so the remaining sources would hit the
// same wall — end this run; they stay due for the next tick.
if (rateLimited) break;
}
if (writtenFiles.length > 0) {
try {
const relativeFiles = writtenFiles.map(file => path.relative(WorkDir, file));
const limitedFiles = limitEventItems(relativeFiles);
await serviceLogger.log({
type: 'changes_identified',
service: run.service,
runId: run.runId,
level: 'info',
message: `Slack updates: ${writtenFiles.length} message artifact${writtenFiles.length === 1 ? '' : 's'}`,
counts: { messages: writtenFiles.length },
items: limitedFiles.items,
truncated: limitedFiles.truncated,
});
await publishSlackSyncEvent(writtenFiles);
} catch (error) {
hadError = true;
console.error('[SlackKnowledge] Failed to publish sync results:', error);
}
}
await serviceLogger.log({
type: 'run_complete',
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Slack sync complete: ${writtenFiles.length} artifact${writtenFiles.length === 1 ? '' : 's'}`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: { artifacts: writtenFiles.length },
});
return writtenFiles;
}
export function getSlackKnowledgeArtifactRoot(): string {
return ARTIFACT_ROOT;
}
export type SlackKnowledgeSourceStatus = {
id: string;
enabled: boolean;
lastSyncAt?: string;
lastStatus?: 'ok' | 'error';
lastError?: { kind: string; message: string };
/** When the source next becomes due, given interval + backoff. */
nextDueAt?: string;
};
/** Per-source sync status for the slack:knowledgeStatus IPC channel. */
export function getSlackKnowledgeSyncStatus(): SlackKnowledgeSourceStatus[] {
const state = loadState();
return knowledgeSourcesRepo
.getConfig()
.sources
.filter(source => source.provider === 'slack')
.map(source => {
const sourceState = state.sources?.[source.id];
const lastMs = sourceState?.lastSyncAt ? Date.parse(sourceState.lastSyncAt) : NaN;
return {
id: source.id,
enabled: source.enabled,
lastSyncAt: sourceState?.lastSyncAt,
lastStatus: sourceState?.lastStatus,
lastError: sourceState?.lastError,
nextDueAt: Number.isFinite(lastMs)
? new Date(lastMs + effectiveIntervalMs(source, sourceState)).toISOString()
: undefined,
};
});
}
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
wakeResolve();
wakeResolve = null;
}
}
function interruptibleSleep(ms: number): Promise<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
export async function init(): Promise<void> {
console.log(`[SlackKnowledge] Starting Slack knowledge sync. Polling every ${DEFAULT_SYNC_INTERVAL_MS / 1000}s`);
while (true) {
await syncSlackKnowledgeSources();
await interruptibleSleep(DEFAULT_SYNC_INTERVAL_MS);
}
}

View file

@ -0,0 +1,49 @@
import { z } from 'zod';
export const KnowledgeSourceProvider = z.enum([
'gmail',
'meeting',
'voice_memo',
'slack',
'github',
'linear',
]);
export type KnowledgeSourceProvider = z.infer<typeof KnowledgeSourceProvider>;
export const KnowledgeSourceScope = z.object({
type: z.string(),
id: z.string(),
name: z.string().optional(),
workspaceUrl: z.string().optional(),
});
export type KnowledgeSourceScope = z.infer<typeof KnowledgeSourceScope>;
export const KnowledgeSourceConfig = z.object({
id: z.string(),
provider: KnowledgeSourceProvider,
enabled: z.boolean(),
artifactDir: z.string(),
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
intervalMs: z.number().int().positive().optional(),
scopes: z.array(KnowledgeSourceScope).default([]),
instructions: z.string().optional(),
filters: z.record(z.string(), z.unknown()).optional(),
});
export type KnowledgeSourceConfig = z.infer<typeof KnowledgeSourceConfig>;
export const KnowledgeSourcesFile = z.object({
sources: z.array(KnowledgeSourceConfig),
});
export type KnowledgeSourcesFile = z.infer<typeof KnowledgeSourcesFile>;
export interface KnowledgeArtifact {
sourceId: string;
provider: KnowledgeSourceProvider;
externalId: string;
version: string;
occurredAt: string;
title: string;
bodyMarkdown: string;
url?: string;
metadata: Record<string, unknown>;
}

View file

@ -1406,6 +1406,8 @@ export interface SendReplyOptions {
bodyText: string;
inReplyTo?: string;
references?: string;
/** Files to attach. contentBase64 is the raw (unwrapped) base64 of the file bytes. */
attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }>;
}
export interface SendReplyResult {
@ -1427,6 +1429,44 @@ export async function getAccountEmail(): Promise<string | null> {
return getUserEmail(auth);
}
let cachedAccountName: string | null | undefined;
/**
* The connected account's display name, parsed from the `From` header of a
* recent SENT message (which is the user themselves). Cached for the process
* lifetime. Uses only the existing gmail.modify scope no profile/userinfo
* scope, so it never triggers a re-consent. Used by the composer to sign off
* AI-generated emails with the real name.
*/
export async function getAccountName(): Promise<string | null> {
if (cachedAccountName !== undefined) return cachedAccountName;
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return null;
const gmailClient = google.gmail({ version: 'v1', auth });
const list = await gmailClient.users.messages.list({ userId: 'me', labelIds: ['SENT'], maxResults: 1 });
const id = list.data.messages?.[0]?.id;
if (!id) {
cachedAccountName = null;
return null;
}
const msg = await gmailClient.users.messages.get({
userId: 'me',
id,
format: 'metadata',
metadataHeaders: ['From'],
});
const from = msg.data.payload?.headers?.find((h) => h.name?.toLowerCase() === 'from')?.value || '';
// Pull the display name out of `"Name" <email>` / `Name <email>`.
const name = from.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim() || null;
cachedAccountName = name;
return name;
} catch (err) {
console.warn('[Gmail] getAccountName failed:', err);
return null;
}
}
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
let email: string | null = null;
@ -1467,6 +1507,17 @@ function encodeMimeBase64(text: string): string {
?.join('\r\n') ?? '';
}
// Re-wrap an already-base64 string into 76-char lines (RFC 2045) and strip any
// whitespace the renderer may have included.
function wrapBase64(base64: string): string {
return base64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\r\n') ?? '';
}
// Quote a filename for a MIME header, dropping characters that would break it.
function sanitizeAttachmentName(name: string): string {
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
@ -1486,7 +1537,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
@ -1496,24 +1550,52 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
const parts: string[] = [];
parts.push(`--${boundary}`);
parts.push('Content-Type: text/plain; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyText));
parts.push('');
parts.push(`--${boundary}`);
parts.push('Content-Type: text/html; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(replyBody.bodyHtml));
parts.push('');
parts.push(`--${boundary}--`);
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')

View file

@ -9,6 +9,8 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { getGatewayProvider } from "./gateway.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
import { withUseCase } from "../analytics/use_case.js";
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
@ -161,3 +163,47 @@ export async function listModelsForProvider(
clearTimeout(timeout);
}
}
export interface GenerateTextOptions {
prompt: string;
system?: string;
/** Model id. Falls back to the active default when omitted. */
model?: string;
/** Provider name (e.g. "rowboat", "openai"). Falls back to the active default. */
provider?: string;
}
export interface GenerateTextResult {
text?: string;
/** The model/provider actually used (after resolving defaults). */
model?: string;
provider?: string;
error?: string;
}
/**
* One-shot text generation for lightweight UI features (e.g. the email
* composer's "write with AI"). Resolves the requested model+provider, falling
* back to the active default, and returns the generated text. Never throws
* errors are returned in the result so the renderer can surface them.
*/
export async function generateOneShot(opts: GenerateTextOptions): Promise<GenerateTextResult> {
try {
const def = await getDefaultModelAndProvider();
const modelId = opts.model || def.model;
const providerName = opts.provider || def.provider;
const providerConfig = await resolveProviderConfig(providerName);
const languageModel = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase(
{ useCase: "copilot_chat", subUseCase: "email_compose" },
() => generateText({
model: languageModel,
...(opts.system ? { system: opts.system } : {}),
prompt: opts.prompt,
}),
);
return { text: result.text.trim(), model: modelId, provider: providerName };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

View file

@ -0,0 +1,190 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { exec } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { agentSlackShimEnv, classifyAgentSlackStderr, resolveAgentSlackCli, runAgentSlack } from './agent-slack-exec.js';
const execAsync = promisify(exec);
// Fixture CLI scripts spawned via process.execPath (real node under vitest),
// exercising the same spawn path the app uses.
let fixtureDir: string;
let jsonCli: string;
let garbageCli: string;
let sleepCli: string;
let failingCli: string;
let stdinCli: string;
function writeFixture(name: string, code: string): string {
const file = path.join(fixtureDir, name);
fs.writeFileSync(file, code, 'utf-8');
return file;
}
beforeAll(() => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-slack-exec-test-'));
jsonCli = writeFixture('json.cjs', `process.stdout.write(JSON.stringify({ args: process.argv.slice(2) }));`);
stdinCli = writeFixture('stdin.cjs', `let s = ''; process.stdin.on('data', c => s += c); process.stdin.on('end', () => process.stdout.write(s.trim()));`);
garbageCli = writeFixture('garbage.cjs', `process.stdout.write('definitely: not json');`);
sleepCli = writeFixture('sleep.cjs', `setTimeout(() => {}, 60_000);`);
failingCli = writeFixture('fail.cjs', `process.stderr.write('boom'); process.exit(2);`);
});
afterAll(() => {
fs.rmSync(fixtureDir, { recursive: true, force: true });
});
const missing = path.join('/nonexistent', 'agent-slack.cjs');
describe('resolveAgentSlackCli', () => {
it('prefers the bundled bin over global and PATH', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [jsonCli],
globalCandidates: [garbageCli],
pathProbe: () => garbageCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'bundled' });
});
it('falls back to a global install when the bundled bin is missing', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [jsonCli],
pathProbe: () => garbageCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'global' });
});
it('falls back to PATH last', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [missing],
pathProbe: () => jsonCli,
});
expect(resolved).toEqual({ entry: jsonCli, source: 'path' });
});
it('returns null when nothing is found', () => {
const resolved = resolveAgentSlackCli({
bundledCandidates: [missing],
globalCandidates: [missing],
pathProbe: () => null,
});
expect(resolved).toBeNull();
});
});
describe('runAgentSlack', () => {
const via = (entry: string) => ({
bundledCandidates: [entry],
globalCandidates: [],
pathProbe: () => null,
});
it('returns parsed JSON stdout and forwards args', async () => {
const result = await runAgentSlack(['auth', 'whoami'], { resolve: via(jsonCli) });
expect(result).toMatchObject({ ok: true, data: { args: ['auth', 'whoami'] } });
});
it('returns raw stdout when parseJson is false', async () => {
const result = await runAgentSlack([], { resolve: via(garbageCli), parseJson: false });
expect(result.ok).toBe(true);
if (result.ok) expect(result.stdout).toBe('definitely: not json');
});
it('writes opts.input to the child stdin (parse-curl path)', async () => {
const result = await runAgentSlack([], { resolve: via(stdinCli), parseJson: false, input: "curl 'https://team.slack.com'" });
expect(result.ok).toBe(true);
if (result.ok) expect(result.stdout).toBe("curl 'https://team.slack.com'");
});
it('reports not_installed when no binary resolves', async () => {
const result = await runAgentSlack(['--version'], {
resolve: { bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null },
});
expect(result).toMatchObject({ ok: false, kind: 'not_installed' });
});
it('reports parse_error on malformed JSON stdout', async () => {
const result = await runAgentSlack([], { resolve: via(garbageCli) });
expect(result).toMatchObject({ ok: false, kind: 'parse_error' });
});
it('kills a hung CLI and reports timeout', async () => {
const result = await runAgentSlack([], { resolve: via(sleepCli), timeoutMs: 300 });
expect(result).toMatchObject({ ok: false, kind: 'timeout' });
}, 10_000);
it('classifies stderr on non-zero exit (unrecognized → unknown)', async () => {
const result = await runAgentSlack([], { resolve: via(failingCli) });
expect(result).toMatchObject({ ok: false, kind: 'unknown', stderr: 'boom', message: 'boom' });
});
});
describe('classifyAgentSlackStderr', () => {
// Fixture corpus: strings marked (captured) are real stderr induced on a
// machine with no Slack auth; the rest are taken verbatim from the
// agent-slack 0.9.3 / @slack/web-api 7.17 sources.
const cases: Array<[string, ReturnType<typeof classifyAgentSlackStderr>]> = [
// not_authed — empty credential store (auth auto-import cascade)
['Firefox extraction is not supported on win32.', 'not_authed'], // (captured)
['Slack Desktop data not found. Checked:\n - C:\\Users\\X\\AppData\\Roaming\\Slack\\Local Storage\\leveldb', 'not_authed'], // (captured)
// not_authed — Slack API codes, both client flavors
['invalid_auth', 'not_authed'],
['token_expired', 'not_authed'],
['An API error occurred: invalid_auth', 'not_authed'],
['account_inactive', 'not_authed'],
// rate_limited
['ratelimited', 'rate_limited'],
['A rate-limit has been reached, you may retry this request in 30 seconds', 'rate_limited'],
['Slack HTTP 429 calling conversations.history', 'rate_limited'],
// network
['A request error occurred: getaddrinfo ENOTFOUND slack.com', 'network'],
['fetch failed', 'network'],
['connect ECONNREFUSED 127.0.0.1:443', 'network'],
['Slack HTTP 503 calling conversations.list', 'network'],
// bad_channel
['channel_not_found', 'bad_channel'],
['An API error occurred: channel_not_found', 'bad_channel'],
['Could not resolve channel name: #nonexistent-channel', 'bad_channel'],
['not_in_channel', 'bad_channel'],
// unknown
['Ambiguous channel name across multiple workspaces. Pass --workspace "<url>"', 'unknown'],
['', 'unknown'],
];
it.each(cases)('%j → %s', (stderr, expected) => {
expect(classifyAgentSlackStderr(stderr)).toBe(expected);
});
it('does not misread substrings of longer identifiers', () => {
// "speedratelimitedness" style false positives guarded by boundaries
expect(classifyAgentSlackStderr('field xratelimitedx in payload')).toBe('unknown');
expect(classifyAgentSlackStderr('saved to channel_not_found_archive.txt')).toBe('unknown');
});
});
describe('agentSlackShimEnv', () => {
it('returns the base env unchanged when no CLI resolves', () => {
const base = { PATH: '/usr/bin' };
const env = agentSlackShimEnv(path.join(fixtureDir, 'bin'), base, {
bundledCandidates: [missing], globalCandidates: [missing], pathProbe: () => null,
});
expect(env).toBe(base);
});
it('makes `agent-slack` runnable by name through a shell', async () => {
const shimDir = path.join(fixtureDir, 'bin');
const env = agentSlackShimEnv(shimDir, process.env, {
bundledCandidates: [jsonCli], globalCandidates: [], pathProbe: () => null,
});
const pathKey = Object.keys(env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH';
expect(env[pathKey]!.startsWith(`${shimDir}${path.delimiter}`)).toBe(true);
// Same spawn shape as executeCommand: command string through a shell.
const { stdout } = await execAsync('agent-slack hello world', { env });
expect(JSON.parse(stdout)).toEqual({ args: ['hello', 'world'] });
});
});

View file

@ -0,0 +1,315 @@
import { execFile, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/**
* Single shared executor for the agent-slack CLI.
*
* Every agent-slack invocation in the app must go through runAgentSlack()
* never execFile('agent-slack', ...) directly. Spawning the bare command
* requires it on PATH (we no longer auto-install it) and on Windows hits the
* .cmd-shim EINVAL bug. Instead we resolve a JS entry file and spawn it with
* process.execPath, which works without Node/npm on the user's machine.
*/
export type AgentSlackSource = 'bundled' | 'global' | 'path';
export interface ResolvedAgentSlack {
/** Absolute path to a JS entry file runnable via `node <entry>`. */
entry: string;
source: AgentSlackSource;
}
export type AgentSlackErrorKind =
// Structural failures (detected without running / from the spawn itself)
| 'not_installed' | 'timeout' | 'parse_error'
// CLI failures classified from stderr (exit code is always 1)
| 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
// agent-slack prints `err.message` to stderr and exits 1 for every failure, so
// stderr text is the only classification signal. Patterns cover both Slack
// client flavors the CLI uses: the browser-token client throws bare Slack
// error codes ("invalid_auth"), @slack/web-api wraps them ("An API error
// occurred: invalid_auth") — plus the CLI's own messages. Word-ish boundaries
// match the CLI's own auth-detection regex.
const SLACK_CODE = (codes: string) => new RegExp(`(?:^|[^a-z_])(?:${codes})(?:$|[^a-z_])`, 'i');
const NOT_AUTHED_RE = SLACK_CODE('invalid_auth|token_expired|token_revoked|account_inactive|not_authed');
// Empty credential store surfaces as the auth auto-import cascade failing,
// e.g. "Slack Desktop data not found." / "Firefox extraction is not supported
// on win32." (real stderr captured on Windows with no Slack installed).
const AUTH_IMPORT_RE = /Slack Desktop data not found|extraction is not supported/i;
const RATE_LIMITED_RE = /(?:^|[^a-z_])ratelimited(?:$|[^a-z_])|A rate-?limit has been reached|Slack HTTP 429/i;
const NETWORK_RE = /A request error occurred|fetch failed|socket hang up|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|Slack HTTP 5\d\d/i;
const BAD_CHANNEL_RE = new RegExp(
`${SLACK_CODE('channel_not_found|not_in_channel|is_archived').source}|Could not resolve channel name`, 'i');
/** Classify an agent-slack failure from its stderr. Exported for tests. */
export function classifyAgentSlackStderr(stderr: string): Exclude<AgentSlackErrorKind, 'not_installed' | 'timeout' | 'parse_error'> {
if (RATE_LIMITED_RE.test(stderr)) return 'rate_limited';
if (BAD_CHANNEL_RE.test(stderr)) return 'bad_channel';
if (NOT_AUTHED_RE.test(stderr) || AUTH_IMPORT_RE.test(stderr)) return 'not_authed';
if (NETWORK_RE.test(stderr)) return 'network';
return 'unknown';
}
export type AgentSlackResult =
| { ok: true; stdout: string; data: unknown }
| { ok: false; kind: AgentSlackErrorKind; message: string; stderr: string };
/** Throwable wrapper for callers with throw-based control flow (sync loop). */
export class AgentSlackRunError extends Error {
constructor(public readonly kind: AgentSlackErrorKind, message: string) {
super(message);
this.name = 'AgentSlackRunError';
}
}
export interface ResolveOptions {
/** Re-probe even if a previous resolution succeeded. */
refresh?: boolean;
/** Test hooks — override the default probe locations. */
bundledCandidates?: string[];
globalCandidates?: string[];
pathProbe?: () => string | null;
}
export interface RunAgentSlackOptions {
timeoutMs?: number;
maxBuffer?: number;
/** Set false for commands with non-JSON output (e.g. --version). */
parseJson?: boolean;
/** Written to the child's stdin then closed (e.g. `auth parse-curl`). */
input?: string;
/** Test hook — bypass the default resolver. */
resolve?: ResolveOptions;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024;
// The CLI is bundled by apps/main/bundle.mjs to agent-slack.cjs next to
// main.cjs. At runtime import.meta.url is rewritten by esbuild to point at
// main.cjs, so the sibling lookup works in dev and packaged builds alike.
// (Under vitest/tsc output the sibling doesn't exist and we fall through.)
function defaultBundledCandidates(): string[] {
return [path.join(path.dirname(fileURLToPath(import.meta.url)), 'agent-slack.cjs')];
}
const GLOBAL_BIN_REL = path.join('node_modules', 'agent-slack', 'bin', 'agent-slack.js');
function defaultGlobalCandidates(): string[] {
if (process.platform === 'win32') {
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appData, 'npm', GLOBAL_BIN_REL)];
}
return [
path.join('/usr/local/lib', GLOBAL_BIN_REL),
path.join('/opt/homebrew/lib', GLOBAL_BIN_REL),
];
}
/** Map a PATH hit (symlink, npm .cmd/.ps1/sh shim) to the underlying JS bin. */
function jsEntryFromPathHit(hit: string): string | null {
try {
const real = fs.realpathSync(hit);
if (/\.(c|m)?js$/.test(real)) return real;
// npm shims live next to the global node_modules tree.
const sibling = path.join(path.dirname(real), GLOBAL_BIN_REL);
if (fs.existsSync(sibling)) return sibling;
} catch {
// Broken symlink or unreadable shim — treat as no hit.
}
return null;
}
function defaultPathProbe(): string | null {
const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
let output: string;
try {
output = execFileSync(lookup, ['agent-slack'], {
timeout: 5_000,
encoding: 'utf-8',
windowsHide: true,
});
} catch {
return null;
}
for (const line of output.split(/\r?\n/)) {
const hit = line.trim();
if (!hit) continue;
const entry = jsEntryFromPathHit(hit);
if (entry) return entry;
}
return null;
}
let cachedResolution: ResolvedAgentSlack | null = null;
export function resolveAgentSlackCli(opts: ResolveOptions = {}): ResolvedAgentSlack | null {
if (cachedResolution && !opts.refresh
&& !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
return cachedResolution;
}
let resolved: ResolvedAgentSlack | null = null;
for (const candidate of opts.bundledCandidates ?? defaultBundledCandidates()) {
if (fs.existsSync(candidate)) {
resolved = { entry: candidate, source: 'bundled' };
break;
}
}
if (!resolved) {
for (const candidate of opts.globalCandidates ?? defaultGlobalCandidates()) {
if (fs.existsSync(candidate)) {
resolved = { entry: candidate, source: 'global' };
break;
}
}
}
if (!resolved) {
const entry = (opts.pathProbe ?? defaultPathProbe)();
if (entry) resolved = { entry, source: 'path' };
}
// Only cache the default probe — test overrides must not leak, and a
// failed probe should retry next call (the user may install meanwhile).
if (resolved && !opts.bundledCandidates && !opts.globalCandidates && !opts.pathProbe) {
cachedResolution = resolved;
}
return resolved;
}
export async function runAgentSlack(args: string[], opts: RunAgentSlackOptions = {}): Promise<AgentSlackResult> {
const resolved = resolveAgentSlackCli(opts.resolve ?? {});
if (!resolved) {
return {
ok: false,
kind: 'not_installed',
message: 'agent-slack CLI not found (bundled copy missing and no global install)',
stderr: '',
};
}
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let stdout: string;
try {
// process.execPath inside Electron's main process is the Electron
// binary, not node — ELECTRON_RUN_AS_NODE makes it behave as plain
// node (and is ignored when we already run under real node).
const promise = execFileAsync(process.execPath, [resolved.entry, ...args], {
timeout,
maxBuffer: opts.maxBuffer ?? DEFAULT_MAX_BUFFER,
encoding: 'utf-8',
windowsHide: true,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
// promisify(execFile) exposes the ChildProcess as `.child`, letting us
// feed stdin for commands that read it (e.g. `auth parse-curl`). Close
// stdin so those commands stop waiting for more input.
if (opts.input != null) {
promise.child.stdin?.end(opts.input);
}
const result = await promise;
stdout = result.stdout;
} catch (error) {
const err = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; stderr?: string };
const stderr = typeof err.stderr === 'string' ? err.stderr : '';
if (err.code === 'ENOENT') {
return { ok: false, kind: 'not_installed', message: `agent-slack entry vanished: ${resolved.entry}`, stderr };
}
if (err.killed || err.signal === 'SIGTERM') {
return { ok: false, kind: 'timeout', message: `agent-slack timed out after ${timeout}ms`, stderr };
}
return { ok: false, kind: classifyAgentSlackStderr(stderr), message: stderr.trim() || err.message || 'agent-slack failed', stderr };
}
if (opts.parseJson === false) {
return { ok: true, stdout, data: undefined };
}
const trimmed = stdout.trim();
try {
return { ok: true, stdout, data: trimmed ? JSON.parse(trimmed) : undefined };
} catch {
return {
ok: false,
kind: 'parse_error',
message: `agent-slack returned non-JSON output: ${trimmed.slice(0, 200)}`,
stderr: '',
};
}
}
export type AgentSlackCliStatus =
| { available: true; version: string; source: AgentSlackSource }
| { available: false };
/** Availability probe backing the slack:cliStatus IPC channel. */
export async function getAgentSlackCliStatus(): Promise<AgentSlackCliStatus> {
const resolved = resolveAgentSlackCli({ refresh: true });
if (!resolved) return { available: false };
const result = await runAgentSlack(['--version'], { timeoutMs: 10_000, parseJson: false });
if (!result.ok) return { available: false };
return { available: true, version: result.stdout.trim(), source: resolved.source };
}
// --- PATH shim for shell consumers (Copilot skill via executeCommand) -------
//
// The Copilot Slack skill runs literal `agent-slack ...` shell commands. Those
// used to rely on the startup `npm install -g` that this module replaced, so
// without help they'd only work on machines with a manual global install.
// We generate a tiny launcher script that forwards to the resolved CLI entry
// and prepend its directory to PATH for executeCommand children.
let shimmedFor: string | null = null;
function ensureAgentSlackShim(shimDir: string, entry: string): void {
const cacheKey = `${process.execPath}${entry}${shimDir}`;
if (shimmedFor === cacheKey) return;
fs.mkdirSync(shimDir, { recursive: true });
if (process.platform === 'win32') {
const cmd = `@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath}" "${entry}" %*\r\n`;
const cmdPath = path.join(shimDir, 'agent-slack.cmd');
if (!fs.existsSync(cmdPath) || fs.readFileSync(cmdPath, 'utf-8') !== cmd) {
fs.writeFileSync(cmdPath, cmd, 'utf-8');
}
} else {
const sh = `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec "${process.execPath}" "${entry}" "$@"\n`;
const shPath = path.join(shimDir, 'agent-slack');
if (!fs.existsSync(shPath) || fs.readFileSync(shPath, 'utf-8') !== sh) {
fs.writeFileSync(shPath, sh, { encoding: 'utf-8', mode: 0o755 });
}
fs.chmodSync(shPath, 0o755);
}
shimmedFor = cacheKey;
}
/**
* Environment for shell commands that may invoke `agent-slack` by name.
* Prepends a shim directory to PATH so the resolved CLI (bundled first) wins
* over or substitutes for a global npm install. Returns the base env
* unchanged when no CLI can be resolved.
*/
export function agentSlackShimEnv(
shimDir: string,
base: NodeJS.ProcessEnv = process.env,
resolve?: ResolveOptions,
): NodeJS.ProcessEnv {
const resolved = resolveAgentSlackCli(resolve ?? {});
if (!resolved) return base;
try {
ensureAgentSlackShim(shimDir, resolved.entry);
} catch (error) {
console.warn('[Slack] Failed to write agent-slack PATH shim:', error);
return base;
}
// Windows env vars are case-insensitive; reuse the existing key ('Path')
// rather than introducing a duplicate 'PATH'.
const pathKey = Object.keys(base).find(key => key.toUpperCase() === 'PATH') ?? 'PATH';
return { ...base, [pathKey]: `${shimDir}${path.delimiter}${base[pathKey] ?? ''}` };
}

View file

@ -27,6 +27,33 @@ import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoIn
// Runtime Validation Schemas (Single Source of Truth)
// ============================================================================
const KnowledgeSourceScopeSchema = z.object({
type: z.string(),
id: z.string(),
name: z.string().optional(),
workspaceUrl: z.string().optional(),
});
// Mirrors AgentSlackErrorKind in @x/core/slack/agent-slack-exec. Kept as a
// standalone enum so the renderer can branch on failure cause without
// importing core.
const SlackErrorKindSchema = z.enum([
'not_installed', 'timeout', 'parse_error',
'not_authed', 'rate_limited', 'network', 'bad_channel', 'unknown',
]);
const KnowledgeSourceConfigSchema = z.object({
id: z.string(),
provider: z.enum(['gmail', 'meeting', 'voice_memo', 'slack', 'github', 'linear']),
enabled: z.boolean(),
artifactDir: z.string(),
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
intervalMs: z.number().int().positive().optional(),
scopes: z.array(KnowledgeSourceScopeSchema).default([]),
instructions: z.string().optional(),
filters: z.record(z.string(), z.unknown()).optional(),
});
const ipcSchemas = {
'app:getVersions': {
req: z.null(),
@ -163,6 +190,15 @@ const ipcSchemas = {
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
mimeType: z.string(),
contentBase64: z.string(),
}),
)
.optional(),
}),
res: z.object({
messageId: z.string().optional(),
@ -184,6 +220,12 @@ const ipcSchemas = {
email: z.string().nullable(),
}),
},
'gmail:getAccountName': {
req: z.object({}),
res: z.object({
name: z.string().nullable(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
@ -371,6 +413,27 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'llm:getDefaultModel': {
req: z.null(),
res: z.object({
model: z.string(),
provider: z.string(),
}),
},
'llm:generate': {
req: z.object({
prompt: z.string().min(1),
system: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),
res: z.object({
text: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
error: z.string().optional(),
}),
},
'models:saveConfig': {
req: LlmModelConfig,
res: z.object({
@ -743,11 +806,112 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
'slack:cliStatus': {
req: z.null(),
res: z.object({
available: z.boolean(),
version: z.string().optional(),
source: z.enum(['bundled', 'global', 'path']).optional(),
}),
},
'slack:listWorkspaces': {
req: z.null(),
res: z.object({
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:importDesktopAuth': {
req: z.null(),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:quitAndImportDesktop': {
req: z.null(),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:parseCurlAuth': {
req: z.object({ curl: z.string() }),
res: z.object({
ok: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'slack:knowledgeStatus': {
req: z.null(),
res: z.object({
cli: z.object({
available: z.boolean(),
version: z.string().optional(),
source: z.enum(['bundled', 'global', 'path']).optional(),
}),
sources: z.array(z.object({
id: z.string(),
enabled: z.boolean(),
lastSyncAt: z.string().optional(),
lastStatus: z.enum(['ok', 'error']).optional(),
lastError: z.object({ kind: z.string(), message: z.string() }).optional(),
nextDueAt: z.string().optional(),
})),
}),
},
'slack:listChannels': {
req: z.object({
workspaceUrl: z.string(),
}),
res: z.object({
channels: z.array(z.object({
id: z.string(),
name: z.string(),
isPrivate: z.boolean().optional(),
isMember: z.boolean().optional(),
})),
error: z.string().optional(),
}),
},
'slack:getRecentMessages': {
req: z.object({
limit: z.number().int().positive().max(20).optional(),
}),
res: z.object({
enabled: z.boolean(),
messages: z.array(z.object({
id: z.string(),
workspaceName: z.string().optional(),
workspaceUrl: z.string().optional(),
channelId: z.string().optional(),
channelName: z.string().optional(),
author: z.string().optional(),
text: z.string(),
ts: z.string(),
url: z.string().optional(),
})),
error: z.string().optional(),
errorKind: SlackErrorKindSchema.optional(),
}),
},
'knowledgeSources:getConfig': {
req: z.null(),
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'knowledgeSources:upsert': {
req: KnowledgeSourceConfigSchema,
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'onboarding:getStatus': {

View file

@ -6,6 +6,7 @@ export const ServiceName = z.enum([
'calendar',
'fireflies',
'granola',
'slack',
'voice_memo',
'email_labeling',
'note_tagging',

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

@ -64,6 +64,9 @@ importers:
'@x/shared':
specifier: workspace:*
version: link:../../packages/shared
agent-slack:
specifier: 0.9.3
version: 0.9.3
chokidar:
specifier: ^4.0.3
version: 4.0.3
@ -1814,6 +1817,9 @@ packages:
'@mermaid-js/parser@1.1.0':
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@modelcontextprotocol/sdk@1.25.1':
resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==}
engines: {node: '>=18'}
@ -3141,6 +3147,18 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@slack/logger@4.0.1':
resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
'@slack/types@2.21.1':
resolution: {integrity: sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==}
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
'@slack/web-api@7.17.0':
resolution: {integrity: sha512-jejr34a8B4L5AS713wOAx1LAqNkW16HVMDEa6sYBvFDc/llUBl8hXaiI4BwF+Al+Sug19Vn2O7iokTVIhVvZ1Q==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
'@smithy/abort-controller@4.2.8':
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
engines: {node: '>=18.0.0'}
@ -3887,6 +3905,9 @@ packages:
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@ -4130,6 +4151,11 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agent-slack@0.9.3:
resolution: {integrity: sha512-A9ts5J7RVUf3Oyja/sPxyr4oCxvJy66s0p9c1YeYmlKTqBsUoHRGcAM+198rH6DiYTLOOTIJbT/mL8Lo0bRlHg==}
engines: {node: '>=22.5'}
hasBin: true
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
@ -4253,6 +4279,9 @@ packages:
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
axios@1.17.0:
resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@ -4413,6 +4442,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@ -4541,6 +4574,10 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -5016,6 +5053,9 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
emojilib@2.4.0:
resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
encode-utf8@1.0.3:
resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
@ -5196,6 +5236,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -5344,6 +5387,15 @@ packages:
debug:
optional: true
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
@ -5735,6 +5787,9 @@ packages:
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
hysnappy@1.1.1:
resolution: {integrity: sha512-/V9XcN2NtRyWjR4LYMfvnvasVVF8jbT/ej0eofBQjZel91E3D813FQ3mQC6gDSMMTCq/FJh28XHeyqr3I/oBRw==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -5868,6 +5923,9 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-electron@2.2.2:
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -6688,6 +6746,10 @@ packages:
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -6854,6 +6916,18 @@ packages:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-retry@4.6.2:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-try@1.0.0:
resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==}
engines: {node: '>=4'}
@ -7128,6 +7202,10 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
@ -7408,6 +7486,10 @@ packages:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@ -7598,6 +7680,10 @@ packages:
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
skin-tone@2.0.0:
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
engines: {node: '>=8'}
slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
engines: {node: '>=12'}
@ -7903,6 +7989,13 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
turndown-plugin-gfm@1.0.2:
resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==}
turndown@7.2.4:
resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==}
engines: {node: '>=18', npm: '>=9'}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@ -7965,6 +8058,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
@ -8398,6 +8495,9 @@ packages:
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -10423,6 +10523,8 @@ snapshots:
dependencies:
langium: 4.2.2
'@mixmark-io/domino@2.2.0': {}
'@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@4.2.1)':
dependencies:
'@hono/node-server': 1.19.7(hono@4.11.3)
@ -11792,6 +11894,30 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
'@slack/logger@4.0.1':
dependencies:
'@types/node': 25.0.3
'@slack/types@2.21.1': {}
'@slack/web-api@7.17.0':
dependencies:
'@slack/logger': 4.0.1
'@slack/types': 2.21.1
'@types/node': 25.0.3
'@types/retry': 0.12.0
axios: 1.17.0
eventemitter3: 5.0.1
form-data: 4.0.5
is-electron: 2.2.2
is-stream: 2.0.1
p-queue: 6.6.2
p-retry: 4.6.2
retry: 0.13.1
transitivePeerDependencies:
- debug
- supports-color
'@smithy/abort-controller@4.2.8':
dependencies:
'@smithy/types': 4.12.0
@ -12713,6 +12839,8 @@ snapshots:
dependencies:
'@types/node': 25.0.3
'@types/retry@0.12.0': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 25.0.3
@ -13016,6 +13144,19 @@ snapshots:
agent-base@7.1.4: {}
agent-slack@0.9.3:
dependencies:
'@slack/web-api': 7.17.0
commander: 14.0.3
hysnappy: 1.1.1
node-emoji: 2.2.0
turndown: 7.2.4
turndown-plugin-gfm: 1.0.2
zod: 4.4.3
transitivePeerDependencies:
- debug
- supports-color
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
@ -13143,6 +13284,16 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.17.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
bail@2.0.2: {}
balanced-match@1.0.2: {}
@ -13339,6 +13490,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
char-regex@1.0.2: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@ -13466,6 +13619,8 @@ snapshots:
commander@11.1.0: {}
commander@14.0.3: {}
commander@2.20.3: {}
commander@5.1.0: {}
@ -13989,6 +14144,8 @@ snapshots:
emoji-regex@9.2.2: {}
emojilib@2.4.0: {}
encode-utf8@1.0.3:
optional: true
@ -14230,6 +14387,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {}
events@3.3.0: {}
@ -14409,6 +14568,8 @@ snapshots:
follow-redirects@1.15.11: {}
follow-redirects@1.16.0: {}
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.18
@ -15007,6 +15168,8 @@ snapshots:
hyphen@1.14.1: {}
hysnappy@1.1.1: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -15104,6 +15267,8 @@ snapshots:
is-docker@3.0.0: {}
is-electron@2.2.2: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@ -16155,6 +16320,13 @@ snapshots:
node-domexception@1.0.0: {}
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
char-regex: 1.0.2
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@ -16322,6 +16494,20 @@ snapshots:
dependencies:
aggregate-error: 3.1.0
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-retry@4.6.2:
dependencies:
'@types/retry': 0.12.0
retry: 0.13.1
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
p-try@1.0.0: {}
package-json-from-dist@1.0.1: {}
@ -16620,6 +16806,8 @@ snapshots:
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
@ -17001,6 +17189,8 @@ snapshots:
retry@0.12.0: {}
retry@0.13.1: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
@ -17249,6 +17439,10 @@ snapshots:
dependencies:
is-arrayish: 0.3.4
skin-tone@2.0.0:
dependencies:
unicode-emoji-modifier-base: 1.0.0
slice-ansi@5.0.0:
dependencies:
ansi-styles: 6.2.3
@ -17571,6 +17765,12 @@ snapshots:
tslib@2.8.1: {}
turndown-plugin-gfm@1.0.2: {}
turndown@7.2.4:
dependencies:
'@mixmark-io/domino': 2.2.0
tw-animate-css@1.4.0: {}
tweetnacl@1.0.3: {}
@ -17623,6 +17823,8 @@ snapshots:
undici-types@7.16.0: {}
unicode-emoji-modifier-base@1.0.0: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
@ -18088,4 +18290,6 @@ snapshots:
zod@4.2.1: {}
zod@4.4.3: {}
zwitch@2.0.4: {}