mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
feat: ship Slack as a knowledge source, hardened for production (#596)
* index slack and add to home page * filter only useful slack messages in homr * feat: bundle agent-slack CLI and route all calls through shared executor Pins agent-slack@0.9.3, bundles it next to main.cjs (replaces the startup npm install -g), adds a structured-result executor with bundled/global/PATH resolution, a slack:cliStatus IPC probe, and a PATH shim so the Copilot skill keeps working. * feat: surface Slack failures and add cross-OS auth fallbacks Classify agent-slack errors (not_authed/rate_limited/network/bad_channel), persist per-source sync status with rate-limit backoff, and expose it via slack:knowledgeStatus. Fix the Settings Enable bounce-back with actionable copy, a browser-paste (parse-curl) fallback, and a Windows quit-Slack-and-import button; add home-feed empty/error states. * feat: rank Slack home feed deterministically by recency Drop the per-load LLM ranker (cost/latency/model dependency) in favor of a stronger deterministic filter + recency ordering. The filter now removes system messages, emoji/reaction-only posts, bare greetings/acks, and empty bodies, with a durable-signal escape hatch. Expand tests to one describe per noise class plus ordering/cap/volume coverage. * fix: hide Slack knowledge Save button once saved Only show the Save button when the channel list or enabled toggle differs from the last-persisted config, so it disappears after a successful save and reappears when a new channel is entered. --------- Co-authored-by: Gagancreates <gaganp000999@gmail.com>
This commit is contained in:
parent
2ddec07712
commit
79162ebc69
21 changed files with 2979 additions and 66 deletions
|
|
@ -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)`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ 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';
|
||||
|
|
@ -46,6 +47,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';
|
||||
|
|
@ -85,6 +90,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';
|
||||
|
||||
/**
|
||||
|
|
@ -854,19 +1043,183 @@ export function setupIpcHandlers() {
|
|||
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
|
||||
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') {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
113
apps/x/packages/core/src/knowledge/sources/repo.ts
Normal file
113
apps/x/packages/core/src/knowledge/sources/repo.ts
Normal 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();
|
||||
182
apps/x/packages/core/src/knowledge/sources/sync_slack.test.ts
Normal file
182
apps/x/packages/core/src/knowledge/sources/sync_slack.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
479
apps/x/packages/core/src/knowledge/sources/sync_slack.ts
Normal file
479
apps/x/packages/core/src/knowledge/sources/sync_slack.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
49
apps/x/packages/core/src/knowledge/sources/types.ts
Normal file
49
apps/x/packages/core/src/knowledge/sources/types.ts
Normal 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>;
|
||||
}
|
||||
190
apps/x/packages/core/src/slack/agent-slack-exec.test.ts
Normal file
190
apps/x/packages/core/src/slack/agent-slack-exec.test.ts
Normal 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'] });
|
||||
});
|
||||
});
|
||||
315
apps/x/packages/core/src/slack/agent-slack-exec.ts
Normal file
315
apps/x/packages/core/src/slack/agent-slack-exec.ts
Normal 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} | ||||