/**
 * Pure index-filtering + prompt-index formatting helpers.
 *
 * Matching semantics are ported from curator's battle-tested
 * `searchSkillsFromIndex`: case-insensitive substring match on
 * name/displayName/description, optional tag filter on top, scope filter
 * that always admits global skills, and active-only visibility.
 */
/** Strip instructions from a full definition to form an index entry. */
export function toSkillIndexItem(skill) {
    const { instructions: _instructions, ...indexItem } = skill;
    return indexItem;
}
/**
 * Sort index entries by name (byte order, locale-independent) so every
 * listing renders byte-identically across calls and store backends.
 * Store enumeration order (Redis SCAN, fs.readdir, S3 listings) is not
 * stable — an unsorted listing would shuffle between calls and invalidate
 * provider prompt caches. Returns a new array.
 */
export function sortSkillIndex(items) {
    return [...items].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
}
/** Filter index entries by query/tag/scope. Active skills only. */
export function filterSkillIndex(items, query) {
    const lowerQuery = query.query?.toLowerCase();
    const lowerTag = query.tag?.toLowerCase();
    const matched = items.filter((item) => {
        if ((item.status ?? "active") !== "active") {
            return false;
        }
        // Scope filter: global skills always pass. Scoped skills require a
        // scopeId that matches; when the caller supplies no scopeId we fail
        // closed and exclude them, so a shared multi-tenant instance that forgets
        // to pass a per-call scopeId never leaks one tenant's scoped skills to
        // everyone (#1139). Callers that legitimately want a tenant's scoped
        // skills must pass that tenant's scopeId.
        if (item.scope === "scoped") {
            if (!query.scopeId || !(item.scopeIds ?? []).includes(query.scopeId)) {
                return false;
            }
        }
        if (lowerQuery) {
            const nameMatch = item.name.toLowerCase().includes(lowerQuery) ||
                (item.displayName ?? "").toLowerCase().includes(lowerQuery) ||
                item.description.toLowerCase().includes(lowerQuery);
            if (!nameMatch) {
                return false;
            }
        }
        if (lowerTag) {
            const tagMatch = (item.tags ?? []).some((t) => t.toLowerCase().includes(lowerTag));
            if (!tagMatch) {
                return false;
            }
        }
        return true;
    });
    return query.limit !== undefined ? matched.slice(0, query.limit) : matched;
}
/**
 * Reject resource paths that could escape a skill's namespace: absolute
 * paths, traversal segments, and empty segments. The manager validates
 * before reaching any store — this is defense in depth for stores whose
 * keys are built by concatenation (S3, Redis) or path joining.
 */
export function isSafeSkillResourcePath(resourcePath) {
    const normalized = resourcePath.replace(/\\/g, "/");
    return (normalized.length > 0 &&
        !normalized.startsWith("/") &&
        !normalized.split("/").some((segment) => segment === ".." || segment === ""));
}
/**
 * Whether a skill is visible from the calling scope. Fails closed, matching
 * `filterSkillIndex`: a scoped skill is hidden unless the caller supplies a
 * `scopeId` that the skill explicitly lists. Omitting `scopeId` therefore
 * yields global skills only — it must never expose a scoped skill by name
 * through `use_skill` / `read_skill_resource` (#1139).
 */
export function isSkillVisibleInScope(skill, scopeId) {
    if (skill.scope !== "scoped") {
        return true;
    }
    if (!scopeId) {
        return false;
    }
    return (skill.scopeIds ?? []).includes(scopeId);
}
/** Trim a description to its first sentence (or the whole text when unsplittable). */
function firstSentence(description) {
    const match = description.match(/^[^.!?]*[.!?]/);
    return match ? match[0].trim() : description;
}
/**
 * Render the `<available_skills>` block embedded in the use_skill tool
 * description ("tool" discovery mode). One line per skill — name,
 * description, tags — instructions never appear here.
 *
 * Budget handling is uniform and stateless: over budget, every
 * description drops to its first sentence, then to a hard 80-char cap.
 * The render is a pure function of the (sorted) index, so the tool
 * description stays byte-identical across calls — a prerequisite for
 * provider prompt caching. Names are never dropped.
 */
export function renderSkillListing(items, budgetChars) {
    if (items.length === 0) {
        return null;
    }
    const render = (describe) => {
        const lines = items.map((item) => {
            const tags = item.tags && item.tags.length > 0
                ? ` [tags: ${item.tags.join(", ")}]`
                : "";
            return `- ${item.name}: ${describe(item)}${tags}`;
        });
        return ["<available_skills>", ...lines, "</available_skills>"].join("\n");
    };
    const full = render((item) => item.description);
    if (full.length <= budgetChars) {
        return full;
    }
    const sentences = render((item) => firstSentence(item.description));
    if (sentences.length <= budgetChars) {
        return sentences;
    }
    const HARD_CAP = 80;
    // Floor render — returned even if it still exceeds the budget.
    return render((item) => {
        const sentence = firstSentence(item.description);
        return sentence.length > HARD_CAP
            ? `${sentence.slice(0, HARD_CAP - 1)}…`
            : sentence;
    });
}
/**
 * Render the compact skills index appended to the system prompt
 * ("system-prompt" discovery mode). Names + descriptions only —
 * instructions are never included; the model loads them via use_skill.
 * Returns null when nothing is visible so callers can skip injection.
 */
export function formatSkillsPromptIndex(items, maxItems) {
    // Nothing visible, or the index is explicitly disabled (maxItems <= 0):
    // skip injection entirely rather than emit a header + "N more skills exist"
    // note with zero skill lines (#1139).
    if (items.length === 0 || maxItems <= 0) {
        return null;
    }
    const visible = items.slice(0, maxItems);
    const lines = visible.map((item) => {
        const label = item.displayName
            ? `${item.name} (${item.displayName})`
            : item.name;
        const tags = item.tags && item.tags.length > 0
            ? ` [tags: ${item.tags.join(", ")}]`
            : "";
        return `- ${label}: ${item.description}${tags}`;
    });
    const truncationNote = items.length > visible.length
        ? `\n(${items.length - visible.length} more skills exist — use list_skills to discover them.)`
        : "";
    return ([
        "## Available Skills",
        "The following team-defined skills (SOPs, playbooks, workflows) are available.",
        "Before answering from general knowledge, check whether one applies to the user's request.",
        "To use a skill, call the use_skill tool with its name to load the full instructions, then follow them exactly.",
        "",
        ...lines,
    ].join("\n") + truncationNote);
}
//# sourceMappingURL=skillMatcher.js.map