import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { z } from "zod"; import { consultationDomainDefinition, consultationDomainIds, type ConsultationDomain, } from "./consultation-domain-registry.ts"; import { resolveLiveJyotishSkill } from "./skill-package-registry.ts"; /** * The strict checklist each consultation domain must be read against, named in * the vocabulary of the skill's own router rather than this codebase's. * * Activating the skill hands the model its instructions plus a flat list of * every file in the package: on the Jyotish package that is over 1500 paths and * about 99KB of undifferentiated filenames, against 30KB of actual method. The * one line telling it to open `references/strict-workflow-router.md` is inside * that method, so in practice no reference was ever opened and every answer was * composed from the model's own background knowledge over server evidence. * * The route is already decided server-side, and the skill already states which * checklist each route requires, so the selection needs no model turn: read the * mandated sections here and deliver them with the evidence. A checklist the * router does not declare is reported as absent instead of substituted. */ const domainMethodology: Readonly> = { career: { strictRoute: "career-timing-strict", eventJudgment: "event_judgment_career.md" }, marriage: { strictRoute: "relationship-timing-strict", eventJudgment: "event_judgment_marriage.md" }, wealth: { strictRoute: "wealth-timing-strict", eventJudgment: "event_judgment_wealth.md" }, timing: { strictRoute: "event-timing-strict", eventJudgment: null }, health: { strictRoute: "health-timing-strict", eventJudgment: null }, annual: { strictRoute: null, eventJudgment: null }, general: { strictRoute: null, eventJudgment: null }, education: { strictRoute: null, eventJudgment: null }, migration: { strictRoute: null, eventJudgment: null }, family: { strictRoute: null, eventJudgment: null }, }; const ROUTER_FILE = "references/strict-workflow-router.md"; const SHARED_BASELINE_HEADING = "Shared mandatory baseline"; const SKELETON_FILE = "references/event_judgment_skeleton.md"; /** Per-section and whole-block ceilings, so one long section cannot crowd out the evidence. */ export const METHODOLOGY_SECTION_MAX_CHARS = 6_000; export const METHODOLOGY_TOTAL_MAX_CHARS = 24_000; /** How many of the references SKILL.md names are offered as a navigable index. */ export const METHODOLOGY_INDEX_MAX_ENTRIES = 24; export const consultationMethodologySectionSchema = z.object({ title: z.string().max(200), source: z.string().max(300), text: z.string().max(METHODOLOGY_SECTION_MAX_CHARS), }).strict(); export const consultationMethodologySchema = z.object({ skill: z.string().max(120), version: z.string().max(60), /** Domains whose strict checklist the router does not declare; nothing was substituted for them. */ domains_without_strict_checklist: z.array(z.enum(consultationDomainIds)).max(6), sections: z.array(consultationMethodologySectionSchema).max(12), /** The references SKILL.md itself names, for reading on purpose with skill_read. */ further_reading: z.array(z.string().max(300)).max(METHODOLOGY_INDEX_MAX_ENTRIES), truncated: z.boolean(), }).strict(); export type ConsultationMethodology = z.infer; const skill = resolveLiveJyotishSkill(); /** Live skill files are reread only after process restart; consult requests share one cache. */ const fileCache = new Map(); function packageFile(relativePath: string): string | null { const hit = fileCache.get(relativePath); if (hit !== undefined) return hit; let content: string | null = null; try { content = readFileSync(resolve(skill.resolvedPath, relativePath), "utf8"); } catch { content = null; } fileCache.set(relativePath, content); return content; } /** * The body of one `## ...` section, found by a heading substring so the section * numbering in the router can be renumbered without breaking the lookup. */ export function markdownSection(source: string, headingContains: string): string | null { const lines = source.split("\n"); const start = lines.findIndex((line) => line.startsWith("## ") && line.includes(headingContains)); if (start === -1) return null; const rest = lines.slice(start + 1); const end = rest.findIndex((line) => line.startsWith("## ")); const body = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim(); return body.length > 0 ? `${lines[start]}\n\n${body}` : null; } function clamp(text: string) { return text.length > METHODOLOGY_SECTION_MAX_CHARS ? `${text.slice(0, METHODOLOGY_SECTION_MAX_CHARS - 3)}...` : text; } /** * The reference paths SKILL.md names, in the order it names them. * * Derived rather than listed so that the index follows the skill: when SKILL.md * starts pointing somewhere else, the model is offered the new pointer without * this file being edited. Paths that are not in the package are dropped, so a * stale pointer cannot be advertised as readable. */ function namedReferences(instructions: string): string[] { const found = new Set(); for (const match of instructions.matchAll(/references\/[\w./-]*[\w/-]/g)) { const path = match[0].replace(/[.]+$/, ""); if (path.includes("..") || !/\.\w+$/.test(path)) continue; if (packageFile(path) === null) continue; found.add(path); if (found.size >= METHODOLOGY_INDEX_MAX_ENTRIES) break; } return [...found]; } const planCache = new Map(); /** * The strict method for one domain plan, read from the live skill tree. */ export function consultationMethodologyForDomains( domains: readonly ConsultationDomain[], ): ConsultationMethodology | null { const unique = [...new Set(domains)]; const key = unique.join(","); const hit = planCache.get(key); if (hit) return hit; const router = packageFile(ROUTER_FILE); const instructions = packageFile("SKILL.md"); if (!router || !instructions) return null; const sections: z.infer[] = []; let budget = METHODOLOGY_TOTAL_MAX_CHARS; let truncated = false; const push = (title: string, source: string, text: string | null) => { if (!text) return; const bounded = clamp(text); if (bounded.length > budget) { truncated = true; return; } budget -= bounded.length; sections.push({ title, source, text: bounded }); }; push("Shared mandatory baseline", ROUTER_FILE, markdownSection(router, SHARED_BASELINE_HEADING)); push("Full-spectrum invocation", ROUTER_FILE, markdownSection(router, "Full-Spectrum Invocation Contract")); push("Event judgment skeleton", SKELETON_FILE, packageFile(SKELETON_FILE)); const withoutChecklist: ConsultationDomain[] = []; for (const domain of unique) { const { strictRoute, eventJudgment } = domainMethodology[domain]; if (!strictRoute) { withoutChecklist.push(domain); } else { const section = markdownSection(router, strictRoute); if (section) { push(`${consultationDomainDefinition(domain).label} · ${strictRoute}`, ROUTER_FILE, section); } else { withoutChecklist.push(domain); } } if (eventJudgment) { push(`${consultationDomainDefinition(domain).label} · event judgment`, `references/${eventJudgment}`, packageFile(`references/${eventJudgment}`)); } } const methodology = consultationMethodologySchema.parse({ skill: skill.name, version: skill.version, domains_without_strict_checklist: withoutChecklist, sections, further_reading: namedReferences(instructions), truncated, }); planCache.set(key, methodology); return methodology; }