Files
Jyotisha/frontend/src/lib/consultation-methodology.ts
T
Jesse_Chen d35828e76d
Independent Staging Quality Gate / validate (push) Successful in 9m18s
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(consult): read the live skill tree instead of a hash-pinned snapshot
Manual SKILL.md updates were blocked by registry sha256 and a byte-equal versions/ gate. Consult now loads the operator-maintained tree; rectification and personal-report stay hashed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:25:55 +08:00

192 lines
7.8 KiB
TypeScript

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<Record<ConsultationDomain, { strictRoute: string | null; eventJudgment: string | null }>> = {
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<typeof consultationMethodologySchema>;
const skill = resolveLiveJyotishSkill();
/** Live skill files are reread only after process restart; consult requests share one cache. */
const fileCache = new Map<string, string | null>();
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<string>();
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<string, ConsultationMethodology>();
/**
* 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<typeof consultationMethodologySectionSchema>[] = [];
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;
}