index slack and add to home page

This commit is contained in:
Arjun 2026-06-04 23:05:25 +05:30
parent d47cab6a0f
commit a22635126a
12 changed files with 1436 additions and 27 deletions

View file

@ -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 { exec, 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';
@ -36,6 +37,9 @@ import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.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 { 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 } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { consumePendingDeepLink } from './deeplink.js';
@ -72,6 +76,145 @@ 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 parseJsonArrayPayload(stdout: string): unknown[] {
const parsed = JSON.parse(stdout || '[]');
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);
}
try {
const { stdout } = await execFileAsync('agent-slack', args, { timeout: 10000, maxBuffer: 512 * 1024 });
const parsed = JSON.parse(stdout || '{}');
const name = extractSlackUserName(parsed);
if (name) {
cache.set(key, name);
return name;
}
} catch (error) {
console.warn(`[Slack] Failed to resolve user ${userId}:`, error);
}
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';
/**
@ -683,6 +826,133 @@ export function setupIpcHandlers() {
return { workspaces: [], error: message };
}
},
'slack:listChannels': async (_event, args) => {
try {
const { stdout } = await execFileAsync('agent-slack', ['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeout: 15000 });
const parsed = JSON.parse(stdout);
const rawChannels = Array.isArray(parsed) ? parsed : (parsed.channels || parsed.items || parsed.results || []);
const channels = rawChannels.map((ch: {
id?: string;
name?: string;
is_private?: boolean;
isPrivate?: boolean;
is_member?: boolean;
isMember?: boolean;
}) => ({
id: ch.id || ch.name || '',
name: ch.name || ch.id || '',
isPrivate: ch.is_private ?? ch.isPrivate,
isMember: ch.is_member ?? ch.isMember,
})).filter((ch: { id: string; name: string }) => ch.id && ch.name);
return { channels };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to list Slack channels';
return { channels: [], error: message };
}
},
'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 { stdout } = await execFileAsync('agent-slack', ['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeout: 15000 });
const rawChannels = parseJsonArrayPayload(stdout);
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);
}
try {
const { stdout } = await execFileAsync('agent-slack', commandArgs, { timeout: 15000, maxBuffer: 1024 * 1024 });
const rawMessages = parseJsonArrayPayload(stdout);
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),
});
}
} catch (error) {
console.warn(`[Slack] Failed to load messages for ${channel.name}:`, error);
}
}
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';
return { enabled: true, messages: [], error: message };
}
},
'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
const complete = isOnboardingComplete();

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
@ -54,6 +54,17 @@ type RawCalEvent = {
}
type EmailThread = { threadId: string; subject: string; from: string }
type SlackFeedMessage = {
id: string
workspaceName?: string
workspaceUrl?: string
channelId?: string
channelName?: string
author?: string
text: string
ts: string
url?: string
}
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
@ -94,6 +105,13 @@ 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)
}
function parseAllDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
@ -218,6 +236,9 @@ 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 [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
@ -260,6 +281,20 @@ 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)
} catch (err) {
console.error('Home: failed to load Slack messages', err)
setSlackEnabled(false)
setSlackMessages([])
setSlackError(null)
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
@ -293,7 +328,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 +495,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">{slackError}</div>
) : slackMessages.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No recent Slack messages found.</div>
) : slackMessages.map((message, i) => (
<div
key={message.id}
className={`flex items-start gap-3 py-2 text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span className="truncate">{message.channelName ?? 'Slack'}</span>
{message.author && (
<>
<span className="shrink-0">·</span>
<span className="truncate">{message.author}</span>
</>
)}
<span className="shrink-0">·</span>
<span className="shrink-0">{relativeSlackTs(message.ts)}</span>
</div>
<div className="line-clamp-2 text-foreground">{message.text}</div>
</div>
{message.url && (
<button
type="button"
onClick={() => window.open(message.url, '_blank')}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-primary transition-colors hover:bg-accent"
>
Open
<ExternalLink className="size-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">

View file

@ -1,9 +1,11 @@
"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"
@ -237,6 +239,150 @@ 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.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>
<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>
</div>
</div>
</>
)}
</div>
</>
)

View file

@ -12,6 +12,18 @@ 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 function useConnectors(active: boolean) {
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -37,6 +49,9 @@ export function useConnectors(active: boolean) {
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
// 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
@ -165,6 +180,17 @@ export function useConnectors(active: boolean) {
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
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("")
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
@ -174,6 +200,77 @@ export function useConnectors(active: boolean) {
}
}, [])
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')
setSlackKnowledgeEnabled(Boolean(slackSource?.enabled))
setSlackKnowledgeChannels((slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => {
const channel = scope.name || scope.id
return scope.workspaceUrl ? `${scope.workspaceUrl} ${channel}` : channel
})
.join('\n'))
} catch (error) {
console.error('Failed to load knowledge sources:', error)
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
}
}, [])
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 +514,7 @@ export function useConnectors(active: boolean) {
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
refreshSlackConfig()
refreshKnowledgeSources()
if (useComposioForGoogle) {
refreshGmailStatus()
@ -461,7 +559,7 @@ export function useConnectors(active: boolean) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
// Refresh when active or providers change
useEffect(() => {
@ -587,9 +685,15 @@ export function useConnectors(active: boolean) {
setSlackPickerOpen,
slackDiscovering,
slackDiscoverError,
slackKnowledgeEnabled,
setSlackKnowledgeEnabled,
slackKnowledgeChannels,
setSlackKnowledgeChannels,
slackKnowledgeSaving,
handleSlackEnable,
handleSlackSaveWorkspaces,
handleSlackDisable,
handleSlackKnowledgeSave,
// Gmail (Composio)
useComposioForGoogle,

View file

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

View file

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

View file

@ -0,0 +1,127 @@
import { z } from 'zod';
import { generateObject } from 'ai';
import { createProvider } from '../../models/models.js';
import {
getDefaultModelAndProvider,
getKgModel,
resolveProviderConfig,
} from '../../models/defaults.js';
import { captureLlmUsage } from '../../analytics/usage.js';
import { withUseCase } from '../../analytics/use_case.js';
export type SlackHomeRankCandidate = {
id: string;
workspaceName?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
};
const RankedSlackMessagesSchema = z.object({
rankedIds: z.array(z.string()).describe('Message ids in the order they should appear on Home.'),
});
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);
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max)}...`;
}
function buildPrompt(candidates: SlackHomeRankCandidate[], limit: number): string {
const messages = candidates.map((candidate, index) => {
const date = Number.isFinite(Number(candidate.ts))
? new Date(Number(candidate.ts.split('.')[0]) * 1000).toISOString()
: candidate.ts;
return [
`## ${index + 1}. ${candidate.id}`,
`Workspace: ${candidate.workspaceName ?? 'unknown'}`,
`Channel: ${candidate.channelName ?? 'unknown'}`,
`Author: ${candidate.author ?? 'unknown'}`,
`Time: ${date}`,
`Text: ${truncate(candidate.text.replace(/\s+/g, ' ').trim(), 700)}`,
].join('\n');
}).join('\n\n');
return `Choose up to ${limit} Slack messages to show on the user's Home screen.
Prioritize messages that are likely useful at a glance:
- direct questions or requests to the user
- decisions, blockers, owners, deadlines, status changes, or shipped/fixed/done updates
- project/customer/product updates
- messages with clear actionability or durable knowledge
Deprioritize:
- greetings, thanks, jokes, reactions, short acknowledgements, bot noise
- vague chatter without clear project/action relevance
- near-duplicates of the same point
Return only ids from the candidate list. Prefer relevance over recency, but use recency as a tiebreaker.
# Candidates
${messages}`;
}
export async function rankSlackHomeMessages(
candidates: SlackHomeRankCandidate[],
limit: number,
): Promise<string[]> {
if (candidates.length <= limit) {
return timeRank(candidates, limit);
}
try {
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
const model = createProvider(config).languageModel(modelId);
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'slack_home_rank' }, () => generateObject({
model,
system: 'You rank Slack messages for a personal productivity Home screen. Be selective and return valid ids only.',
prompt: buildPrompt(candidates, limit),
schema: RankedSlackMessagesSchema,
}));
captureLlmUsage({
useCase: 'knowledge_sync',
subUseCase: 'slack_home_rank',
model: modelId,
provider,
usage: result.usage,
});
const validIds = new Set(candidates.map(candidate => candidate.id));
const ranked = result.object.rankedIds.filter(id => validIds.has(id));
const seen = new Set<string>();
const deduped = ranked.filter(id => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
if (deduped.length === 0) {
return timeRank(candidates, limit);
}
const fallback = timeRank(candidates, limit);
for (const id of fallback) {
if (deduped.length >= limit) break;
if (!seen.has(id)) {
deduped.push(id);
seen.add(id);
}
}
return deduped.slice(0, limit);
} catch (error) {
console.warn('[SlackHomeRank] LLM ranking failed, falling back to recency:', error);
return timeRank(candidates, limit);
}
}

View file

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

View file

@ -0,0 +1,411 @@
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
import { execFile } from 'child_process';
import { WorkDir } from '../../config/config.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 execFileAsync = promisify(execFile);
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');
type SlackSyncState = {
lastSyncAt?: string;
sources?: Record<string, { lastSyncAt?: string }>;
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');
}
function isSourceDue(source: KnowledgeSourceConfig, state: SlackSyncState): boolean {
const sourceState = state.sources?.[source.id];
if (!sourceState?.lastSyncAt) return true;
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
const intervalMs = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
return !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= intervalMs;
}
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 parseJsonOutput(stdout: string): unknown {
const trimmed = stdout.trim();
if (!trimmed) return [];
return JSON.parse(trimmed);
}
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 { stdout } = await execFileAsync('agent-slack', args, {
timeout: 30_000,
maxBuffer: 2 * 1024 * 1024,
});
return parseJsonOutput(stdout);
}
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'),
});
}
async function syncSource(source: KnowledgeSourceConfig): Promise<string[]> {
if (!source.enabled || source.provider !== 'slack') return [];
if (source.scopes.length === 0) {
console.log(`[SlackKnowledge] Source ${source.id} has no channel scopes; skipping`);
return [];
}
const state = loadState();
const sourceState = state.sources?.[source.id];
const intervalMs = source.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
if (sourceState?.lastSyncAt) {
const lastSyncMs = Date.parse(sourceState.lastSyncAt);
if (Number.isFinite(lastSyncMs) && Date.now() - lastSyncMs < intervalMs) {
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 };
}
state.lastSyncAt = new Date().toISOString();
state.sources = {
...(state.sources ?? {}),
[source.id]: { lastSyncAt: state.lastSyncAt },
};
saveState(state);
return writtenFiles;
}
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;
try {
for (const source of sources) {
const files = await syncSource(source);
writtenFiles.push(...files);
}
if (writtenFiles.length > 0) {
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] Sync failed:', error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: 'Slack knowledge sync error',
error: error instanceof Error ? error.message : String(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;
}
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
wakeResolve();
wakeResolve = null;
}
}
function interruptibleSleep(ms: number): Promise<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
export async function init(): Promise<void> {
console.log(`[SlackKnowledge] Starting Slack knowledge sync. Polling every ${DEFAULT_SYNC_INTERVAL_MS / 1000}s`);
while (true) {
await syncSlackKnowledgeSources();
await interruptibleSleep(DEFAULT_SYNC_INTERVAL_MS);
}
}

View file

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

View file

@ -24,6 +24,25 @@ import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
// Runtime Validation Schemas (Single Source of Truth)
// ============================================================================
const KnowledgeSourceScopeSchema = z.object({
type: z.string(),
id: z.string(),
name: z.string().optional(),
workspaceUrl: z.string().optional(),
});
const KnowledgeSourceConfigSchema = z.object({
id: z.string(),
provider: z.enum(['gmail', 'meeting', 'voice_memo', 'slack', 'github', 'linear']),
enabled: z.boolean(),
artifactDir: z.string(),
syncMode: z.enum(['file', 'poll', 'event', 'manual']).default('file'),
intervalMs: z.number().int().positive().optional(),
scopes: z.array(KnowledgeSourceScopeSchema).default([]),
instructions: z.string().optional(),
filters: z.record(z.string(), z.unknown()).optional(),
});
const ipcSchemas = {
'app:getVersions': {
req: z.null(),
@ -478,6 +497,52 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'slack:listChannels': {
req: z.object({
workspaceUrl: z.string(),
}),
res: z.object({
channels: z.array(z.object({
id: z.string(),
name: z.string(),
isPrivate: z.boolean().optional(),
isMember: z.boolean().optional(),
})),
error: z.string().optional(),
}),
},
'slack:getRecentMessages': {
req: z.object({
limit: z.number().int().positive().max(20).optional(),
}),
res: z.object({
enabled: z.boolean(),
messages: z.array(z.object({
id: z.string(),
workspaceName: z.string().optional(),
workspaceUrl: z.string().optional(),
channelId: z.string().optional(),
channelName: z.string().optional(),
author: z.string().optional(),
text: z.string(),
ts: z.string(),
url: z.string().optional(),
})),
error: z.string().optional(),
}),
},
'knowledgeSources:getConfig': {
req: z.null(),
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'knowledgeSources:upsert': {
req: KnowledgeSourceConfigSchema,
res: z.object({
sources: z.array(KnowledgeSourceConfigSchema),
}),
},
'onboarding:getStatus': {
req: z.null(),
res: z.object({

View file

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