/** * T-172…T-175 (A-010) — AI Knowledge Assistant. * * Five tools on the EXISTING gateway + permissions stack (no new provider, * vector store, or conversation surface): * * extract-actions meeting notes → real tasks (TasksService.create) * generate-faqs visible pages → FAQ draft page (Page/BlockService) * find-duplicates candidate pool → duplicate groups (read-only) * find-stale recent-update pool → stale pages (read-only) * suggest-metadata one page → title/description suggestion + apply * * Every call repeats the canonical gate sequence (assertCan → rate limit → * budget guard), retrieves ONLY what the requester can view (per-page * `actorLevel` VIEW re-check — the authoritative permission layer, same IDOR * guard as `assembleWritingContext`), validates the UNTRUSTED tool output with * zod (retry-once on structure errors), drops any model-emitted id that is * not in the retrieved set (no fabricated refs), and writes only through the * normal domain services AS THE REQUESTING USER. A hidden per-call * conversation (`assistant:` prefix, filtered out of the panel) records the * tokens; each capability audits its own `ai.assistant_*` event. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import type { Prisma, PrismaClient } from "@prisma/client"; import { PrismaService } from "../../../common/prisma.service.js"; import { AuditService } from "../../../common/audit.service.js"; import { ApiError } from "../../../common/errors.js"; import { logger } from "../../../common/logger.js"; import { assertCan, actorLevel } from "../../../lib/authorize.js"; import { AI_GATEWAY, mapAiError, primeWorkspaceCache, type AiBudgetGuard } from "../gateway-provider.js"; import { AiBudgetGuardService } from "../budget-guard.service.js"; import { AiRateLimitService } from "../ai-rate-limit.js"; import { stripHtml } from "../prompts.js"; import { SearchService } from "../../search/search.service.js"; import { PageService } from "../../pages/page.service.js"; import { BlockService } from "../../blocks/block.service.js"; import { TasksService } from "../../tasks/tasks.service.js"; import type { AiGateway } from "@factory/ai"; import { ASSISTANT_CONV_PREFIX, EXTRACT_ACTION_ITEMS_TOOL, EXTRACT_ACTIONS_SYSTEM_PROMPT, FIND_DUPLICATE_GROUPS_TOOL, FIND_DUPLICATES_SYSTEM_PROMPT, FIND_STALE_PAGES_TOOL, FIND_STALE_SYSTEM_PROMPT, GENERATE_FAQS_SYSTEM_PROMPT, GENERATE_FAQS_TOOL, SUGGEST_METADATA_SYSTEM_PROMPT, SUGGEST_PAGE_METADATA_TOOL, renderExtractPrompt, renderFaqsPrompt, renderDuplicatesPrompt, renderMetadataPrompt, renderStalePrompt, validateDuplicateGroups, validateExtractActions, validateGenerateFaqs, validateMetadataSuggestion, validateStalePages, } from "./assistant-prompts.js"; import type { AiExtractActionsInput, AiGenerateFaqsInput, AiFindDuplicatesInput, AiFindStaleInput, AiSuggestMetadataInput, AiApplyMetadataInput, } from "./assistant.dto.js"; type AssistantTool = | typeof EXTRACT_ACTION_ITEMS_TOOL | typeof GENERATE_FAQS_TOOL | typeof FIND_DUPLICATE_GROUPS_TOOL | typeof FIND_STALE_PAGES_TOOL | typeof SUGGEST_PAGE_METADATA_TOOL; const CANDIDATE_CAP = 200; const CANDIDATE_FLOOR = 10; @Injectable() export class AssistantService { constructor( @Inject(PrismaService) private readonly prisma: PrismaClient, @Inject(AuditService) private readonly audit: AuditService, @Inject(AI_GATEWAY) private readonly gateway: AiGateway, @Inject(AiBudgetGuardService) private readonly guardService: AiBudgetGuardService, @Inject(AiRateLimitService) private readonly rateLimit: AiRateLimitService, @Inject(SearchService) private readonly search: SearchService, @Inject(PageService) private readonly pages: PageService, @Inject(BlockService) private readonly blocks: BlockService, @Inject(TasksService) private readonly tasks: TasksService, ) {} private get guard(): AiBudgetGuard { return this.guardService.guard; } // ------------------------------------------------------------------- gates --- /** Rate cap + budget (canonical JSON envelope on failure, pre-write). */ private async preGate(workspaceId: string, userId: string, req: Request): Promise { const rl = await this.rateLimit.check(userId); if (!rl.allowed) { throw new ApiError("RATE_LIMITED", 429, "AI call limit reached.", [ { reason: "ai_call_rate_limited", retryAfterSeconds: rl.retryAfterSeconds }, ]); } await primeWorkspaceCache(this.prisma, workspaceId); try { await this.guard.check(workspaceId, undefined, req); } catch (err) { throw mapAiError(err); } } /** Hidden per-call conversation (never listed in the panel) + user row. */ private async beginConversation( workspaceId: string, userId: string, action: string, userContent: string, context: Record, ) { const conversation = await this.prisma.aiConversation.create({ data: { workspaceId, userId, title: `${ASSISTANT_CONV_PREFIX}${action}`, context: context as object, }, }); await this.prisma.aiMessage.create({ data: { conversationId: conversation.id, role: "user", content: userContent }, }); return conversation; } private async persistAssistant( conversationId: string, model: string, usage: { in: number; out: number }, metadata: Record, ): Promise { try { const row = await this.prisma.aiMessage.create({ data: { conversationId, role: "assistant", content: metadata.error ? `error: ${String(metadata.error)}` : "ok", model, ...(usage.in > 0 ? { tokensIn: usage.in } : {}), ...(usage.out > 0 ? { tokensOut: usage.out } : {}), metadata: metadata as object, }, }); return row.id; } catch (err) { logger.warn(`assistant: persist failed workspace conversation=${conversationId}: ${String(err)}`); return undefined; } } /** * One forced tool call. `metadata.mode = "assistant"` + `action` is the * MockProvider branch for deterministic unit/integration coverage. */ private async chatTool(opts: { workspaceId: string; userId: string; system: string; user: string; tool: AssistantTool; action: string; feedback?: string; }): Promise<{ args: unknown; usage: { in: number; out: number } }> { const messages: Array<{ role: "system" | "user"; content: string }> = [ { role: "system", content: opts.system }, { role: "user", content: opts.feedback ? `${opts.user}\n\nPrevious attempt was INVALID: ${opts.feedback}\nCorrect it and call the tool again.` : opts.user, }, ]; let args: unknown; let usage = { in: 0, out: 0 }; for await (const chunk of this.gateway.chat({ workspaceId: opts.workspaceId, userId: opts.userId, capability: "assistant", messages, tools: [opts.tool], toolChoice: { name: opts.tool.name }, metadata: { mode: "assistant", action: opts.action }, })) { if (chunk.toolCallDelta) args = chunk.toolCallDelta.arguments; if (chunk.done && chunk.usage) usage = { in: chunk.usage.in, out: chunk.usage.out }; } if (args === undefined) { throw ApiError.unprocessable("The model returned no tool call.", [{ reason: "ai_invalid_structure", issue: "no tool call" }]); } return { args, usage }; } /** Run chatTool + a `validate*` helper, retrying once with the error as feedback. */ private async callValidated( opts: { workspaceId: string; userId: string; system: string; user: string; tool: AssistantTool; action: string; }, validate: (raw: unknown) => { ok: true; value: T; dropped?: string[] } | { ok: false; feedback: string }, ): Promise<{ value: T; dropped: string[]; usage: { in: number; out: number }; invalid?: string }> { let attempt = await this.chatTool(opts); let parsed = validate(attempt.args); if (!parsed.ok) { attempt = await this.chatTool({ ...opts, feedback: parsed.feedback }); parsed = validate(attempt.args); } if (!parsed.ok) { return { value: undefined as T, dropped: [], usage: attempt.usage, invalid: parsed.feedback }; } return { value: parsed.value, dropped: parsed.dropped ?? [], usage: attempt.usage }; } // ------------------------------------------------------------- retrieval --- /** Plain text of a page's blocks (title block included; HTML stripped). */ private async pageText(pageId: string, cap: number): Promise { const rows = await this.prisma.block.findMany({ where: { pageId, deletedAt: null }, select: { content: true, position: true }, orderBy: { position: "asc" }, }); return rows.map((r) => blockText(r.content)).filter(Boolean).join("\n").slice(0, cap); } /** * Permission-filtered candidate pages: a workspace-scoped pool (most recent * or search-hit) re-checked PER PAGE against the authoritative permission * layer (`actorLevel` — page grants, workspace role). Unviewable pages are * dropped + logged (IDOR guard; nothing unseen reaches the model). */ private async viewableCandidates( workspaceId: string, userId: string, opts: { pageIds?: string[]; limit: number; olderThan?: Date }, ): Promise> { const limit = Math.max(CANDIDATE_FLOOR, Math.min(CANDIDATE_CAP, opts.limit)); const where: Prisma.PageWhereInput = { workspaceId, deletedAt: null, trashedAt: null, ...(opts.pageIds ? { id: { in: opts.pageIds } } : {}), ...(opts.olderThan ? { updatedAt: { lt: opts.olderThan } } : {}), }; const pool = await this.prisma.page.findMany({ where, select: { id: true, title: true, updatedAt: true }, orderBy: { updatedAt: "desc" }, take: opts.pageIds ? undefined : limit, }); // Per-page VIEW re-check (the authoritative layer — never the model, never // a cached snapshot): drop what the requester cannot see. const visible: Array<{ id: string; title: string; updatedAt: Date }> = []; for (const p of pool) { const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: p.id } }); if (level === "NONE") { logger.info(`assistant: page ${p.id} dropped from candidates — user ${userId} lacks VIEW`); continue; } visible.push(p); } const ids = visible.map((p) => p.id); const blockRows = ids.length ? await this.prisma.block.findMany({ where: { pageId: { in: ids }, deletedAt: null }, select: { pageId: true, content: true, position: true }, orderBy: [{ pageId: "asc" }, { position: "asc" }], }) : []; const textByPage = new Map(); for (const b of blockRows) { const t = blockText(b.content); if (!t) continue; textByPage.set(b.pageId, (textByPage.get(b.pageId) ?? "") + (textByPage.get(b.pageId) ? "\n" : "") + t); } return visible.map((p) => ({ id: p.id, title: p.title, updatedAt: p.updatedAt.toISOString(), text: (textByPage.get(p.id) ?? "").slice(0, 1200), })); } // ------------------------------------------------------------ 1. actions --- /** Meeting notes → tasks, created through TasksService as the requester. */ async extractActions(workspaceId: string, userId: string, input: AiExtractActionsInput, req: Request) { await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); await this.preGate(workspaceId, userId, req); const conversation = await this.beginConversation( workspaceId, userId, "extract-actions", `extract-actions: ${input.text.slice(0, 200)}`, { feature: "assistant.extract-actions", chars: input.text.length }, ); const result = await this.callValidated( { workspaceId, userId, system: EXTRACT_ACTIONS_SYSTEM_PROMPT, user: renderExtractPrompt(input.text), tool: EXTRACT_ACTION_ITEMS_TOOL, action: "extract-actions", }, validateExtractActions, ); if ((result as { invalid?: string }).invalid) { await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.extract-actions", error: "ai_invalid_structure", }); throw ApiError.unprocessable("The AI returned an invalid action list.", [ { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid }, ]); } const tasksOut: Array<{ id: string; title: string; dueDate?: string }> = []; for (const a of result.value) { const row = await this.tasks.create( workspaceId, userId, { title: a.title, dueAt: a.dueDate ?? null }, req, ); tasksOut.push({ id: row.id, title: a.title, dueDate: a.dueDate }); } const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.extract-actions", taskIds: tasksOut.map((t) => t.id), }); await this.audit.audit(req, req?.auth, { action: "ai.assistant_extract_actions", resourceType: "task", resourceId: tasksOut[0]?.id ?? assistantId ?? conversation.id, workspaceId, metadata: { taskCount: tasksOut.length }, }); return { tasks: tasksOut, tokensIn: result.usage.in, tokensOut: result.usage.out }; } // ---------------------------------------------------------------- 2. faqs --- /** Visible pages → an FAQ draft PAGE (title + H2/paragraph per Q/A). */ async generateFaqs(workspaceId: string, userId: string, input: AiGenerateFaqsInput, req: Request) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); await this.preGate(workspaceId, userId, req); const unique = [...new Set(input.pageIds)]; const pages_ = await this.prisma.page.findMany({ where: { id: { in: unique }, workspaceId, deletedAt: null }, select: { id: true, title: true }, }); const sections: Array<{ label: string; text: string }> = []; for (const p of pages_) { const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: p.id } }); if (level === "NONE") { logger.info(`assistant: faqs — page ${p.id} dropped, user ${userId} lacks VIEW`); continue; } sections.push({ label: p.title || p.id, text: await this.pageText(p.id, 4000) }); } if (sections.length === 0) throw ApiError.notFound("Pages not found."); const conversation = await this.beginConversation( workspaceId, userId, "generate-faqs", `generate-faqs: ${sections.map((s) => s.label).join(", ").slice(0, 200)}`, { feature: "assistant.generate-faqs", pageIds: sections.map((s) => s.label) }, ); const result = await this.callValidated( { workspaceId, userId, system: GENERATE_FAQS_SYSTEM_PROMPT, user: renderFaqsPrompt(sections, sections.length), tool: GENERATE_FAQS_TOOL, action: "generate-faqs", }, validateGenerateFaqs, ); if ((result as { invalid?: string }).invalid) { await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.generate-faqs", error: "ai_invalid_structure", }); throw ApiError.unprocessable("The AI returned an invalid FAQ list.", [ { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid }, ]); } // Write through the normal services (EDIT re-checked + audited inside). const page = await this.pages.create(workspaceId, userId, { title: result.value.title }, req); const pageId = page.id; for (const faq of result.value.faqs) { await this.blocks.create(pageId, userId, { type: "HEADING_2", content: { text: faq.question }, properties: {} }, req); await this.blocks.create(pageId, userId, { type: "PARAGRAPH", content: { text: faq.answer }, properties: {} }, req); } const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.generate-faqs", pageId, faqCount: result.value.faqs.length, }); await this.audit.audit(req, req?.auth, { action: "ai.assistant_faq_page", resourceType: "page", resourceId: pageId, workspaceId, pageId, metadata: { faqCount: result.value.faqs.length }, }); return { page: { id: pageId, title: result.value.title }, faqCount: result.value.faqs.length, tokensIn: result.usage.in, tokensOut: result.usage.out }; } // -------------------------------------------------------- 3. duplicates --- /** Candidate pool (search or most-recent) → duplicate groups (read-only). */ async findDuplicates(workspaceId: string, userId: string, input: AiFindDuplicatesInput, req: Request) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); await this.preGate(workspaceId, userId, req); const limit = input.limit ?? 100; let pageIds: string[] | undefined; if (input.query) { const hits = await this.search.query(userId, { q: input.query, workspaceId, types: ["page"], limit: CANDIDATE_CAP }); pageIds = hits.map((h) => h.id); } const candidates = await this.viewableCandidates(workspaceId, userId, { pageIds, limit }); if (candidates.length < 2) { return { groups: [], considered: candidates.length, tokensIn: 0, tokensOut: 0 }; } const conversation = await this.beginConversation( workspaceId, userId, "find-duplicates", `find-duplicates: ${input.query ?? "recent pages"} (${candidates.length} candidates)`, { feature: "assistant.find-duplicates", candidateCount: candidates.length, query: input.query ?? null }, ); const known = new Set(candidates.map((c) => c.id)); const result = await this.callValidated( { workspaceId, userId, system: FIND_DUPLICATES_SYSTEM_PROMPT, user: renderDuplicatesPrompt(candidates), tool: FIND_DUPLICATE_GROUPS_TOOL, action: "find-duplicates", }, (raw) => validateDuplicateGroups(raw, known), ); if ((result as { invalid?: string }).invalid) { await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.find-duplicates", error: "ai_invalid_structure", }); throw ApiError.unprocessable("The AI returned an invalid duplicate list.", [ { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid }, ]); } const byId = new Map(candidates.map((c) => [c.id, c])); const groups = result.value.map((g) => ({ similarity: g.similarity, reason: g.reason, pages: g.pageIds.map((id) => ({ pageId: id, title: byId.get(id)?.title ?? "", updatedAt: byId.get(id)?.updatedAt ?? "" })), })); await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.find-duplicates", groups: result.value, ...(result.dropped.length ? { droppedIds: result.dropped } : {}), }); await this.audit.audit(req, req?.auth, { action: "ai.assistant_duplicates_found", resourceType: "ai_suggestion", resourceId: conversation.id, workspaceId, metadata: { groupCount: groups.length, candidateCount: candidates.length }, }); return { groups, considered: candidates.length, tokensIn: result.usage.in, tokensOut: result.usage.out }; } // ---------------------------------------------------------------- 4. stale --- /** Pages not updated in N days → model judges which look stale. */ async findStale(workspaceId: string, userId: string, input: AiFindStaleInput, req: Request) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); await this.preGate(workspaceId, userId, req); const cutoff = new Date(Date.now() - input.days * 86_400_000); const candidates = await this.viewableCandidates(workspaceId, userId, { limit: input.limit ?? 100, olderThan: cutoff }); if (candidates.length === 0) { return { pages: [], considered: 0, tokensIn: 0, tokensOut: 0 }; } const conversation = await this.beginConversation( workspaceId, userId, "find-stale", `find-stale: ${candidates.length} candidates (older than ${input.days} days)`, { feature: "assistant.find-stale", candidateCount: candidates.length, days: input.days }, ); const known = new Set(candidates.map((c) => c.id)); const result = await this.callValidated( { workspaceId, userId, system: FIND_STALE_SYSTEM_PROMPT, user: renderStalePrompt(candidates), tool: FIND_STALE_PAGES_TOOL, action: "find-stale", }, (raw) => validateStalePages(raw, known), ); if ((result as { invalid?: string }).invalid) { await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.find-stale", error: "ai_invalid_structure", }); throw ApiError.unprocessable("The AI returned an invalid stale list.", [ { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid }, ]); } const byId = new Map(candidates.map((c) => [c.id, c])); const pages_ = result.value.map((p) => ({ pageId: p.pageId, title: byId.get(p.pageId)?.title ?? "", updatedAt: byId.get(p.pageId)?.updatedAt ?? "", reason: p.reason, })); await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.find-stale", pageIds: pages_.map((p) => p.pageId), ...(result.dropped.length ? { droppedIds: result.dropped } : {}), }); await this.audit.audit(req, req?.auth, { action: "ai.assistant_stale_found", resourceType: "ai_suggestion", resourceId: conversation.id, workspaceId, metadata: { staleCount: pages_.length, candidateCount: candidates.length }, }); return { pages: pages_, considered: candidates.length, tokensIn: result.usage.in, tokensOut: result.usage.out }; } // ---------------------------------------------------------- 5. metadata --- /** One page → suggested title/description (suggestion; apply is separate). */ async suggestMetadata(workspaceId: string, userId: string, input: AiSuggestMetadataInput, req: Request) { const page = await this.prisma.page.findFirst({ where: { id: input.pageId, workspaceId, deletedAt: null }, select: { id: true, title: true, description: true }, }); if (!page) throw ApiError.notFound("Page not found."); const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: page.id } }); if (level === "NONE") throw ApiError.notFound("Page not found."); await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); await this.preGate(workspaceId, userId, req); const conversation = await this.beginConversation( workspaceId, userId, "suggest-metadata", `suggest-metadata: ${page.title || page.id}`, { feature: "assistant.suggest-metadata", pageId: page.id }, ); const result = await this.callValidated( { workspaceId, userId, system: SUGGEST_METADATA_SYSTEM_PROMPT, user: renderMetadataPrompt({ title: page.title, text: await this.pageText(page.id, 8000) }), tool: SUGGEST_PAGE_METADATA_TOOL, action: "suggest-metadata", }, validateMetadataSuggestion, ); if ((result as { invalid?: string }).invalid) { await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.suggest-metadata", pageId: page.id, error: "ai_invalid_structure", }); throw ApiError.unprocessable("The AI returned an invalid suggestion.", [ { reason: "ai_invalid_structure", issue: (result as { invalid?: string }).invalid }, ]); } const assistantId = await this.persistAssistant(conversation.id, this.gateway.resolve(workspaceId).chatModel, result.usage, { feature: "assistant.suggest-metadata", pageId: page.id, suggestion: result.value, }); await this.audit.audit(req, req?.auth, { action: "ai.assistant_metadata_suggested", resourceType: "page", resourceId: page.id, workspaceId, pageId: page.id, metadata: { fields: Object.keys(result.value) }, }); return { suggestionId: assistantId, pageId: page.id, current: { title: page.title, description: page.description }, suggested: result.value, tokensIn: result.usage.in, tokensOut: result.usage.out, }; } /** Apply a previously-suggested field subset via the normal page PATCH path. */ async applyMetadata(workspaceId: string, userId: string, input: AiApplyMetadataInput, req: Request) { // Ownership-scoped lookup (404 no-leak, like the writing apply path). const message = await this.prisma.aiMessage.findFirst({ where: { id: input.suggestionId, role: "assistant" }, select: { id: true, conversationId: true, metadata: true }, }); const conversation = message ? await this.prisma.aiConversation.findUnique({ where: { id: message.conversationId }, select: { workspaceId: true, userId: true } }) : null; if (!message || !conversation || conversation.workspaceId !== workspaceId || conversation.userId !== userId) { throw ApiError.notFound("Suggestion not found."); } const meta = (message.metadata ?? {}) as { feature?: string; pageId?: string; suggestion?: { title?: string; description?: string } }; if (meta.feature !== "assistant.suggest-metadata" || !meta.pageId || !meta.suggestion) { throw ApiError.notFound("Suggestion not found."); } // Only fields EXACTLY as suggested may be applied (no smuggling). const updates: { title?: string; description?: string } = {}; if (input.fields.title !== undefined) { if (meta.suggestion.title !== input.fields.title) { throw new ApiError("VALIDATION_ERROR", 400, "fields.title does not match the suggestion.", [{ field: "fields.title", issue: "not_in_suggestion" }]); } updates.title = input.fields.title; } if (input.fields.description !== undefined) { if (meta.suggestion.description !== input.fields.description) { throw new ApiError("VALIDATION_ERROR", 400, "fields.description does not match the suggestion.", [{ field: "fields.description", issue: "not_in_suggestion" }]); } updates.description = input.fields.description; } await this.pages.update(meta.pageId, userId, updates, req); await this.audit.audit(req, req?.auth, { action: "ai.assistant_metadata_applied", resourceType: "page", resourceId: meta.pageId, workspaceId, pageId: meta.pageId, metadata: { fields: Object.keys(updates), suggestionId: input.suggestionId }, }); return { pageId: meta.pageId, applied: Object.keys(updates) }; } } /** Block content → plain text (string-or-object, HTML stripped — see prompts.ts). */ function blockText(content: unknown): string { if (typeof content === "string") return stripHtml(content); if (typeof content !== "object" || content === null) return ""; const c = content as { text?: unknown; title?: unknown }; const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : ""; return stripHtml(raw); }