diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 72c905c..bc474f9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,5 @@ { - "name": "dd3ok-agent-skills-stash", + "name": "dd3ok-stash", "owner": { "name": "dd3ok", "url": "https://github.com/dd3ok" @@ -9,7 +9,7 @@ "name": "stash", "source": "./adapters/claude-code", "description": "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "strict": true } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f4f3f7e..b405b43 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "stash", - "version": "0.1.0", + "version": "0.1.1", "description": "One explicit doorway to a local Agent Skills library—search by name or task and load only the selected SKILL.md.", "author": { "name": "dd3ok", @@ -23,7 +23,7 @@ ], "skills": "./skills/", "interface": { - "displayName": "Agent Skills Stash", + "displayName": "Stash", "shortDescription": "Open local Agent Skills only when requested.", "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search by exact name or task, then load only the selected SKILL.md instructions and required resources.", "developerName": "dd3ok", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b44dfd..002da16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: run: npm ci - name: Check generated artifacts - run: npm run build && npm run lint:artifacts + run: npm run lint:artifacts - name: Enforce routing regression budgets run: npm run bench diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1c299..1642352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,18 @@ ## Unreleased +- Unify the public brand, package, repository, and marketplace identifiers as Stash. +- Reject partial-word compact-name matches that promoted unrelated short queries. +- Restore material routing for dense three-term descriptions at a regression-tested threshold. +- Derive generated Adapter versions from the package manifest. +- Fail validation on stale or orphaned generated Adapter files. - Add source provenance metadata, attribution, and explicit source-scoped routing by ID, repository display name, or URL. - Version the source-aware index and routing profile, including cursor invalidation and executable golden cases. - Keep valid ID-less sources in distinct list-output groups. ## 0.1.0 -- Publish as Agent Skills Stash while keeping `stash` as the invocation ID. +- Publish Stash with `stash` as the invocation ID. - Add a read-only `StashCatalog` Module and CLI. - Add deterministic exact lookup and evidence-gated lexical discovery. - Add complete relevant result counts with cursor pagination. diff --git a/README.ko.md b/README.ko.md index f17a2d8..932dbf4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,8 +1,8 @@ -# Agent Skills Stash +# Stash [English](README.md) | 한국어 -Agent Skills Stash는 `$stash`로 호출합니다. 자주 사용하지 않는 +Stash는 `$stash`로 호출합니다. 자주 사용하지 않는 [`SKILL.md`](https://agentskills.io) 패키지를 제품의 기본 검색 경로 밖에 두고, 사용자가 명시적으로 호출하면 정확한 이름으로 스킬을 열거나 작업 내용으로 로컬 보관함을 검색합니다. diff --git a/README.md b/README.md index 644022b..4861fba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Agent Skills Stash +# Stash English | [한국어](README.ko.md) -Agent Skills Stash, invoked as `$stash`, keeps infrequently used +Stash, invoked as `$stash`, keeps infrequently used [`SKILL.md`](https://agentskills.io) packages outside the host's normal discovery path. Invoke it explicitly to open a skill by exact name or search the local library by task. diff --git a/adapters/antigravity/cli/scripts/stash.mjs b/adapters/antigravity/cli/scripts/stash.mjs index 00e5238..7152369 100644 --- a/adapters/antigravity/cli/scripts/stash.mjs +++ b/adapters/antigravity/cli/scripts/stash.mjs @@ -8251,7 +8251,7 @@ async function acquireLock(lockPath) { } // src/internal/search.ts -var ROUTING_PROFILE_VERSION = 2; +var ROUTING_PROFILE_VERSION = 3; var FIELD_WEIGHTS = { name: 6, alias: 6, @@ -8380,6 +8380,23 @@ function scoreRecord(prepared, totalRecords, frequenciesByDocument, queryTerms) score *= negativePenalty(prepared.record, queryTerms); return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value) { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} +function containsTermSequence(haystack, needle) { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => start + needle.length <= haystack.length && needle.every((term, offset) => haystack[start + offset] === term) + ); +} +function hasTermBoundaryMatch(left, right) { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return containsTermSequence(leftTerms, rightTerms) || containsTermSequence(rightTerms, leftTerms); +} function hasPhraseMatch(record, query) { const compactQuery = compactText(query); if (!compactQuery) { @@ -8389,7 +8406,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "name", value: query } }; } for (const alias of record.aliases) { - if (compactText(alias) === compactQuery || compactText(alias).includes(compactQuery)) { + if (compactText(alias) === compactQuery || hasTermBoundaryMatch(alias, query)) { return { matched: true, reason: { kind: "alias", value: alias } }; } } @@ -8402,7 +8419,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "source", value: source } }; } } - if (compactText(record.name).includes(compactQuery) || compactQuery.includes(compactText(record.name))) { + if (hasTermBoundaryMatch(record.name, query)) { return { matched: true, reason: { kind: "name", value: record.name } }; } return { matched: false }; @@ -8429,7 +8446,7 @@ function classifyCandidate(prepared, totalRecords, frequenciesByDocument, query, } const denseDescriptionEvidence = scored.matchedTerms.size >= 3 && scored.matchedKinds.size === 1 && scored.matchedKinds.has("description"); const materialEvidence = scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) || denseDescriptionEvidence || queryTerms.length === 1 && highPriorityMatch(scored.matchedKinds) && !(scored.matchedKinds.size === 1 && scored.matchedKinds.has("description")); - const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.6 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; + const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; if (materialEvidence && scored.score >= evidenceAdjustedThreshold) { return { record, diff --git a/adapters/antigravity/cli/skills/stash.md b/adapters/antigravity/cli/skills/stash.md index 46fac53..8b21062 100644 --- a/adapters/antigravity/cli/skills/stash.md +++ b/adapters/antigravity/cli/skills/stash.md @@ -3,7 +3,7 @@ name: stash description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `/stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. --- -# Agent Skills Stash +# Stash Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. diff --git a/adapters/antigravity/ide/skills/stash/SKILL.md b/adapters/antigravity/ide/skills/stash/SKILL.md index 2a3e2e9..47938ce 100644 --- a/adapters/antigravity/ide/skills/stash/SKILL.md +++ b/adapters/antigravity/ide/skills/stash/SKILL.md @@ -3,7 +3,7 @@ name: stash description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. --- -# Agent Skills Stash +# Stash Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. diff --git a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs index 00e5238..7152369 100644 --- a/adapters/antigravity/ide/skills/stash/scripts/stash.mjs +++ b/adapters/antigravity/ide/skills/stash/scripts/stash.mjs @@ -8251,7 +8251,7 @@ async function acquireLock(lockPath) { } // src/internal/search.ts -var ROUTING_PROFILE_VERSION = 2; +var ROUTING_PROFILE_VERSION = 3; var FIELD_WEIGHTS = { name: 6, alias: 6, @@ -8380,6 +8380,23 @@ function scoreRecord(prepared, totalRecords, frequenciesByDocument, queryTerms) score *= negativePenalty(prepared.record, queryTerms); return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value) { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} +function containsTermSequence(haystack, needle) { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => start + needle.length <= haystack.length && needle.every((term, offset) => haystack[start + offset] === term) + ); +} +function hasTermBoundaryMatch(left, right) { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return containsTermSequence(leftTerms, rightTerms) || containsTermSequence(rightTerms, leftTerms); +} function hasPhraseMatch(record, query) { const compactQuery = compactText(query); if (!compactQuery) { @@ -8389,7 +8406,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "name", value: query } }; } for (const alias of record.aliases) { - if (compactText(alias) === compactQuery || compactText(alias).includes(compactQuery)) { + if (compactText(alias) === compactQuery || hasTermBoundaryMatch(alias, query)) { return { matched: true, reason: { kind: "alias", value: alias } }; } } @@ -8402,7 +8419,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "source", value: source } }; } } - if (compactText(record.name).includes(compactQuery) || compactQuery.includes(compactText(record.name))) { + if (hasTermBoundaryMatch(record.name, query)) { return { matched: true, reason: { kind: "name", value: record.name } }; } return { matched: false }; @@ -8429,7 +8446,7 @@ function classifyCandidate(prepared, totalRecords, frequenciesByDocument, query, } const denseDescriptionEvidence = scored.matchedTerms.size >= 3 && scored.matchedKinds.size === 1 && scored.matchedKinds.has("description"); const materialEvidence = scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) || denseDescriptionEvidence || queryTerms.length === 1 && highPriorityMatch(scored.matchedKinds) && !(scored.matchedKinds.size === 1 && scored.matchedKinds.has("description")); - const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.6 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; + const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; if (materialEvidence && scored.score >= evidenceAdjustedThreshold) { return { record, diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 6a2cb44..3e15411 100644 --- a/adapters/claude-code/.claude-plugin/plugin.json +++ b/adapters/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "stash", - "version": "0.1.0", + "version": "0.1.1", "description": "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", "author": { "name": "dd3ok", diff --git a/adapters/claude-code/skills/stash/SKILL.md b/adapters/claude-code/skills/stash/SKILL.md index 4682a9c..04bc454 100644 --- a/adapters/claude-code/skills/stash/SKILL.md +++ b/adapters/claude-code/skills/stash/SKILL.md @@ -4,7 +4,7 @@ description: Search a separate local Agent Skills library by exact name, source, disable-model-invocation: true --- -# Agent Skills Stash +# Stash Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. diff --git a/adapters/claude-code/skills/stash/scripts/stash.mjs b/adapters/claude-code/skills/stash/scripts/stash.mjs index 00e5238..7152369 100644 --- a/adapters/claude-code/skills/stash/scripts/stash.mjs +++ b/adapters/claude-code/skills/stash/scripts/stash.mjs @@ -8251,7 +8251,7 @@ async function acquireLock(lockPath) { } // src/internal/search.ts -var ROUTING_PROFILE_VERSION = 2; +var ROUTING_PROFILE_VERSION = 3; var FIELD_WEIGHTS = { name: 6, alias: 6, @@ -8380,6 +8380,23 @@ function scoreRecord(prepared, totalRecords, frequenciesByDocument, queryTerms) score *= negativePenalty(prepared.record, queryTerms); return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value) { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} +function containsTermSequence(haystack, needle) { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => start + needle.length <= haystack.length && needle.every((term, offset) => haystack[start + offset] === term) + ); +} +function hasTermBoundaryMatch(left, right) { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return containsTermSequence(leftTerms, rightTerms) || containsTermSequence(rightTerms, leftTerms); +} function hasPhraseMatch(record, query) { const compactQuery = compactText(query); if (!compactQuery) { @@ -8389,7 +8406,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "name", value: query } }; } for (const alias of record.aliases) { - if (compactText(alias) === compactQuery || compactText(alias).includes(compactQuery)) { + if (compactText(alias) === compactQuery || hasTermBoundaryMatch(alias, query)) { return { matched: true, reason: { kind: "alias", value: alias } }; } } @@ -8402,7 +8419,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "source", value: source } }; } } - if (compactText(record.name).includes(compactQuery) || compactQuery.includes(compactText(record.name))) { + if (hasTermBoundaryMatch(record.name, query)) { return { matched: true, reason: { kind: "name", value: record.name } }; } return { matched: false }; @@ -8429,7 +8446,7 @@ function classifyCandidate(prepared, totalRecords, frequenciesByDocument, query, } const denseDescriptionEvidence = scored.matchedTerms.size >= 3 && scored.matchedKinds.size === 1 && scored.matchedKinds.has("description"); const materialEvidence = scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) || denseDescriptionEvidence || queryTerms.length === 1 && highPriorityMatch(scored.matchedKinds) && !(scored.matchedKinds.size === 1 && scored.matchedKinds.has("description")); - const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.6 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; + const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; if (materialEvidence && scored.score >= evidenceAdjustedThreshold) { return { record, diff --git a/adapters/codex/.codex-plugin/plugin.json b/adapters/codex/.codex-plugin/plugin.json index f4f3f7e..b405b43 100644 --- a/adapters/codex/.codex-plugin/plugin.json +++ b/adapters/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "stash", - "version": "0.1.0", + "version": "0.1.1", "description": "One explicit doorway to a local Agent Skills library—search by name or task and load only the selected SKILL.md.", "author": { "name": "dd3ok", @@ -23,7 +23,7 @@ ], "skills": "./skills/", "interface": { - "displayName": "Agent Skills Stash", + "displayName": "Stash", "shortDescription": "Open local Agent Skills only when requested.", "longDescription": "Keep a large local Agent Skills library behind one explicit entry point. Search by exact name or task, then load only the selected SKILL.md instructions and required resources.", "developerName": "dd3ok", diff --git a/adapters/codex/skills/stash/SKILL.md b/adapters/codex/skills/stash/SKILL.md index 1ab02ba..dc5a2c8 100644 --- a/adapters/codex/skills/stash/SKILL.md +++ b/adapters/codex/skills/stash/SKILL.md @@ -3,7 +3,7 @@ name: stash description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `$stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. --- -# Agent Skills Stash +# Stash Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. diff --git a/adapters/codex/skills/stash/agents/openai.yaml b/adapters/codex/skills/stash/agents/openai.yaml index 51d7250..de22d64 100644 --- a/adapters/codex/skills/stash/agents/openai.yaml +++ b/adapters/codex/skills/stash/agents/openai.yaml @@ -1,5 +1,5 @@ interface: - display_name: "Agent Skills Stash" + display_name: "Stash" short_description: "Search local Agent Skills by name, source, or task" default_prompt: "Use $stash to list skills from a source, open an exact stored skill, or find every stored skill relevant to a task." diff --git a/adapters/codex/skills/stash/scripts/stash.mjs b/adapters/codex/skills/stash/scripts/stash.mjs index 00e5238..7152369 100644 --- a/adapters/codex/skills/stash/scripts/stash.mjs +++ b/adapters/codex/skills/stash/scripts/stash.mjs @@ -8251,7 +8251,7 @@ async function acquireLock(lockPath) { } // src/internal/search.ts -var ROUTING_PROFILE_VERSION = 2; +var ROUTING_PROFILE_VERSION = 3; var FIELD_WEIGHTS = { name: 6, alias: 6, @@ -8380,6 +8380,23 @@ function scoreRecord(prepared, totalRecords, frequenciesByDocument, queryTerms) score *= negativePenalty(prepared.record, queryTerms); return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value) { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} +function containsTermSequence(haystack, needle) { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => start + needle.length <= haystack.length && needle.every((term, offset) => haystack[start + offset] === term) + ); +} +function hasTermBoundaryMatch(left, right) { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return containsTermSequence(leftTerms, rightTerms) || containsTermSequence(rightTerms, leftTerms); +} function hasPhraseMatch(record, query) { const compactQuery = compactText(query); if (!compactQuery) { @@ -8389,7 +8406,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "name", value: query } }; } for (const alias of record.aliases) { - if (compactText(alias) === compactQuery || compactText(alias).includes(compactQuery)) { + if (compactText(alias) === compactQuery || hasTermBoundaryMatch(alias, query)) { return { matched: true, reason: { kind: "alias", value: alias } }; } } @@ -8402,7 +8419,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "source", value: source } }; } } - if (compactText(record.name).includes(compactQuery) || compactQuery.includes(compactText(record.name))) { + if (hasTermBoundaryMatch(record.name, query)) { return { matched: true, reason: { kind: "name", value: record.name } }; } return { matched: false }; @@ -8429,7 +8446,7 @@ function classifyCandidate(prepared, totalRecords, frequenciesByDocument, query, } const denseDescriptionEvidence = scored.matchedTerms.size >= 3 && scored.matchedKinds.size === 1 && scored.matchedKinds.has("description"); const materialEvidence = scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) || denseDescriptionEvidence || queryTerms.length === 1 && highPriorityMatch(scored.matchedKinds) && !(scored.matchedKinds.size === 1 && scored.matchedKinds.has("description")); - const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.6 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; + const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; if (materialEvidence && scored.score >= evidenceAdjustedThreshold) { return { record, diff --git a/benchmarks/routing-golden.jsonl b/benchmarks/routing-golden.jsonl index e11ae78..ce30449 100644 --- a/benchmarks/routing-golden.jsonl +++ b/benchmarks/routing-golden.jsonl @@ -4,6 +4,9 @@ {"id":"mixed-language-discovery","kind":"search","query":"브랜드 identity logo art direction","expectedRelevant":["brand-worlds","visual-identity"]} {"id":"specialist-selection","kind":"search","query":"frontend component tokens","expectedRelevant":["design-system"],"expectedPossible":["frontend-workbench"]} {"id":"broad-generic-term","kind":"search","query":"design","expectedRelevant":["design-game-encounters","design-system"]} +{"id":"short-ui-term-respects-boundaries","kind":"search","query":"ui","expectedRelevant":["design-system","ui-craft"]} +{"id":"short-go-term-abstains","kind":"search","query":"go","expectedRelevant":[]} +{"id":"dense-description-three-term-material","kind":"search","query":"generate voice audio","expectedRelevant":["elevenlabs-tts"]} {"id":"typo-fallback","kind":"search","query":"security-audi","expectedRelevant":["security-audit"]} {"id":"negative-example-demotion","kind":"search","query":"database migration review","expectedRelevant":["api-contract-review","database-migration-review"],"expectedFirst":"database-migration-review"} {"id":"source-id-discovers-all","kind":"search","query":"mengto","expectedRelevant":["create-brand-world","design-game-encounters"]} diff --git a/docs/installation.md b/docs/installation.md index 87a78a3..3c8f5af 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -53,7 +53,7 @@ Claude Code can add it as a local marketplace: ```text /plugin marketplace add . -/plugin install stash@dd3ok-agent-skills-stash +/plugin install stash@dd3ok-stash /reload-plugins ``` diff --git a/docs/routing.md b/docs/routing.md index 99a7921..48ff1f1 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -21,7 +21,7 @@ It never invokes natural-language reranking. ## Discovery -Current routing profile: `2`. +Current routing profile: `3`. Discovery uses BM25F-style scoring with initial weights: @@ -38,13 +38,23 @@ Discovery uses BM25F-style scoring with initial weights: A score alone cannot make a result relevant. The evidence gate also requires: -- phrase evidence in name/alias; or +- whole-term phrase evidence in name/alias; or - multiple query terms across multiple fields with a high-priority field; or +- at least three query terms in a description, above the dense-description + threshold; or - a single specific term in name, alias, intent, or tag. -Description-only and group-only generic matches remain `possible`. An exact source ID or display name is strong provenance evidence. Use an explicit source filter when the request names an author or repository; the filter accepts an exact source ID, display name, or URL. Source comparison is Unicode- and case-normalized but preserves punctuation, so `foo-bar` and `foobar` remain different sources. - -Negative examples reduce the score. Character trigram similarity is a fallback only when no normal result is relevant. +Single-term description-only and group-only generic matches remain `possible`. +An exact source ID or display name is strong provenance evidence. Use an +explicit source filter when the request names an author or repository; the +filter accepts an exact source ID, display name, or URL. Source comparison is +Unicode- and case-normalized but preserves punctuation, so `foo-bar` and +`foobar` remain different sources. + +Name and alias phrase matches preserve token boundaries, so short queries such +as `ui` do not activate unrelated words containing the same characters. +Negative examples reduce the score. Character trigram similarity is a fallback +only when no normal result is relevant. ## All relevant diff --git a/package-lock.json b/package-lock.json index 6031de9..f2fe2c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@dd3ok/agent-skills-stash", - "version": "0.1.0", + "name": "@dd3ok/stash", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@dd3ok/agent-skills-stash", - "version": "0.1.0", + "name": "@dd3ok/stash", + "version": "0.1.1", "license": "MIT", "dependencies": { "yaml": "^2.8.1" diff --git a/package.json b/package.json index f3cec32..b5ceb39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@dd3ok/agent-skills-stash", - "version": "0.1.0", + "name": "@dd3ok/stash", + "version": "0.1.1", "description": "Search a separate local Agent Skills library by name or task and load only the selected SKILL.md", "type": "module", "bin": { @@ -34,11 +34,11 @@ "build": "npm run clean && npm run build:types && npm run build:skill && npm run build:adapters", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --import tsx --test tests/*.test.ts", - "test:all": "npm run typecheck && npm test && npm run build && npm run test:dist", + "test:all": "npm run lint:artifacts && npm run typecheck && npm test && npm run build && npm run test:dist", "test:dist": "node --test tests-dist/*.test.mjs", "bench": "node --import tsx benchmarks/route-benchmark.ts", - "lint:artifacts": "node scripts/check-generated.mjs", - "prepack": "npm run build && npm run test:dist && npm run lint:artifacts", + "lint:artifacts": "node scripts/check-generated.mjs && node scripts/check-branding.mjs", + "prepack": "npm run lint:artifacts && npm run build && npm run test:dist", "pack:check": "npm pack --dry-run" }, "engines": { @@ -47,7 +47,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/dd3ok/agent-skills-stash.git" + "url": "git+https://github.com/dd3ok/stash.git" }, "keywords": [ "agent-skills", diff --git a/scripts/build-adapters.mjs b/scripts/build-adapters.mjs index b6c9fa8..39e5129 100644 --- a/scripts/build-adapters.mjs +++ b/scripts/build-adapters.mjs @@ -12,6 +12,13 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const adapters = path.join(root, "adapters"); const sourceSkill = path.join(root, "skills", "stash"); const sourceSkillFile = path.join(sourceSkill, "SKILL.md"); +const packageManifest = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +if (typeof packageManifest.version !== "string") { + throw new Error("package.json must define a string version."); +} +const packageVersion = packageManifest.version; const sourceBody = await readFile(sourceSkillFile, "utf8"); const frontmatterMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(sourceBody); @@ -40,6 +47,9 @@ function withInvocation(value, invocation) { return value.replaceAll("$stash", invocation); } +await rm(adapters, { recursive: true, force: true }); +await mkdir(adapters, { recursive: true }); + const codexRoot = path.join(adapters, "codex"); await copySkill(path.join(codexRoot, "skills", "stash")); await cp( @@ -59,7 +69,7 @@ await writeFile( ); await writeJson(path.join(claudeRoot, ".claude-plugin", "plugin.json"), { name: "stash", - version: "0.1.0", + version: packageVersion, description: "Search and load local Agent Skills from a separate SKILL.md library on explicit request.", author: { name: "dd3ok", url: "https://github.com/dd3ok" }, diff --git a/scripts/check-branding.mjs b/scripts/check-branding.mjs new file mode 100644 index 0000000..a0361fc --- /dev/null +++ b/scripts/check-branding.mjs @@ -0,0 +1,68 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const brandSeparator = String.raw`(?:[-_]|\s)+`; +const forbiddenBrand = new RegExp( + ["agent", "skills", "stash"].join(brandSeparator), + "iu", +); +const ignoredDirectories = new Set([ + "coverage", + "dist", + "node_modules", +]); +const files = []; +const violations = new Set(); + +function shouldIgnoreDirectory(name) { + return ignoredDirectories.has(name) || name.startsWith(".stash-cache"); +} + +function relativePath(filePath) { + return path.relative(root, filePath).split(path.sep).join("/"); +} + +async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if ( + entry.name === ".git" || + (entry.isDirectory() && shouldIgnoreDirectory(entry.name)) + ) { + continue; + } + const entryPath = path.join(directory, entry.name); + if (entry.isFile() && entry.name.endsWith(".tgz")) { + continue; + } + const relative = relativePath(entryPath); + if (forbiddenBrand.test(relative)) { + violations.add(`${relative} (path)`); + } + if (entry.isDirectory()) { + await walk(entryPath); + } else if (entry.isFile()) { + files.push(entryPath); + } + } +} + +await walk(root); + +for (const file of files) { + const content = await readFile(file); + if (content.includes(0)) { + continue; + } + if (forbiddenBrand.test(content.toString("utf8"))) { + violations.add(relativePath(file)); + } +} + +if (violations.size > 0) { + const sortedViolations = [...violations].sort(); + throw new Error( + `Legacy three-part brand remains in: ${sortedViolations.join(", ")}`, + ); +} diff --git a/skills/stash/SKILL.md b/skills/stash/SKILL.md index 1ab02ba..dc5a2c8 100644 --- a/skills/stash/SKILL.md +++ b/skills/stash/SKILL.md @@ -3,7 +3,7 @@ name: stash description: Search a separate local Agent Skills library by exact name, source, or task and load only the selected SKILL.md instructions. Use only when the user explicitly invokes `$stash` to open an exact stored skill, apply it to a task, list the library, list skills from an author or repository, or find every materially relevant stored skill. Do not invoke Stash implicitly for ordinary work. --- -# Agent Skills Stash +# Stash Use the bundled CLI to search and read a separate local Agent Skills library. Keep skills intended for normal host discovery and all skill lifecycle management outside this workflow. diff --git a/skills/stash/agents/openai.yaml b/skills/stash/agents/openai.yaml index 51d7250..de22d64 100644 --- a/skills/stash/agents/openai.yaml +++ b/skills/stash/agents/openai.yaml @@ -1,5 +1,5 @@ interface: - display_name: "Agent Skills Stash" + display_name: "Stash" short_description: "Search local Agent Skills by name, source, or task" default_prompt: "Use $stash to list skills from a source, open an exact stored skill, or find every stored skill relevant to a task." diff --git a/skills/stash/scripts/stash.mjs b/skills/stash/scripts/stash.mjs index 00e5238..7152369 100644 --- a/skills/stash/scripts/stash.mjs +++ b/skills/stash/scripts/stash.mjs @@ -8251,7 +8251,7 @@ async function acquireLock(lockPath) { } // src/internal/search.ts -var ROUTING_PROFILE_VERSION = 2; +var ROUTING_PROFILE_VERSION = 3; var FIELD_WEIGHTS = { name: 6, alias: 6, @@ -8380,6 +8380,23 @@ function scoreRecord(prepared, totalRecords, frequenciesByDocument, queryTerms) score *= negativePenalty(prepared.record, queryTerms); return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value) { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} +function containsTermSequence(haystack, needle) { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => start + needle.length <= haystack.length && needle.every((term, offset) => haystack[start + offset] === term) + ); +} +function hasTermBoundaryMatch(left, right) { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return containsTermSequence(leftTerms, rightTerms) || containsTermSequence(rightTerms, leftTerms); +} function hasPhraseMatch(record, query) { const compactQuery = compactText(query); if (!compactQuery) { @@ -8389,7 +8406,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "name", value: query } }; } for (const alias of record.aliases) { - if (compactText(alias) === compactQuery || compactText(alias).includes(compactQuery)) { + if (compactText(alias) === compactQuery || hasTermBoundaryMatch(alias, query)) { return { matched: true, reason: { kind: "alias", value: alias } }; } } @@ -8402,7 +8419,7 @@ function hasPhraseMatch(record, query) { return { matched: true, reason: { kind: "source", value: source } }; } } - if (compactText(record.name).includes(compactQuery) || compactQuery.includes(compactText(record.name))) { + if (hasTermBoundaryMatch(record.name, query)) { return { matched: true, reason: { kind: "name", value: record.name } }; } return { matched: false }; @@ -8429,7 +8446,7 @@ function classifyCandidate(prepared, totalRecords, frequenciesByDocument, query, } const denseDescriptionEvidence = scored.matchedTerms.size >= 3 && scored.matchedKinds.size === 1 && scored.matchedKinds.has("description"); const materialEvidence = scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) || denseDescriptionEvidence || queryTerms.length === 1 && highPriorityMatch(scored.matchedKinds) && !(scored.matchedKinds.size === 1 && scored.matchedKinds.has("description")); - const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.6 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; + const evidenceAdjustedThreshold = denseDescriptionEvidence ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) ? materialScoreThreshold * 0.75 : materialScoreThreshold; if (materialEvidence && scored.score >= evidenceAdjustedThreshold) { return { record, diff --git a/src/internal/search.ts b/src/internal/search.ts index 21886ad..43ba43f 100644 --- a/src/internal/search.ts +++ b/src/internal/search.ts @@ -27,7 +27,7 @@ interface SearchResult { expandedTerms: string[]; } -export const ROUTING_PROFILE_VERSION = 2 as const; +export const ROUTING_PROFILE_VERSION = 3 as const; const FIELD_WEIGHTS = { name: 6, @@ -200,6 +200,31 @@ function scoreRecord( return { score, matchedTerms, matchedKinds, reasons }; } +function normalizedTerms(value: string): string[] { + const normalized = normalizeText(value); + return normalized ? normalized.split(/\s+/u) : []; +} + +function containsTermSequence(haystack: string[], needle: string[]): boolean { + if (needle.length === 0 || needle.length > haystack.length) { + return false; + } + return haystack.some( + (_, start) => + start + needle.length <= haystack.length && + needle.every((term, offset) => haystack[start + offset] === term), + ); +} + +function hasTermBoundaryMatch(left: string, right: string): boolean { + const leftTerms = normalizedTerms(left); + const rightTerms = normalizedTerms(right); + return ( + containsTermSequence(leftTerms, rightTerms) || + containsTermSequence(rightTerms, leftTerms) + ); +} + function hasPhraseMatch(record: SkillRecord, query: string): { matched: boolean; reason?: RelevanceReason; @@ -214,7 +239,7 @@ function hasPhraseMatch(record: SkillRecord, query: string): { for (const alias of record.aliases) { if ( compactText(alias) === compactQuery || - compactText(alias).includes(compactQuery) + hasTermBoundaryMatch(alias, query) ) { return { matched: true, reason: { kind: "alias", value: alias } }; } @@ -232,8 +257,7 @@ function hasPhraseMatch(record: SkillRecord, query: string): { } } if ( - compactText(record.name).includes(compactQuery) || - compactQuery.includes(compactText(record.name)) + hasTermBoundaryMatch(record.name, query) ) { return { matched: true, reason: { kind: "name", value: record.name } }; } @@ -293,7 +317,7 @@ function classifyCandidate( scored.matchedKinds.has("description") )); const evidenceAdjustedThreshold = denseDescriptionEvidence - ? materialScoreThreshold * 0.6 + ? materialScoreThreshold * 0.58 : scored.matchedTerms.size >= 2 && scored.matchedKinds.size >= 2 && highPriorityMatch(scored.matchedKinds) diff --git a/tests-dist/cli.test.mjs b/tests-dist/cli.test.mjs index 6230f03..c459ef5 100644 --- a/tests-dist/cli.test.mjs +++ b/tests-dist/cli.test.mjs @@ -168,6 +168,59 @@ test("npm package entrypoints match the compiled layout", async () => { assert.match(stdout, /--source /u); }); +test("distribution metadata uses one Stash identity and version", async () => { + const packageManifest = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), + ); + assert.equal(packageManifest.name, "@dd3ok/stash"); + assert.equal( + packageManifest.repository.url, + "git+https://github.com/dd3ok/stash.git", + ); + + const codexManifest = JSON.parse( + await readFile(path.join(root, ".codex-plugin", "plugin.json"), "utf8"), + ); + assert.equal(codexManifest.name, "stash"); + assert.equal(codexManifest.interface.displayName, "Stash"); + assert.equal(codexManifest.version, packageManifest.version); + + const openaiMetadata = YAML.parse( + await readFile( + path.join(root, "skills", "stash", "agents", "openai.yaml"), + "utf8", + ), + ); + assert.equal(openaiMetadata.interface.display_name, "Stash"); + + const claudeMarketplace = JSON.parse( + await readFile( + path.join(root, ".claude-plugin", "marketplace.json"), + "utf8", + ), + ); + assert.equal(claudeMarketplace.name, "dd3ok-stash"); + assert.equal(claudeMarketplace.plugins.length, 1); + const claudePlugin = claudeMarketplace.plugins[0]; + assert.equal(claudePlugin.name, "stash"); + assert.equal(claudePlugin.version, packageManifest.version); + + const claudeManifest = JSON.parse( + await readFile( + path.join( + root, + "adapters", + "claude-code", + ".claude-plugin", + "plugin.json", + ), + "utf8", + ), + ); + assert.equal(claudeManifest.name, "stash"); + assert.equal(claudeManifest.version, packageManifest.version); +}); + test("vendor adapters contain only their documented invocation policy", async () => { const claudeMarketplace = JSON.parse( await readFile( @@ -175,7 +228,7 @@ test("vendor adapters contain only their documented invocation policy", async () "utf8", ), ); - assert.equal(claudeMarketplace.name, "dd3ok-agent-skills-stash"); + assert.equal(claudeMarketplace.name, "dd3ok-stash"); assert.equal(claudeMarketplace.owner.name, "dd3ok"); assert.equal( claudeMarketplace.plugins[0].source, diff --git a/tests/routing-golden.test.ts b/tests/routing-golden.test.ts index 6dfbb37..583e461 100644 --- a/tests/routing-golden.test.ts +++ b/tests/routing-golden.test.ts @@ -43,6 +43,25 @@ source: name: "frontend-workbench", description: "Handle general frontend implementation across web projects.", }, + { + group: "frontend", + name: "ui-craft", + description: "Design and implement polished user interfaces.", + }, + { + group: "tooling", + name: "build-audio-tools", + description: "Build audio processing tools.", + }, + { + group: "web-design", + name: "company-logos", + description: "Collect and present company logos.", + sidecar: `schemaVersion: 1 +aliases: + - logo gallery +`, + }, { group: "web-design", name: "create-brand-world", @@ -52,6 +71,38 @@ source: id: mengto displayName: MengTo/Skills url: https://github.com/MengTo/Skills +`, + }, + { + group: "media-production", + name: "elevenlabs-tts", + description: + "Generate realistic voice audio from text with ElevenLabs speech and expressive multilingual studio delivery controls.", + }, + { + group: "media-production", + name: "waveform-previews", + description: "Generate audio previews and waveforms for media projects.", + }, + { + group: "media-production", + name: "recording-cleanup", + description: "Edit voice and audio recordings with cleanup and mixing.", + }, + { + group: "media-production", + name: "narration-workflows", + description: "Compare voice narration workflows for video production.", + }, + { + group: "documentation", + name: "media-options-guide", + description: + "Generate reference documentation that compares available voice and audio options for production teams and workflows.", + sidecar: `schemaVersion: 1 +examples: + negative: + - generate voice audio `, }, {