import type { CommandMode, ParsedCommand, WorkPackage } from "./work-package-types"; export type CommandParseResult = { parsed: ParsedCommand; matchedWorkPackageId?: string; matchedWorkPackageTitle?: string; suggestions?: { id: string; title: string; shortName: string }[]; error?: string; }; const MODES: CommandMode[] = ["ask", "plan", "change", "execute"]; function normalize(s: string): string { return s.trim().replace(/\s+/g, " ").toLowerCase(); } function matchWorkPackageByName( workPackages: WorkPackage[], name: string, ): { id: string; title: string; shortName: string } | undefined { const n = normalize(name); if (!n) return; return workPackages.find((wp) => { return normalize(wp.title) === n || normalize(wp.shortName) === n; }); } function getSuggestions(workPackages: WorkPackage[], name: string) { const n = normalize(name); if (!n) return []; // Cheap “contains” suggestion without pulling in a fuzzy-match dep. return workPackages .map((wp) => ({ id: wp.id, title: wp.title, shortName: wp.shortName, score: Number(normalize(wp.title).includes(n)) + Number(normalize(wp.shortName).includes(n)), })) .filter((x) => x.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 5) .map(({ id, title, shortName }) => ({ id, title, shortName })); } export function parseCommand( text: string, workPackages: WorkPackage[], ): CommandParseResult { const raw = text.trim(); if (!raw.startsWith("@")) { return { parsed: { instruction: raw } }; } const afterAt = raw.slice(1).trim(); if (!afterAt) { return { parsed: { instruction: "" }, error: "Missing work package name." }; } const tokens = afterAt.split(/\s+/g); // Find explicit mode token (if present). let modeIdx = -1; for (let i = 0; i < tokens.length; i++) { if (MODES.includes(tokens[i].toLowerCase() as CommandMode)) { modeIdx = i; break; } } // If mode is missing, infer the longest package-name prefix that matches. if (modeIdx === -1) { let bestMatchLen = 0; let bestMatch: | { id: string; title: string; shortName: string; matchedName: string } | undefined; for (let len = Math.min(tokens.length, 10); len >= 1; len--) { const candidate = tokens.slice(0, len).join(" "); const m = matchWorkPackageByName(workPackages, candidate); if (m) { bestMatchLen = len; bestMatch = { ...m, matchedName: candidate }; break; } } if (!bestMatch) { const guessName = tokens[0]; return { parsed: { referencedPackageName: guessName, mode: "ask", instruction: tokens.slice(1).join(" ") }, suggestions: getSuggestions(workPackages, guessName), error: `Work package not found: "${guessName}".`, }; } const instruction = tokens.slice(bestMatchLen).join(" ").trim(); return { parsed: { referencedPackageName: bestMatch.matchedName, mode: "ask", instruction, }, matchedWorkPackageId: bestMatch.id, matchedWorkPackageTitle: bestMatch.title, }; } const packageName = tokens.slice(0, modeIdx).join(" ").trim(); const mode = tokens[modeIdx].toLowerCase() as CommandMode; const instruction = tokens.slice(modeIdx + 1).join(" ").trim(); const m = matchWorkPackageByName(workPackages, packageName); if (!m) { return { parsed: { referencedPackageName: packageName, mode, instruction }, suggestions: getSuggestions(workPackages, packageName), error: `Work package not found: "${packageName}".`, }; } return { parsed: { referencedPackageName: m.title, mode, instruction }, matchedWorkPackageId: m.id, matchedWorkPackageTitle: m.title, }; }