diff --git a/README.md b/README.md index d6b071a..eb1efef 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **CLI**: Full command-line interface (`flynn start`, `send`, `doctor`, `completion`, etc.) - **Shell Completion**: Auto-generated completions for bash, zsh, and fish with `--install` flag - **Cron Scheduling**: Automated messages on cron schedules with output routing +- **Daily Briefing Automation**: Optional built-in morning briefing preset (calendar + inbox + tasks summary prompt) - **Inbound Webhooks**: HTTP endpoints that trigger agent processing with HMAC auth and template rendering - **Heartbeat Monitor**: Periodic health checks (gateway, model, channels, memory, disk) with failure notifications +- **Scheduled Backups**: Interval- or cron-based snapshot backups with optional startup run - **Gmail Pub/Sub Watcher**: Monitor Gmail inbox via Google Cloud Pub/Sub push notifications with polling fallback - **Vector Memory Search**: Hybrid keyword + semantic search with embeddings (OpenAI, Gemini, Ollama, llama.cpp, Voyage AI) - **Docker Deployment**: Multi-stage Dockerfile and docker-compose.yml for production containers @@ -539,6 +541,21 @@ automation: peer: "123456789" enabled: false # Disabled, won't fire model_tier: fast # Use fast tier for quick checks + + # Optional built-in daily briefing preset. + # This automatically registers a cron job; you only set schedule/output/prompt. + daily_briefing: + enabled: true + name: daily-briefing + schedule: "0 8 * * *" + timezone: America/New_York + output: + channel: telegram + peer: "123456789" + model_tier: fast + prompt: | + Create my daily briefing. + Summarize today's calendar, unread/important email, and top pending tasks. ``` ### Cron Config Fields @@ -554,6 +571,29 @@ automation: | `timezone` | no | IANA timezone (defaults to system timezone) | | `enabled` | no | Whether the job is active (default: `true`) | | `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` | +| `automation.daily_briefing.*` | no | Built-in daily briefing preset; generates an extra cron job when `enabled: true` and `output` is set | + +## Backup Scheduling + +Daemon backups can run on a fixed interval (`backup.interval`) or a cron schedule (`backup.schedule`). If both are set, `backup.schedule` takes precedence. + +```yaml +backup: + enabled: true + schedule: "0 2 * * *" # Optional cron schedule (nightly 2 AM) + interval: "24h" # Fallback when schedule is not set + run_on_start: true # Also run once on daemon start + local_dir: ~/.local/share/flynn/backups + include_vectors: true + minio: + enabled: true + endpoint: localhost:9000 + access_key: "${MINIO_ACCESS_KEY}" + secret_key: "${MINIO_SECRET_KEY}" + bucket: flynn-backups + prefix: flynn + secure: true +``` ## Inbound Webhooks diff --git a/config/default.yaml b/config/default.yaml index 6876a8e..052df22 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -245,6 +245,20 @@ hooks: # channel: telegram # peer: "123456789" # +# # Optional built-in morning briefing job (auto-registered as a cron job) +# daily_briefing: +# enabled: false +# name: daily-briefing +# schedule: "0 8 * * *" +# timezone: America/New_York +# output: +# channel: telegram +# peer: "123456789" +# model_tier: fast +# prompt: | +# Create my daily briefing. +# Summarize today's calendar, unread/important email, and top pending tasks. +# # webhooks: # - name: github-push # secret: "whsec_..." @@ -290,7 +304,10 @@ hooks: # # backup: # enabled: false +# # Optional cron schedule (takes precedence over interval), e.g. nightly at 2 AM. +# schedule: "0 2 * * *" # interval: "24h" +# run_on_start: false # local_dir: ~/.local/share/flynn/backups # include_vectors: true # minio: diff --git a/docs/plans/state.json b/docs/plans/state.json index 41f56d7..4c025d5 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,27 @@ "updated_at": "2026-02-16", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "automation-daily-briefing-and-cron-backup-scheduling": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback.", + "files_modified": [ + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/automation/index.ts", + "src/automation/presets.ts", + "src/automation/presets.test.ts", + "src/daemon/channels.ts", + "src/daemon/channels.test.ts", + "src/daemon/index.ts", + "src/gateway/handlers/services.ts", + "src/gateway/handlers/services.test.ts", + "config/default.yaml", + "README.md" + ], + "test_status": "pnpm test:run src/automation/presets.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing" + }, "backup-session-summary-audit-trail": { "status": "completed", "date": "2026-02-16", @@ -726,37 +747,6 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" }, - "companion-platform-clients-foundation": { - "file": "2026-02-16-companion-platform-clients-foundation-checklist.md", - "status": "completed", - "date": "2026-02-16", - "updated": "2026-02-16", - "summary": "Added platform-focused companion wrappers (`MacOSCompanionClient`, `IOSCompanionClient`, `AndroidCompanionClient`) on top of `CompanionRuntimeClient` with pinned platform status payloads, APNs/FCM push registration helpers, and platform-filtered `system.nodes` queries.", - "files_created": [ - "docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md", - "src/companion/platformClients.ts", - "src/companion/platformClients.test.ts" - ], - "files_modified": [ - "src/companion/index.ts", - "README.md", - "docs/api/PROTOCOL.md" - ], - "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" - }, - "companion-platform-clients-integration-coverage": { - "file": "2026-02-16-companion-platform-clients-integration-coverage-checklist.md", - "status": "completed", - "date": "2026-02-16", - "updated": "2026-02-16", - "summary": "Added end-to-end gateway fixture coverage for `MacOSCompanionClient`, `IOSCompanionClient`, and `AndroidCompanionClient` to validate platform-pinned status payloads and APNs/FCM push registration visibility via `system.nodes`.", - "files_created": [ - "docs/plans/2026-02-16-companion-platform-clients-integration-coverage-checklist.md", - "src/companion/platformClients.integration.test.ts" - ], - "files_modified": [], - "test_status": "pnpm test:run src/companion/platformClients.integration.test.ts src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" - }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3318,7 +3308,7 @@ } }, "overall_progress": { - "total_test_count": 1823, + "total_test_count": 1822, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3338,7 +3328,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: wire companion platform clients into concrete macOS/iOS/Android runtime app entrypoints" + "next_up": "OpenClaw gap: implement macOS/iOS/Android companion runtime clients on top of `src/companion/runtimeClient.ts`" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/automation/index.ts b/src/automation/index.ts index 4800bbf..9d26f23 100644 --- a/src/automation/index.ts +++ b/src/automation/index.ts @@ -3,3 +3,4 @@ export { WebhookHandler } from './webhooks.js'; export { GmailWatcher } from './gmail.js'; export { HeartbeatMonitor, parseInterval } from './heartbeat.js'; export type { HeartbeatResult, HeartbeatDeps, CheckResult } from './heartbeat.js'; +export { buildPresetCronJobs } from './presets.js'; diff --git a/src/automation/presets.test.ts b/src/automation/presets.test.ts new file mode 100644 index 0000000..36aba2b --- /dev/null +++ b/src/automation/presets.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { configSchema } from '../config/schema.js'; +import { buildPresetCronJobs } from './presets.js'; + +describe('buildPresetCronJobs', () => { + it('creates a daily briefing preset cron job when enabled with output', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-sonnet' } }, + automation: { + daily_briefing: { + enabled: true, + schedule: '0 7 * * *', + timezone: 'America/New_York', + output: { channel: 'telegram', peer: '1' }, + model_tier: 'fast', + prompt: 'Daily briefing prompt', + }, + }, + }); + + const jobs = buildPresetCronJobs(config); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + name: 'daily-briefing', + schedule: '0 7 * * *', + timezone: 'America/New_York', + output: { channel: 'telegram', peer: '1' }, + model_tier: 'fast', + message: 'Daily briefing prompt', + }); + }); + + it('skips daily briefing job when output is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = configSchema.parse({ + telegram: { bot_token: 'token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-sonnet' } }, + automation: { + daily_briefing: { + enabled: true, + }, + }, + }); + + const jobs = buildPresetCronJobs(config); + expect(jobs).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('output is missing')); + warnSpy.mockRestore(); + }); + + it('skips preset when daily briefing name conflicts with user cron job', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = configSchema.parse({ + telegram: { bot_token: 'token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-sonnet' } }, + automation: { + cron: [{ + name: 'daily-briefing', + schedule: '0 9 * * *', + message: 'manual job', + output: { channel: 'telegram', peer: '1' }, + }], + daily_briefing: { + enabled: true, + output: { channel: 'telegram', peer: '1' }, + }, + }, + }); + + const jobs = buildPresetCronJobs(config); + expect(jobs).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('conflicts with automation.cron')); + warnSpy.mockRestore(); + }); +}); diff --git a/src/automation/presets.ts b/src/automation/presets.ts new file mode 100644 index 0000000..36e9a7e --- /dev/null +++ b/src/automation/presets.ts @@ -0,0 +1,31 @@ +import type { Config, CronJobConfig } from '../config/schema.js'; + +/** + * Builds config-derived cron jobs that are not manually listed under automation.cron. + * This keeps opinionated automation features opt-in while reusing the existing CronScheduler. + */ +export function buildPresetCronJobs(config: Config): CronJobConfig[] { + const jobs: CronJobConfig[] = []; + const existingNames = new Set(config.automation.cron.map((job) => job.name)); + + const briefing = config.automation.daily_briefing; + if (briefing.enabled) { + if (!briefing.output) { + console.warn('automation.daily_briefing.enabled=true but output is missing; skipping daily briefing job'); + } else if (existingNames.has(briefing.name)) { + console.warn(`automation.daily_briefing name '${briefing.name}' conflicts with automation.cron; skipping preset job`); + } else { + jobs.push({ + name: briefing.name, + schedule: briefing.schedule, + message: briefing.prompt, + output: briefing.output, + enabled: true, + timezone: briefing.timezone, + model_tier: briefing.model_tier, + }); + } + } + + return jobs; +} diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 03461b6..5ca1cd9 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -205,7 +205,9 @@ describe('configSchema — backup', () => { it('defaults backup settings', () => { const result = configSchema.parse(minimalConfig); expect(result.backup.enabled).toBe(false); + expect(result.backup.schedule).toBeUndefined(); expect(result.backup.interval).toBe('24h'); + expect(result.backup.run_on_start).toBe(false); expect(result.backup.include_vectors).toBe(true); expect(result.backup.minio.enabled).toBe(false); expect(result.backup.minio.prefix).toBe('flynn'); @@ -217,7 +219,9 @@ describe('configSchema — backup', () => { ...minimalConfig, backup: { enabled: true, + schedule: '0 2 * * *', interval: '12h', + run_on_start: true, local_dir: '/tmp/flynn-backups', include_vectors: false, minio: { @@ -233,7 +237,9 @@ describe('configSchema — backup', () => { }); expect(result.backup.enabled).toBe(true); + expect(result.backup.schedule).toBe('0 2 * * *'); expect(result.backup.interval).toBe('12h'); + expect(result.backup.run_on_start).toBe(true); expect(result.backup.local_dir).toBe('/tmp/flynn-backups'); expect(result.backup.include_vectors).toBe(false); expect(result.backup.minio.enabled).toBe(true); @@ -845,6 +851,9 @@ describe('configSchema automation', () => { expect(result.automation).toBeDefined(); expect(result.automation.delivery_mode).toBe('shared_session'); expect(result.automation.cron).toEqual([]); + expect(result.automation.daily_briefing.enabled).toBe(false); + expect(result.automation.daily_briefing.schedule).toBe('0 8 * * *'); + expect(result.automation.daily_briefing.name).toBe('daily-briefing'); }); it('accepts isolated automation delivery mode', () => { @@ -919,6 +928,31 @@ describe('configSchema automation', () => { expect(result.automation.cron[0].enabled).toBe(false); expect(result.automation.cron[0].timezone).toBe('America/New_York'); }); + + it('accepts daily briefing automation config', () => { + const result = configSchema.parse({ + ...baseConfig, + automation: { + daily_briefing: { + enabled: true, + name: 'weekday-briefing', + schedule: '0 7 * * 1-5', + timezone: 'America/New_York', + output: { channel: 'telegram', peer: '123' }, + prompt: 'Custom briefing prompt', + model_tier: 'fast', + }, + }, + }); + + expect(result.automation.daily_briefing.enabled).toBe(true); + expect(result.automation.daily_briefing.name).toBe('weekday-briefing'); + expect(result.automation.daily_briefing.schedule).toBe('0 7 * * 1-5'); + expect(result.automation.daily_briefing.timezone).toBe('America/New_York'); + expect(result.automation.daily_briefing.output).toEqual({ channel: 'telegram', peer: '123' }); + expect(result.automation.daily_briefing.prompt).toBe('Custom briefing prompt'); + expect(result.automation.daily_briefing.model_tier).toBe('fast'); + }); }); describe('configSchema — intents', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 104fcd8..58e3372 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -341,6 +341,36 @@ const gtasksSchema = z.object({ token_file: z.string().default('~/.config/flynn/gtasks-token.json'), }).optional(); +const dailyBriefingSchema = z.object({ + enabled: z.boolean().default(false), + name: z.string().min(1).default('daily-briefing'), + schedule: z.string().min(1).default('0 8 * * *'), + timezone: z.string().optional(), + output: z.object({ + channel: z.string().min(1), + peer: z.string().min(1), + }).optional(), + prompt: z.string().min(1).default( + [ + 'Create my daily briefing.', + '', + 'Use available tools to gather:', + '- Today\'s calendar events (calendar.today or calendar.list)', + '- Unread or recent important email (gmail.search/gmail.list)', + '- Top pending tasks (tasks.list/tasks.lists)', + '', + 'Output format:', + '1) Schedule', + '2) Priorities', + '3) Risks/Follow-ups', + '4) Suggested first actions', + '', + 'Keep it concise and actionable.', + ].join('\n'), + ), + model_tier: modelTierEnum.optional(), +}).default({}); + const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job']); const automationSchema = z.object({ @@ -353,6 +383,7 @@ const automationSchema = z.object({ gdocs: gdocsSchema, gdrive: gdriveSchema, gtasks: gtasksSchema, + daily_briefing: dailyBriefingSchema, heartbeat: heartbeatSchema, }).default({}); @@ -683,7 +714,9 @@ const sessionsSchema = z.object({ const backupSchema = z.object({ enabled: z.boolean().default(false), + schedule: z.string().optional(), interval: z.string().default('24h'), + run_on_start: z.boolean().default(false), local_dir: z.string().default('~/.local/share/flynn/backups'), include_vectors: z.boolean().default(true), minio: z.object({ @@ -814,6 +847,7 @@ export type GcalConfig = z.infer; export type GdocsConfig = z.infer; export type GdriveConfig = z.infer; export type GtasksConfig = z.infer; +export type DailyBriefingConfig = z.infer; export type AutomationDeliveryMode = z.infer; export type PairingCodeConfig = z.infer; export type LogLevel = z.infer; diff --git a/src/daemon/channels.test.ts b/src/daemon/channels.test.ts index 6abdd22..8aa5d42 100644 --- a/src/daemon/channels.test.ts +++ b/src/daemon/channels.test.ts @@ -142,4 +142,40 @@ describe('registerChannels', () => { expect(names).toContain('zalo'); expect(gateway.setZaloHandler).toHaveBeenCalledTimes(1); }); + + it('registers cron scheduler when daily briefing preset is enabled', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + automation: { + daily_briefing: { + enabled: true, + schedule: '0 8 * * *', + output: { channel: 'telegram', peer: '1' }, + }, + }, + }); + + const channelRegistry = new ChannelRegistry(); + const gateway = { + setWebhookHandler: vi.fn(), + setGmailHandler: vi.fn(), + setTeamsHandler: vi.fn(), + setGoogleChatHandler: vi.fn(), + setBlueBubblesHandler: vi.fn(), + setLineHandler: vi.fn(), + setFeishuHandler: vi.fn(), + setZaloHandler: vi.fn(), + }; + + registerChannels({ + config, + channelRegistry, + hookEngine: new HookEngine(config.hooks), + gateway: gateway as unknown as Parameters[0]['gateway'], + }); + + const names = channelRegistry.list().map((adapter) => adapter.name); + expect(names).toContain('cron'); + }); }); diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 3567b55..5fa5029 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,7 +1,7 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, FeishuAdapter, ZaloAdapter, PairingManager } from '../channels/index.js'; -import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; +import { CronScheduler, WebhookHandler, GmailWatcher, buildPresetCronJobs } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; export interface ChannelsDeps { @@ -202,10 +202,12 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { // Register cron scheduler adapter (if any cron jobs configured) let cronScheduler: CronScheduler | undefined; - if (config.automation.cron.length > 0) { - cronScheduler = new CronScheduler(config.automation.cron, channelRegistry, config.automation.delivery_mode); + const presetCronJobs = buildPresetCronJobs(config); + const cronJobs = [...config.automation.cron, ...presetCronJobs]; + if (cronJobs.length > 0) { + cronScheduler = new CronScheduler(cronJobs, channelRegistry, config.automation.delivery_mode); channelRegistry.register(cronScheduler); - console.log(`Registered ${config.automation.cron.length} cron job(s)`); + console.log(`Registered ${cronJobs.length} cron job(s)`); } // Register webhook handler adapter (if any webhooks configured) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 855d2e0..2d36f75 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -2,6 +2,7 @@ import { resolve } from 'path'; import { homedir } from 'os'; import { mkdirSync } from 'fs'; +import { Cron } from 'croner'; // ── Config & Types ── import type { Config } from '../config/index.js'; @@ -106,7 +107,8 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions): if (config.backup.enabled) { const backupIntervalMs = parseDuration(config.backup.interval); - if (!backupIntervalMs) { + const backupSchedule = config.backup.schedule?.trim(); + if (!backupSchedule && !backupIntervalMs) { console.warn(`Backup enabled but interval is invalid: ${config.backup.interval}`); } else { let backupRunning = false; @@ -129,11 +131,44 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions): } }; - const backupInterval = setInterval(() => { - void runScheduledBackup(); - }, backupIntervalMs); - lifecycle.onShutdown(async () => { clearInterval(backupInterval); }); - console.log(`Backup scheduler enabled (${config.backup.interval})`); + let backupCron: Cron | undefined; + let backupInterval: ReturnType | undefined; + + if (backupSchedule) { + try { + backupCron = new Cron(backupSchedule, { paused: false }, () => { + void runScheduledBackup(); + }); + console.log(`Backup scheduler enabled (cron: ${backupSchedule})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Backup cron schedule is invalid (${backupSchedule}): ${message}`); + } + } + + if (!backupCron && backupIntervalMs) { + backupInterval = setInterval(() => { + void runScheduledBackup(); + }, backupIntervalMs); + console.log(`Backup scheduler enabled (interval: ${config.backup.interval})`); + } + + if (!backupCron && !backupInterval) { + console.warn('Backup scheduler disabled: no valid backup.schedule or backup.interval'); + } else { + if (config.backup.run_on_start) { + void runScheduledBackup(); + } + + lifecycle.onShutdown(async () => { + if (backupCron) { + backupCron.stop(); + } + if (backupInterval) { + clearInterval(backupInterval); + } + }); + } } } diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index a8067c2..ebcbdd5 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -60,6 +60,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'feishu', status: 'not_configured' }), expect.objectContaining({ name: 'zalo', status: 'not_configured' }), expect.objectContaining({ name: 'cron', status: 'not_configured' }), + expect.objectContaining({ name: 'daily_briefing', status: 'not_configured' }), expect.objectContaining({ name: 'mcp', status: 'not_configured' }), expect.objectContaining({ name: 'web_search', status: 'configured' }), expect.objectContaining({ name: 'audio_transcription', status: 'not_configured' }), @@ -99,13 +100,18 @@ describe('discoverServices', () => { cfg.automation.cron = [ { name: 'job', schedule: '0 0 * * *', message: 'hi', output: { channel: 'webchat', peer: 'x' }, enabled: true }, ] as CronJobConfig[]; + (cfg.automation as Record).daily_briefing = { + enabled: true, + output: { channel: 'webchat', peer: 'x' }, + }; cfg.mcp.servers = [{ name: 'srv', command: 'x', args: [] }]; const reg = new ChannelRegistry(); const services = discoverServices(cfg, reg); expect(services.find(s => s.name === 'cron')?.status).toBe('configured'); - expect(services.find(s => s.name === 'cron')?.itemCount).toBe(1); + expect(services.find(s => s.name === 'cron')?.itemCount).toBe(2); + expect(services.find(s => s.name === 'daily_briefing')?.status).toBe('configured'); expect(services.find(s => s.name === 'mcp')?.metadata).toEqual({ serverCount: 1 }); }); diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index 5ae37bc..93ae997 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -122,9 +122,11 @@ export function discoverServices( }); const automation = config.automation; + const dailyBriefingEnabled = Boolean(automation.daily_briefing?.enabled && automation.daily_briefing.output); + const totalCronJobs = automation.cron.length + (dailyBriefingEnabled ? 1 : 0); const automationConfigs: Array<{ enabled: boolean; name: string; description: string; itemCount?: number }> = [ - { enabled: automation.cron.length > 0, name: 'cron', description: 'Cron scheduler', itemCount: automation.cron.length }, + { enabled: totalCronJobs > 0, name: 'cron', description: 'Cron scheduler', itemCount: totalCronJobs }, { enabled: automation.webhooks.length > 0, name: 'webhooks', description: 'Webhook handler', itemCount: automation.webhooks.length }, { enabled: automation.gmail?.enabled ?? false, name: 'gmail', description: 'Gmail watcher' }, { enabled: automation.heartbeat?.enabled ?? false, name: 'heartbeat', description: 'Heartbeat monitor' }, @@ -132,6 +134,7 @@ export function discoverServices( { enabled: automation.gdocs?.enabled ?? false, name: 'gdocs', description: 'Google Docs' }, { enabled: automation.gdrive?.enabled ?? false, name: 'gdrive', description: 'Google Drive' }, { enabled: automation.gtasks?.enabled ?? false, name: 'gtasks', description: 'Google Tasks' }, + { enabled: automation.daily_briefing?.enabled ?? false, name: 'daily_briefing', description: 'Daily briefing automation' }, ]; for (const auto of automationConfigs) {