mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
291 lines
7.4 KiB
TypeScript
291 lines
7.4 KiB
TypeScript
import { dirname } from "node:path";
|
|
|
|
import type { PathNormalizer } from "./path-normalizer";
|
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
|
|
/**
|
|
* Narrow interface for the raw (no-session-rules) permission checker used by
|
|
* skill prompt resolution. `PermissionResolver` implements it (#478).
|
|
*/
|
|
export interface SkillPermissionChecker {
|
|
checkPermission(
|
|
surface: string,
|
|
input: unknown,
|
|
agentName?: string,
|
|
): PermissionCheckResult;
|
|
}
|
|
|
|
const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
|
|
const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
|
|
const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
|
|
const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
|
|
const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
|
|
const SKILL_LOCATION_REGEX = /<location>([\s\S]*?)<\/location>/;
|
|
|
|
type ParsedSkillPromptEntry = {
|
|
name: string;
|
|
description: string;
|
|
location: string;
|
|
};
|
|
|
|
export type SkillPromptEntry = {
|
|
name: string;
|
|
description: string;
|
|
location: string;
|
|
state: PermissionState;
|
|
normalizedLocation: string;
|
|
normalizedBaseDir: string;
|
|
};
|
|
|
|
export type SkillPromptSection = {
|
|
start: number;
|
|
end: number;
|
|
entries: ParsedSkillPromptEntry[];
|
|
};
|
|
|
|
function decodeXml(value: string): string {
|
|
return value
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, "&");
|
|
}
|
|
|
|
function encodeXml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
const entries: ParsedSkillPromptEntry[] = [];
|
|
const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
|
|
|
|
for (const match of sectionBody.matchAll(skillBlockRegex)) {
|
|
const block = match[1];
|
|
const nameMatch = SKILL_NAME_REGEX.exec(block);
|
|
const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
|
|
const locationMatch = SKILL_LOCATION_REGEX.exec(block);
|
|
|
|
if (!nameMatch || !descriptionMatch || !locationMatch) {
|
|
continue;
|
|
}
|
|
|
|
const name = decodeXml(nameMatch[1].trim());
|
|
const description = decodeXml(descriptionMatch[1].trim());
|
|
const location = decodeXml(locationMatch[1].trim());
|
|
|
|
if (!name || !location) {
|
|
continue;
|
|
}
|
|
|
|
entries.push({ name, description, location });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
export function parseAllSkillPromptSections(
|
|
prompt: string,
|
|
): SkillPromptSection[] {
|
|
const sections: SkillPromptSection[] = [];
|
|
let searchStart = 0;
|
|
|
|
while (searchStart < prompt.length) {
|
|
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart);
|
|
if (start === -1) {
|
|
break;
|
|
}
|
|
|
|
const closeStart = prompt.indexOf(
|
|
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
);
|
|
if (closeStart === -1) {
|
|
break;
|
|
}
|
|
|
|
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
|
|
const sectionBody = prompt.slice(
|
|
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
closeStart,
|
|
);
|
|
sections.push({
|
|
start,
|
|
end,
|
|
entries: parseSkillEntries(sectionBody),
|
|
});
|
|
searchStart = end;
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function resolvePermissionState(
|
|
skillName: string,
|
|
permissionManager: SkillPermissionChecker,
|
|
agentName: string | null,
|
|
cache: Map<string, PermissionState>,
|
|
): PermissionState {
|
|
const cachedState = cache.get(skillName);
|
|
if (cachedState) {
|
|
return cachedState;
|
|
}
|
|
|
|
const state = permissionManager.checkPermission(
|
|
"skill",
|
|
{ name: skillName },
|
|
agentName ?? undefined,
|
|
).state;
|
|
cache.set(skillName, state);
|
|
return state;
|
|
}
|
|
|
|
function createResolvedSkillEntry(
|
|
entry: ParsedSkillPromptEntry,
|
|
state: PermissionState,
|
|
normalizer: PathNormalizer,
|
|
): SkillPromptEntry {
|
|
return {
|
|
name: entry.name,
|
|
description: entry.description,
|
|
location: entry.location,
|
|
state,
|
|
normalizedLocation: normalizer.comparableValue(entry.location),
|
|
normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
|
|
};
|
|
}
|
|
|
|
function renderAvailableSkillsSection(
|
|
entries: readonly SkillPromptEntry[],
|
|
): string {
|
|
return [
|
|
AVAILABLE_SKILLS_OPEN_TAG,
|
|
...entries.flatMap((entry) => [
|
|
" <skill>",
|
|
` <name>${encodeXml(entry.name)}</name>`,
|
|
` <description>${encodeXml(entry.description)}</description>`,
|
|
` <location>${encodeXml(entry.location)}</location>`,
|
|
" </skill>",
|
|
]),
|
|
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
].join("\n");
|
|
}
|
|
|
|
function removePromptRange(prompt: string, start: number, end: number): string {
|
|
const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
|
|
const afterSection = prompt.slice(end);
|
|
return `${beforeSection}${afterSection}`;
|
|
}
|
|
|
|
export function resolveSkillPromptEntries(
|
|
prompt: string,
|
|
permissionManager: SkillPermissionChecker,
|
|
agentName: string | null,
|
|
normalizer: PathNormalizer,
|
|
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
const sections = parseAllSkillPromptSections(prompt);
|
|
if (sections.length === 0) {
|
|
return { prompt, entries: [] };
|
|
}
|
|
|
|
const permissionCache = new Map<string, PermissionState>();
|
|
const visibleEntries: SkillPromptEntry[] = [];
|
|
const replacements: Array<{ start: number; end: number; content: string }> =
|
|
[];
|
|
|
|
for (const section of sections) {
|
|
const resolvedEntries = section.entries.map((entry) => {
|
|
const state = resolvePermissionState(
|
|
entry.name,
|
|
permissionManager,
|
|
agentName,
|
|
permissionCache,
|
|
);
|
|
return createResolvedSkillEntry(entry, state, normalizer);
|
|
});
|
|
|
|
const visibleSectionEntries = resolvedEntries.filter(
|
|
(entry) => entry.state !== "deny",
|
|
);
|
|
visibleEntries.push(...visibleSectionEntries);
|
|
|
|
if (visibleSectionEntries.length === resolvedEntries.length) {
|
|
continue;
|
|
}
|
|
|
|
replacements.push({
|
|
start: section.start,
|
|
end: section.end,
|
|
content:
|
|
visibleSectionEntries.length > 0
|
|
? renderAvailableSkillsSection(visibleSectionEntries)
|
|
: "",
|
|
});
|
|
}
|
|
|
|
if (replacements.length === 0) {
|
|
return { prompt, entries: visibleEntries };
|
|
}
|
|
|
|
let sanitizedPrompt = prompt;
|
|
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
const replacement = replacements[i];
|
|
sanitizedPrompt =
|
|
replacement.content.length > 0
|
|
? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
|
|
: removePromptRange(
|
|
sanitizedPrompt,
|
|
replacement.start,
|
|
replacement.end,
|
|
);
|
|
}
|
|
|
|
return {
|
|
prompt: sanitizedPrompt,
|
|
entries: visibleEntries,
|
|
};
|
|
}
|
|
|
|
export function findSkillPathMatch(
|
|
normalizedPath: string,
|
|
entries: readonly SkillPromptEntry[],
|
|
normalizer: PathNormalizer,
|
|
): SkillPromptEntry | null {
|
|
if (!normalizedPath || entries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (
|
|
entry.normalizedLocation &&
|
|
normalizedPath === entry.normalizedLocation
|
|
) {
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
let bestMatch: SkillPromptEntry | null = null;
|
|
for (const entry of entries) {
|
|
if (
|
|
!entry.normalizedBaseDir ||
|
|
!normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!bestMatch ||
|
|
entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length
|
|
) {
|
|
bestMatch = entry;
|
|
}
|
|
}
|
|
|
|
return bestMatch;
|
|
}
|