/** * Suggested prompt producer for the Home feed. * * Returns an array of `SuggestedPrompt` items shown at the top of the * Home page as conversation starters. All prompts are generated by the * assistant based on the user's connected services and context — read * from a checkpoint-backed cache in the GET path. Generation runs on * demand via `refreshAssistantSuggestedPrompts`, invoked fire-and-forget * by the home-content revalidation coordinator when a client fetches the * home feed and the cache is stale (see `home-content-refresh.ts`). * Nothing generates at daemon startup or on a timer. * * The cache persists in the `memory_checkpoints` table so a daemon * restart does not force a regeneration. OAuth connect/disconnect paths * invalidate it explicitly so suggestions track integration state. */ import { resolveCallSiteConfig } from "../config/llm-resolver.js"; import { getConfig } from "../config/loader.js"; import { buildSystemPrompt } from "../prompts/system-prompt.js"; import { getConfiguredProvider } from "../providers/provider-send-message.js"; import { runBtwSidechain } from "../runtime/btw-sidechain.js"; import { formatIntegrationSummary } from "../schedule/integration-status.js"; import { getLogger } from "../util/logger.js"; import type { SuggestedPrompt } from "./feed-types.js"; import { readCachedPrompts, writeCachedPrompts, } from "./suggested-prompts-cache.js"; const log = getLogger("suggested-prompts"); const LLM_SUGGESTIONS_TIMEOUT_MS = 5_000; /** * Return cached assistant-generated prompts. No LLM calls happen in * this path — safe for GET. Returns an empty array until the first * background refresh populates the cache. */ export async function getSuggestedPrompts(): Promise { return readCachedPrompts() ?? []; } /** * Generate LLM-based suggestion prompts and write them to the cache. * No-ops when the cache is still fresh. Intended for fire-and-forget * background invocation, not the GET path. Returns `true` when a new * batch was generated and cached. */ export async function refreshAssistantSuggestedPrompts(): Promise { if (readCachedPrompts() !== null) { return false; } try { const llmPrompts = await generateAssistantPrompts(); // Cache empty results too — an empty or unparseable LLM response // would otherwise leave the cache unpopulated and every Home feed // GET would re-trigger generation until the TTL window closed. // Only report success when the cache write landed AND there is new // content — otherwise the coordinator would publish // home_feed_updated for content the next GET cannot serve. const wrote = writeCachedPrompts(llmPrompts); return wrote && llmPrompts.length > 0; } catch (err) { log.warn({ err }, "Failed to refresh assistant suggested prompts"); return false; } } // --------------------------------------------------------------------------- // LLM-generated suggestions // --------------------------------------------------------------------------- interface LLMSuggestion { label: string; prompt: string; } /** * Ask the LLM to generate contextual conversation-starter suggestions * based on the assistant's persona and the user's connected services. * Returns an empty array on failure. */ async function generateAssistantPrompts(): Promise { const config = getConfig(); const resolved = resolveCallSiteConfig("homeSuggestedPrompts", config.llm); const provider = await getConfiguredProvider("homeSuggestedPrompts"); if (!provider) { return []; } const systemPrompt = buildSystemPrompt({ excludeBootstrap: true, excludeCustomPrefix: true, }); let integrationContext = ""; try { integrationContext = `\nConnected integrations: ${await formatIntegrationSummary()}`; } catch { // Best-effort — continue without integration info } const result = await runBtwSidechain({ content: "Suggest 2-3 short, actionable conversation starters for the home page. " + "Each should be something specific and helpful you can do for the user right now. " + "Focus on things the user's connected services enable — don't suggest connecting services they already have. " + "You may suggest connecting a service only if it's not yet connected and would be genuinely useful." + integrationContext + ' Return ONLY a JSON array of objects with "label" (max 5 words) and "prompt" (the full message to send). ' + "No markdown fences, no explanation.", provider, systemPrompt, messages: [], tools: [], callSite: "homeSuggestedPrompts", maxTokens: resolved.maxTokens, timeoutMs: LLM_SUGGESTIONS_TIMEOUT_MS, }); const text = result.text.trim(); if (!text) { return []; } const parsed = parseLLMSuggestions(text); return parsed.map((s, i) => ({ id: `assistant-${i}-${s.label.toLowerCase().replace(/\s+/g, "-")}`, label: s.label, prompt: s.prompt, source: "assistant" as const, })); } function parseLLMSuggestions(text: string): LLMSuggestion[] { try { const cleaned = text .replace(/^```(?:json)?\n?/m, "") .replace(/\n?```$/m, ""); const parsed = JSON.parse(cleaned); if (!Array.isArray(parsed)) { return []; } return parsed.filter( (item): item is LLMSuggestion => typeof item === "object" && item !== null && typeof item.label === "string" && typeof item.prompt === "string", ); } catch { log.warn("Failed to parse LLM suggestions response"); return []; } }