0265afa054
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
197 lines
6.2 KiB
JavaScript
197 lines
6.2 KiB
JavaScript
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();
|