diff --git a/CHANGESET.md b/CHANGESET.md index 54991af78c0d..cd758578e617 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -154,6 +154,7 @@ Newest first. Each entry names the upstream range brought in. ### Features +- **#159** Add `skills.exclude`, glob patterns for `SKILL.md` files that skip copies in every skill root, such as hidden per-host installs and `.trash` folders. Patterns match relative to each root, and excluded skills are not loaded, so they cannot shadow the canonical skill of the same name (swxtchio/swx-opencode#59). _Upstreamable._ - **#73** V1 server prompt queue core (swxtchio/swx-opencode#68): every V1 prompt except `noReply` is admitted to the durable `session_prompt_queue` table before it becomes a user message. `prompt`, `prompt_async` and `command` take `delivery: "steer" | "queue"`; the default steer reaches the next step as before, while `queue` waits until the run would go idle and then runs as its own turn, one per boundary in admission order. Steers wait behind a pending compaction; an abort or error parks pending prompts until the next admission or wake; `/session/:id/queue` lists, withdraws (404 `QueueItemNotPending` once delivered), restores and re-delivers them, with `session.queue.updated` carrying the full list. A sync prompt answers with the final reply of the turn its prompt joined, recorded for it where the loop ends that turn: steers share the reply of the turn they steered, and a queued prompt gets its own turn's; an editor's withdraw that wins answers it with 409 `PromptWithdrawn`. Deliberately, a compaction whose summary turn stopped on an abort or error is not retried, where upstream V1 retried it on the next prompt with that prompt as its parent (summarising it instead of answering it); if the context is still too large, the next overflow check starts a new compaction. Also closes the V1 lost wakeup where a prompt admitted as a run finished joined it and was never answered. Known ordering-only residuals: a `noReply` prompt and the synthetic message a subtask writes to have its output summarised are stored directly rather than through the queue, so they can land out of admission order relative to pending items. _Fork-only; the lost-wakeup fix is upstreamable._ - **#64** Show each configured router member's request count and share so far in `llmrouter/auto` labels in the TUI and run CLI. (swxtchio/swx-opencode#60). _Fork-only._ - **#58** Price each routed step at its configured serving model and show per-model cost shares in `opencode stats`; `db export-usage` exposes per-served-model token/cost splits and derives record-level tokens, costs and summary `reportedCostTotal` from step-finish parts when present, falling back to message totals under the requested model for legacy rows. (swxtchio/swx-opencode#54). _Fork-only._ diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts index febf062daa47..cb691b58fdb9 100644 --- a/packages/core/src/util/glob.ts +++ b/packages/core/src/util/glob.ts @@ -8,6 +8,7 @@ export namespace Glob { include?: "file" | "all" dot?: boolean symlink?: boolean + ignore?: string[] } function toGlobOptions(options: Options): GlobOptions { @@ -17,6 +18,7 @@ export namespace Glob { dot: options.dot, follow: options.symlink ?? false, nodir: options.include !== "all", + ignore: options.ignore, } } diff --git a/packages/core/src/v1/config/skills.ts b/packages/core/src/v1/config/skills.ts index 9879634b4720..4cfaca66be12 100644 --- a/packages/core/src/v1/config/skills.ts +++ b/packages/core/src/v1/config/skills.ts @@ -9,5 +9,9 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), + exclude: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: + "Glob patterns of SKILL.md files to skip in every skill root, matched relative to that root (e.g., **/.trash/**)", + }), }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..653ace55249c 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -92,6 +92,8 @@ type DiscoveryState = { type ScanState = { matches: Set dirs: Set + // skills.exclude globs, matched relative to the root each scan starts from. + exclude: string[] } export interface Interface { @@ -153,6 +155,7 @@ const scan = Effect.fnUntraced(function* ( include: "file", symlink: true, dot: opts?.dot, + ignore: state.exclude, }), catch: (error) => error, }).pipe( @@ -180,7 +183,8 @@ const discoverSkills = Effect.fnUntraced(function* ( directory: string, worktree: string, ) { - const state: ScanState = { matches: new Set(), dirs: new Set() } + const cfg = yield* config.get() + const state: ScanState = { matches: new Set(), dirs: new Set(), exclude: cfg.skills?.exclude ?? [] } const externalDirs: string[] = [] if (!disableExternalSkills) { @@ -207,7 +211,6 @@ const discoverSkills = Effect.fnUntraced(function* ( yield* scan(state, dir, OPENCODE_SKILL_PATTERN) } - const cfg = yield* config.get() for (const item of cfg.skills?.paths ?? []) { const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f365b63f0d72..130e451050f4 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -49,6 +49,24 @@ This skill is loaded from the global home directory. ) } +const reviewSkill = `--- +name: review +description: A review skill, also copied into a hidden per-host folder and the trash. +--- + +# Review +` + +// The canonical review skill, plus the copies gstack leaves behind: a hidden per-host +// install and a trash folder. All three share one skill name. +async function createReviewCopies(skillsRoot: string) { + await Promise.all( + ["gstack/review", "gstack/.openclaw/review", ".trash/review"].map((dir) => + Bun.write(path.join(skillsRoot, dir, "SKILL.md"), reviewSkill), + ), + ) +} + const withHome = (home: string, self: Effect.Effect) => Effect.acquireUseRelease( Effect.sync(() => { @@ -582,4 +600,40 @@ description: A skill in the .opencode/skills directory. { git: true }, ), ) + + it.live("loads only the canonical skill when skills.exclude matches its hidden and trash copies", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const skillsRoot = path.join(dir, ".claude", "skills") + yield* Effect.promise(() => createReviewCopies(skillsRoot)) + + const skill = yield* Skill.Service + expect(yield* skill.dirs()).toEqual([path.join(skillsRoot, "gstack", "review")]) + const list = (yield* skill.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.location)).toEqual([path.join(skillsRoot, "gstack", "review", "SKILL.md")]) + }), + { git: true, config: { skills: { exclude: ["**/.trash/**", "**/gstack/.*/**"] } } }, + ), + ) + + it.live("loads hidden and trash copies when skills.exclude is not set", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const skillsRoot = path.join(dir, ".claude", "skills") + yield* Effect.promise(() => createReviewCopies(skillsRoot)) + + const skill = yield* Skill.Service + expect((yield* skill.dirs()).toSorted()).toEqual( + [ + path.join(skillsRoot, "gstack", "review"), + path.join(skillsRoot, "gstack", ".openclaw", "review"), + path.join(skillsRoot, ".trash", "review"), + ].toSorted(), + ) + }), + { git: true }, + ), + ) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 92c67c012acf..f2f4fef0b0a5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2012,6 +2012,7 @@ export type Config = { skills?: { paths?: Array urls?: Array + exclude?: Array } references?: { [key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 94ae1bfa77ac..e75b20e71ce5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21938,6 +21938,12 @@ "items": { "type": "string" } + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea5682f..50fd347e4214 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -29,6 +29,18 @@ It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.cla Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`. +To skip copies you do not want loaded, such as a hidden per-host install or a trash folder, set glob patterns under `skills.exclude`: + +```json +{ + "skills": { + "exclude": ["**/.trash/**", "**/gstack/.*/**"] + } +} +``` + +Each pattern is matched against the `SKILL.md` path relative to the folder the scan started from, such as `.claude` or `.opencode`, so one pattern works in every location above. Excluded skills are never loaded, so they cannot shadow a skill with the same name. + --- ## Write frontmatter