feat(setup): add onboarding live checks and first-success guidance

This commit is contained in:
William Valentin
2026-02-26 18:18:12 -08:00
parent 03926a81eb
commit 62c427da4a
14 changed files with 646 additions and 9 deletions
+28 -1
View File
@@ -13,7 +13,8 @@ import {
getMinioExtractorInstallHints,
renderMinioExtractorSetupLines,
} from './minioExtractors.js';
import { renderOnboardingChecklist } from './setup/summary.js';
import { renderFirstSuccessTask, renderOnboardingChecklist } from './setup/summary.js';
import { countSetupLiveCheckStatuses, renderSetupLiveChecks, runSetupLiveChecks } from './setup/liveChecks.js';
import type { SetupConfig } from './setup/config.js';
export async function runSetup(configPath: string): Promise<void> {
@@ -30,6 +31,8 @@ export async function runSetup(configPath: string): Promise<void> {
saveConfig(configPath, builder, p);
const config = builder.build();
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
printSetupLiveChecks(p, config as Record<string, unknown>);
printFirstSuccessTask(p, config as Record<string, unknown>);
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
await runGoogleAuth(p, config);
} else {
@@ -38,6 +41,8 @@ export async function runSetup(configPath: string): Promise<void> {
saveConfig(configPath, builder, p);
const config = builder.build();
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
printSetupLiveChecks(p, config as Record<string, unknown>);
printFirstSuccessTask(p, config as Record<string, unknown>);
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
await runGoogleAuth(p, config);
@@ -62,6 +67,8 @@ export async function runSetup(configPath: string): Promise<void> {
saveConfig(configPath, menuBuilder, p);
const config = menuBuilder.build();
printOnboardingChecklist(p, config as Record<string, unknown>, configPath);
printSetupLiveChecks(p, config as Record<string, unknown>);
printFirstSuccessTask(p, config as Record<string, unknown>);
await printMinioExtractorSetupStatus(p, config as Record<string, unknown>);
await runGoogleAuth(p, config);
}
@@ -88,6 +95,26 @@ function printOnboardingChecklist(
p.println(renderOnboardingChecklist(config as SetupConfig, { configPath }));
}
function printSetupLiveChecks(
p: { println(msg?: string): void },
config: Record<string, unknown>,
): void {
const results = runSetupLiveChecks(config as SetupConfig);
const counts = countSetupLiveCheckStatuses(results);
p.println();
p.println(renderSetupLiveChecks(results));
p.println(` Summary: ${counts.pass} pass, ${counts.fail} fail, ${counts.warn} warn, ${counts.skip} skip`);
}
function printFirstSuccessTask(
p: { println(msg?: string): void },
config: Record<string, unknown>,
): void {
p.println();
p.println(renderFirstSuccessTask(config as SetupConfig));
}
async function printMinioExtractorSetupStatus(
p: { println(msg?: string): void },
config: Record<string, unknown>,
+30
View File
@@ -127,4 +127,34 @@ describe('ConfigBuilder', () => {
namespace: 'global',
});
});
it('applies personal assistant mode defaults', () => {
const builder = new ConfigBuilder();
builder.applyPersonalAssistantMode();
const obj = builder.build();
expect((obj.automation as Record<string, unknown>)?.delivery_mode).toBe('announce');
expect((obj.memory as Record<string, unknown>)?.daily_log).toEqual({
enabled: true,
namespace_prefix: 'daily',
});
expect((obj.memory as Record<string, unknown>)?.proactive_extract).toEqual({
enabled: true,
min_tool_calls: 2,
namespace: 'global',
});
expect((obj.audio as Record<string, unknown>)?.talk_mode).toMatchObject({
enabled: true,
wake_phrase: 'hey flynn',
timeout_ms: 120000,
allow_manual_toggle: true,
});
expect((obj.tts as Record<string, unknown>)?.fallback).toEqual({
max_attempts: 3,
failure_cooldown_ms: 60000,
});
expect((obj.server as Record<string, unknown>)?.queue).toMatchObject({
mode: 'interrupt',
});
});
});
+76 -1
View File
@@ -23,6 +23,9 @@ export interface SetupConfig {
token?: string;
lock?: boolean;
tailscale?: { serve?: boolean };
queue?: {
mode?: 'collect' | 'followup' | 'steer' | 'steer_backlog' | 'interrupt';
};
} & Record<string, unknown>;
hooks?: Record<string, unknown>;
telegram?: { bot_token: string; allowed_chat_ids: number[] };
@@ -30,6 +33,22 @@ export interface SetupConfig {
slack?: { bot_token: string; app_token: string; signing_secret: string; allowed_channel_ids: string[] };
whatsapp?: { allowed_numbers: string[] };
memory?: { embedding?: { enabled?: boolean; provider?: string; api_key?: string; endpoint?: string } };
audio?: {
enabled?: boolean;
talk_mode?: {
enabled?: boolean;
wake_phrase?: string;
timeout_ms?: number;
allow_manual_toggle?: boolean;
};
};
tts?: {
enabled?: boolean;
fallback?: {
max_attempts?: number;
failure_cooldown_ms?: number;
};
};
sandbox?: { enabled?: boolean };
pairing?: { enabled?: boolean };
tools?: { profile?: string };
@@ -55,7 +74,15 @@ export interface SetupConfig {
gdrive?: { enabled?: boolean };
gtasks?: { enabled?: boolean };
heartbeat?: { enabled?: boolean };
daily_briefing?: { enabled?: boolean };
daily_briefing?: {
enabled?: boolean;
schedule?: string;
output?: {
channel?: string;
peer?: string;
};
model_tier?: 'fast' | 'default' | 'complex' | 'local';
};
minio_sync?: { enabled?: boolean };
} & Record<string, unknown>;
backup?: {
@@ -79,6 +106,11 @@ interface ResearchAgentOptions {
modelTier: 'fast' | 'default' | 'complex' | 'local';
}
interface PersonalAssistantModeOptions {
enableTalkMode?: boolean;
enableTts?: boolean;
}
export class ConfigBuilder {
private config: SetupConfig;
@@ -317,6 +349,49 @@ export class ConfigBuilder {
this.config.memory = memory;
}
applyPersonalAssistantMode(options?: PersonalAssistantModeOptions): void {
const automation = (this.config.automation ?? {}) as Record<string, unknown>;
const memory = (this.config.memory ?? {}) as Record<string, unknown>;
const audio = (this.config.audio ?? {}) as Record<string, unknown>;
const talkMode = (audio.talk_mode ?? {}) as Record<string, unknown>;
const tts = (this.config.tts ?? {}) as Record<string, unknown>;
const ttsFallback = (tts.fallback ?? {}) as Record<string, unknown>;
const server = (this.config.server ?? {}) as Record<string, unknown>;
const queue = (server.queue ?? {}) as Record<string, unknown>;
automation.delivery_mode = 'announce';
memory.daily_log = {
enabled: true,
namespace_prefix: 'daily',
};
memory.proactive_extract = {
enabled: true,
min_tool_calls: 2,
namespace: 'global',
};
talkMode.enabled = options?.enableTalkMode ?? true;
talkMode.wake_phrase = 'hey flynn';
talkMode.timeout_ms = 120000;
talkMode.allow_manual_toggle = true;
audio.talk_mode = talkMode;
audio.enabled = Boolean(audio.enabled || talkMode.enabled);
tts.enabled = options?.enableTts ?? true;
ttsFallback.max_attempts = 3;
ttsFallback.failure_cooldown_ms = 60000;
tts.fallback = ttsFallback;
queue.mode = 'interrupt';
server.queue = queue;
this.config.automation = automation;
this.config.memory = memory;
this.config.audio = audio;
this.config.tts = tts;
this.config.server = server as SetupConfig['server'];
}
build(): SetupConfig {
return structuredClone(this.config) as SetupConfig;
}
+30
View File
@@ -26,6 +26,7 @@ describe('first-run wizard integration', () => {
'n', // confirm: Configure a fast tier? (no)
'', // ask: Gateway port (default)
'n', // confirm: Add a messaging channel? (no)
'n', // confirm: Enable Personal Assistant Mode defaults? (no)
'n', // confirm: Configure automation now? (no)
]);
const p = createPrompter(rl);
@@ -54,6 +55,7 @@ describe('first-run wizard integration', () => {
'123:ABCdef', // password: Bot token
'12345678', // ask: Allowed chat IDs
'n', // confirm: Add another channel? (no)
'n', // confirm: Enable Personal Assistant Mode defaults? (no)
'n', // confirm: Configure automation now? (no)
]);
const p = createPrompter(rl);
@@ -78,6 +80,7 @@ describe('first-run wizard integration', () => {
'123:ABCdef', // password: Bot token
'12345678', // ask: Allowed chat IDs
'n', // confirm: Add another channel? (no)
'y', // confirm: Enable Personal Assistant Mode defaults? (yes)
'y', // confirm: Configure automation now? (yes)
'y', // confirm: Enable operator automation pack? (yes)
'', // ask: Operator notifications output channel (default)
@@ -101,4 +104,31 @@ describe('first-run wizard integration', () => {
expect(backup.schedule).toBe('0 2 * * *');
expect(heartbeat.enabled).toBe(true);
});
it('applies personal assistant mode defaults when accepted', async () => {
const rl = mockReadline([
'1', // choose: Anthropic
'sk-ant-key', // password: API key
'', // ask: Model (default)
'n', // confirm: Configure a fast tier? (no)
'', // ask: Gateway port (default)
'n', // confirm: Add a messaging channel? (no)
'', // confirm: Enable Personal Assistant Mode defaults? (yes, default)
'n', // confirm: Configure automation now? (no)
]);
const p = createPrompter(rl);
const builder = await runFirstRunWizard(p);
const config = builder.build() as Record<string, unknown>;
const automation = config.automation as Record<string, unknown>;
const memory = config.memory as Record<string, unknown>;
const audio = config.audio as Record<string, unknown>;
expect(automation.delivery_mode).toBe('announce');
expect(memory.daily_log).toEqual({
enabled: true,
namespace_prefix: 'daily',
});
expect((audio.talk_mode as Record<string, unknown>)?.enabled).toBe(true);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { countSetupLiveCheckStatuses, renderSetupLiveChecks, runSetupLiveChecks } from './liveChecks.js';
import type { SetupConfig } from './config.js';
function createBaseConfig(): SetupConfig {
return {
models: {
default: {
provider: 'openai',
model: 'gpt-4.1-mini',
api_key: 'sk-test',
},
},
server: {
port: 18800,
localhost: true,
},
};
}
describe('setup live checks', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns pass/skip checks for a minimal healthy setup', () => {
const results = runSetupLiveChecks(createBaseConfig());
expect(results).toHaveLength(4);
expect(results[0]).toMatchObject({ status: 'pass', label: 'Model connectivity' });
expect(results[1]).toMatchObject({ status: 'pass', label: 'Channel connectivity' });
expect(results[2]).toMatchObject({ status: 'skip', label: 'Memory readiness' });
expect(results[3]).toMatchObject({ status: 'skip', label: 'Automation readiness' });
});
it('fails model readiness when api-key provider has no credentials', () => {
const config = createBaseConfig();
config.models.default.api_key = undefined;
const results = runSetupLiveChecks(config);
const model = results.find((entry) => entry.label === 'Model connectivity');
expect(model).toMatchObject({ status: 'fail' });
expect(String(model?.detail ?? '')).toContain('missing credentials');
});
it('fails automation readiness when daily briefing output is missing', () => {
const config = createBaseConfig();
config.automation = {
daily_briefing: {
enabled: true,
},
} as SetupConfig['automation'];
const results = runSetupLiveChecks(config);
const automation = results.find((entry) => entry.label === 'Automation readiness');
expect(automation).toMatchObject({ status: 'fail' });
expect(String(automation?.detail ?? '')).toContain('output.channel/output.peer');
});
it('warns memory readiness for local embeddings without endpoint', () => {
const config = createBaseConfig();
config.memory = {
embedding: {
enabled: true,
provider: 'ollama',
},
};
const results = runSetupLiveChecks(config);
const memory = results.find((entry) => entry.label === 'Memory readiness');
expect(memory).toMatchObject({ status: 'warn' });
});
it('renders and counts check statuses', () => {
const config = createBaseConfig();
config.memory = {
embedding: {
enabled: true,
provider: 'openai',
api_key: 'sk-embed',
},
};
config.automation = {
daily_briefing: {
enabled: true,
output: { channel: 'telegram', peer: '123' },
},
} as SetupConfig['automation'];
const results = runSetupLiveChecks(config);
const summary = countSetupLiveCheckStatuses(results);
const rendered = renderSetupLiveChecks(results);
expect(summary.pass).toBeGreaterThanOrEqual(3);
expect(summary.fail).toBe(0);
expect(rendered).toContain('Setup live checks:');
expect(rendered).toContain('[PASS] Model connectivity');
});
});
+267
View File
@@ -0,0 +1,267 @@
import type { SetupConfig } from './config.js';
export interface SetupLiveCheckResult {
status: 'pass' | 'warn' | 'fail' | 'skip';
label: string;
detail?: string;
}
const PROVIDER_ENV_KEY: Record<string, string> = {
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
gemini: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
vercel: 'AI_GATEWAY_API_KEY',
zhipuai: 'ZHIPUAI_API_KEY',
xai: 'XAI_API_KEY',
minimax: 'MINIMAX_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
voyage: 'VOYAGE_API_KEY',
};
function hasNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function runModelReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
const defaultModel = config.models?.default;
if (!defaultModel || !hasNonEmptyString(defaultModel.provider) || !hasNonEmptyString(defaultModel.model)) {
return {
status: 'fail',
label: 'Model connectivity',
detail: 'default model provider/model is missing',
};
}
const provider = defaultModel.provider;
const modelName = defaultModel.model;
const envVar = PROVIDER_ENV_KEY[provider];
const envHasKey = envVar ? hasNonEmptyString(process.env[envVar]) : false;
const configHasKey = hasNonEmptyString(defaultModel.api_key);
if (envVar && !envHasKey && !configHasKey) {
return {
status: 'fail',
label: 'Model connectivity',
detail: `${provider}/${modelName} missing credentials (set ${envVar} or configure api_key)`,
};
}
const keySource = configHasKey ? 'config' : envHasKey ? 'env' : 'n/a';
return {
status: 'pass',
label: 'Model connectivity',
detail: `${provider}/${modelName} (auth=${keySource})`,
};
}
function runChannelReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
const port = Number(config.server?.port ?? 18800);
const issues: string[] = [];
const enabledChannels: string[] = [];
if (config.telegram) {
enabledChannels.push('telegram');
if (!hasNonEmptyString(config.telegram.bot_token)) {
issues.push('telegram.bot_token missing');
}
if (!Array.isArray(config.telegram.allowed_chat_ids) || config.telegram.allowed_chat_ids.length === 0) {
issues.push('telegram.allowed_chat_ids missing');
}
}
if (config.discord) {
enabledChannels.push('discord');
if (!hasNonEmptyString(config.discord.bot_token)) {
issues.push('discord.bot_token missing');
}
}
if (config.slack) {
enabledChannels.push('slack');
if (!hasNonEmptyString(config.slack.bot_token)) {
issues.push('slack.bot_token missing');
}
if (!hasNonEmptyString(config.slack.app_token)) {
issues.push('slack.app_token missing');
}
if (!hasNonEmptyString(config.slack.signing_secret)) {
issues.push('slack.signing_secret missing');
}
}
if (config.whatsapp) {
enabledChannels.push('whatsapp');
}
if (issues.length > 0) {
return {
status: 'fail',
label: 'Channel connectivity',
detail: issues.join('; '),
};
}
const channelSummary = enabledChannels.length > 0
? enabledChannels.join(', ')
: 'webchat';
return {
status: 'pass',
label: 'Channel connectivity',
detail: `${channelSummary} (webchat http://localhost:${port}/#/chat)`,
};
}
function runMemoryReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
const embedding = config.memory?.embedding;
if (!embedding?.enabled) {
return {
status: 'skip',
label: 'Memory readiness',
detail: 'vector memory disabled (keyword memory still available)',
};
}
const provider = embedding.provider ?? 'openai';
const envVar = PROVIDER_ENV_KEY[provider];
const configHasKey = hasNonEmptyString(embedding.api_key);
const envHasKey = envVar ? hasNonEmptyString(process.env[envVar]) : false;
if ((provider === 'openai' || provider === 'gemini' || provider === 'voyage') && !configHasKey && !envHasKey) {
return {
status: 'fail',
label: 'Memory readiness',
detail: `${provider} embeddings enabled but credentials are missing${envVar ? ` (set ${envVar} or embedding.api_key)` : ''}`,
};
}
if ((provider === 'ollama' || provider === 'llamacpp') && !hasNonEmptyString(embedding.endpoint)) {
return {
status: 'warn',
label: 'Memory readiness',
detail: `${provider} embeddings enabled without endpoint (set memory.embedding.endpoint for reliability)`,
};
}
return {
status: 'pass',
label: 'Memory readiness',
detail: `vector memory enabled (${provider})`,
};
}
function runAutomationReadinessCheck(config: SetupConfig): SetupLiveCheckResult {
const automation = config.automation ?? {};
const enabledFeatures: string[] = [];
if ((automation.daily_briefing as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('daily_briefing');
}
if ((automation.heartbeat as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('heartbeat');
}
if ((automation.gmail as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('gmail');
}
if ((automation.gcal as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('gcal');
}
if ((automation.gdocs as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('gdocs');
}
if ((automation.gdrive as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('gdrive');
}
if ((automation.gtasks as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('gtasks');
}
if ((automation.minio_sync as { enabled?: boolean } | undefined)?.enabled) {
enabledFeatures.push('minio_sync');
}
if (Array.isArray(automation.cron) && automation.cron.length > 0) {
enabledFeatures.push(`cron:${automation.cron.length}`);
}
if (Array.isArray(automation.webhooks) && automation.webhooks.length > 0) {
enabledFeatures.push(`webhooks:${automation.webhooks.length}`);
}
if (config.backup?.enabled) {
enabledFeatures.push('backup');
}
if (enabledFeatures.length === 0) {
return {
status: 'skip',
label: 'Automation readiness',
detail: 'no automation enabled',
};
}
const dailyBriefing = automation.daily_briefing as {
enabled?: boolean;
output?: { channel?: string; peer?: string };
} | undefined;
if (dailyBriefing?.enabled) {
const output = dailyBriefing.output;
if (!hasNonEmptyString(output?.channel) || !hasNonEmptyString(output?.peer)) {
return {
status: 'fail',
label: 'Automation readiness',
detail: 'daily_briefing enabled but output.channel/output.peer are missing',
};
}
}
const backupEnabled = config.backup?.enabled;
if (backupEnabled && !hasNonEmptyString(config.backup?.schedule)) {
return {
status: 'warn',
label: 'Automation readiness',
detail: `enabled features: ${enabledFeatures.join(', ')} (backup schedule missing)`,
};
}
return {
status: 'pass',
label: 'Automation readiness',
detail: `enabled features: ${enabledFeatures.join(', ')}`,
};
}
export function runSetupLiveChecks(config: SetupConfig): SetupLiveCheckResult[] {
return [
runModelReadinessCheck(config),
runChannelReadinessCheck(config),
runMemoryReadinessCheck(config),
runAutomationReadinessCheck(config),
];
}
export function renderSetupLiveChecks(results: SetupLiveCheckResult[]): string {
const lines: string[] = ['Setup live checks:'];
const marker: Record<SetupLiveCheckResult['status'], string> = {
pass: 'PASS',
warn: 'WARN',
fail: 'FAIL',
skip: 'SKIP',
};
for (const result of results) {
const detail = result.detail ? `${result.detail}` : '';
lines.push(` [${marker[result.status]}] ${result.label}${detail}`);
}
return lines.join('\n');
}
export function countSetupLiveCheckStatuses(results: SetupLiveCheckResult[]): {
pass: number;
warn: number;
fail: number;
skip: number;
} {
return results.reduce(
(acc, result) => {
acc[result.status] += 1;
return acc;
},
{ pass: 0, warn: 0, fail: 0, skip: 0 },
);
}
+12 -1
View File
@@ -71,7 +71,18 @@ export async function runFirstRunWizard(p: Prompter): Promise<ConfigBuilder> {
p.println();
await setupChannels(p, builder);
// Step 3: Optional automation pack
// Step 3: Personal Assistant Mode defaults
p.println();
const enablePersonalAssistantMode = await p.confirm(
'Enable Personal Assistant Mode defaults (announce delivery, proactive memory, talk mode, TTS fallback)?',
true,
);
if (enablePersonalAssistantMode) {
builder.applyPersonalAssistantMode();
p.println('✓ Personal Assistant Mode defaults enabled');
}
// Step 4: Optional automation pack
p.println();
const configureAutomation = await p.confirm('Configure automation now (operator pack, cron, webhooks, Google services)?', false);
if (configureAutomation) {
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { renderOnboardingChecklist, renderOperatorPackStatus, renderSummary } from './summary.js';
import { renderFirstSuccessTask, renderOnboardingChecklist, renderOperatorPackStatus, renderSummary } from './summary.js';
import type { SetupConfig } from './config.js';
describe('renderSummary', () => {
@@ -89,3 +89,30 @@ describe('renderOnboardingChecklist', () => {
expect(output).toContain('Slack: message the bot');
});
});
describe('renderFirstSuccessTask', () => {
it('renders daily-briefing-first guidance when daily briefing is enabled', () => {
const config = {
server: { port: 18800, localhost: true },
automation: {
daily_briefing: {
enabled: true,
output: { channel: 'telegram', peer: '123' },
},
},
} as unknown as SetupConfig;
const output = renderFirstSuccessTask(config);
expect(output).toContain('Create my daily briefing now');
expect(output).toContain('telegram/123');
});
it('falls back to proposal guidance when no automation is enabled', () => {
const config = {
server: { port: 18800, localhost: true },
} as unknown as SetupConfig;
const output = renderFirstSuccessTask(config);
expect(output).toContain('Propose one useful automation');
});
});
+41
View File
@@ -112,3 +112,44 @@ export function renderOnboardingChecklist(config: SetupConfig, opts?: { configPa
lines.push(' 5. Run `flynn doctor` if any channel test fails');
return lines.join('\n');
}
function hasGoogleAutomationEnabled(config: SetupConfig): boolean {
const automation = config.automation ?? {};
return Boolean(
automation.gmail?.enabled
|| automation.gcal?.enabled
|| automation.gdocs?.enabled
|| automation.gdrive?.enabled
|| automation.gtasks?.enabled,
);
}
export function renderFirstSuccessTask(config: SetupConfig): string {
const lines: string[] = [];
const port = config.server?.port ?? 18800;
const automation = config.automation ?? {};
const dailyBriefingEnabled = Boolean(automation.daily_briefing?.enabled);
const dailyBriefingOutput = automation.daily_briefing?.output as
| { channel?: string; peer?: string }
| undefined;
lines.push('Guided first-success task (under 5 minutes):');
lines.push(` 1. Open WebChat: http://localhost:${port}/#/chat`);
lines.push(' 2. Send `/status` and confirm Flynn replies.');
if (dailyBriefingEnabled && dailyBriefingOutput?.channel && dailyBriefingOutput?.peer) {
lines.push(' 3. Send: "Create my daily briefing now. Use available tools and output: Schedule, Priorities, Risks, First actions."');
lines.push(` 4. Confirm the reply is actionable and routed for ${dailyBriefingOutput.channel}/${dailyBriefingOutput.peer}.`);
} else if (hasGoogleAutomationEnabled(config)) {
lines.push(' 3. Send: "Use my connected Google services to summarize today\'s schedule and top 3 priorities."');
lines.push(' 4. Confirm at least one connected service signal appears in the reply.');
} else if (Array.isArray(automation.cron) && automation.cron.length > 0) {
lines.push(' 3. Send: "List my active automation jobs and recommend which one to run now."');
lines.push(' 4. Confirm Flynn identifies at least one configured automation by name.');
} else {
lines.push(' 3. Send: "Propose one useful automation I can enable today with exact config fields and values."');
lines.push(' 4. Confirm the reply includes channel + schedule/output details you can apply directly.');
}
return lines.join('\n');
}