basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { PermissionManager } from '../permissions/permission-manager.js';8import type { Config } from '../config/config.js';9import type { SkillManager } from '../skills/skill-manager.js';10import type { SkillConfig, SkillLevel } from '../skills/types.js';11import { escapeXml } from '../utils/xml.js';12 13/**14 * Builds the LLM-facing content string when a skill body is injected.15 * Shared between SkillToolInvocation (runtime) and /context (estimation)16 * so that token estimates stay in sync with actual usage.17 */18export function buildSkillLlmContent(baseDir: string, body: string): string {19 return `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${body}\n`;20}21 22/**23 * One model-facing skill/command entry, normalized so file-based skills and24 * model-invocable commands (MCP prompts / file commands) render through a single25 * code path. `level` is present only for file-based skills — when set, the26 * rendered entry carries a `(level)` suffix and a <location> tag (matching the27 * legacy `SkillTool.updateDescriptionAndSchema` output); commands omit both.28 */29export interface AvailableSkillEntry {30 name: string;31 description: string;32 whenToUse?: string;33 level?: SkillLevel;34}35 36/**37 * Result of `collectAvailableSkillEntries`. The first three fields back38 * `SkillTool.validateToolParams` (in-memory only — never serialized into a39 * request, so refreshing them is prompt-cache-neutral); `entries` feeds the40 * pure `renderAvailableSkillsBlock`.41 */42export interface CollectedAvailableSkills {43 /** Active, model-invocable file-based skills. */44 availableSkills: SkillConfig[];45 /**46 * Conditional skills (`paths:` frontmatter) that exist but are not yet47 * activated — tracked so validation can distinguish "gated by paths:" from48 * "not found".49 */50 pendingConditionalSkillNames: Set<string>;51 /** Model-invocable commands, deduped against file-based skill names. */52 modelInvocableCommands: ReadonlyArray<{ name: string; description: string }>;53 /** Normalized entries, ready for `renderAvailableSkillsBlock`. */54 entries: AvailableSkillEntry[];55}56 57/**58 * Short-lived memo cache for `collectAvailableSkillEntries`. Keyed by59 * `SkillManager` instance so independent managers (e.g. in tests) don't60 * share results. Each entry stores the in-flight or resolved promise and a61 * monotonic timestamp; entries older than `COLLECT_CACHE_TTL_MS` are62 * discarded on the next call.63 */64interface CachedCollect {65 promise: Promise<CollectedAvailableSkills>;66 ts: number;67}68 69let collectCache = new WeakMap<SkillManager, CachedCollect>();70 71/** Cache lifetime in milliseconds. */72const COLLECT_CACHE_TTL_MS = 2_000;73 74/**75 * Evict any cached result for the given manager, or reset the entire cache76 * when called without an argument. Exported for tests and explicit77 * invalidation hooks.78 */79export function clearCollectedSkillEntriesCache(80 skillManager?: SkillManager,81): void {82 if (skillManager) {83 collectCache.delete(skillManager);84 } else {85 // Replace the WeakMap entirely to clear all entries.86 collectCache = new WeakMap();87 }88}89 90/**91 * Collects the model-facing skill set — active file-based skills + model-invocable92 * commands — applying the same filtering/dedup rules `SkillTool.refreshSkills`93 * used to apply inline. Stateful/async (reads `SkillManager` + `Config`). The94 * returned validation fields and the `entries` list are always consistent, so95 * the Skill tool, the startup snapshot, and activation reminders share identical96 * bytes from one source.97 *98 * Results are memoized for up to 2 s per `SkillManager` instance so that99 * near-simultaneous startup callers (SkillTool, drainSkillAndCommandReminders,100 * buildAvailableSkillsReminder, coreToolScheduler) share a single scan.101 */102export async function collectAvailableSkillEntries(103 skillManager: SkillManager,104 config: Config,105): Promise<CollectedAvailableSkills> {106 const cached = collectCache.get(skillManager);107 if (cached && Date.now() - cached.ts < COLLECT_CACHE_TTL_MS) {108 return cached.promise;109 }110 111 const promise = collectAvailableSkillEntriesUncached(skillManager, config);112 collectCache.set(skillManager, { promise, ts: Date.now() });113 114 // If the underlying scan fails, evict the cache so the next caller retries115 // instead of getting a cached rejection.116 promise.catch(() => {117 const entry = collectCache.get(skillManager);118 if (entry?.promise === promise) {119 collectCache.delete(skillManager);120 }121 });122 123 return promise;124}125 126/** Uncached implementation — see `collectAvailableSkillEntries` for the127 * memoized public API. */128async function collectAvailableSkillEntriesUncached(129 skillManager: SkillManager,130 config: Config,131): Promise<CollectedAvailableSkills> {132 // Include a skill only when (a) it is not hidden from the model133 // (`disable-model-invocation`), (b) it is not user-disabled via134 // `skills.disabled`, and (c) it is unconditional or already activated by a135 // matching file path this session. Keeps the listing small in large monorepos136 // where most conditional skills are not yet relevant.137 const allSkills = await skillManager.listSkills();138 const disabledNames = config.getDisabledSkillNames();139 const isDisabled = (name: string) => disabledNames.has(name.toLowerCase());140 141 const availableSkills = allSkills.filter(142 (s) =>143 !s.disableModelInvocation &&144 skillManager.isSkillActive(s) &&145 !isDisabled(s.name),146 );147 148 // Track still-pending conditional skills so validation can emit a distinct149 // "gated by paths:" hint. Disabled conditional skills are excluded — no point150 // hinting at a skill the user explicitly hid.151 const pendingConditionalSkillNames = new Set(152 allSkills153 .filter(154 (s) =>155 !s.disableModelInvocation &&156 s.paths &&157 s.paths.length > 0 &&158 !skillManager.isSkillActive(s) &&159 !isDisabled(s.name),160 )161 .map((s) => s.name),162 );163 164 // Merge in model-invocable commands, excluding any whose name appears as a165 // model-invocable file-based skill (including pending conditional ones). Using166 // `availableSkills` here would let a path-gated skill leak through and bypass167 // the pendingConditionalSkillNames validation check. A skill marked168 // `disable-model-invocation` or user-disabled is intentionally hidden and must169 // not block an unrelated same-named command/MCP prompt, so it is excluded from170 // the dedup set.171 const provider = config.getModelInvocableCommandsProvider();172 const allCommands = provider ? provider() : [];173 const fileBasedSkillNames = new Set(174 allSkills175 .filter((s) => !s.disableModelInvocation && !isDisabled(s.name))176 .map((s) => s.name),177 );178 const modelInvocableCommands = allCommands.filter(179 (cmd) => !fileBasedSkillNames.has(cmd.name),180 );181 182 const entries: AvailableSkillEntry[] = [183 ...availableSkills.map((s) => ({184 name: s.name,185 description: s.description,186 whenToUse: s.whenToUse,187 level: s.level,188 })),189 ...modelInvocableCommands.map((c) => ({190 name: c.name,191 description: c.description,192 })),193 ];194 195 return {196 availableSkills,197 pendingConditionalSkillNames,198 modelInvocableCommands,199 entries,200 };201}202 203// File-based skills (with a `level`) first, then commands; each alphabetical by204// name. A deterministic order keeps the rendered block byte-stable across205// session-boundary rebuilds (resume / compaction) so it doesn't needlessly bust206// the prompt cache.207function compareSkillEntries(208 a: AvailableSkillEntry,209 b: AvailableSkillEntry,210): number {211 const aGroup = a.level !== undefined ? 0 : 1;212 const bGroup = b.level !== undefined ? 0 : 1;213 if (aGroup !== bGroup) return aGroup - bGroup;214 return a.name.localeCompare(b.name);215}216 217/**218 * Renders normalized skill entries into the `<available_skills>` body. Pure: no219 * I/O, no config — XML-escapes every untrusted field (extension/command names220 * bypass `validateSkillName`, so a crafted name could otherwise inject raw tags)221 * and emits a stable order. Returns '' when there are no entries; callers decide222 * the empty-state messaging.223 */224export function renderAvailableSkillsBlock(225 entries: AvailableSkillEntry[],226): string {227 return [...entries]228 .sort(compareSkillEntries)229 .map((entry) => {230 if (entry.level !== undefined) {231 const descText = `${escapeXml(entry.description)}${232 entry.whenToUse ? ` — ${escapeXml(entry.whenToUse)}` : ''233 } (${entry.level})`;234 return `<skill>235<name>236${escapeXml(entry.name)}237</name>238<description>239${descText}240</description>241<location>242${entry.level}243</location>244</skill>`;245 }246 return `<skill>247<name>248${escapeXml(entry.name)}249</name>250<description>251${escapeXml(entry.description)}252</description>253</skill>`;254 })255 .join('\n');256}257 258/**259 * Grants a skill's `allowedTools` as session-scoped permission allow rules.260 *261 * Each entry is a permission rule string in the same syntax as `settings.json`262 * `permissions.allow` (e.g. `Bash(git *)`, `Edit`, `mcp__server__tool`) and is263 * handed verbatim to the session allow list, so matching tool calls are264 * auto-approved for the rest of the session instead of prompting. This is an265 * additive grant only — it never hides or restricts the tools the model sees.266 *267 * No-ops when there is no permission manager or nothing to grant.268 */269export function applySkillAllowedTools(270 permissionManager: PermissionManager | null | undefined,271 allowedTools: string[] | undefined,272): void {273 if (!permissionManager || !allowedTools?.length) {274 return;275 }276 for (const rule of allowedTools) {277 permissionManager.addSessionAllowRule(rule);278 }279}280 