import { contentLoader } from './ContentLoader.js'; import { questEngine } from './QuestEngine.js'; import { saveState } from './SaveState.js'; import { ticketService } from './TicketService.js'; function normalizeText(value) { return String(value ?? '').trim().toLowerCase(); } function includesAny(text, needles) { return needles.some((needle) => text.includes(needle)); } export class SageService { constructor({ loader = contentLoader, quests = questEngine, save = saveState, tickets = ticketService } = {}) { this.loader = loader; this.quests = quests; this.save = save; this.tickets = tickets; } reply(message) { const text = normalizeText(message); const activeQuest = this._getPrimaryActiveQuest(); if (!activeQuest) { return { response: "Nothing urgent is active right now. Check your tickets or mail and ask again once you've got something assigned.", followUps: ['Show my open tickets', 'What docs do I have access to?'] }; } if (!text) { return this._buildQuestIntro(activeQuest); } if (includesAny(text, ['ticket', 'task', 'what am i doing', 'what should i do'])) { return this._buildTicketSummary(activeQuest); } if (includesAny(text, ['vm', 'server', 'host', 'machine'])) { return this._buildVmSummary(activeQuest); } if (includesAny(text, ['doc', 'runbook', 'guide', 'manual'])) { return this._buildDocSummary(activeQuest); } if (includesAny(text, ['help', 'hint', 'stuck', 'clue', 'what now'])) { return this._buildHint(activeQuest); } if (includesAny(text, ['summary', 'recap', 'remind'])) { return this._buildQuestIntro(activeQuest); } return { response: "I can help with the active quest, but I'm not improvising answers yet. Ask for a hint, a summary, the target VM, or which docs are relevant.", followUps: ['Give me a hint', 'Summarize the task', 'Which VM am I working on?'] }; } _buildQuestIntro(quest) { const intro = this._findDialogueMessage(quest.id, ['intro', 'welcome', 'setup']); return { response: intro ?? quest.summary ?? quest.description ?? `You're working on ${quest.id}.`, followUps: ['Give me a hint', 'Summarize the task', 'Which VM am I working on?'] }; } _buildTicketSummary(quest) { const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null; if (!ticket) { return this._buildQuestIntro(quest); } return { response: `${ticket.id}: ${ticket.subject}\n\n${ticket.body}`, followUps: ['Give me a hint', 'Which VM am I working on?', 'Which docs are relevant?'] }; } _buildVmSummary(quest) { const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null; const targetVm = ticket?.target_vm ?? 'workstation'; const profile = this.loader.get('vmProfiles', targetVm); const hostname = profile?.hostname ?? targetVm; const distro = profile?.distro ?? 'unknown distro'; return { response: `The current target is ${targetVm} (${hostname}). It is authored as ${distro}. Start there unless the ticket explicitly says otherwise.`, followUps: ['Give me a hint', 'Summarize the task', 'Which docs are relevant?'] }; } _buildDocSummary(quest) { const unlockedDocs = new Set(this.save.get()?.progression?.unlocked_docs ?? []); const docs = [...this.loader.docs.values()] .filter((doc) => unlockedDocs.has(doc.id)) .filter((doc) => this._docLooksRelevant(doc, quest)) .map((doc) => doc.title); return { response: docs.length > 0 ? `Relevant unlocked docs:\n- ${docs.join('\n- ')}` : 'You do not currently have an obviously relevant unlocked doc for this quest. Ask for a hint instead.', followUps: ['Give me a hint', 'Which VM am I working on?', 'Summarize the task'] }; } _buildHint(quest) { const sageState = this.save.get()?.sage ?? {}; const nextHintIndex = Number(sageState.hint_counts?.[quest.id] ?? 0) + 1; const stageCandidates = [`hint_${nextHintIndex}`, `hint_${nextHintIndex - 1}`, 'hint_1']; let hint = null; let usedIndex = nextHintIndex; for (const stage of stageCandidates) { hint = this._findDialogueMessage(quest.id, [stage]); if (hint) { const match = stage.match(/^hint_(\d+)$/); usedIndex = Number(match?.[1] ?? 1); break; } } if (!hint) { hint = "There isn't another authored hint for this quest yet. Check the ticket body, the target VM, and any unlocked runbooks."; usedIndex = Math.max(1, nextHintIndex); } const nextHints = { ...(sageState.hint_counts ?? {}), [quest.id]: usedIndex }; this.save.set({ sage: { ...(sageState ?? {}), hint_counts: nextHints } }); return { response: hint, followUps: ['Another hint', 'Summarize the task', 'Which docs are relevant?'] }; } _getPrimaryActiveQuest() { const active = this.quests.getAllEntries() .filter(([, entry]) => entry?.state === 'active') .sort(([, left], [, right]) => { const leftTs = Date.parse(left?.started_at ?? '') || 0; const rightTs = Date.parse(right?.started_at ?? '') || 0; return rightTs - leftTs; }); if (active.length === 0) { return null; } return this.loader.get('quests', active[0][0]) ?? null; } _findDialogueMessage(questId, preferredStages) { const dialogues = [...this.loader.dialogue.values()] .filter((dialogue) => dialogue.quest_id === questId); for (const stage of preferredStages) { for (const dialogue of dialogues) { const message = dialogue.messages?.find((entry) => entry.stage === stage); if (message?.body) { return message.body; } } } return null; } _docLooksRelevant(doc, quest) { const text = `${doc.id} ${doc.title} ${doc.body}`.toLowerCase(); const ticket = quest.ticket_id ? this.loader.get('tickets', quest.ticket_id) : null; const vm = ticket?.target_vm ?? ''; const tags = ticket?.tags ?? []; return [quest.id.toLowerCase(), vm, ...tags].some((needle) => needle && text.includes(String(needle).toLowerCase())); } } export const sageService = new SageService();