import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import type { Rule } from "../../capability/rule";
import { buildRuleFromMarkdown, createSourceMeta } from "../../discovery/helpers";
import { TtsrManager, type TtsrMatchContext } from "../../export/ttsr";
export interface ParsedGeneratedRule {
rule: Rule;
fileContent: string;
}
export type GeneratedRuleParseResult = ParsedGeneratedRule | { error: string };
export interface RuleHistoryValidation {
matched: boolean;
feedback?: string;
}
export interface ParsedRuleHistoryValidation {
candidate: ParsedGeneratedRule;
validation: RuleHistoryValidation;
repairedCondition: boolean;
}
export type OmfgRuleSourceLevel = "project" | "user";
const JSON_FENCE_PATTERN = /```(?:json)?\s*([\s\S]*?)```/i;
export function extractGeneratedRuleJson(text: string): string | null {
const trimmed = text.trim();
const fenced = JSON_FENCE_PATTERN.exec(trimmed);
if (fenced?.[1]) {
const fencedObject = extractBalancedJsonObject(fenced[1]);
if (fencedObject) return fencedObject;
}
return extractBalancedJsonObject(trimmed);
}
export function sanitizeRuleName(rawName: string): string {
return rawName
.trim()
.toLowerCase()
.replace(/["'`]/g, "")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
export function buildOmfgRuleForPath(
ruleName: string,
fileContent: string,
filePath: string,
level: OmfgRuleSourceLevel,
): Rule {
return buildRuleFromMarkdown(ruleName, fileContent, filePath, createSourceMeta("omfg", filePath, level), {
ruleName,
});
}
function normalizeConditionRegexes(conditions: readonly string[]): { condition: string[] } | { error: string } {
const normalized: string[] = [];
for (const condition of conditions) {
const normalizedCondition = normalizeConditionRegex(condition);
if ("error" in normalizedCondition) {
return normalizedCondition;
}
if (!normalized.includes(normalizedCondition.condition)) {
normalized.push(normalizedCondition.condition);
}
}
return { condition: normalized };
}
function normalizeConditionRegex(condition: string): { condition: string } | { error: string } {
try {
new RegExp(condition);
return { condition };
} catch (originalError) {
const repaired = unescapeRegexConditionOnce(condition);
if (repaired !== condition) {
try {
new RegExp(repaired);
return { condition: repaired };
} catch {}
}
const message = originalError instanceof Error ? originalError.message : String(originalError);
return { error: `Invalid condition regex ${JSON.stringify(condition)}: ${message}` };
}
}
function unescapeRegexConditionOnce(condition: string): string {
return condition.replace(/\\\\/g, "\\");
}
export function parseGeneratedRule(text: string): GeneratedRuleParseResult {
const jsonText = extractGeneratedRuleJson(text);
if (!jsonText) {
return { error: "Missing generated rule JSON object" };
}
const payloadResult = parseGeneratedRulePayload(jsonText);
if ("error" in payloadResult) {
return payloadResult;
}
const ruleName = sanitizeRuleName(payloadResult.name);
if (ruleName.length === 0) {
return { error: "Rule name must contain at least one letter or digit" };
}
const conditionResult = normalizeConditionRegexes(payloadResult.condition);
if ("error" in conditionResult) {
return conditionResult;
}
const fileContent = assembleRuleMarkdown({
name: ruleName,
description: payloadResult.description,
condition: conditionResult.condition,
scope: payloadResult.scope,
body: payloadResult.body,
});
const virtualPath = path.join(process.cwd(), `${ruleName}.md`);
let rule: Rule;
try {
rule = buildOmfgRuleForPath(ruleName, fileContent, virtualPath, "project");
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
if (!rule.condition || rule.condition.length === 0) {
return { error: "Generated rule JSON must include at least one condition" };
}
for (const condition of rule.condition) {
if (isValidRegexCondition(condition) || isRepairableEscapedRegexCondition(condition)) {
continue;
}
try {
new RegExp(condition);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: `Invalid condition regex ${JSON.stringify(condition)}: ${message}` };
}
}
const manager = new TtsrManager();
if (!manager.addRule(rule)) {
return { error: "Rule has no valid condition or reachable scope" };
}
return { rule, fileContent };
}
interface GeneratedRulePayload {
name: string;
description: string;
condition: string[];
scope: string[];
body: string;
}
function extractBalancedJsonObject(text: string): string | null {
const start = text.indexOf("{");
if (start === -1) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const char = text[i];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth++;
continue;
}
if (char === "}") {
depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
}
return null;
}
function parseGeneratedRulePayload(jsonText: string): GeneratedRulePayload | { error: string } {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: `Generated rule JSON is invalid: ${message}` };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { error: "Generated rule JSON must be an object" };
}
const object = parsed as Record<string, unknown>;
const rawName = stringField(object, "name");
if (!rawName) {
return { error: "Generated rule JSON must include a non-empty name" };
}
const description = stringField(object, "description") ?? stringField(object, "desc");
if (!description) {
return { error: "Generated rule JSON must include a non-empty description" };
}
const condition = stringArrayField(object, "condition") ?? stringArrayField(object, "cond");
if (!condition || condition.length === 0) {
return { error: "Generated rule JSON must include at least one condition" };
}
const scope = stringArrayField(object, "scope");
if (!scope || scope.length === 0) {
return { error: "Generated rule JSON must include at least one scope" };
}
const body = stringField(object, "body");
if (!body) {
return { error: "Generated rule JSON must include a non-empty body" };
}
return {
name: rawName,
description,
condition,
scope,
body,
};
}
function stringField(object: Record<string, unknown>, key: string): string | undefined {
const value = object[key];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function stringArrayField(object: Record<string, unknown>, key: string): string[] | undefined {
const value = object[key];
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? [trimmed] : undefined;
}
if (!Array.isArray(value)) return undefined;
const items: string[] = [];
for (const item of value) {
if (typeof item !== "string") continue;
const trimmed = item.trim();
if (trimmed.length > 0 && !items.includes(trimmed)) {
items.push(trimmed);
}
}
return items.length > 0 ? items : undefined;
}
function assembleRuleMarkdown(payload: GeneratedRulePayload): string {
return [
"---",
`name: ${payload.name}`,
`description: ${JSON.stringify(payload.description)}`,
`condition: ${formatFrontmatterStringArray(payload.condition)}`,
`scope: ${formatFrontmatterStringArray(payload.scope)}`,
"---",
"",
payload.body.trim().replace(/\r\n?/g, "\n"),
].join("\n");
}
function formatFrontmatterStringArray(values: readonly string[]): string {
if (values.length === 1) {
return JSON.stringify(values[0]);
}
return `[${values.map(value => JSON.stringify(value)).join(", ")}]`;
}
interface HistorySurface {
text: string;
label: string;
context: TtsrMatchContext;
}
function collectAssistantSurfaces(messages: readonly AgentMessage[]): HistorySurface[] {
const surfaces: HistorySurface[] = [];
for (const message of messages) {
if (!isAssistantMessage(message)) continue;
for (let index = 0; index < message.content.length; index++) {
const block = message.content[index];
if (block.type === "text") {
surfaces.push({
text: block.text,
label: "assistant text",
context: { source: "text" },
});
continue;
}
if (block.type === "thinking") {
surfaces.push({
text: block.thinking,
label: "assistant thinking",
context: { source: "thinking" },
});
continue;
}
if (block.type === "toolCall") {
const filePaths = extractArgPaths(block.arguments);
surfaces.push({
text: stringifyToolArguments(block.arguments),
label: formatToolSurfaceLabel(block.name, filePaths),
context: {
source: "tool",
toolName: block.name,
filePaths,
streamKey: block.id ? `toolcall:${block.id}` : `tool:${block.name}:${index}`,
},
});
}
}
}
return surfaces;
}
export function validateRuleAgainstAssistantHistory(
rule: Rule,
messages: readonly AgentMessage[],
): RuleHistoryValidation {
const manager = new TtsrManager();
if (!manager.addRule(rule)) {
return {
matched: false,
feedback: "TTSR rejected the rule: it has no valid condition or its scope cannot reach any stream.",
};
}
const surfaces = collectAssistantSurfaces(messages);
const matches: HistorySurface[] = [];
for (const surface of surfaces) {
manager.resetBuffer();
if (surface.text.length > 0 && manager.checkDelta(surface.text, surface.context).length > 0) {
matches.push(surface);
}
}
if (matches.length === 0) {
return { matched: false, feedback: buildNoMatchFeedback(rule, surfaces) };
}
const scopeFeedback = buildScopeFeedback(rule, matches);
if (scopeFeedback) {
return { matched: false, feedback: scopeFeedback };
}
return { matched: true };
}
export function validateParsedRuleAgainstAssistantHistory(
candidate: ParsedGeneratedRule,
messages: readonly AgentMessage[],
): ParsedRuleHistoryValidation {
const validation = validateRuleAgainstAssistantHistory(candidate.rule, messages);
if (validation.matched) {
return { candidate, validation, repairedCondition: false };
}
const repaired = repairEscapedConditions(candidate);
if (!repaired) {
return { candidate, validation, repairedCondition: false };
}
const repairedValidation = validateRuleAgainstAssistantHistory(repaired.rule, messages);
if (repairedValidation.matched) {
return { candidate: repaired, validation: repairedValidation, repairedCondition: true };
}
return { candidate, validation, repairedCondition: false };
}
export function ruleMatchesAssistantHistory(rule: Rule, messages: readonly AgentMessage[]): boolean {
return validateRuleAgainstAssistantHistory(rule, messages).matched;
}
function isValidRegexCondition(condition: string): boolean {
try {
new RegExp(condition);
return true;
} catch {
return false;
}
}
function isRepairableEscapedRegexCondition(condition: string): boolean {
const repaired = condition.replace(/\\\\/g, "\\");
return repaired !== condition && isValidRegexCondition(repaired);
}
function repairEscapedConditions(candidate: ParsedGeneratedRule): ParsedGeneratedRule | undefined {
const currentConditions = candidate.rule.condition;
if (!currentConditions || currentConditions.length === 0) return undefined;
const repairedConditions: string[] = [];
let changed = false;
for (const condition of currentConditions) {
const repaired = condition.replace(/\\\\/g, "\\");
repairedConditions.push(repaired);
if (repaired !== condition) {
changed = true;
}
}
if (!changed) return undefined;
const scope = candidate.rule.scope;
if (!scope || scope.length === 0) return undefined;
const fileContent = assembleRuleMarkdown({
name: candidate.rule.name,
description: candidate.rule.description ?? candidate.rule.name,
condition: repairedConditions,
scope,
body: candidate.rule.content,
});
const level = candidate.rule._source.level === "user" ? "user" : "project";
return {
rule: buildOmfgRuleForPath(candidate.rule.name, fileContent, candidate.rule.path, level),
fileContent,
};
}
function buildNoMatchFeedback(rule: Rule, surfaces: readonly HistorySurface[]): string {
const hints = extractConditionHints(rule.condition);
const lines = [
`No assistant history surface matched condition ${formatRuleList(rule.condition)} within scope ${formatRuleList(rule.scope)}.`,
];
if (surfaces.length === 0) {
lines.push("No assistant text, thinking, or tool-call argument surfaces were available to check.");
return lines.join("\n");
}
lines.push("Checked surfaces:");
const max = Math.min(surfaces.length, 5);
for (let i = 0; i < max; i++) {
const surface = surfaces[i];
lines.push(`- ${surface.label}: ${JSON.stringify(excerptForSurface(surface.text, hints))}`);
}
if (surfaces.length > max) {
lines.push(`- ... ${surfaces.length - max} more surface(s)`);
}
lines.push(
'If the visible bad code contains quotes, remember tool arguments are checked as serialized JSON, so quotes may appear as escaped sequences such as \\".',
);
lines.push("If the condition looks right, fix the scope so it reaches the offending tool and file glob.");
return lines.join("\n");
}
function buildScopeFeedback(rule: Rule, matches: readonly HistorySurface[]): string | undefined {
const toolMatch = findFileToolMatch(matches);
if (!toolMatch) return undefined;
const recommendedScope = recommendedToolScope(toolMatch);
if (!recommendedScope) return undefined;
const scope = rule.scope ?? [];
let hasBroadToolScope = scope.length === 0;
let hasTextScope = false;
for (const rawToken of scope) {
const token = rawToken.trim().toLowerCase();
if (token === "tool" || token === "toolcall") {
hasBroadToolScope = true;
continue;
}
if (token === "text") {
hasTextScope = true;
}
}
if (!hasBroadToolScope && !hasTextScope) {
return undefined;
}
const problems: string[] = [];
if (hasBroadToolScope) {
problems.push(`scope ${formatRuleList(rule.scope)} is broader than the matching file-specific tool call`);
}
if (hasTextScope) {
problems.push("scope includes `text`, but the offending content was confirmed in tool arguments");
}
return `The condition matched ${toolMatch.label}, but ${problems.join("; ")}. Use a narrow scope such as ${JSON.stringify(
recommendedScope,
)} and do not repeat the failed scope ${formatRuleList(rule.scope)}.`;
}
function findFileToolMatch(matches: readonly HistorySurface[]): HistorySurface | undefined {
for (const match of matches) {
if (match.context.source !== "tool") continue;
if (!match.context.toolName) continue;
if (!extensionGlob(match.context.filePaths)) continue;
return match;
}
return undefined;
}
function recommendedToolScope(surface: HistorySurface): string | undefined {
const toolName = surface.context.toolName;
const glob = extensionGlob(surface.context.filePaths);
if (!toolName || !glob) return undefined;
return `tool:${toolName}(${glob})`;
}
function extensionGlob(filePaths: readonly string[] | undefined): string | undefined {
for (const filePath of filePaths ?? []) {
const extension = path.extname(filePath.replaceAll("\\", "/")).toLowerCase();
if (extension.length > 1) {
return `*${extension}`;
}
}
return undefined;
}
function formatToolSurfaceLabel(toolName: string, filePaths: readonly string[] | undefined): string {
if (!filePaths || filePaths.length === 0) {
return `tool:${toolName} serialized arguments`;
}
return `tool:${toolName}(${filePaths.join(", ")}) serialized arguments`;
}
function formatRuleList(values: readonly string[] | undefined): string {
if (!values || values.length === 0) {
return "<default>";
}
return values.map(value => JSON.stringify(value)).join(", ");
}
function extractConditionHints(conditions: readonly string[] | undefined): string[] {
const hints: string[] = [];
for (const condition of conditions ?? []) {
const matches = condition.match(/[A-Za-z_][A-Za-z0-9_]{2,}/g) ?? [];
for (const match of matches) {
const normalized = match.toLowerCase();
if (
normalized === "tool" ||
normalized === "text" ||
normalized === "any" ||
normalized === "true" ||
normalized === "false"
) {
continue;
}
if (!hints.includes(normalized)) {
hints.push(normalized);
}
}
}
return hints;
}
function excerptForSurface(text: string, hints: readonly string[]): string {
const normalized = text.replace(/\s+/g, " ");
if (normalized.length <= 260) {
return normalized;
}
const lower = normalized.toLowerCase();
let bestIndex = -1;
for (const hint of hints) {
const index = lower.indexOf(hint);
if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
bestIndex = index;
}
}
if (bestIndex === -1) {
return `${normalized.slice(0, 260)}…`;
}
const start = Math.max(0, bestIndex - 120);
const end = Math.min(normalized.length, bestIndex + 140);
const prefix = start > 0 ? "…" : "";
const suffix = end < normalized.length ? "…" : "";
return `${prefix}${normalized.slice(start, end)}${suffix}`;
}
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
const candidate = message as { role?: unknown; content?: unknown };
return candidate.role === "assistant" && Array.isArray(candidate.content);
}
function stringifyToolArguments(args: unknown): string {
try {
const text = JSON.stringify(args);
return typeof text === "string" ? text : "";
} catch {
return "";
}
}
function extractArgPaths(args: unknown): string[] | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return undefined;
}
const paths: string[] = [];
for (const key in args as Record<string, unknown>) {
const value = (args as Record<string, unknown>)[key];
const normalizedKey = key.toLowerCase();
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
paths.push(value);
continue;
}
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
for (const candidate of value) {
if (typeof candidate === "string") {
paths.push(candidate);
}
}
}
}
const uniquePaths: string[] = [];
for (const candidate of paths) {
if (!uniquePaths.includes(candidate)) {
uniquePaths.push(candidate);
}
}
return uniquePaths.length > 0 ? uniquePaths : undefined;
}