opencode-agent-skills-md 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/.local_version +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +61 -0
- package/.beads/deletions.jsonl +1 -0
- package/.beads/issues.jsonl +64 -0
- package/.beads/metadata.json +4 -0
- package/.gitattributes +3 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/copilot-instructions.md +78 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/release.yml +51 -0
- package/.opencode/command/test-compaction.md +9 -0
- package/.opencode/command/test-find-skills.md +7 -0
- package/.opencode/command/test-read-skill-file.md +14 -0
- package/.opencode/command/test-run-skill-script.md +13 -0
- package/.opencode/command/test-skills.md +14 -0
- package/.opencode/command/test-use-skill.md +10 -0
- package/.opencode/skills/git-helper/SKILL.md +65 -0
- package/.opencode/skills/test-skill/SKILL.md +43 -0
- package/.opencode/skills/test-skill/example-config.json +16 -0
- package/.opencode/skills/test-skill/helper-docs.md +29 -0
- package/.opencode/skills/test-skill/scripts/echo-args +14 -0
- package/.opencode/skills/test-skill/scripts/greet +6 -0
- package/AGENTS.md +43 -0
- package/CHANGELOG.md +178 -0
- package/Justfile +39 -0
- package/LICENSE +9 -0
- package/README.md +189 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
- package/openspec/specs/core-decoupling/spec.md +110 -0
- package/package.json +35 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/content.d.ts +16 -0
- package/packages/core/src/content.ts +30 -0
- package/packages/core/src/debug.ts +16 -0
- package/packages/core/src/discovery.d.ts +86 -0
- package/packages/core/src/discovery.ts +257 -0
- package/packages/core/src/index.d.ts +20 -0
- package/packages/core/src/index.ts +55 -0
- package/packages/core/src/match.d.ts +19 -0
- package/packages/core/src/match.ts +75 -0
- package/packages/core/src/parse.d.ts +26 -0
- package/packages/core/src/parse.ts +141 -0
- package/packages/core/src/scripts.d.ts +17 -0
- package/packages/core/src/scripts.ts +79 -0
- package/packages/core/src/search.d.ts +83 -0
- package/packages/core/src/search.ts +188 -0
- package/packages/core/src/types.d.ts +82 -0
- package/packages/core/src/types.ts +131 -0
- package/packages/core/src/walk.ts +109 -0
- package/packages/core/tests/agnostic.test.ts +346 -0
- package/packages/core/tests/content.test.ts +65 -0
- package/packages/core/tests/discovery.test.ts +370 -0
- package/packages/core/tests/package-boundary.test.ts +310 -0
- package/packages/core/tests/parse-trigger.test.ts +282 -0
- package/packages/core/tests/search.test.ts +374 -0
- package/packages/core/tests/subpath.test.ts +87 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/opencode-agent-skills-md/package.json +42 -0
- package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
- package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
- package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
- package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
- package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
- package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
- package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
- package/packages/opencode-agent-skills-md/src/host.ts +119 -0
- package/packages/opencode-agent-skills-md/src/index.ts +25 -0
- package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
- package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
- package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
- package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
- package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
- package/plans/001-ci-gate.md +177 -0
- package/plans/002-is-path-safe.md +243 -0
- package/plans/003-escape-prompts.md +310 -0
- package/plans/004-test-security-paths.md +228 -0
- package/plans/005-stop-swallowing-errors.md +246 -0
- package/plans/006-preserve-jsonc-commas.md +144 -0
- package/plans/007-write-before-purge.md +144 -0
- package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
- package/plans/README.md +43 -0
- package/pnpm-workspace.yaml +6 -0
- package/tests/workspace.test.ts +367 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode host adapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the OpenCode SDK client (`PluginInput["client"]`) and provides
|
|
5
|
+
* a bounded surface for content injection, session context, and filesystem
|
|
6
|
+
* access consumed by the plugin and skill tools.
|
|
7
|
+
*
|
|
8
|
+
* The boundary contracts (`SkillHostClient`, `SkillHostSession`,
|
|
9
|
+
* `SkillHostContext`) are declared in the `opencode-agent-skills-md-core`
|
|
10
|
+
* package per spec R2; this module IMPLEMENTS them over the OpenCode SDK
|
|
11
|
+
* client plus `node:fs/promises`. No other package may declare a concrete
|
|
12
|
+
* implementation — the plugin package owns exactly one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
17
|
+
import type {
|
|
18
|
+
SkillHostClient,
|
|
19
|
+
SkillHostContext,
|
|
20
|
+
SkillHostSession,
|
|
21
|
+
} from "opencode-agent-skills-md-core";
|
|
22
|
+
import { debugLog } from "opencode-agent-skills-md-core";
|
|
23
|
+
|
|
24
|
+
/** Concrete OpenCode client (the SDK's generated client type). */
|
|
25
|
+
export type OpencodeClient = PluginInput["client"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* File access surface exposed alongside the host client. Tools
|
|
29
|
+
* consume these via the host instead of importing `node:fs/promises` so the
|
|
30
|
+
* boundary stays explicit and easy to stub in tests.
|
|
31
|
+
*/
|
|
32
|
+
export interface OpencodeHostFileAccess {
|
|
33
|
+
readFile(path: string): Promise<string>;
|
|
34
|
+
readdir(path: string): Promise<string[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Concrete OpenCode client surface.
|
|
39
|
+
*
|
|
40
|
+
* Structurally identical to the core boundary contract `SkillHostClient`
|
|
41
|
+
* (it implements all four methods). The alias is preserved for backward
|
|
42
|
+
* compatibility with prior plugin-package consumers and to make the
|
|
43
|
+
* OpenCode-specific implementation obvious at use sites.
|
|
44
|
+
*/
|
|
45
|
+
export type OpencodeSkillHostClient = SkillHostClient;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The full host surface: a bounded client plus a session factory. Each call
|
|
49
|
+
* to `session(id)` returns a `SkillHostSession` carrying only the id the core
|
|
50
|
+
* needs to thread through host calls.
|
|
51
|
+
*/
|
|
52
|
+
export interface OpencodeSkillHost {
|
|
53
|
+
client: OpencodeSkillHostClient;
|
|
54
|
+
session: (id: string) => SkillHostSession;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build an `OpencodeSkillHost` over the supplied OpenCode SDK client.
|
|
59
|
+
*
|
|
60
|
+
* The host is the only place in the codebase that touches the SDK's
|
|
61
|
+
* `client.session.prompt` and `client.session.messages` methods.
|
|
62
|
+
*/
|
|
63
|
+
export const createOpencodeSkillHost = (client: OpencodeClient): OpencodeSkillHost => {
|
|
64
|
+
const skillClient: OpencodeSkillHostClient = {
|
|
65
|
+
async injectContent(sessionID, text, context) {
|
|
66
|
+
await client.session.prompt({
|
|
67
|
+
path: { id: sessionID },
|
|
68
|
+
body: {
|
|
69
|
+
noReply: true,
|
|
70
|
+
model: context?.model,
|
|
71
|
+
agent: context?.agent,
|
|
72
|
+
parts: [{ type: "text", text, synthetic: true }],
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async getSessionContext(sessionID) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await client.session.messages({
|
|
80
|
+
path: { id: sessionID },
|
|
81
|
+
query: { limit: 50 },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (response.data) {
|
|
85
|
+
for (const msg of response.data) {
|
|
86
|
+
if (
|
|
87
|
+
msg.info.role === "user" &&
|
|
88
|
+
"model" in msg.info &&
|
|
89
|
+
msg.info.model
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
model: msg.info.model,
|
|
93
|
+
agent: msg.info.agent,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
debugLog("getSessionContext: session lookup failed", sessionID, (error as Error)?.name);
|
|
100
|
+
// Fall through to undefined - mirrors the legacy behaviour where
|
|
101
|
+
// getSessionContext returns undefined on any lookup failure.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return undefined;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async readFile(filePath) {
|
|
108
|
+
return fs.readFile(filePath, "utf-8");
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async readdir(dirPath) {
|
|
112
|
+
return fs.readdir(dirPath);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const session = (id: string): SkillHostSession => ({ id });
|
|
117
|
+
|
|
118
|
+
return { client: skillClient, session };
|
|
119
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode host adapter — root entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the plugin factory as the package's default export so the
|
|
5
|
+
* `rolldown` build can target this file directly. The root `src/plugin.ts`
|
|
6
|
+
* shim forwards to this module to preserve the legacy import path while
|
|
7
|
+
* `package.json` still resolves `dist/plugin.mjs` to the package main.
|
|
8
|
+
*
|
|
9
|
+
* Public surface:
|
|
10
|
+
* - default export: SkillsPlugin (the @opencode-ai/plugin Plugin factory)
|
|
11
|
+
* - named exports: SkillsPlugin, createOpencodeSkillHost
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SkillsPlugin } from "./plugin";
|
|
15
|
+
|
|
16
|
+
export { SkillsPlugin };
|
|
17
|
+
export { createOpencodeSkillHost } from "./host";
|
|
18
|
+
export type {
|
|
19
|
+
OpencodeClient,
|
|
20
|
+
OpencodeSkillHost,
|
|
21
|
+
OpencodeSkillHostClient,
|
|
22
|
+
OpencodeHostFileAccess,
|
|
23
|
+
} from "./host";
|
|
24
|
+
|
|
25
|
+
export default SkillsPlugin;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Agent Skills Plugin (host adapter).
|
|
3
|
+
*
|
|
4
|
+
* The plugin factory builds the host over the OpenCode SDK client, composes
|
|
5
|
+
* the four skill tools, and wires the chat.message and event hooks. The
|
|
6
|
+
* keyword matcher and session/loaded-skill bookkeeping are the only
|
|
7
|
+
* adapter-specific logic; everything else delegates to the portable core
|
|
8
|
+
* or the host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
12
|
+
import {
|
|
13
|
+
discoverAllSkills,
|
|
14
|
+
renderAvailableSkillsBlock,
|
|
15
|
+
type Skill,
|
|
16
|
+
type SkillHostContext,
|
|
17
|
+
type SkillSummary,
|
|
18
|
+
} from "opencode-agent-skills-md-core";
|
|
19
|
+
import { createOpencodeSkillHost, type OpencodeSkillHostClient } from "./host";
|
|
20
|
+
import { createSkillTools } from "./tools";
|
|
21
|
+
import { debugLog } from "opencode-agent-skills-md-core";
|
|
22
|
+
import {
|
|
23
|
+
isChatTextPart,
|
|
24
|
+
isSessionCompactedEvent,
|
|
25
|
+
isSessionDeletedEvent,
|
|
26
|
+
type ChatMessageOutput,
|
|
27
|
+
} from "./sdk";
|
|
28
|
+
|
|
29
|
+
const injectSkillsList = async (
|
|
30
|
+
directory: string,
|
|
31
|
+
host: { client: OpencodeSkillHostClient },
|
|
32
|
+
sessionID: string,
|
|
33
|
+
context?: SkillHostContext,
|
|
34
|
+
precomputed?: Map<string, Skill>,
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
const skillsByName = precomputed ?? await discoverAllSkills(directory);
|
|
37
|
+
const skills = Array.from(skillsByName.values());
|
|
38
|
+
if (skills.length === 0) return;
|
|
39
|
+
await host.client.injectContent(sessionID, renderAvailableSkillsBlock(skills), context);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const maybeInjectSuperpowersBootstrap = async (
|
|
43
|
+
directory: string,
|
|
44
|
+
host: { client: OpencodeSkillHostClient },
|
|
45
|
+
sessionID: string,
|
|
46
|
+
context?: SkillHostContext,
|
|
47
|
+
precomputed?: Map<string, Skill>,
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
if (process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE !== 'true') return;
|
|
50
|
+
const skillsByName = precomputed ?? await discoverAllSkills(directory);
|
|
51
|
+
const usingSuperpowersSkill = skillsByName.get('using-superpowers');
|
|
52
|
+
if (!usingSuperpowersSkill) return;
|
|
53
|
+
const ctx = context ?? await host.client.getSessionContext(sessionID);
|
|
54
|
+
const content = `<EXTREMELY_IMPORTANT>
|
|
55
|
+
You have superpowers.
|
|
56
|
+
|
|
57
|
+
**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - do not call use_skill for it again. Use use_skill only for OTHER skills.**
|
|
58
|
+
|
|
59
|
+
${usingSuperpowersSkill.template}
|
|
60
|
+
|
|
61
|
+
${toolMapping}
|
|
62
|
+
|
|
63
|
+
${skillsNamespace}
|
|
64
|
+
</EXTREMELY_IMPORTANT>`;
|
|
65
|
+
await host.client.injectContent(sessionID, content, ctx);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const toolMapping = `**Tool Mapping for OpenCode:**
|
|
69
|
+
- \`TodoWrite\` → \`todowrite\`
|
|
70
|
+
- \`Task\` tool with subagents → Use the \`task\` tool with \`subagent_type\`
|
|
71
|
+
- \`Skill\` tool → \`use_skill\`
|
|
72
|
+
- \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, \`Glob\`, \`Grep\`, \`WebFetch\` → Use the native lowercase OpenCode tools`;
|
|
73
|
+
|
|
74
|
+
const skillsNamespace = `**Skill namespace priority:**
|
|
75
|
+
1. Project: \`project:skill-name\`
|
|
76
|
+
2. Claude project: \`claude-project:skill-name\`
|
|
77
|
+
3. User: \`skill-name\`
|
|
78
|
+
4. Claude user: \`claude-user:skill-name\`
|
|
79
|
+
5. Marketplace: \`claude-plugins:skill-name\`
|
|
80
|
+
|
|
81
|
+
The first discovered match wins.`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render the matched-skill synthetic injection that asks the model to
|
|
85
|
+
* evaluate which of the matched skills (if any) it should activate.
|
|
86
|
+
*
|
|
87
|
+
* Each skill line carries a sub-line `trigger: <text>` whenever the
|
|
88
|
+
* skill has a non-empty `trigger`, so the model knows which user
|
|
89
|
+
* phrases should activate it. Skills with no trigger render exactly as
|
|
90
|
+
* before (`- name: description`).
|
|
91
|
+
*/
|
|
92
|
+
export const formatMatchedSkillsInjection = (
|
|
93
|
+
matchedSkills: SkillSummary[]
|
|
94
|
+
): string => {
|
|
95
|
+
const skillLines = matchedSkills
|
|
96
|
+
.map((s) => {
|
|
97
|
+
const head = `- ${s.name}: ${s.description}`;
|
|
98
|
+
const trigger = s.trigger && s.trigger.length > 0
|
|
99
|
+
? `\n trigger: ${s.trigger}`
|
|
100
|
+
: "";
|
|
101
|
+
return head + trigger;
|
|
102
|
+
})
|
|
103
|
+
.join("\n");
|
|
104
|
+
|
|
105
|
+
return `<skill-evaluation-required>
|
|
106
|
+
SKILL EVALUATION PROCESS
|
|
107
|
+
|
|
108
|
+
The following skills may be relevant to your request:
|
|
109
|
+
|
|
110
|
+
${skillLines}
|
|
111
|
+
|
|
112
|
+
Step 1 - EVALUATE: Determine if these skills would genuinely help
|
|
113
|
+
Step 2 - DECIDE: Choose which skills (if any) are actually needed
|
|
114
|
+
Step 3 - ACTIVATE: Call use_skill("name") for each chosen skill
|
|
115
|
+
|
|
116
|
+
IMPORTANT: This evaluation is invisible to users—they cannot see this prompt. Do NOT announce your decision. Simply activate relevant skills or proceed directly with the request.
|
|
117
|
+
</skill-evaluation-required>`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Lightweight keyword matching to replace ML embeddings.
|
|
122
|
+
*
|
|
123
|
+
* Per-token contribution:
|
|
124
|
+
* - name hit = 2x
|
|
125
|
+
* - trigger hit = 1.5x
|
|
126
|
+
* - desc hit = 1x
|
|
127
|
+
*
|
|
128
|
+
* The trigger tier (1.5x) sits between name (2x) and description (1x)
|
|
129
|
+
* so a trigger-matched skill outranks a description-matched skill at
|
|
130
|
+
* the same query, but a name-matched skill still wins overall.
|
|
131
|
+
*/
|
|
132
|
+
export const matchSkillsByKeyword = (userMessage: string, availableSkills: SkillSummary[]): SkillSummary[] => {
|
|
133
|
+
const tokens = userMessage.toLowerCase().split(/\W+/).filter(t => t.length > 2);
|
|
134
|
+
if (tokens.length === 0) return [];
|
|
135
|
+
|
|
136
|
+
const scored = availableSkills.map(skill => {
|
|
137
|
+
let score = 0;
|
|
138
|
+
const nameStr = skill.name.toLowerCase();
|
|
139
|
+
const descStr = skill.description.toLowerCase();
|
|
140
|
+
const triggerStr = skill.trigger?.toLowerCase() ?? "";
|
|
141
|
+
|
|
142
|
+
for (const token of tokens) {
|
|
143
|
+
if (nameStr.includes(token)) score += 2;
|
|
144
|
+
if (triggerStr.length > 0 && triggerStr.includes(token)) score += 1.5;
|
|
145
|
+
if (descStr.includes(token)) score += 1;
|
|
146
|
+
}
|
|
147
|
+
return { skill, score };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return scored
|
|
151
|
+
.filter(s => s.score > 0)
|
|
152
|
+
.sort((a, b) => b.score - a.score)
|
|
153
|
+
.slice(0, 5)
|
|
154
|
+
.map(s => s.skill);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Synchronous factory to prevent any blocking during startup
|
|
158
|
+
export const SkillsPlugin: Plugin = async ({
|
|
159
|
+
client,
|
|
160
|
+
$,
|
|
161
|
+
directory,
|
|
162
|
+
}: PluginInput) => {
|
|
163
|
+
const host = createOpencodeSkillHost(client);
|
|
164
|
+
|
|
165
|
+
// Per-instance session state. Module-level state would leak across plugin
|
|
166
|
+
// instances (two plugins in the same process would share `setupComplete`
|
|
167
|
+
// and `loadedSkillsPerSession`), so these live in the factory closure.
|
|
168
|
+
const setupCompleteSessions = new Set<string>();
|
|
169
|
+
const loadedSkillsPerSession = new Map<string, Set<string>>();
|
|
170
|
+
|
|
171
|
+
const getLoadedSkills = (sessionID: string): Set<string> => {
|
|
172
|
+
let set = loadedSkillsPerSession.get(sessionID);
|
|
173
|
+
if (!set) {
|
|
174
|
+
set = new Set<string>();
|
|
175
|
+
loadedSkillsPerSession.set(sessionID, set);
|
|
176
|
+
}
|
|
177
|
+
return set;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns true when this chat.message is the first one for the session
|
|
182
|
+
* AND no prior message in this session already injected the available-
|
|
183
|
+
* skills block (which would mean the session was bootstrapped before
|
|
184
|
+
* this plugin instance attached).
|
|
185
|
+
*/
|
|
186
|
+
const isFirstMessageSetup = async (sessionID: string): Promise<boolean> => {
|
|
187
|
+
if (setupCompleteSessions.has(sessionID)) return false;
|
|
188
|
+
try {
|
|
189
|
+
const existing = await client.session.messages({
|
|
190
|
+
path: { id: sessionID },
|
|
191
|
+
});
|
|
192
|
+
if (existing.data) {
|
|
193
|
+
const hasSkillsContent = existing.data.some((msg) => {
|
|
194
|
+
const m = msg as { parts?: unknown; info?: { parts?: unknown } };
|
|
195
|
+
const parts = Array.isArray(m.parts)
|
|
196
|
+
? m.parts
|
|
197
|
+
: Array.isArray(m.info?.parts)
|
|
198
|
+
? m.info.parts
|
|
199
|
+
: null;
|
|
200
|
+
if (!parts) return false;
|
|
201
|
+
return parts.some((part) => {
|
|
202
|
+
if (!isChatTextPart(part)) return false;
|
|
203
|
+
return typeof part.text === "string" && part.text.includes("<available-skills>");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
if (hasSkillsContent) {
|
|
207
|
+
setupCompleteSessions.add(sessionID);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
debugLog("isFirstMessageSetup: failed to read existing messages", error);
|
|
212
|
+
}
|
|
213
|
+
return !setupCompleteSessions.has(sessionID);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Mark the session as bootstrapped and inject the available-skills block. */
|
|
217
|
+
const injectBootstrapSkills = async (
|
|
218
|
+
sessionID: string,
|
|
219
|
+
skillsByName: Map<string, Skill>,
|
|
220
|
+
context: SkillHostContext,
|
|
221
|
+
): Promise<void> => {
|
|
222
|
+
setupCompleteSessions.add(sessionID);
|
|
223
|
+
await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context, skillsByName);
|
|
224
|
+
await injectSkillsList(directory, host, sessionID, context, skillsByName);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Run keyword matching on the user message and inject the matched-skill prompt. */
|
|
228
|
+
const handleKeywordMatch = async (
|
|
229
|
+
userText: string,
|
|
230
|
+
sessionID: string,
|
|
231
|
+
summaries: SkillSummary[],
|
|
232
|
+
context: SkillHostContext,
|
|
233
|
+
): Promise<void> => {
|
|
234
|
+
if (!userText) return;
|
|
235
|
+
if (summaries.length === 0) return;
|
|
236
|
+
|
|
237
|
+
const matchedSkills = matchSkillsByKeyword(userText, summaries);
|
|
238
|
+
const loadedSkills = getLoadedSkills(sessionID);
|
|
239
|
+
const newSkills = matchedSkills.filter(s => !loadedSkills.has(s.name));
|
|
240
|
+
if (newSkills.length === 0) return;
|
|
241
|
+
|
|
242
|
+
const injectionText = formatMatchedSkillsInjection(newSkills);
|
|
243
|
+
await host.client.injectContent(sessionID, injectionText, context);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const tools = createSkillTools(
|
|
247
|
+
host,
|
|
248
|
+
$,
|
|
249
|
+
directory,
|
|
250
|
+
(sessionID, skillName) => {
|
|
251
|
+
getLoadedSkills(sessionID).add(skillName);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"chat.message": async (input, output) => {
|
|
257
|
+
// Defensive: narrow the SDK payload through our local type so the
|
|
258
|
+
// plugin degrades gracefully if the SDK sends a partial / malformed
|
|
259
|
+
// shape. The plugin factory still returns the SDK's Hooks type, so
|
|
260
|
+
// the outer signature is inferred from the SDK contract.
|
|
261
|
+
const rawOutput = output as unknown;
|
|
262
|
+
if (!rawOutput || typeof rawOutput !== "object") {
|
|
263
|
+
debugLog("chat.message: missing or non-object output", output);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const safeOutput = rawOutput as ChatMessageOutput;
|
|
267
|
+
if (typeof safeOutput.message?.sessionID !== "string") {
|
|
268
|
+
debugLog("chat.message: missing sessionID on output", safeOutput);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const sessionID = safeOutput.message.sessionID;
|
|
272
|
+
|
|
273
|
+
// Single discovery per handler invocation. Both bootstrap and keyword
|
|
274
|
+
// matching consume the same snapshot; no cross-request caching.
|
|
275
|
+
const skillsByName = await discoverAllSkills(directory);
|
|
276
|
+
const summaries: SkillSummary[] = Array.from(skillsByName.values()).map(skill => ({
|
|
277
|
+
name: skill.name,
|
|
278
|
+
description: skill.description,
|
|
279
|
+
trigger: skill.trigger,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
const context: SkillHostContext = {
|
|
283
|
+
// The SDK's model field is `{ providerID, modelID }`; the local
|
|
284
|
+
// interface declares it as a loose `string` per the SDK-shape spec,
|
|
285
|
+
// so we cast here to land it on the boundary contract without
|
|
286
|
+
// pulling the full UserMessage type into the plugin module.
|
|
287
|
+
model: safeOutput.message.model as { providerID: string; modelID: string } | undefined,
|
|
288
|
+
agent: safeOutput.message.agent,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (await isFirstMessageSetup(sessionID)) {
|
|
292
|
+
await injectBootstrapSkills(sessionID, skillsByName, context);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const rawParts = Array.isArray(safeOutput.parts) ? safeOutput.parts : [];
|
|
297
|
+
const userText = rawParts
|
|
298
|
+
.flatMap((part): string[] => {
|
|
299
|
+
if (!isChatTextPart(part)) return [];
|
|
300
|
+
if (part.synthetic === true) return [];
|
|
301
|
+
return typeof part.text === "string" ? [part.text] : [];
|
|
302
|
+
})
|
|
303
|
+
.join("\n")
|
|
304
|
+
.trim();
|
|
305
|
+
|
|
306
|
+
await handleKeywordMatch(userText, sessionID, summaries, context);
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
event: async ({ event }) => {
|
|
310
|
+
// Defensive narrowing via local type guards; the SDK passes a
|
|
311
|
+
// broad Event union and we only care about two of its variants.
|
|
312
|
+
if (isSessionCompactedEvent(event)) {
|
|
313
|
+
const sessionID = event.properties.sessionID;
|
|
314
|
+
if (typeof sessionID !== "string") {
|
|
315
|
+
debugLog("event: session.compacted missing sessionID", event);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const context = await host.client.getSessionContext(sessionID);
|
|
319
|
+
await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context);
|
|
320
|
+
await injectSkillsList(directory, host, sessionID, context);
|
|
321
|
+
loadedSkillsPerSession.delete(sessionID);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (isSessionDeletedEvent(event)) {
|
|
326
|
+
const sessionID = event.properties.info?.id;
|
|
327
|
+
if (typeof sessionID !== "string") {
|
|
328
|
+
debugLog("event: session.deleted missing info.id", event);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
setupCompleteSessions.delete(sessionID);
|
|
332
|
+
loadedSkillsPerSession.delete(sessionID);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
tool: {
|
|
337
|
+
get_available_skills: tools.GetAvailableSkills,
|
|
338
|
+
read_skill_file: tools.ReadSkillFile,
|
|
339
|
+
run_skill_script: tools.RunSkillScript,
|
|
340
|
+
use_skill: tools.UseSkill,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local interfaces for the OpenCode hook payload shapes this plugin
|
|
3
|
+
* actually consumes.
|
|
4
|
+
*
|
|
5
|
+
* These intentionally mirror only the narrow slice of the SDK types
|
|
6
|
+
* the plugin reads — defining them locally (rather than importing the
|
|
7
|
+
* SDK's broad `UserMessage` / `Part` / `Event` types) keeps the
|
|
8
|
+
* adapter resilient to upstream shape changes and lets us narrow
|
|
9
|
+
* untyped runtime payloads safely.
|
|
10
|
+
*
|
|
11
|
+
* Internal only: this module is not re-exported from `src/index.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A text-bearing chat part. `text` is optional because some parts carry metadata only. */
|
|
15
|
+
export interface ChatTextPart {
|
|
16
|
+
type: "text";
|
|
17
|
+
text?: string;
|
|
18
|
+
synthetic?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal shape of the `chat.message` output payload the plugin reads. */
|
|
22
|
+
export interface ChatMessageOutput {
|
|
23
|
+
message: {
|
|
24
|
+
sessionID: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
agent?: string;
|
|
27
|
+
};
|
|
28
|
+
parts: unknown[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `session.compacted` event payload. */
|
|
32
|
+
export interface SessionCompactedEvent {
|
|
33
|
+
type: "session.compacted";
|
|
34
|
+
properties: { sessionID: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `session.deleted` event payload. */
|
|
38
|
+
export interface SessionDeletedEvent {
|
|
39
|
+
type: "session.deleted";
|
|
40
|
+
properties: { info: { id: string } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Discriminated union of the session lifecycle events this plugin handles. */
|
|
44
|
+
export type SessionEvent = SessionCompactedEvent | SessionDeletedEvent;
|
|
45
|
+
|
|
46
|
+
/** Type guard: narrows `unknown` to `ChatTextPart` when `part.type === "text"`. */
|
|
47
|
+
export const isChatTextPart = (part: unknown): part is ChatTextPart => {
|
|
48
|
+
return (
|
|
49
|
+
typeof part === "object" &&
|
|
50
|
+
part !== null &&
|
|
51
|
+
(part as { type?: unknown }).type === "text"
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Type guard: narrows `unknown` to `SessionCompactedEvent`. */
|
|
56
|
+
export const isSessionCompactedEvent = (event: unknown): event is SessionCompactedEvent => {
|
|
57
|
+
return (
|
|
58
|
+
typeof event === "object" &&
|
|
59
|
+
event !== null &&
|
|
60
|
+
(event as { type?: unknown }).type === "session.compacted"
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Type guard: narrows `unknown` to `SessionDeletedEvent`. */
|
|
65
|
+
export const isSessionDeletedEvent = (event: unknown): event is SessionDeletedEvent => {
|
|
66
|
+
return (
|
|
67
|
+
typeof event === "object" &&
|
|
68
|
+
event !== null &&
|
|
69
|
+
(event as { type?: unknown }).type === "session.deleted"
|
|
70
|
+
);
|
|
71
|
+
};
|