CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
skill-utils.ts280 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace