UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

1,439 lines • 59.9 kB
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
import fs from "node:fs";
import path from "node:path";
import { parseBundleRef } from "../../core/asset/asset-ref.js";
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
import { asNonEmptyString } from "../../core/common.js";
import { isVerbose, warn } from "../../core/warn.js";
export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
// ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
/**
 * Well-known quality values. `generated`, `curated`, and `enriched` are included in
 * default search; `proposed` is excluded by default and opt-in via
 * `--include-proposed`. Unknown values warn once and remain searchable.
 */
export const KNOWN_QUALITY_VALUES = new Set(["generated", "curated", "enriched", "proposed"]);
/** Tracks unknown quality values we've already warned about (one warn per value per process). */
const warnedUnknownQualityValues = new Set();
/**
 * Normalize a `quality` string off a stash entry. Known values pass through
 * untouched. Unknown values are accepted as-is (preserved verbatim on the
 * entry) but trigger a one-time warning per unique value via the shared
 * `warn()` helper (honours --quiet / `setQuiet()`).
 */
export function normalizeQuality(raw) {
    if (KNOWN_QUALITY_VALUES.has(raw))
        return raw;
    if (!warnedUnknownQualityValues.has(raw)) {
        warnedUnknownQualityValues.add(raw);
        warn(`Warning: unknown quality value "${raw}" — entry remains searchable, but consider using "generated", "curated", or "proposed" (v1 spec §4.2).`);
    }
    return raw;
}
/**
 * Test-only: clear the per-process unknown-quality warning memo so a test
 * can re-trigger the warning. Not part of the public API.
 */
export function _resetUnknownQualityWarnings() {
    warnedUnknownQualityValues.clear();
}
/**
 * Returns true if an entry's quality marks it as "proposed". Proposed
 * entries are excluded from default search per v1 spec §4.2.
 */
export function isProposedQuality(quality) {
    return quality === "proposed";
}
/**
 * Validate and normalize a raw object into a `IndexDocument`.
 *
 * Open type token: `entry.type` accepts any non-empty string. Type ownership
 * and capability decisions belong to the adapter; this format-neutral
 * projection must not reject a value merely because AKM does not own it.
 */
export function validateStashEntry(entry) {
    if (typeof entry !== "object" || entry === null)
        return null;
    const e = entry;
    if (typeof e.name !== "string" || !e.name)
        return null;
    if (typeof e.type !== "string" || !e.type)
        return null;
    const result = {
        name: e.name,
        type: e.type,
    };
    if (typeof e.description === "string" && e.description)
        result.description = e.description;
    if (Array.isArray(e.tags))
        result.tags = e.tags.filter((t) => typeof t === "string");
    if (Array.isArray(e.examples))
        result.examples = e.examples.filter((x) => typeof x === "string");
    if (Array.isArray(e.searchHints)) {
        const filtered = e.searchHints.filter((s) => typeof s === "string" && s.trim().length > 0);
        if (filtered.length > 0)
            result.searchHints = filtered;
    }
    if (typeof e.intent === "object" && e.intent !== null) {
        const intent = e.intent;
        result.intent = {};
        if (typeof intent.when === "string")
            result.intent.when = intent.when;
        if (typeof intent.input === "string")
            result.intent.input = intent.input;
        if (typeof intent.output === "string")
            result.intent.output = intent.output;
    }
    if (typeof e.filename === "string" && e.filename)
        result.filename = e.filename;
    if (typeof e.quality === "string" && e.quality.length > 0) {
        result.quality = normalizeQuality(e.quality);
    }
    if (typeof e.confidence === "number" && Number.isFinite(e.confidence))
        result.confidence = Math.max(0, Math.min(1, e.confidence));
    if (typeof e.source === "string" &&
        ["package", "frontmatter", "comments", "filename", "manual", "llm"].includes(e.source)) {
        result.source = e.source;
    }
    if (Array.isArray(e.aliases)) {
        const filtered = e.aliases.filter((a) => typeof a === "string" && a.trim().length > 0);
        if (filtered.length > 0)
            result.aliases = normalizeTerms(filtered);
    }
    if (Array.isArray(e.toc)) {
        const validated = e.toc.filter((h) => {
            if (typeof h !== "object" || h === null)
                return false;
            const rec = h;
            return typeof rec.level === "number" && typeof rec.text === "string" && typeof rec.line === "number";
        });
        if (validated.length > 0)
            result.toc = validated;
    }
    const usage = normalizeNonEmptyStringList(e.usage);
    if (usage)
        result.usage = usage;
    // SECURITY NOTE: run, setup, and cwd are advisory metadata fields for AI agent consumers.
    // They are NOT executed by akm directly. Consumers should validate and sanitize before execution.
    if (typeof e.run === "string" && e.run.trim())
        result.run = e.run.trim();
    if (typeof e.setup === "string" && e.setup.trim())
        result.setup = e.setup.trim();
    if (typeof e.cwd === "string" && e.cwd.trim())
        result.cwd = e.cwd.trim();
    if (typeof e.fileSize === "number" && Number.isFinite(e.fileSize) && e.fileSize >= 0)
        result.fileSize = e.fileSize;
    if (isWikiRole(e.wikiRole))
        result.wikiRole = e.wikiRole;
    if (typeof e.pageKind === "string" && e.pageKind.trim().length > 0) {
        result.pageKind = e.pageKind.trim();
    }
    const xrefs = normalizeNonEmptyStringList(e.xrefs);
    if (xrefs)
        result.xrefs = xrefs;
    const sources = normalizeNonEmptyStringList(e.sources);
    if (sources)
        result.sources = sources;
    // SPEC-6: `category` must survive the projection. Non-string values are
    // dropped, not coerced.
    if (typeof e.category === "string" && e.category.trim().length > 0) {
        result.category = e.category.trim();
    }
    if (typeof e.beliefState === "string" && e.beliefState.trim().length > 0) {
        result.beliefState = e.beliefState.trim();
    }
    const supersededBy = normalizeNonEmptyStringList(e.supersededBy);
    if (supersededBy)
        result.supersededBy = supersededBy;
    const contradictedBy = normalizeNonEmptyStringList(e.contradictedBy);
    if (contradictedBy)
        result.contradictedBy = contradictedBy;
    const currentBeliefRefs = normalizeNonEmptyStringList(e.currentBeliefRefs);
    if (currentBeliefRefs)
        result.currentBeliefRefs = currentBeliefRefs;
    if (e.captureMode === "hot" || e.captureMode === "background") {
        result.captureMode = e.captureMode;
    }
    if (typeof e.whenToUse === "string" && e.whenToUse.trim().length > 0) {
        result.whenToUse = e.whenToUse.trim();
    }
    if (typeof e.lessonStrength === "number" && Number.isFinite(e.lessonStrength) && e.lessonStrength >= 0) {
        result.lessonStrength = Math.floor(e.lessonStrength);
    }
    const evidenceSources = normalizeNonEmptyStringList(e.evidenceSources);
    if (evidenceSources)
        result.evidenceSources = evidenceSources;
    if (typeof e.derivedFrom === "string" && e.derivedFrom.trim().length > 0) {
        result.derivedFrom = e.derivedFrom.trim();
    }
    if (typeof e.content === "string" && e.content.trim().length > 0) {
        result.content = e.content;
    }
    if (typeof e.scope === "object" && e.scope !== null && !Array.isArray(e.scope)) {
        const scope = normalizeScopeObject(e.scope);
        if (scope)
            result.scope = scope;
    }
    if (Array.isArray(e.parameters)) {
        const validated = e.parameters
            .filter((p) => {
            if (typeof p !== "object" || p === null)
                return false;
            const rec = p;
            return typeof rec.name === "string" && rec.name.trim().length > 0;
        })
            .map((p) => {
            const rec = p;
            const param = { name: rec.name.trim() };
            if (typeof rec.type === "string" && rec.type.trim())
                param.type = rec.type.trim();
            if (typeof rec.description === "string" && rec.description.trim())
                param.description = rec.description.trim();
            if (typeof rec.required === "boolean")
                param.required = rec.required;
            if (typeof rec.default === "string" && rec.default.trim().length > 0)
                param.default = rec.default;
            return param;
        });
        if (validated.length > 0)
            result.parameters = validated;
    }
    return result;
}
/**
 * Coerce a raw `{ user, agent, run, channel }` object into a clean
 * `StashEntryScope`, dropping non-string and empty values. Returns
 * `undefined` when no recognized keys carry a value.
 */
function normalizeScopeObject(raw) {
    const out = {};
    for (const key of SCOPE_KEYS) {
        const value = raw[key];
        if (typeof value === "string") {
            const trimmed = value.trim();
            if (trimmed)
                out[key] = trimmed;
        }
        else if (typeof value === "number" && Number.isFinite(value)) {
            out[key] = String(value);
        }
    }
    return Object.keys(out).length > 0 ? out : undefined;
}
/**
 * Pull `scope_user` / `scope_agent` / `scope_run` / `scope_channel` out of
 * a parsed frontmatter block and attach them as `entry.scope`. Tolerates
 * missing or malformed values; legacy memories without these keys are left
 * untouched (no `scope` field added).
 */
export function applyScopeFrontmatter(entry, fmData) {
    const collected = {};
    for (const key of SCOPE_KEYS) {
        const fmKey = `scope_${key}`;
        if (Object.hasOwn(fmData, fmKey)) {
            collected[key] = fmData[fmKey];
        }
    }
    if (Object.keys(collected).length === 0)
        return;
    const scope = normalizeScopeObject(collected);
    if (scope)
        entry.scope = scope;
}
function normalizeIntent(value) {
    if (typeof value !== "object" || value === null || Array.isArray(value))
        return undefined;
    const raw = value;
    const intent = {};
    const when = asNonEmptyString(raw.when);
    const input = asNonEmptyString(raw.input);
    const output = asNonEmptyString(raw.output);
    if (when)
        intent.when = when;
    if (input)
        intent.input = input;
    if (output)
        intent.output = output;
    return Object.keys(intent).length > 0 ? intent : undefined;
}
/**
 * Normalize a current derived-memory parent ref to its `memories/<name>`
 * conceptId for the `derived_from` column. Returns `undefined` for a non-memory
 * or bare value so the caller can inspect the current `derivedFrom` key.
 */
function normalizeMemoryBackref(value) {
    if (!value)
        return undefined;
    try {
        const parsed = parseBundleRef(value);
        return parsed.fragment === undefined && parsed.conceptId.startsWith("memories/") ? parsed.conceptId : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * Resolve a derived memory's parent conceptId from its current `source:` backref
 * or, failing that, its bare `derivedFrom: <name>` frontmatter key.
 */
function derivedFromConceptId(source, derivedFrom) {
    const fromSource = normalizeMemoryBackref(source);
    if (fromSource)
        return fromSource;
    const rawDerivedFrom = derivedFrom?.trim();
    if (!rawDerivedFrom)
        return undefined;
    const qualified = normalizeMemoryBackref(rawDerivedFrom);
    if (qualified)
        return qualified;
    if (rawDerivedFrom.includes(":") || rawDerivedFrom.includes("//"))
        return undefined;
    try {
        return parseBundleRef(`memories/${rawDerivedFrom}`).conceptId;
    }
    catch {
        return undefined;
    }
}
export function applyCuratedFrontmatter(entry, fmData) {
    const description = asNonEmptyString(fmData.description);
    if (description) {
        entry.description = description;
        entry.source = "frontmatter";
        entry.confidence = 0.9;
    }
    const tags = normalizeNonEmptyStringList(fmData.tags);
    if (tags)
        entry.tags = normalizeTerms(tags);
    const aliases = normalizeNonEmptyStringList(fmData.aliases);
    if (aliases)
        entry.aliases = normalizeTerms(aliases);
    const searchHints = normalizeNonEmptyStringList(fmData.searchHints);
    if (searchHints)
        entry.searchHints = searchHints;
    const usage = normalizeNonEmptyStringList(fmData.usage);
    if (usage)
        entry.usage = usage;
    const examples = normalizeNonEmptyStringList(fmData.examples);
    if (examples)
        entry.examples = examples;
    const run = asNonEmptyString(fmData.run);
    if (run)
        entry.run = run;
    const setup = asNonEmptyString(fmData.setup);
    if (setup)
        entry.setup = setup;
    const cwd = asNonEmptyString(fmData.cwd);
    if (cwd)
        entry.cwd = cwd;
    const quality = asNonEmptyString(fmData.quality);
    if (quality)
        entry.quality = normalizeQuality(quality);
    // SPEC-6 capture step: the `category:` frontmatter key (e.g. `convention`,
    // `meta` on facts) must land on the indexed entry so category-keyed
    // policies can see it. Trimmed; blank/non-string values are ignored.
    const category = asNonEmptyString(fmData.category);
    if (category)
        entry.category = category;
    const beliefState = asNonEmptyString(fmData.beliefState);
    if (beliefState)
        entry.beliefState = beliefState;
    const supersededBy = normalizeNonEmptyStringList(fmData.supersededBy);
    if (supersededBy)
        entry.supersededBy = supersededBy;
    const contradictedBy = normalizeNonEmptyStringList(fmData.contradictedBy);
    if (contradictedBy)
        entry.contradictedBy = contradictedBy;
    const currentBeliefRefs = normalizeNonEmptyStringList(fmData.currentBeliefRefs);
    if (currentBeliefRefs)
        entry.currentBeliefRefs = currentBeliefRefs;
    // captureMode: "hot" | "background" — strict whitelist; unknown values are ignored.
    if (fmData.captureMode === "hot" || fmData.captureMode === "background") {
        entry.captureMode = fmData.captureMode;
    }
    // when_to_use → whenToUse — free-form guidance for retrieval/intent matching.
    const whenToUse = asNonEmptyString(fmData.when_to_use);
    if (whenToUse)
        entry.whenToUse = whenToUse;
    // lessonStrength: array → length, number → direct. Negative numbers clamp to 0.
    if (Array.isArray(fmData.lessonStrength)) {
        entry.lessonStrength = fmData.lessonStrength.length;
    }
    else if (typeof fmData.lessonStrength === "number" && Number.isFinite(fmData.lessonStrength)) {
        entry.lessonStrength = Math.max(0, Math.floor(fmData.lessonStrength));
    }
    const evidenceSources = normalizeNonEmptyStringList(fmData.evidenceSources);
    if (evidenceSources)
        entry.evidenceSources = evidenceSources;
    // Phase 5A / Advantage D5: capture parent ref for derived memories.
    // Memory-inference writes `source: "memories/<parent>"` and `inferred: true`
    // (and a derived child name suffix `.derived`). We mirror that source ref into
    // `entry.derivedFrom` so the indexer can populate the dedicated `derived_from`
    // column. Group-C item 2: the column is stored in the 0.9.0 `memories/<name>`
    // conceptId grammar — moving in lockstep with the `getDerivedForParent` lookup
    // key (search-hit-enrichers) so producer + consumer speak one grammar.
    // Non-derived entries leave this field unset.
    if (entry.type === "memory") {
        const isDerivedByName = entry.name.toLowerCase().endsWith(".derived");
        const isDerivedByFm = fmData.inferred === true;
        if (isDerivedByName || isDerivedByFm) {
            const parent = derivedFromConceptId(asNonEmptyString(fmData.source), asNonEmptyString(fmData.derivedFrom));
            if (parent)
                entry.derivedFrom = parent;
        }
    }
    const intent = normalizeIntent(fmData.intent);
    if (intent)
        entry.intent = intent;
    if (typeof fmData.scope === "object" && fmData.scope !== null && !Array.isArray(fmData.scope)) {
        const normalizedScope = normalizeScopeObject(fmData.scope);
        if (normalizedScope)
            entry.scope = normalizedScope;
    }
    applyScopeFrontmatter(entry, fmData);
}
function normalizeNonEmptyStringList(value) {
    if (typeof value === "string") {
        const trimmed = value.trim();
        return trimmed ? [trimmed] : undefined;
    }
    if (!Array.isArray(value))
        return undefined;
    const filtered = value
        .filter((item) => typeof item === "string")
        .map((item) => item.trim())
        .filter((item) => item.length > 0);
    return filtered.length > 0 ? filtered : undefined;
}
// ── Parameter Extraction ─────────────────────────────────────────────────────
/**
 * Extract structured parameters from a command template containing
 * `$ARGUMENTS`, `$1`-`$9`, or `{{named}}` placeholders.
 */
export function extractCommandParameters(template) {
    const params = [];
    if (/\$ARGUMENTS\b/.test(template)) {
        params.push({ name: "ARGUMENTS" });
    }
    for (const match of template.matchAll(/\$([1-9])(?!\d)/g)) {
        const name = `$${match[1]}`;
        if (!params.some((p) => p.name === name)) {
            params.push({ name });
        }
    }
    for (const match of template.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g)) {
        const name = match[1];
        if (!params.some((p) => p.name === name)) {
            params.push({ name });
        }
    }
    return params.length > 0 ? params : undefined;
}
/**
 * Extract wiki frontmatter fields (wikiRole, pageKind, xrefs, sources) from a parsed
 * frontmatter block and apply them to the entry. Tolerates missing or malformed values.
 */
export function applyWikiFrontmatter(entry, fmData) {
    if (isWikiRole(fmData.wikiRole))
        entry.wikiRole = fmData.wikiRole;
    const pageKind = fmData.pageKind;
    if (typeof pageKind === "string" && pageKind.trim().length > 0) {
        entry.pageKind = pageKind.trim();
    }
    // Only lists: a bare string is not a citation list here.
    const xrefs = Array.isArray(fmData.xrefs) ? normalizeNonEmptyStringList(fmData.xrefs) : undefined;
    if (xrefs)
        entry.xrefs = xrefs;
    const sources = Array.isArray(fmData.sources) ? normalizeNonEmptyStringList(fmData.sources) : undefined;
    if (sources)
        entry.sources = sources;
}
function isWikiRole(value) {
    return value === "schema" || value === "index" || value === "log" || value === "raw" || value === "page";
}
/**
 * Parse one `verified:` family value into the `IndexDocument.provenance` shape.
 * Accepts OKF v0.2's list form and its documented single-mapping shorthand
 * ("consumers MUST treat a bare mapping as a one-element list", SPEC §5.2).
 * Tolerant: an entry without a usable `by` is dropped individually.
 */
function parseVerifiedFamily(value) {
    const entries = Array.isArray(value) ? value : [value];
    return entries
        .filter((v) => v !== null && typeof v === "object" && !Array.isArray(v))
        .map((v) => {
        const by = asNonEmptyString(v.by);
        if (!by)
            return undefined;
        const at = asNonEmptyString(v.at);
        return at ? { by, at } : { by };
    })
        .filter((v) => v !== undefined);
}
/**
 * Extract the OKF v0.2 provenance families that `promoteProposal` stamps onto
 * accepted AKM-native proposals (D2, #730), and apply them to the entry.
 *
 * The on-disk shape is deliberately **hybrid** (owner decision, #730 review):
 * `generated:` and `verified:` are stamped BARE at the top level, exactly as
 * OKF v0.2 spells them, because neither has any pre-existing AKM consumer — so
 * a third-party OKF v0.2 reader treating an AKM stash as an OKF bundle sees
 * spec-conformant trust metadata, which is what `okf-support.md`'s
 * "AKM Markdown is an OKF-compatible superset" positioning promises. Only
 * `sources` stays namespaced under `provenance:`, because a bare top-level
 * `sources:` genuinely collides with {@link applyWikiFrontmatter}'s
 * pre-existing citation-**string** convention (which silently drops
 * non-strings).
 *
 * The nested `provenance.generatedBy` / `.generatedAt` / `.verified` spellings
 * are still read as a fallback so assets stamped by an earlier build of this
 * branch keep resolving. Tolerant throughout: any malformed sub-field is
 * dropped individually rather than rejecting the whole block.
 */
export function applyProvenanceFrontmatter(entry, fmData) {
    const provenance = fmData.provenance;
    const nested = provenance !== null && typeof provenance === "object" && !Array.isArray(provenance)
        ? provenance
        : {};
    const result = {};
    // Bare `generated: {by, at}` (OKF v0.2 §5.3) wins; nested is the fallback.
    const generatedMapping = fmData.generated !== null && typeof fmData.generated === "object" && !Array.isArray(fmData.generated)
        ? fmData.generated
        : undefined;
    const generatedBy = asNonEmptyString(generatedMapping?.by) ?? asNonEmptyString(nested.generatedBy);
    if (generatedBy)
        result.generatedBy = generatedBy;
    const generatedAt = asNonEmptyString(generatedMapping?.at) ?? asNonEmptyString(nested.generatedAt);
    if (generatedAt)
        result.generatedAt = generatedAt;
    // Bare `verified:` (list or single mapping) wins; nested is the fallback.
    const verified = fmData.verified !== undefined ? parseVerifiedFamily(fmData.verified) : parseVerifiedFamily(nested.verified);
    if (verified.length > 0)
        result.verified = verified;
    // `sources` is namespaced-only — see the collision note above.
    if (Array.isArray(nested.sources)) {
        const sources = nested.sources
            .filter((s) => s !== null && typeof s === "object" && !Array.isArray(s))
            .map((s) => {
            const resource = asNonEmptyString(s.resource);
            return resource ? { resource } : undefined;
        })
            .filter((s) => s !== undefined);
        if (sources.length > 0)
            result.sources = sources;
    }
    if (Object.keys(result).length > 0)
        entry.provenance = result;
}
// AKM-stash indexing policy (env/vaults/secrets sensitive-marker + wiki-infra
// exclusions) moved to the `akm` adapter's `recognize` as path/stat-based
// abstention (owner ruling 2026-07-21 — adapter-owned filtering). See
// `akmStashAbstains` in `src/core/adapter/adapters/akm-adapter.ts`.
/**
 * Extract `@param` JSDoc tags from a script file's leading comment block.
 *
 * Supports both JSDoc-style (`/** ... * /`) and hash-style (`# @param ...`)
 * comments. Optionally captures `{type}` annotations.
 */
export function extractScriptParameters(filePath, content) {
    if (content === undefined) {
        try {
            content = fs.readFileSync(filePath, "utf8");
        }
        catch {
            return undefined;
        }
    }
    const lines = content.split(/\r?\n/).slice(0, 50);
    const params = [];
    // Match @param lines in any comment style:
    // JSDoc:  * @param {string} name - description
    // JSDoc:  * @param name - description
    // Hash:   # @param name - description
    const paramRegex = /^[\s/*#;-]*@param\s+(?:\{([^}]+)\}\s+)?(\w+)(?:\s+-\s+(.+))?/;
    for (const line of lines) {
        const match = line.match(paramRegex);
        if (match) {
            const param = { name: match[2] };
            if (match[1])
                param.type = match[1].trim();
            if (match[3])
                param.description = match[3].trim();
            params.push(param);
        }
    }
    return params.length > 0 ? params : undefined;
}
/**
 * Extract parameters from frontmatter `params:` key.
 *
 * The frontmatter parser produces a nested object for `params:` like:
 * ```
 * { region: "AWS region to deploy to", instance_type: "EC2 instance type" }
 * ```
 */
export function extractFrontmatterParameters(fmData) {
    const paramsRaw = fmData.params;
    if (typeof paramsRaw !== "object" || paramsRaw === null || Array.isArray(paramsRaw))
        return undefined;
    const paramsObj = paramsRaw;
    const params = [];
    for (const [key, value] of Object.entries(paramsObj)) {
        const param = { name: key };
        if (typeof value === "string" && value.trim()) {
            param.description = value.trim();
        }
        params.push(param);
    }
    return params.length > 0 ? params : undefined;
}
/**
 * Merge two parameter lists, deduplicating by name.
 * Parameters from `additional` are appended only if their name is not already present.
 */
function mergeParameters(existing, additional) {
    if (!additional || additional.length === 0)
        return existing;
    if (!existing || existing.length === 0)
        return additional;
    const names = new Set(existing.map((p) => p.name));
    const merged = [...existing];
    for (const param of additional) {
        if (!names.has(param.name)) {
            merged.push(param);
            names.add(param.name);
        }
    }
    return merged;
}
function splitCommentList(raw) {
    return raw
        .split(/[;,]/)
        .map((item) => item.trim())
        .filter((item) => item.length > 0);
}
function parseCommentScope(raw) {
    const pairs = raw
        .split(/[;,]/)
        .map((item) => item.trim())
        .filter((item) => item.length > 0);
    if (pairs.length === 0)
        return undefined;
    const scopeRaw = {};
    for (const pair of pairs) {
        const [keyPart, ...valueParts] = pair.split("=");
        const key = keyPart?.trim();
        const value = valueParts.join("=").trim();
        if (!key || !value)
            continue;
        if (SCOPE_KEYS.includes(key)) {
            scopeRaw[key] = value;
        }
    }
    return normalizeScopeObject(scopeRaw);
}
function parseIntentCommentLine(cleaned, metadata) {
    const intentMatch = cleaned.match(/^@intent(?:\.(when|input|output))?\s+(.+)$/);
    if (!intentMatch)
        return false;
    metadata.intent ??= {};
    const value = intentMatch[2].trim();
    const key = intentMatch[1];
    if (key === "when")
        metadata.intent.when = value;
    else if (key === "input")
        metadata.intent.input = value;
    else if (key === "output")
        metadata.intent.output = value;
    else
        metadata.intent.when ??= value;
    return true;
}
/** `@tag value` comment lines, in match order; the first matching tag wins a line. */
const COMMENT_TAGS = [
    [/^@description\s+(.+)$/, (m, v) => (m.description = v.trim())],
    [/^@tags?\s+(.+)$/, (m, v) => (m.tags = splitCommentList(v))],
    [/^@aliases?\s+(.+)$/, (m, v) => (m.aliases = splitCommentList(v))],
    [/^@searchHints?\s+(.+)$/, (m, v) => (m.searchHints = splitCommentList(v))],
    [/^@usage\s+(.+)$/, (m, v) => (m.usage = [...(m.usage ?? []), v.trim()])],
    [/^@examples?\s+(.+)$/, (m, v) => (m.examples = [...(m.examples ?? []), v.trim()])],
    [/^@run\s+(.+)$/, (m, v) => (m.run = v.trim())],
    [/^@setup\s+(.+)$/, (m, v) => (m.setup = v.trim())],
    [/^@cwd\s+(.+)$/, (m, v) => (m.cwd = v.trim())],
    [
        /^@scope\s+(.+)$/,
        (m, v) => {
            const scope = parseCommentScope(v);
            if (scope)
                m.scope = scope;
        },
    ],
];
export function extractCommentMetadata(filePath, content) {
    if (content === undefined) {
        try {
            content = fs.readFileSync(filePath, "utf8");
        }
        catch {
            return undefined;
        }
    }
    const lines = content.split(/\r?\n/).slice(0, 50);
    const metadata = {};
    for (const line of lines) {
        const trimmed = line.trim();
        if (!/^(?:\/\/|#|\/?\*|;|--)/.test(trimmed) && !trimmed.startsWith("'"))
            continue;
        const cleaned = trimmed
            .replace(/^(?:\/\/|##?|\/?\*\*?\/?|;|--|'?)\s*/, "")
            .replace(/\*\/\s*$/, "")
            .trim();
        if (!cleaned)
            continue;
        if (parseIntentCommentLine(cleaned, metadata))
            continue;
        for (const [pattern, apply] of COMMENT_TAGS) {
            const match = cleaned.match(pattern);
            if (match) {
                apply(metadata, match[1]);
                break;
            }
        }
    }
    return Object.keys(metadata).length > 0 ? metadata : undefined;
}
export function applyCommentMetadata(entry, metadata) {
    if (!metadata)
        return;
    let usedCommentMetadata = false;
    const use = (key, value) => {
        entry[key] = value;
        usedCommentMetadata = true;
    };
    if (metadata.description && !entry.description)
        use("description", metadata.description);
    if (metadata.tags?.length && (!entry.tags || entry.tags.length === 0))
        use("tags", normalizeTerms(metadata.tags));
    if (metadata.aliases?.length)
        use("aliases", normalizeTerms(metadata.aliases));
    if (metadata.searchHints?.length)
        use("searchHints", metadata.searchHints);
    if (metadata.usage?.length)
        use("usage", metadata.usage);
    if (metadata.examples?.length)
        use("examples", metadata.examples);
    if (metadata.intent && Object.keys(metadata.intent).length > 0)
        use("intent", metadata.intent);
    if (metadata.run)
        use("run", metadata.run);
    if (metadata.setup)
        use("setup", metadata.setup);
    if (metadata.cwd)
        use("cwd", metadata.cwd);
    if (metadata.scope)
        use("scope", metadata.scope);
    if (usedCommentMetadata && entry.source !== "frontmatter" && entry.source !== "manual") {
        entry.source = "comments";
        entry.confidence = Math.max(entry.confidence ?? 0, 0.7);
    }
}
function mergeAliases(existing, generated) {
    const merged = normalizeTerms([...(existing ?? []), ...generated]);
    return merged.length > 0 ? merged : undefined;
}
// ── Native Markdown search projection ──────────────────────────────────────
/**
 * Maximum native Markdown prose carried by the low-weight `content` field.
 *
 * Raised far past any real authored document:
 * this used to sit at 16_384 chars, tight enough that ordinary long-form
 * skills/knowledge docs lost their tail from both FTS and the embedding
 * input with no visible signal (the cut was reported via `warnVerbose`,
 * silent unless `AKM_VERBOSE` was set). The remaining bound exists only to
 * stop a truly pathological single file (a committed data dump, a decompressed
 * log) from ballooning index size — not to shave real content — so a caller
 * that hits it is always told, unconditionally.
 */
export const MARKDOWN_CONTENT_MAX_CHARS = 1_000_000;
/**
 * Locate a leading nested frontmatter block in a body: up to three blank
 * lines, then a `---` line, closed by a later `---` line. Mirrors the
 * base-linter's `parseInnerFrontmatterBlock` recognition — when `akm
 * remember` wraps a session-capture hook's file in its own frontmatter, the
 * hook's `---\nakm_memory_kind: …\n---` block survives at the top of the
 * body. Returns the open/close line indexes, or `null` when no block opens.
 * Location only — callers apply their own interior checks (marker scan in
 * {@link hasSessionMemoryMarker}, shape test in {@link isFrontmatterShaped}).
 */
function findInnerFrontmatterBlock(lines) {
    let i = 0;
    while (i < lines.length && i < 3 && lines[i].trim() === "")
        i += 1;
    if (lines[i] !== "---")
        return null;
    for (let j = i + 1; j < lines.length; j += 1) {
        if (lines[j] === "---")
            return { open: i, close: j };
    }
    return null;
}
/**
 * True when the document carries the session-capture `akm_memory_kind`
 * marker — in the outer frontmatter data OR in a nested inner block at the
 * top of the body (both producer layouts exist; see base-linter's
 * `extractFrontmatterRefs`). Session bodies are raw transcripts, never a
 * searchable content projection.
 */
function hasSessionMemoryMarker(fmData, body) {
    if (typeof fmData.akm_memory_kind === "string")
        return true;
    const lines = body.split(/\r?\n/);
    const block = findInnerFrontmatterBlock(lines);
    if (!block)
        return false;
    for (let i = block.open + 1; i < block.close; i += 1) {
        if (/^akm_memory_kind:\s*\S/.test(lines[i]))
            return true;
    }
    return false;
}
/**
 * True when the interior of a candidate inner block (located by
 * {@link findInnerFrontmatterBlock}) actually reads as YAML frontmatter:
 * every line is blank, indented (a continuation or nested value), or shaped
 * like a top-level `key:` mapping entry. Ordinary prose bracketed by two
 * thematic-break `---` lines fails this test, so a decorative opening
 * callout is treated as the paragraph it is instead of being discarded
 * (review finding on SPEC-8 — the block finder alone accepts ANY content up
 * to an arbitrarily distant closing `---`).
 */
function isFrontmatterShaped(lines, block) {
    for (let i = block.open + 1; i < block.close; i += 1) {
        const line = lines[i];
        if (line.trim() === "")
            continue;
        if (/^\s/.test(line))
            continue; // indented continuation / nested value
        if (/^[A-Za-z0-9_.-]+:(\s|$)/.test(line))
            continue; // top-level key
        return false;
    }
    return true;
}
function truncateUnicodeSafe(text, maxChars) {
    if (text.length <= maxChars)
        return text;
    let cut = text.slice(0, maxChars);
    const lastCode = cut.charCodeAt(cut.length - 1);
    if (lastCode >= 0xd800 && lastCode <= 0xdbff)
        cut = cut.slice(0, -1);
    const boundary = cut.lastIndexOf(" ");
    if (boundary >= Math.floor(maxChars * 0.9))
        cut = cut.slice(0, boundary);
    return cut.trimEnd();
}
function parseMarkdownFenceOpening(line) {
    const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
    const run = match?.[1];
    if (!run)
        return undefined;
    const marker = run[0];
    // CommonMark forbids backticks in the info string of a backtick fence.
    if (marker === "`" && match?.[2]?.includes("`"))
        return undefined;
    return { marker, length: run.length };
}
function isMarkdownFenceClosing(line, fence) {
    let cursor = 0;
    while (cursor < line.length && cursor < 3 && line[cursor] === " ")
        cursor += 1;
    const runStart = cursor;
    while (cursor < line.length && line[cursor] === fence.marker)
        cursor += 1;
    if (cursor - runStart < fence.length)
        return false;
    return /^[\t ]*$/.test(line.slice(cursor));
}
function stripMarkdownHtmlComments(line, state) {
    let cursor = 0;
    let visible = "";
    while (cursor < line.length) {
        if (state.inComment) {
            const close = line.indexOf("-->", cursor);
            if (close < 0)
                return visible;
            state.inComment = false;
            cursor = close + 3;
            continue;
        }
        const open = line.indexOf("<!--", cursor);
        if (open < 0)
            return `${visible}${line.slice(cursor)}`;
        visible += line.slice(cursor, open);
        state.inComment = true;
        cursor = open + 4;
    }
    return visible;
}
function findBalancedMarkdownClose(text, openAt, open, close) {
    let depth = 1;
    for (let cursor = openAt + 1; cursor < text.length; cursor += 1) {
        const char = text[cursor];
        if (char === "\\") {
            cursor += 1;
            continue;
        }
        if (char === open)
            depth += 1;
        if (char !== close)
            continue;
        depth -= 1;
        if (depth === 0)
            return cursor;
    }
    return undefined;
}
/**
 * Find the closing parenthesis of an inline link destination. Parentheses in
 * angle-delimited destinations and quoted titles are data, while unquoted
 * parentheses remain balanced. Backslash escapes suppress delimiter meaning.
 */
function findMarkdownDestinationClose(text, openAt) {
    let depth = 1;
    let phase = "before-destination";
    let quote;
    let parenthesizedTitle = false;
    let titleSeparator = false;
    for (let cursor = openAt + 1; cursor < text.length; cursor += 1) {
        const char = text[cursor];
        if (char === "\\") {
            cursor += 1;
            continue;
        }
        if (phase === "before-destination") {
            if (/\s/u.test(char ?? ""))
                continue;
            if (char === "<") {
                phase = "angle-destination";
                continue;
            }
            if (char === ")")
                return cursor;
            phase = "bare-destination";
        }
        if (phase === "angle-destination") {
            if (char === ">")
                phase = "after-destination";
            continue;
        }
        if (phase === "title") {
            if (quote) {
                if (char === quote) {
                    quote = undefined;
                    phase = "after-destination";
                    titleSeparator = false;
                }
                continue;
            }
            if (parenthesizedTitle) {
                if (char === "(")
                    depth += 1;
                if (char !== ")")
                    continue;
                depth -= 1;
                if (depth === 1) {
                    parenthesizedTitle = false;
                    phase = "after-destination";
                    titleSeparator = false;
                }
            }
            continue;
        }
        if (phase === "after-destination") {
            if (/\s/u.test(char ?? "")) {
                titleSeparator = true;
                continue;
            }
            if (char === ")")
                return cursor;
            if (titleSeparator && (char === '"' || char === "'")) {
                quote = char;
                phase = "title";
                continue;
            }
            if (titleSeparator && char === "(") {
                depth += 1;
                parenthesizedTitle = true;
                phase = "title";
                continue;
            }
            // Invalid trailing bytes are still consumed conservatively until the
            // balanced outer close. Quotes here are ordinary bytes, never titles.
            phase = "bare-destination";
        }
        if (phase !== "bare-destination")
            continue;
        if (char === "(") {
            depth += 1;
            continue;
        }
        if (char === ")") {
            depth -= 1;
            if (depth === 0)
                return cursor;
            continue;
        }
        if (/\s/u.test(char ?? "") && depth === 1) {
            phase = "after-destination";
            titleSeparator = true;
        }
    }
    return undefined;
}
const MAX_MARKDOWN_LINK_NESTING = 32;
/** Retain recursively projected link labels while dropping complete destinations. */
function stripMarkdownLinkDestinations(text, nesting = 0) {
    let visible = "";
    let cursor = 0;
    while (cursor < text.length) {
        const isImage = text[cursor] === "!" && text[cursor + 1] === "[";
        const labelOpen = isImage ? cursor + 1 : cursor;
        if (text[labelOpen] !== "[") {
            visible += text[cursor];
            cursor += 1;
            continue;
        }
        const labelClose = findBalancedMarkdownClose(text, labelOpen, "[", "]");
        const destinationOpen = labelClose === undefined ? undefined : labelClose + 1;
        if (destinationOpen === undefined || text[destinationOpen] !== "(") {
            visible += text[cursor];
            cursor += 1;
            continue;
        }
        const destinationClose = findMarkdownDestinationClose(text, destinationOpen);
        if (destinationClose === undefined) {
            visible += text[cursor];
            cursor += 1;
            continue;
        }
        // Labels may themselves contain images/links. Recursively project them so
        // an inner destination cannot become visible when its outer link is
        // removed. At an adversarial nesting depth, omit the label rather than
        // leaking its unparsed bytes into the search projection.
        if (nesting < MAX_MARKDOWN_LINK_NESTING) {
            visible += stripMarkdownLinkDestinations(text.slice(labelOpen + 1, labelClose), nesting + 1);
        }
        cursor = destinationClose + 1;
    }
    return visible;
}
/**
 * Derive the one low-weight search projection for an AKM-native Markdown body.
 * Frontmatter is removed by the caller. This projection drops comments,
 * fenced code, raw link targets, and structural punctuation while retaining
 * prose, headings, link labels, and inline identifiers. It never receives
 * secret/env/session bytes; that policy is enforced at the adapter metadata
 * boundary below.
 */
export function projectMarkdownContent(body, truncationInfo) {
    const lines = body.split(/\r?\n/);
    const innerBlock = findInnerFrontmatterBlock(lines);
    const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
    const projected = [];
    forEachVisibleMarkdownLine(lines, start, (_index, visible) => {
        let trimmed = visible.trim();
        if (!trimmed || /^(-{3,}|\*{3,}|_{3,}|=+)$/.test(trimmed))
            return;
        if (/^\s*\[[^\]]+\]:\s*\S+/.test(trimmed))
            return;
        if (/^<[^>]+>$/.test(trimmed))
            return;
        // Preserve human-facing labels and inline identifiers, never destinations.
        trimmed = stripMarkdownLinkDestinations(trimmed);
        trimmed = trimmed.replace(/`{1,2}([^`]+)`{1,2}/g, "$1");
        trimmed = trimmed.replace(/^#{1,6}\s+/, "");
        trimmed = trimmed.replace(/^(?:>|[-+*]|\d+[.)])\s+/, "");
        trimmed = trimmed.replace(/<[^>]+>/g, " ");
        trimmed = trimmed.replace(/[|]+/g, " ");
        trimmed = trimmed.replace(/\s+/g, " ").trim();
        if (trimmed)
            projected.push(trimmed);
    });
    const text = projected.join(" ").replace(/\s+/g, " ").trim();
    if (!text)
        return undefined;
    if (truncationInfo)
        truncationInfo.truncated = text.length > MARKDOWN_CONTENT_MAX_CHARS;
    return truncateUnicodeSafe(text, MARKDOWN_CONTENT_MAX_CHARS);
}
/**
 * Visit each line from `start` that lies outside fenced code, with HTML
 * comments removed (`visible`); fence lines themselves are never visited.
 */
function forEachVisibleMarkdownLine(lines, start, visit) {
    let fence;
    const htmlComment = { inComment: false };
    for (let index = start; index < lines.length; index += 1) {
        const rawLine = lines[index];
        if (fence) {
            if (isMarkdownFenceClosing(rawLine, fence))
                fence = undefined;
            continue;
        }
        // A leading fence owns the whole line, including any info string that
        // resembles HTML. Comment state therefore cannot begin inside a fence.
        const leadingFence = htmlComment.inComment ? undefined : parseMarkdownFenceOpening(rawLine);
        if (leadingFence) {
            fence = leadingFence;
            continue;
        }
        const visible = stripMarkdownHtmlComments(rawLine, htmlComment);
        const openingFence = parseMarkdownFenceOpening(visible.trim());
        if (openingFence) {
            fence = openingFence;
            continue;
        }
        visit(index, visible);
    }
}
// Fragment text is intentionally not an IndexDocument field. IndexDocument is
// an adapter/search payload boundary; this is an internal, derived index input
// that is persisted separately by the entries repository for deterministic FTS
// rebuilds. The WeakMap follows the already-read document through recognition
// without making safe body bytes observable through public payloads.
const markdownFragmentContentByEntry = new WeakMap();
const markdownFragmentProjectionEntries = new WeakSet();
export function setMarkdownFragmentContent(entry, content) {
    markdownFragmentProjectionEntries.add(entry);
    if (content)
        markdownFragmentContentByEntry.set(entry, content);
}
export function getMarkdownFragmentContent(entry) {
    return markdownFragmentContentByEntry.get(entry);
}
export function hasMarkdownFragmentContent(entry) {
    return markdownFragmentProjectionEntries.has(entry);
}
/**
 * A copy of `entry` carrying `filePath`'s size (and its fragment projection),
 * or `entry` itself when the file cannot be stat'ed.
 */
export function withFileSize(entry, filePath) {
    try {
        const sized = { ...entry, fileSize: fs.statSync(filePath).size };
        if (hasMarkdownFragmentContent(entry))
            setMarkdownFragmentContent(sized, getMarkdownFragmentContent(entry));
        return sized;
    }
    catch {
        return entry;
    }
}
/**
 * Produce a safe, structure-preserving projection for fragment indexing.
 * Excluded source lines are retained as blank lines so fragment locations map
 * exactly to authored line numbers. This must be called from both indexing and
 * `show`; it deliberately never performs a storage-layer file reread.
 */
export function projectMarkdownFragmentContent(raw) {
    const lines = raw.split(/\r?\n/);
    const parsed = parseFrontmatter(raw);
    const projected = lines.map(() => "");
    forEachVisibleMarkdownLine(lines, parsed.frontmatter ? parsed.bodyStartLine - 1 : 0, (index, visible) => {
        // Reference link destinations and standalone HTML are not retrieval
        // evidence and can contain credential-bearing URLs.
        if (/^\s*\[[^\]]+\]:\s*\S+/.test(visible) || /^\s*<[^>]+>\s*$/.test(visible))
            return;
        projected[index] = stripMarkdownLinkDestinations(visible)
            .replace(/<[^>]+>/g, " ")
            .replace(/[ \t]+$/g, "");
    });
    const text = projected.join("\n");
    return text.trim() ? text : undefined;
}
// ── Metadata Generation ─────────────────────────────────────────────────────
/**
 * Priorities 1-2 of the metadata pipeline — package.json (P1), `.md`
 * frontmatter (P2), and script `@param`/comment metadata (P2b) — everything
 * that runs BEFORE the renderer-contributor step (P3). Extracted (Chunk 5 M-b)
 * so the `akm` adapter's synchronous `recognize` shares this exact assembly
 * with the live index-drain path; the two differ ONLY in how they obtain the
 * P3 renderer metadata (the adapter uses the synchronous
 * `foldRecognizedMetadata`), guaranteeing P1/P2/P4 parity by construction.
 * Behavior-preserving: this is a verbatim lift of the former inline P1/P2/P2b
 * block. Mutates `entry` in place.
 */
export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
    const ext = path.extname(file).toLowerCase();
    // Priority 1: Package.json metadata
    if (pkgMeta) {
        if (pkgMeta.description && !entry.description) {
            entry.description = pkgMeta.description;
            entry.source = "package";
            entry.confidence = 0.8;
        }
        if (pkgMeta.keywords && pkgMeta.keywords.length > 0)
            entry.tags = normalizeTerms(pkgMeta.keywords);
    }
    // Priority 2: Frontmatter (for .md files -- overrides package.json description)
    // Secrets are excluded even when the file happens to be `.md`: the whole file
    // is the secret value and must never be read for frontmatter or any metadata.
    if (ext === ".md" && entry.type !== "secret") {
        const content = ctx.content();
        const parsed = parseFrontmatter(content);
        applyCuratedFrontmatter(entry, parsed.data);
        // Extract parameters from frontmatter params: key
        const fmParams = extractFrontmatterParameters(parsed.data);
        if (fmParams)
            entry.parameters = fmParams;
        // Pass wiki-pattern frontmatter through onto the entry
        applyWikiFrontmatter(entry, parsed.data);
        // D2 (#730): reread the namespaced `provenance:` block promoteProposal stamps.
        applyProvenanceFrontmatter(entry, parsed.data);
        // Native Markdown has one bounded low-weight body projection. Sensitive
        // types and raw session/checkpoint material never cross this boundary.
        const safeForFragments = entry.type !== "env" && entry.type !== "session" && !hasSessionMemoryMarker(parsed.data, parsed.content);
        setMarkdownFragmentContent(entry, safeForFragments ? projectMarkdownFragmentContent(content) : undefined);
        if (safeForFragments) {
            const truncationInfo = { truncated: false };
            const contentProjection = projectMarkdownContent(parsed.content, truncationInfo);
            if (contentProjection) {
                entry.content = contentProjection;
                if (truncationInfo.truncated) {
                    entry.contentTruncated = true;
                    // Unconditional, not warnVerbose: this bound now sits far past any
                    // real document, so tripping it means
                    // something unusual is in the bundle and the operator should see
                    // it without having to pass --verbose.
                    warn(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
                }
            }
        }
        // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
        if (entry.type === "command") {
            const cmdParams = extractCommandParameters(parsed.content);
            if (cmdParams) {
                entry.parameters = mergeParameters(entry.parameters, cmdParams);
            }
        }
    }
    // Extract @param from script files.
    // Env files (.env) and secret files (whole-file secrets) are deliberately
    // excluded — their contents are secrets and must never be parsed for @param
    // or any other metadata that could embed a value into the entry.
    if (ext !== ".md" && entry.type !== "env" && entry.type !== "secret") {
        const content = ctx.content();
        const scriptParams = extractScriptParameters(file, content);
        if (scriptParams)
            entry.parameters = scriptParams;
        applyCommentMetadata(entry, extractCommentMetadata(file, content));
    }
}
/**
 * Priority 4 of the metadata pipeline — filename-heuristic fallbacks (P4) that
 * run AFTER the renderer-contributor step (P3): a filename description when none
 * was set, path/dir-derived tags, tag normalization, and alias generation.
 * Extracted (Chunk 5 M-b) alongside {@link applyPreContributorFields} so both
 * pipeline paths share it. Behavior-preserving verbatim lift. Mutates `entry`.
 */
export function applyPostContributorFields(entry, file, canonicalName, dirPath) {
    const ext = path.extname(file).toLowerCase();
    const baseName = path.basename(file, ext);
    // Priority 4: Filename heuristics (fallback)
    if (!entry.description) {
        entry.description = fileNameToDescription(baseName);
        entry.source = "filename";
        entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
    }
    if (!entry.tags || entry.tags.length === 0) {
        entry.tags = extractTagsFromPath(file, dirPath);
    }
    // Stash-organization conventions (SPEC-2): directory (scope/domain) tokens
    // always reach the tags column, even when the author set explicit tags, so
    // nested assets keep the exact-tag ranking boost for their scope token.
    // Derived from canonicalName (the ref subpath) rather than the filesystem
    // path so the stash-walk and flat-walk indexing paths agree (the flat walk
    // passes `path.dirname(file)` as dirPath, which strips directory segments
    // from the fallback above). Filename tokens are deliberately NOT merged when
    // explicit tags exist — they already live in the FTS name column and in
    // aliases, and merging them would inflate exact-tag matches for every
    // filename word. `normalizeTerms` below dedupes author-restated tokens.
    entry.tags = [...(entry.tags ?? []), ...extractDirTagsFromName(canonicalName)];
    entry.tags = normalizeTerms(entry.tags ?? []);
    entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
    // Search hints are only generated when LLM is configured (via enhanceEntriesWithLlm)
    // Heuristic search hints are too noisy to be useful for search quality
    entry.filename = path.basename(file);
}
// The pre-0.9.0 flat-walk matcher-pass metadata source was DELETED in Chunk 5
// F4a M-core-3. Its role (recognize a stash root's files into durable entries)
// is now the `akm` adapter's `recognize`, drained by `indexer/scan/drain-dir.ts`
// (`drainDirDocuments` for the live indexer, `recognizeStashEntries` for the
// `manifest` fallback / `registry` index builder / metadata unit tests). The
// flip is the F4 engine swap; the shadow-parity gate proved recognize produced
// the identical entries before the old pass was removed.
export function buildMetadataSkipWarning(filePath, assetType, error) {
    const detail = error instanceof Error ? error.message : String(error);
    // Workflow errors are already multi-line `path:line — message` blocks; print
    // them as-is so the author sees a flat list without a redundant prefix.
    const warning = assetType === "workflow"
        ? `Skipped workflow ${filePath}:\n${detail}`
        : `Skipped malformed ${assetType} asset at ${filePath}: ${detail}`;
    // Workflow validation warnings are noisy on cold-start search against fresh
    // registry-cloned content (see issue #273). At default verbosity we suppress
    // the per-spec stderr line and rely on a one-line summary emitted by the
    // indexer driver after the run completes. The full per-file detail is still
    // returned in the warnings[] array (and IndexResponse.warnings) for
    // programmatic consumers, and verbose mode restores the immediate stderr
    // print so workflow authors keep the rich feedback they expect.
    if (assetType === "workflow" && !isVerbose()) {
        return warning;
    }
    warn(warning);
    return warning;
}
/**
 * Returns true when a metadata-skip warning was produced by the workflow
 * validator. Used by the indexer driver to count workflow skips for the
 * default-verbosity summary line. Matches the prefix produced by
 * `buildMetadataSkipWarning` for `assetType === "workflow"`.
 */
export function isWorkflowSkipWarning(warning) {
    return warning.startsWith("Skipped workflow ");
}
function normalizeTerms(values) {
    const normalized = new Set();
    for (const value of values) {
        const cleaned = value.toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
        if (!cleaned)
            continue;
        normalized.add(cleaned);
        // De-pluralization heuristic removed: the FTS5 porter stemmer (configured
        // with `tokenize='porter unicode61'`) handles stemming correctly, including
        // edge cases like "kubernetes" and "status" that the naive s-strip mangled.
    }
    return Array.from(normalized);
}
function buildAliases(name, tags) {
    const aliases = new Set();
    const spaced = name.replace(/[-_]+/g, " ").trim().toLowerCase();
    if (spaced && spaced !== name.toLowerCase())
        aliases.add(spaced);
    if (tags.length > 1)
        aliases.add(tags.join(" "));
    return Array.from(aliases);
}
export function extractDescriptionFromComments(filePath) {
    let content;
    try {
        content = fs.readFileSync(filePath, "utf8");
    }
    catch {
        return null;
    }
    const lines = content.split(/\r?\n/).slice(0, 50);
    // Try JSDoc-style block comment: /** ... */
    const blockStart = lines.findIndex((l) => /^\s*\/\*\*/.test(l));
    if (blockStart >= 0) {
        const desc = [];
        for (let i = blockStart; i < lines.length; i++) {
            const line = lines[i];
            if (i > blockStart && /\*\//.test(line))
                break;
            const cleaned = line
                .replace(/^\s*\/?\*\*?\s?/, "")
                .replace(/\*\/\s*$/, "")
                .trim();
            if (cleaned)
                desc.push(cleaned);
        }
        if (desc.length > 0)
            return desc.join(" ");
    }
    // Try hash comments at start of file (skip shebang)
    let start = 0;
    if (lines[0]?.startsWith("#!"))
        start = 1;
    const hashLines = [];
    for (let i = start; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line.startsWith("#") && !line.startsWith("#!")) {
            hashLines.push(line.replace(/^#+\s*/, "").trim());
        }
        else if (line === "") {
        }
        else {
            break;
        }
    }
    if (hashLines.length > 0)
        return hashLines.join(" ");
    return null;
}
export function extractPackageMetadata(dirPath) {
    const pkgPath = path.join(dirPath, "package.json");
    if (!fs.existsSync(pkgPath))
        return null;
    try {
        const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
        const result = {};
        if (typeof pkg.name === "string")
            result.name = pkg.name;
        if (typeof pkg.description === "string")
            result.description = pkg.description;
        if (Array.isArray(pkg.keywords)) {
            result.keywords = pkg.keywords.filter((k) => typeof k === "string");
        }
        return Object.keys(result).length > 0 ? result : null;
    }
    catch {
        return null;
    }
}
export function fileNameToDescription(fileName) {
    return fileName
        .replace(/[-_]+/g, " ")
        .replace(/([a-z])([A-Z])/g, "$1 $2")
        .toLowerCase()
        .trim();
}
export function extractTagsFromPath(filePath, rootDir) {
    const rel = path.relative(rootDir, filePath);
    const parts = rel.split(path.sep);
    const tags = new Set();
    for (const part of parts) {
        const name = part.replace(path.extname(part), "");
        for (const token of name.split(/[-_./\\]+/)) {
            const clean = token.toLowerCase().trim();
            if (clean && clean.length > 1)
                tags.add(clean);
        }
    }
    return Array.from(tags);
}
/**
 * Extract scope/domain tags from the DIRECTORY segments of a canonical asset
 * name (the ref subpath — e.g. `"projectA/auth-tip"` → `["projecta"]`).
 *
 * Unlike {@link extractTagsFromPath} this never tokenizes the filename
 * segment: it exists so a nested asset's directory (scope/domain) tokens can
 * be merged into explicit author tags without dragging every filename word
 * into exact-tag matching (SPEC-2, docs/architecture/specs/stash-conventions-code-spec.md).
 * Tokenization mirrors `extractTagsFromPath`: each segment splits on `-`/`_`/
 * `.`, lowercased, single-character tokens dropped. A name with no directory
 * segments yields no tags.
 */
export function extractDirTagsFromName(name) {
    const tags = new Set();
    for (const segment of name.split("/").slice(0, -1)) {
        for (const token of segment.split(/[-_.]+/)) {
            const clean = token.toLowerCase().trim();
            if (clean && clean.length > 1)
                tags.add(clean);
        }
    }
    return Array.from(tags);
}