opencode-snippets 2.2.4 → 3.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/dist/index.js CHANGED
@@ -1,736 +1,2323 @@
1
- import { rmdir, unlink } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { createCommandExecuteHandler } from "./src/commands.js";
5
- import { loadConfig } from "./src/config.js";
6
- import { assembleMessage, expandHashtags } from "./src/expander.js";
7
- import { InjectionManager } from "./src/injection-manager.js";
8
- import { loadSnippets } from "./src/loader.js";
9
- import { logger } from "./src/logger.js";
10
- import { deleteSessionMessage, deleteSessionPart, sendIgnoredMessage } from "./src/notification.js";
11
- import { refreshPendingDraftsForText } from "./src/pending-drafts.js";
12
- import { consumeSnippetReloadRequest } from "./src/reload-signal.js";
13
- import { executeShellCommands } from "./src/shell.js";
14
- import { SkillLoadManager } from "./src/skill-load-manager.js";
15
- import { loadSkills } from "./src/skill-loader.js";
16
- import { buildSkillPayloadsFromVisibleText, expandSkillLoads } from "./src/skill-loading.js";
17
- import { expandSkillTags } from "./src/skill-renderer.js";
18
- const __filename = fileURLToPath(import.meta.url);
19
- const __dirname = dirname(__filename);
20
- const PLUGIN_ROOT = join(__dirname, "..");
21
- const SKILL_DIR = join(PLUGIN_ROOT, "skill");
22
- const MARKER_ID_RANDOM_FILL = "0000000000";
23
- /**
24
- * Clean up legacy skill installation from pre-v1.7.0
25
- * We used to force-install SKILL.md to ~/.config/opencode/skill/snippets/
26
- * Now we register the skill path instead, so we remove the orphaned file.
27
- *
28
- * TODO: Remove this cleanup code around mid-2026 when most users have upgraded.
29
- */
30
- async function cleanupLegacySkillInstall() {
31
- const home = process.env.HOME || process.env.USERPROFILE || "";
32
- if (!home)
33
- return;
34
- const legacySkillDir = join(home, ".config", "opencode", "skill", "snippets");
35
- const legacySkillPath = join(legacySkillDir, "SKILL.md");
36
- try {
37
- const file = Bun.file(legacySkillPath);
38
- if (await file.exists()) {
39
- await unlink(legacySkillPath);
40
- logger.debug("Cleaned up legacy skill file", { path: legacySkillPath });
41
- // Try to remove the empty directory too
42
- await rmdir(legacySkillDir).catch(() => {
43
- // Directory not empty or doesn't exist - that's fine
44
- });
1
+ // index.ts
2
+ import { access as access2, rmdir, unlink as unlink2 } from "node:fs/promises";
3
+ import { dirname as dirname3, join as join7 } from "node:path";
4
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5
+
6
+ // src/arg-parser.ts
7
+ function parseCommandArgs(input) {
8
+ const args = [];
9
+ let current = "";
10
+ let state = "normal";
11
+ let hasQuotedContent = false;
12
+ let i = 0;
13
+ while (i < input.length) {
14
+ const char = input[i];
15
+ if (state === "normal") {
16
+ if (char === " " || char === "\t") {
17
+ if (current.length > 0 || hasQuotedContent) {
18
+ args.push(current);
19
+ current = "";
20
+ hasQuotedContent = false;
45
21
  }
22
+ } else if (char === '"') {
23
+ state = "double";
24
+ hasQuotedContent = true;
25
+ } else if (char === "'") {
26
+ state = "single";
27
+ hasQuotedContent = true;
28
+ } else {
29
+ current += char;
30
+ }
31
+ } else if (state === "double") {
32
+ if (char === "\\") {
33
+ const next = input[i + 1];
34
+ if (next === '"' || next === "\\") {
35
+ current += next;
36
+ i++;
37
+ } else {
38
+ current += char;
39
+ }
40
+ } else if (char === '"') {
41
+ state = "normal";
42
+ } else {
43
+ current += char;
44
+ }
45
+ } else if (state === "single") {
46
+ if (char === "\\") {
47
+ const next = input[i + 1];
48
+ if (next === "'" || next === "\\") {
49
+ current += next;
50
+ i++;
51
+ } else {
52
+ current += char;
53
+ }
54
+ } else if (char === "'") {
55
+ state = "normal";
56
+ } else {
57
+ current += char;
58
+ }
46
59
  }
47
- catch (err) {
48
- logger.debug("Failed to cleanup legacy skill", { error: String(err) });
49
- }
50
- }
51
- /**
52
- * Snippets Plugin for OpenCode
53
- *
54
- * Expands hashtag-based shortcuts in user messages into predefined text snippets.
55
- * Also provides /snippets commands for managing snippets.
56
- *
57
- * @see https://github.com/JosXa/opencode-snippets for full documentation
58
- */
59
- export const SnippetsPlugin = async (ctx) => {
60
- // Load configuration (global + project-local override)
61
- const config = loadConfig(ctx.directory);
62
- // Apply config settings
63
- logger.debugEnabled = config.logging.debug;
64
- // Clean up legacy skill installation (pre-v1.7.0)
65
- cleanupLegacySkillInstall();
66
- // Load all snippets at startup (global + project directory)
67
- const startupStart = performance.now();
68
- const snippets = await loadSnippets(ctx.directory);
69
- const opencodeSkillDirs = [];
70
- const loadRuntimeSkills = () => loadSkills(ctx.directory, { opencodeSkillDirs });
71
- // Load skills if either skill feature is enabled. The config hook below refreshes this
72
- // after OpenCode exposes paths registered by earlier plugins.
73
- let skills = new Map();
74
- if (config.experimental.skillRendering || config.experimental.skillLoading) {
75
- skills = await loadRuntimeSkills();
60
+ i++;
61
+ }
62
+ if (current.length > 0 || hasQuotedContent) {
63
+ args.push(current);
64
+ }
65
+ return args;
66
+ }
67
+
68
+ // src/constants.ts
69
+ import { homedir } from "node:os";
70
+ import { join } from "node:path";
71
+ var PATTERNS = {
72
+ HASHTAG: /#([a-z0-9\-_]+)/gi,
73
+ SHELL_COMMAND: /(!>?)`([^`]+)`/g,
74
+ SKILL_LOAD: /#skill\(\s*([^\r\n)]+?)\s*\)/gi,
75
+ SKILL_TAG_SELF_CLOSING: /<skill\s+name=["']([^"']+)["']\s*\/>/gi,
76
+ SKILL_TAG_BLOCK: /<skill>([^<]+)<\/skill>/gi
77
+ };
78
+ var PATHS = {
79
+ CONFIG_DIR: join(homedir(), ".config", "opencode"),
80
+ SNIPPETS_DIR: join(homedir(), ".config", "opencode", "snippet"),
81
+ SNIPPETS_DIR_ALT: join(homedir(), ".config", "opencode", "snippets"),
82
+ CONFIG_FILE_GLOBAL: join(homedir(), ".config", "opencode", "snippet", "config.jsonc")
83
+ };
84
+ function getProjectPaths(projectDir) {
85
+ const snippetDir = join(projectDir, ".opencode", "snippet");
86
+ return {
87
+ SNIPPETS_DIR: snippetDir,
88
+ SNIPPETS_DIR_ALT: join(projectDir, ".opencode", "snippets"),
89
+ CONFIG_FILE: join(snippetDir, "config.jsonc")
90
+ };
91
+ }
92
+ var CONFIG = {
93
+ SNIPPET_EXTENSION: ".md"
94
+ };
95
+
96
+ // src/loader.ts
97
+ import { access, mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
98
+ import { basename, join as join3 } from "node:path";
99
+
100
+ // src/cjs-interop.ts
101
+ async function importCjs(pkg) {
102
+ let val = await import(pkg);
103
+ for (let i = 0;i < 4; i++) {
104
+ if (val == null || typeof val === "function")
105
+ break;
106
+ if (!("default" in val) || val.default === undefined)
107
+ break;
108
+ val = val.default;
109
+ }
110
+ return val;
111
+ }
112
+
113
+ // src/logger.ts
114
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
115
+ import { join as join2 } from "path";
116
+ class Logger {
117
+ logDir;
118
+ debugEnabled;
119
+ silent;
120
+ constructor(logDirOverride, debugEnabled = false, silent = false) {
121
+ this.logDir = logDirOverride ?? join2(PATHS.CONFIG_DIR, "logs", "snippets");
122
+ this.debugEnabled = debugEnabled;
123
+ this.silent = silent;
124
+ }
125
+ ensureLogDir() {
126
+ if (!existsSync(this.logDir)) {
127
+ mkdirSync(this.logDir, { recursive: true });
76
128
  }
77
- const startupTime = performance.now() - startupStart;
78
- logger.debug("Plugin startup complete", {
79
- startupTimeMs: startupTime.toFixed(2),
80
- snippetCount: snippets.size,
81
- skillCount: skills.size,
82
- skillRenderingEnabled: config.experimental.skillRendering,
83
- skillLoadingEnabled: config.experimental.skillLoading,
84
- injectBlocksEnabled: config.experimental.injectBlocks,
85
- debugLogging: config.logging.debug,
86
- });
87
- // Create command handler
88
- const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory);
89
- const injectionManager = new InjectionManager();
90
- const skillLoadManager = new SkillLoadManager();
91
- const injectionMarkerIdsBySession = new Map();
92
- const injectionMarkerRenderQueueBySession = new Map();
93
- /**
94
- * Processes text parts for snippet expansion, skill rendering, and shell command execution.
95
- * Returns collected inject blocks from expanded snippets with snippet names.
96
- */
97
- const processTextParts = async (parts) => {
98
- const messageStart = performance.now();
99
- let expandTimeTotal = 0;
100
- let skillTimeTotal = 0;
101
- let shellTimeTotal = 0;
102
- let processedParts = 0;
103
- const allInjected = [];
104
- const expandOptions = {
105
- extractInject: config.experimental.injectBlocks,
106
- onInjectBlock: (block) => {
107
- allInjected.push(block);
108
- },
109
- };
110
- for (const part of parts) {
111
- if (part.type === "text" && part.text) {
112
- // 1. Expand skill tags if skill rendering is enabled
113
- if (config.experimental.skillRendering && skills.size > 0) {
114
- const skillStart = performance.now();
115
- part.text = expandSkillTags(part.text, skills);
116
- skillTimeTotal += performance.now() - skillStart;
117
- }
118
- const skillPayloads = [];
119
- const loadSkills = async () => {
120
- if (!config.experimental.skillLoading || skills.size === 0)
121
- return;
122
- const skillLoadResult = await expandSkillLoads(part.text || "", skills, snippets, {
123
- expandSkillTagsInContent: config.experimental.skillRendering,
124
- extractInject: config.experimental.injectBlocks,
125
- });
126
- part.text = skillLoadResult.text;
127
- skillPayloads.push(...skillLoadResult.payloads);
128
- };
129
- if (config.experimental.skillLoading && skills.size > 0) {
130
- const skillStart = performance.now();
131
- // User requirement: reserve explicit #skill(...) syntax even if a plain
132
- // #skill snippet also exists.
133
- await loadSkills();
134
- skillTimeTotal += performance.now() - skillStart;
135
- }
136
- // 2. Expand hashtags recursively with loop detection
137
- const expandStart = performance.now();
138
- if (await consumeSnippetReloadRequest(ctx.directory)) {
139
- const fresh = await loadSnippets(ctx.directory);
140
- snippets.clear();
141
- for (const [key, value] of fresh) {
142
- snippets.set(key, value);
143
- }
144
- }
145
- await refreshPendingDraftsForText(part.text, snippets, ctx.directory, async () => {
146
- await loadSnippets(ctx.directory).then((fresh) => {
147
- snippets.clear();
148
- for (const [key, value] of fresh) {
149
- snippets.set(key, value);
150
- }
151
- });
152
- });
153
- const expansionResult = expandHashtags(part.text, snippets, new Map(), expandOptions);
154
- part.text = assembleMessage(expansionResult);
155
- expandTimeTotal += performance.now() - expandStart;
156
- // User requirement: snippet-expanded text can also contain #skill(...),
157
- // so run skill loading again after hashtag expansion.
158
- if (config.experimental.skillLoading && skills.size > 0) {
159
- const skillStart = performance.now();
160
- await loadSkills();
161
- part.skillLoads = skillPayloads;
162
- skillTimeTotal += performance.now() - skillStart;
163
- }
164
- // 3. Execute shell commands: !`command` or !>`command`
165
- const shellStart = performance.now();
166
- part.text = await executeShellCommands(part.text, ctx);
167
- shellTimeTotal += performance.now() - shellStart;
168
- processedParts += 1;
169
- }
129
+ }
130
+ formatData(data) {
131
+ if (!data)
132
+ return "";
133
+ const parts = [];
134
+ for (const [key, value] of Object.entries(data)) {
135
+ if (value === undefined || value === null)
136
+ continue;
137
+ if (Array.isArray(value)) {
138
+ if (value.length === 0)
139
+ continue;
140
+ parts.push(`${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`);
141
+ } else if (typeof value === "object") {
142
+ const str = JSON.stringify(value);
143
+ if (str.length < 50) {
144
+ parts.push(`${key}=${str}`);
170
145
  }
171
- if (processedParts > 0) {
172
- const totalTime = performance.now() - messageStart;
173
- logger.debug("Text parts processing complete", {
174
- totalTimeMs: totalTime.toFixed(2),
175
- skillTimeMs: skillTimeTotal.toFixed(2),
176
- snippetExpandTimeMs: expandTimeTotal.toFixed(2),
177
- shellTimeMs: shellTimeTotal.toFixed(2),
178
- processedParts,
179
- injectedCount: allInjected.length,
180
- });
181
- }
182
- return allInjected;
183
- };
184
- const isIgnoredMessage = (message) => message.parts.some((part) => part.ignored);
185
- const isSkillContentMessage = (message) => message.parts.some((part) => part.type === "text" && (part.text || "").includes("<skill_content name="));
186
- const isInjectionMarkerMessage = (message) => isIgnoredMessage(message) &&
187
- message.parts.some((part) => part.type === "text" && part.text?.startsWith("↳ Injected "));
188
- const injectionMarkerPartIdsByMessage = (messages) => {
189
- const ids = new Map();
190
- for (const message of messages) {
191
- if (!message.info.id || !isInjectionMarkerMessage(message))
192
- continue;
193
- const partIds = message.parts
194
- .filter((part) => part.type === "text" && part.text?.startsWith("↳ Injected "))
195
- .map((part) => part.id)
196
- .filter((id) => !!id);
197
- if (partIds.length > 0)
198
- ids.set(message.info.id, partIds);
146
+ } else {
147
+ parts.push(`${key}=${value}`);
148
+ }
149
+ }
150
+ return parts.join(" ");
151
+ }
152
+ getCallerFile() {
153
+ const originalPrepareStackTrace = Error.prepareStackTrace;
154
+ try {
155
+ const err = new Error;
156
+ Error.prepareStackTrace = (_, stack2) => stack2;
157
+ const stack = err.stack;
158
+ Error.prepareStackTrace = originalPrepareStackTrace;
159
+ for (let i = 3;i < stack.length; i++) {
160
+ const filename = stack[i]?.getFileName();
161
+ if (filename && !filename.includes("logger.")) {
162
+ const match = filename.match(/([^/\\]+)\.[tj]s$/);
163
+ return match ? match[1] : "unknown";
199
164
  }
200
- return ids;
165
+ }
166
+ return "unknown";
167
+ } catch {
168
+ return "unknown";
169
+ }
170
+ }
171
+ write(level, component, message, data) {
172
+ if (this.silent)
173
+ return;
174
+ if (level === "DEBUG" && !this.debugEnabled)
175
+ return;
176
+ try {
177
+ this.ensureLogDir();
178
+ const timestamp = new Date().toISOString();
179
+ const dataStr = this.formatData(data);
180
+ const dailyLogDir = join2(this.logDir, "daily");
181
+ if (!existsSync(dailyLogDir)) {
182
+ mkdirSync(dailyLogDir, { recursive: true });
183
+ }
184
+ const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? ` | ${dataStr}` : ""}
185
+ `;
186
+ const logFile = join2(dailyLogDir, `${new Date().toISOString().split("T")[0]}.log`);
187
+ writeFileSync(logFile, logLine, { flag: "a" });
188
+ } catch {}
189
+ }
190
+ info(message, data) {
191
+ const component = this.getCallerFile();
192
+ this.write("INFO", component, message, data);
193
+ }
194
+ debug(message, data) {
195
+ const component = this.getCallerFile();
196
+ this.write("DEBUG", component, message, data);
197
+ }
198
+ warn(message, data) {
199
+ const component = this.getCallerFile();
200
+ this.write("WARN", component, message, data);
201
+ }
202
+ error(message, data) {
203
+ const component = this.getCallerFile();
204
+ this.write("ERROR", component, message, data);
205
+ }
206
+ }
207
+ var logger = new Logger(undefined, false, false);
208
+
209
+ // src/loader.ts
210
+ var matter = await importCjs("gray-matter");
211
+ function getGlobalSnippetDirs(globalDir) {
212
+ if (globalDir)
213
+ return [globalDir];
214
+ return [PATHS.SNIPPETS_DIR_ALT, PATHS.SNIPPETS_DIR];
215
+ }
216
+ function getProjectSnippetDirs(projectDir) {
217
+ const paths = getProjectPaths(projectDir);
218
+ return [paths.SNIPPETS_DIR_ALT, paths.SNIPPETS_DIR];
219
+ }
220
+ async function pathExists(path) {
221
+ try {
222
+ await access(path);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+ async function resolveWritableSnippetDir(projectDir) {
229
+ const paths = projectDir ? getProjectPaths(projectDir) : { SNIPPETS_DIR: PATHS.SNIPPETS_DIR, SNIPPETS_DIR_ALT: PATHS.SNIPPETS_DIR_ALT };
230
+ for (const dir of [paths.SNIPPETS_DIR, paths.SNIPPETS_DIR_ALT]) {
231
+ if (await pathExists(dir))
232
+ return dir;
233
+ }
234
+ return paths.SNIPPETS_DIR;
235
+ }
236
+ async function loadSnippets(projectDir, globalDir) {
237
+ const snippets = new Map;
238
+ for (const dir of getGlobalSnippetDirs(globalDir)) {
239
+ await loadFromDirectory(dir, snippets, "global");
240
+ }
241
+ if (projectDir) {
242
+ for (const dir of getProjectSnippetDirs(projectDir)) {
243
+ await loadFromDirectory(dir, snippets, "project");
244
+ }
245
+ }
246
+ return snippets;
247
+ }
248
+ async function loadFromDirectory(dir, registry, source) {
249
+ try {
250
+ const files = await readdir(dir);
251
+ for (const file of files) {
252
+ if (!file.endsWith(CONFIG.SNIPPET_EXTENSION))
253
+ continue;
254
+ const snippet = await loadSnippetFile(dir, file, source);
255
+ if (snippet) {
256
+ registerSnippet(registry, snippet);
257
+ }
258
+ }
259
+ logger.debug(`Loaded snippets from ${source} directory`, {
260
+ path: dir,
261
+ fileCount: files.length
262
+ });
263
+ } catch (error) {
264
+ logger.debug(`${source} snippets directory not found or unreadable`, {
265
+ path: dir,
266
+ error: error instanceof Error ? error.message : String(error)
267
+ });
268
+ }
269
+ }
270
+ async function loadSnippetFile(dir, filename, source) {
271
+ try {
272
+ const name = basename(filename, CONFIG.SNIPPET_EXTENSION);
273
+ const filePath = join3(dir, filename);
274
+ const fileContent = await readFile(filePath, "utf8");
275
+ const parsed = matter(fileContent);
276
+ const content = parsed.content.trim();
277
+ const frontmatter = parsed.data;
278
+ let aliases = [];
279
+ const aliasSource = frontmatter.aliases ?? frontmatter.alias;
280
+ if (aliasSource) {
281
+ if (Array.isArray(aliasSource)) {
282
+ aliases = aliasSource;
283
+ } else {
284
+ aliases = [aliasSource];
285
+ }
286
+ }
287
+ return {
288
+ name,
289
+ content,
290
+ aliases,
291
+ description: frontmatter.description,
292
+ filePath,
293
+ source
201
294
  };
202
- const countConversationMessages = (messages) => messages.filter((message) => !isIgnoredMessage(message)).length;
203
- const messageIdPrefix = (messageId) => {
204
- const match = /^msg_([0-9a-f]{12})/.exec(messageId);
205
- return match?.[1];
295
+ } catch (error) {
296
+ logger.warn("Failed to load snippet file", {
297
+ filename,
298
+ error: error instanceof Error ? error.message : String(error)
299
+ });
300
+ return null;
301
+ }
302
+ }
303
+ function registerSnippet(registry, snippet) {
304
+ const key = snippet.name.toLowerCase();
305
+ const existing = registry.get(key);
306
+ if (existing) {
307
+ for (const alias of existing.aliases) {
308
+ registry.delete(alias.toLowerCase());
309
+ }
310
+ }
311
+ registry.set(key, snippet);
312
+ for (const alias of snippet.aliases) {
313
+ registry.set(alias.toLowerCase(), snippet);
314
+ }
315
+ }
316
+ function listSnippets(registry) {
317
+ const seen = new Set;
318
+ const snippets = [];
319
+ for (const snippet of registry.values()) {
320
+ if (!seen.has(snippet.name)) {
321
+ seen.add(snippet.name);
322
+ snippets.push(snippet);
323
+ }
324
+ }
325
+ return snippets;
326
+ }
327
+ async function ensureSnippetsDir(projectDir) {
328
+ const dir = await resolveWritableSnippetDir(projectDir);
329
+ await mkdir(dir, { recursive: true });
330
+ return dir;
331
+ }
332
+ async function createSnippet(name, content, options = {}, projectDir) {
333
+ const dir = await ensureSnippetsDir(projectDir);
334
+ const filePath = join3(dir, `${name}${CONFIG.SNIPPET_EXTENSION}`);
335
+ const frontmatter = {};
336
+ if (options.aliases?.length) {
337
+ frontmatter.aliases = options.aliases;
338
+ }
339
+ if (options.description) {
340
+ frontmatter.description = options.description;
341
+ }
342
+ let fileContent;
343
+ if (Object.keys(frontmatter).length > 0) {
344
+ fileContent = matter.stringify(content, frontmatter);
345
+ } else {
346
+ fileContent = content;
347
+ }
348
+ await writeFile(filePath, fileContent, "utf8");
349
+ logger.info("Created snippet", { name, path: filePath });
350
+ return filePath;
351
+ }
352
+ async function deleteSnippet(name, projectDir) {
353
+ if (projectDir) {
354
+ const paths = getProjectPaths(projectDir);
355
+ for (const dir of [paths.SNIPPETS_DIR, paths.SNIPPETS_DIR_ALT]) {
356
+ const filePath = join3(dir, `${name}${CONFIG.SNIPPET_EXTENSION}`);
357
+ try {
358
+ await unlink(filePath);
359
+ logger.info("Deleted project snippet", { name, path: filePath });
360
+ return filePath;
361
+ } catch {}
362
+ }
363
+ }
364
+ for (const dir of [PATHS.SNIPPETS_DIR, PATHS.SNIPPETS_DIR_ALT]) {
365
+ const filePath = join3(dir, `${name}${CONFIG.SNIPPET_EXTENSION}`);
366
+ try {
367
+ await unlink(filePath);
368
+ logger.info("Deleted global snippet", { name, path: filePath });
369
+ return filePath;
370
+ } catch {}
371
+ }
372
+ logger.warn("Snippet not found for deletion", { name });
373
+ return null;
374
+ }
375
+ async function reloadSnippets(registry, projectDir) {
376
+ registry.clear();
377
+ const fresh = await loadSnippets(projectDir);
378
+ for (const [key, value] of fresh) {
379
+ registry.set(key, value);
380
+ }
381
+ }
382
+
383
+ // src/notification.ts
384
+ async function sendIgnoredMessage(client, sessionId, text, messageId) {
385
+ try {
386
+ await client.session.prompt({
387
+ path: { id: sessionId },
388
+ body: {
389
+ messageID: messageId,
390
+ noReply: true,
391
+ parts: [{ type: "text", text, ignored: true }]
392
+ }
393
+ });
394
+ } catch (error) {
395
+ logger.error("Failed to send ignored message", {
396
+ error: error instanceof Error ? error.message : String(error)
397
+ });
398
+ }
399
+ }
400
+ async function deleteSessionMessage(client, serverUrl, sessionId, messageId) {
401
+ try {
402
+ const session = client.session;
403
+ const sdkResponse = await session.deleteMessage?.({
404
+ sessionID: sessionId,
405
+ messageID: messageId
406
+ });
407
+ if (sdkResponse)
408
+ return sdkResponse.data !== false;
409
+ const legacySession = client.session;
410
+ const legacyResponse = await legacySession._client?.delete?.({
411
+ url: "/session/{id}/message/{messageID}",
412
+ path: { id: sessionId, messageID: messageId }
413
+ });
414
+ if (legacyResponse)
415
+ return legacyResponse.data !== false;
416
+ const url = new URL(`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, serverUrl);
417
+ const fetchResponse = await fetch(url, {
418
+ method: "DELETE",
419
+ signal: AbortSignal.timeout(1000)
420
+ });
421
+ if (fetchResponse.ok)
422
+ return true;
423
+ logger.debug("Failed to delete ignored message", {
424
+ messageId,
425
+ status: fetchResponse.status,
426
+ statusText: fetchResponse.statusText
427
+ });
428
+ return false;
429
+ } catch (error) {
430
+ logger.debug("Failed to delete ignored message", {
431
+ messageId,
432
+ error: error instanceof Error ? error.message : String(error)
433
+ });
434
+ return false;
435
+ }
436
+ }
437
+ async function deleteSessionPart(client, serverUrl, sessionId, messageId, partId) {
438
+ try {
439
+ const legacySession = client.session;
440
+ const legacyResponse = await legacySession._client?.delete?.({
441
+ url: "/session/{id}/message/{messageID}/part/{partID}",
442
+ path: { id: sessionId, messageID: messageId, partID: partId }
443
+ });
444
+ if (legacyResponse)
445
+ return legacyResponse.data !== false;
446
+ const url = new URL(`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}/part/${encodeURIComponent(partId)}`, serverUrl);
447
+ const fetchResponse = await fetch(url, {
448
+ method: "DELETE",
449
+ signal: AbortSignal.timeout(1000)
450
+ });
451
+ if (fetchResponse.ok)
452
+ return true;
453
+ logger.debug("Failed to delete ignored message part", {
454
+ messageId,
455
+ partId,
456
+ status: fetchResponse.status,
457
+ statusText: fetchResponse.statusText
458
+ });
459
+ return false;
460
+ } catch (error) {
461
+ logger.debug("Failed to delete ignored message part", {
462
+ messageId,
463
+ partId,
464
+ error: error instanceof Error ? error.message : String(error)
465
+ });
466
+ return false;
467
+ }
468
+ }
469
+
470
+ // src/commands.ts
471
+ var COMMAND_HANDLED_MARKER = "__SNIPPETS_COMMAND_HANDLED__";
472
+ function parseAddOptions(args) {
473
+ const result = {
474
+ aliases: [],
475
+ description: undefined,
476
+ isProject: false
477
+ };
478
+ for (let i = 0;i < args.length; i++) {
479
+ const arg = args[i];
480
+ if (!arg.startsWith("--")) {
481
+ continue;
482
+ }
483
+ if (arg === "--project") {
484
+ result.isProject = true;
485
+ continue;
486
+ }
487
+ if (arg === "--alias" || arg === "--aliases") {
488
+ const nextArg = args[i + 1];
489
+ if (nextArg && !nextArg.startsWith("--")) {
490
+ result.aliases = parseAliasValue(nextArg);
491
+ i++;
492
+ }
493
+ continue;
494
+ }
495
+ if (arg.startsWith("--alias=") || arg.startsWith("--aliases=")) {
496
+ const value = arg.includes("--aliases=") ? arg.slice("--aliases=".length) : arg.slice("--alias=".length);
497
+ result.aliases = parseAliasValue(value);
498
+ continue;
499
+ }
500
+ if (arg === "--desc" || arg === "--description") {
501
+ const nextArg = args[i + 1];
502
+ if (nextArg && !nextArg.startsWith("--")) {
503
+ result.description = nextArg;
504
+ i++;
505
+ }
506
+ continue;
507
+ }
508
+ if (arg.startsWith("--desc=") || arg.startsWith("--description=")) {
509
+ const value = arg.startsWith("--description=") ? arg.slice("--description=".length) : arg.slice("--desc=".length);
510
+ result.description = value;
511
+ }
512
+ }
513
+ return result;
514
+ }
515
+ function parseAliasValue(value) {
516
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
517
+ }
518
+ function createCommandExecuteHandler(client, snippets, projectDir) {
519
+ return async (input, output) => {
520
+ if (input.command === "snippets:reload") {
521
+ if (output) {
522
+ output.parts.length = 0;
523
+ }
524
+ await handleReloadCommand({
525
+ client,
526
+ sessionId: input.sessionID,
527
+ args: [],
528
+ rawArguments: input.arguments,
529
+ snippets,
530
+ projectDir
531
+ });
532
+ throw new Error(COMMAND_HANDLED_MARKER);
533
+ }
534
+ if (input.command !== "snippets")
535
+ return;
536
+ const args = parseCommandArgs(input.arguments);
537
+ const subcommand = args[0]?.toLowerCase() || "help";
538
+ const ctx = {
539
+ client,
540
+ sessionId: input.sessionID,
541
+ args: args.slice(1),
542
+ rawArguments: input.arguments,
543
+ snippets,
544
+ projectDir
206
545
  };
207
- const markerSuffix = (targetPosition, index) => {
208
- const suffix = `000${(index + 1).toString(36)}`.slice(-3);
209
- if (targetPosition === 0)
210
- return `${MARKER_ID_RANDOM_FILL}${suffix}`;
211
- return `zzzzzzzzzzz${suffix}`;
546
+ try {
547
+ switch (subcommand) {
548
+ case "add":
549
+ case "create":
550
+ case "new":
551
+ await handleAddCommand(ctx);
552
+ break;
553
+ case "delete":
554
+ case "remove":
555
+ case "rm":
556
+ await handleDeleteCommand(ctx);
557
+ break;
558
+ case "list":
559
+ case "ls":
560
+ await handleListCommand(ctx);
561
+ break;
562
+ default:
563
+ await handleHelpCommand(ctx);
564
+ break;
565
+ }
566
+ } catch (error) {
567
+ if (error instanceof Error && error.message === COMMAND_HANDLED_MARKER) {
568
+ throw error;
569
+ }
570
+ logger.error("Command execution failed", {
571
+ subcommand,
572
+ error: error instanceof Error ? error.message : String(error)
573
+ });
574
+ await sendIgnoredMessage(ctx.client, ctx.sessionId, `Error: ${error instanceof Error ? error.message : String(error)}`);
575
+ }
576
+ throw new Error(COMMAND_HANDLED_MARKER);
577
+ };
578
+ }
579
+ async function handleAddCommand(ctx) {
580
+ const { client, sessionId, args, snippets, projectDir } = ctx;
581
+ if (args.length === 0) {
582
+ await sendIgnoredMessage(client, sessionId, `Usage: /snippets add <name> ["content"] [options]
583
+
584
+ ` + `Adds a new snippet. Defaults to global directory.
585
+
586
+ ` + `Examples:
587
+ ` + ` /snippets add greeting
588
+ ` + ` /snippets add bye "see you later"
589
+ ` + ` /snippets add hi "hello there" --aliases hello,hey
590
+ ` + ` /snippets add fix "fix imports" --project
591
+
592
+ ` + `Options:
593
+ ` + ` --project Add to project directory (.opencode/snippet/)
594
+ ` + ` --aliases X,Y,Z Add aliases (comma-separated)
595
+ ` + ' --desc "..." Add a description');
596
+ return;
597
+ }
598
+ const name = args[0];
599
+ let content = "";
600
+ let optionArgs = args.slice(1);
601
+ if (args[1] && !args[1].startsWith("--")) {
602
+ content = args[1];
603
+ optionArgs = args.slice(2);
604
+ }
605
+ const options = parseAddOptions(optionArgs);
606
+ const targetDir = options.isProject ? projectDir : undefined;
607
+ const location = options.isProject && projectDir ? "project" : "global";
608
+ try {
609
+ const filePath = await createSnippet(name, content, { aliases: options.aliases, description: options.description }, targetDir);
610
+ await reloadSnippets(snippets, projectDir);
611
+ let message = `Added ${location} snippet: ${name}
612
+ File: ${filePath}`;
613
+ if (content) {
614
+ message += `
615
+ Content: "${truncate(content, 50)}"`;
616
+ } else {
617
+ message += `
618
+
619
+ Edit the file to add your snippet content.`;
620
+ }
621
+ if (options.aliases.length > 0) {
622
+ message += `
623
+ Aliases: ${options.aliases.join(", ")}`;
624
+ }
625
+ await sendIgnoredMessage(client, sessionId, message);
626
+ } catch (error) {
627
+ await sendIgnoredMessage(client, sessionId, `Failed to add snippet: ${error instanceof Error ? error.message : String(error)}`);
628
+ }
629
+ }
630
+ async function handleDeleteCommand(ctx) {
631
+ const { client, sessionId, args, snippets, projectDir } = ctx;
632
+ if (args.length === 0) {
633
+ await sendIgnoredMessage(client, sessionId, `Usage: /snippets delete <name>
634
+
635
+ Deletes a snippet by name. ` + "Project snippets are checked first, then global.");
636
+ return;
637
+ }
638
+ const name = args[0];
639
+ const deletedPath = await deleteSnippet(name, projectDir);
640
+ if (deletedPath) {
641
+ await reloadSnippets(snippets, projectDir);
642
+ await sendIgnoredMessage(client, sessionId, `Deleted snippet: #${name}
643
+ Removed: ${deletedPath}`);
644
+ } else {
645
+ await sendIgnoredMessage(client, sessionId, `Snippet not found: #${name}
646
+
647
+ Use /snippets list to see available snippets.`);
648
+ }
649
+ }
650
+ async function handleReloadCommand(ctx) {
651
+ const { snippets, projectDir } = ctx;
652
+ await reloadSnippets(snippets, projectDir);
653
+ }
654
+ var MAX_CONTENT_PREVIEW_LENGTH = 200;
655
+ var MAX_ALIASES_LENGTH = 50;
656
+ var DIVIDER = "────────────────────────────────────────────────";
657
+ function truncate(text, maxLength) {
658
+ if (text.length <= maxLength)
659
+ return text;
660
+ return `${text.slice(0, maxLength - 3)}...`;
661
+ }
662
+ function formatAliases(aliases) {
663
+ if (aliases.length === 0)
664
+ return "";
665
+ const joined = aliases.join(", ");
666
+ if (joined.length <= MAX_ALIASES_LENGTH) {
667
+ return ` (aliases: ${joined})`;
668
+ }
669
+ const truncated = truncate(joined, MAX_ALIASES_LENGTH - 10);
670
+ return ` (aliases: ${truncated} +${aliases.length})`;
671
+ }
672
+ function globalSnippetLocations() {
673
+ return `${PATHS.SNIPPETS_DIR}/ or ${PATHS.SNIPPETS_DIR_ALT}/`;
674
+ }
675
+ function projectSnippetLocations(projectDir) {
676
+ const paths = getProjectPaths(projectDir);
677
+ return `${paths.SNIPPETS_DIR}/ or ${paths.SNIPPETS_DIR_ALT}/`;
678
+ }
679
+ function formatSnippetEntry(s) {
680
+ const header = `${s.name}${formatAliases(s.aliases)}`;
681
+ const content = truncate(s.content.trim(), MAX_CONTENT_PREVIEW_LENGTH);
682
+ return `${header}
683
+ ${DIVIDER}
684
+ ${content || "(empty)"}`;
685
+ }
686
+ async function handleListCommand(ctx) {
687
+ const { client, sessionId, snippets, projectDir } = ctx;
688
+ const snippetList = listSnippets(snippets);
689
+ if (snippetList.length === 0) {
690
+ await sendIgnoredMessage(client, sessionId, `No snippets found.
691
+
692
+ ` + `Global snippets: ${globalSnippetLocations()}
693
+ ` + (projectDir ? `Project snippets: ${projectSnippetLocations(projectDir)}` : "No project directory detected.") + `
694
+
695
+ Use /snippets add <name> to add a new snippet.`);
696
+ return;
697
+ }
698
+ const lines = [];
699
+ const globalSnippets = snippetList.filter((s) => s.source === "global");
700
+ const projectSnippets = snippetList.filter((s) => s.source === "project");
701
+ if (globalSnippets.length > 0) {
702
+ lines.push(`── Global (${globalSnippetLocations()}) ──`, "");
703
+ for (const s of globalSnippets) {
704
+ lines.push(formatSnippetEntry(s), "");
705
+ }
706
+ }
707
+ if (projectSnippets.length > 0 && projectDir) {
708
+ lines.push(`── Project (${projectSnippetLocations(projectDir)}) ──`, "");
709
+ for (const s of projectSnippets) {
710
+ lines.push(formatSnippetEntry(s), "");
711
+ }
712
+ }
713
+ await sendIgnoredMessage(client, sessionId, lines.join(`
714
+ `).trimEnd());
715
+ }
716
+ async function handleHelpCommand(ctx) {
717
+ const { client, sessionId } = ctx;
718
+ const helpText = `Snippets Command - Manage text snippets
719
+
720
+ Usage: /snippets <command> [options]
721
+
722
+ Commands:
723
+ add <name> ["content"] [options]
724
+ --project Add to project directory (default: global)
725
+ --aliases X,Y,Z Add aliases (comma-separated)
726
+ --desc "..." Add a description
727
+
728
+ delete <name> Delete a snippet
729
+ list List all available snippets
730
+ /snippets:reload Reload snippet files from disk
731
+ help Show this help message
732
+
733
+ Snippet Locations:
734
+ Global: ~/.config/opencode/snippet/ or ~/.config/opencode/snippets/
735
+ Project: <project>/.opencode/snippet/ or <project>/.opencode/snippets/
736
+
737
+ Usage in messages:
738
+ Type #snippet-name to expand a snippet inline.
739
+ Snippets can reference other snippets recursively.
740
+
741
+ Examples:
742
+ /snippets add greeting
743
+ /snippets add bye "see you later"
744
+ /snippets add hi "hello there" --aliases hello,hey
745
+ /snippets add fix "fix imports" --project
746
+ /snippets delete old-snippet
747
+ /snippets list
748
+ /snippets:reload`;
749
+ await sendIgnoredMessage(client, sessionId, helpText);
750
+ }
751
+
752
+ // src/config.ts
753
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "node:fs";
754
+ var { parse: parseJsonc } = await importCjs("jsonc-parser");
755
+ var DEFAULT_CONFIG = {
756
+ logging: {
757
+ debug: false
758
+ },
759
+ experimental: {
760
+ skillRendering: false,
761
+ skillLoading: false,
762
+ injectBlocks: false
763
+ },
764
+ injectRecencyMessages: 5
765
+ };
766
+ var DEFAULT_CONFIG_CONTENT = `{
767
+ // JSON Schema for editor autocompletion
768
+ "$schema": "https://raw.githubusercontent.com/JosXa/opencode-snippets/v3.0.0/schema/config.schema.json",
769
+
770
+ // Logging settings
771
+ "logging": {
772
+ // Enable debug logging to file
773
+ // Logs are written to ~/.config/opencode/logs/snippets/daily/
774
+ // Values: true, false, "enabled", "disabled"
775
+ // Default: false
776
+ "debug": false
777
+ },
778
+
779
+ // Experimental features (may change or be removed)
780
+ "experimental": {
781
+ // Enable skill rendering with <skill>name</skill> or <skill name="name" /> syntax
782
+ // When enabled, skill tags are replaced with the skill's content body
783
+ // Skills are loaded from OpenCode's standard skill directories
784
+ // Values: true, false, "enabled", "disabled"
785
+ // Default: false
786
+ "skillRendering": false,
787
+
788
+ // Enable #skill(name) syntax that shows a placeholder inline while injecting
789
+ // the OpenCode-style <skill_content> payload to the model after the message
790
+ // Values: true, false, "enabled", "disabled"
791
+ // Default: false
792
+ "skillLoading": false
793
+ },
794
+
795
+ // How many messages from the bottom of the conversation to place injected context
796
+ // Higher = injection feels "older" to the model, lower = closer to recent context
797
+ // Default: 5
798
+ "injectRecencyMessages": 5
799
+ }
800
+ `;
801
+ function normalizeBooleanSetting(value) {
802
+ if (value === undefined)
803
+ return;
804
+ if (typeof value === "boolean")
805
+ return value;
806
+ if (value === "enabled")
807
+ return true;
808
+ if (value === "disabled")
809
+ return false;
810
+ return;
811
+ }
812
+ function normalizePositiveInteger(value) {
813
+ if (value === undefined)
814
+ return;
815
+ const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
816
+ if (!Number.isFinite(parsed) || parsed < 1)
817
+ return;
818
+ return Math.floor(parsed);
819
+ }
820
+ function parseJsoncFile(filePath) {
821
+ try {
822
+ const content = readFileSync(filePath, "utf-8");
823
+ const parsed = parseJsonc(content);
824
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
825
+ return parsed;
826
+ }
827
+ logger.warn("Config file has invalid structure, using defaults", { filePath });
828
+ return {};
829
+ } catch (error) {
830
+ logger.warn("Failed to parse config file", {
831
+ filePath,
832
+ error: error instanceof Error ? error.message : String(error)
833
+ });
834
+ return {};
835
+ }
836
+ }
837
+ function ensureGlobalConfigExists() {
838
+ if (!existsSync2(PATHS.SNIPPETS_DIR)) {
839
+ mkdirSync2(PATHS.SNIPPETS_DIR, { recursive: true });
840
+ logger.debug("Created global snippets directory", { path: PATHS.SNIPPETS_DIR });
841
+ }
842
+ if (!existsSync2(PATHS.CONFIG_FILE_GLOBAL)) {
843
+ writeFileSync2(PATHS.CONFIG_FILE_GLOBAL, DEFAULT_CONFIG_CONTENT, "utf-8");
844
+ logger.debug("Created default config file", { path: PATHS.CONFIG_FILE_GLOBAL });
845
+ }
846
+ }
847
+ function loadConfig(projectDir) {
848
+ ensureGlobalConfigExists();
849
+ let config = structuredClone(DEFAULT_CONFIG);
850
+ if (existsSync2(PATHS.CONFIG_FILE_GLOBAL)) {
851
+ const globalConfig = parseJsoncFile(PATHS.CONFIG_FILE_GLOBAL);
852
+ config = mergeConfig(config, globalConfig);
853
+ logger.debug("Loaded global config", { path: PATHS.CONFIG_FILE_GLOBAL });
854
+ }
855
+ if (projectDir) {
856
+ const projectPaths = getProjectPaths(projectDir);
857
+ if (existsSync2(projectPaths.CONFIG_FILE)) {
858
+ const projectConfig = parseJsoncFile(projectPaths.CONFIG_FILE);
859
+ config = mergeConfig(config, projectConfig);
860
+ logger.debug("Loaded project config", { path: projectPaths.CONFIG_FILE });
861
+ }
862
+ }
863
+ logger.debug("Final config", {
864
+ loggingDebug: config.logging.debug,
865
+ experimentalSkillRendering: config.experimental.skillRendering,
866
+ experimentalSkillLoading: config.experimental.skillLoading,
867
+ injectRecencyMessages: config.injectRecencyMessages
868
+ });
869
+ return config;
870
+ }
871
+ function mergeConfig(base, raw) {
872
+ const debugValue = normalizeBooleanSetting(raw.logging?.debug);
873
+ const skillRenderingValue = normalizeBooleanSetting(raw.experimental?.skillRendering);
874
+ const skillLoadingValue = normalizeBooleanSetting(raw.experimental?.skillLoading);
875
+ const injectBlocksValue = normalizeBooleanSetting(raw.experimental?.injectBlocks);
876
+ const injectRecencyValue = normalizePositiveInteger(raw.injectRecencyMessages);
877
+ return {
878
+ logging: {
879
+ debug: debugValue !== undefined ? debugValue : base.logging.debug
880
+ },
881
+ experimental: {
882
+ skillRendering: skillRenderingValue !== undefined ? skillRenderingValue : base.experimental.skillRendering,
883
+ skillLoading: skillLoadingValue !== undefined ? skillLoadingValue : base.experimental.skillLoading,
884
+ injectBlocks: injectBlocksValue !== undefined ? injectBlocksValue : base.experimental.injectBlocks
885
+ },
886
+ injectRecencyMessages: injectRecencyValue !== undefined ? injectRecencyValue : base.injectRecencyMessages
887
+ };
888
+ }
889
+
890
+ // src/expander.ts
891
+ var MAX_EXPANSION_COUNT = 15;
892
+ var BLOCK_TYPES = ["prepend", "append", "inject"];
893
+ function createCollector() {
894
+ return {
895
+ prepend: [],
896
+ append: [],
897
+ inject: [],
898
+ seen: new Set
899
+ };
900
+ }
901
+ function addBlock(collector, type, snippetName, content, onInjectBlock) {
902
+ if (!content)
903
+ return;
904
+ const key = `${type}\x00${snippetName.toLowerCase()}\x00${content}`;
905
+ if (collector.seen.has(key))
906
+ return;
907
+ collector.seen.add(key);
908
+ collector[type].push({ type, snippetName, content });
909
+ if (type === "inject") {
910
+ onInjectBlock?.({ snippetName, content });
911
+ }
912
+ }
913
+ function addNestedBlocks(collector, nested, onInjectBlock) {
914
+ for (const type of BLOCK_TYPES) {
915
+ for (const block of nested[type]) {
916
+ addBlock(collector, type, block.snippetName, block.content, onInjectBlock);
917
+ }
918
+ }
919
+ }
920
+ function expandBlock(block, registry, expansionCounts, options) {
921
+ const nested = createCollector();
922
+ const content = expandText(block, registry, expansionCounts, nested, {
923
+ ...options,
924
+ onInjectBlock: undefined
925
+ });
926
+ return { content, nested };
927
+ }
928
+ function expandText(text, registry, expansionCounts, collector, options) {
929
+ const { onInjectBlock } = options;
930
+ let expanded = text;
931
+ let hasChanges = true;
932
+ while (hasChanges) {
933
+ const previous = expanded;
934
+ let loopDetected = false;
935
+ PATTERNS.HASHTAG.lastIndex = 0;
936
+ expanded = expanded.replace(PATTERNS.HASHTAG, (match, name, offset, input) => {
937
+ if (name.toLowerCase() === "skill" && input[offset + match.length] === "(") {
938
+ return match;
939
+ }
940
+ const snippet = registry.get(name.toLowerCase());
941
+ if (snippet === undefined) {
942
+ return match;
943
+ }
944
+ const key = snippet.name.toLowerCase();
945
+ const count = (expansionCounts.get(key) || 0) + 1;
946
+ if (count > MAX_EXPANSION_COUNT) {
947
+ logger.warn(`Loop detected: snippet '#${key}' expanded ${count} times (max: ${MAX_EXPANSION_COUNT})`);
948
+ loopDetected = true;
949
+ return match;
950
+ }
951
+ expansionCounts.set(key, count);
952
+ const parsed = parseSnippetBlocks(snippet.content, options);
953
+ if (parsed === null) {
954
+ logger.warn(`Failed to parse snippet '${key}', leaving hashtag unchanged`);
955
+ return match;
956
+ }
957
+ if (parsed.inline === "" && parsed.prepend.length === 0 && parsed.append.length === 0 && parsed.inject.length === 0) {
958
+ return match;
959
+ }
960
+ for (const block of parsed.prepend) {
961
+ const expanded2 = expandBlock(block, registry, expansionCounts, options);
962
+ addBlock(collector, "prepend", snippet.name, expanded2.content, onInjectBlock);
963
+ addNestedBlocks(collector, expanded2.nested, onInjectBlock);
964
+ }
965
+ for (const block of parsed.append) {
966
+ const expanded2 = expandBlock(block, registry, expansionCounts, options);
967
+ addBlock(collector, "append", snippet.name, expanded2.content, onInjectBlock);
968
+ addNestedBlocks(collector, expanded2.nested, onInjectBlock);
969
+ }
970
+ for (const block of parsed.inject) {
971
+ const expanded2 = expandBlock(block, registry, expansionCounts, options);
972
+ addBlock(collector, "inject", snippet.name, expanded2.content, onInjectBlock);
973
+ addNestedBlocks(collector, expanded2.nested, onInjectBlock);
974
+ }
975
+ return expandText(parsed.inline, registry, expansionCounts, collector, options);
976
+ });
977
+ hasChanges = expanded !== previous && !loopDetected;
978
+ }
979
+ return expanded;
980
+ }
981
+ function parseSnippetBlocks(content, options = {}) {
982
+ const { extractInject = true } = options;
983
+ const prepend = [];
984
+ const append = [];
985
+ const inject = [];
986
+ let inline = "";
987
+ const tagTypes = extractInject ? "prepend|append|inject" : "prepend|append";
988
+ const tagPattern = new RegExp(`<(/?)(?<tagName>${tagTypes})>`, "gi");
989
+ let lastIndex = 0;
990
+ let currentBlock = null;
991
+ let match = tagPattern.exec(content);
992
+ while (match !== null) {
993
+ const isClosing = match[1] === "/";
994
+ const tagName = match.groups?.tagName?.toLowerCase();
995
+ const tagStart = match.index;
996
+ const tagEnd = tagStart + match[0].length;
997
+ if (isClosing) {
998
+ if (currentBlock === null) {
999
+ continue;
1000
+ }
1001
+ if (currentBlock.type !== tagName) {
1002
+ logger.warn(`Mismatched closing tag: expected </${currentBlock.type}>, found </${tagName}>`);
1003
+ return null;
1004
+ }
1005
+ const blockContent = content.slice(currentBlock.contentStart, tagStart).trim();
1006
+ if (blockContent) {
1007
+ if (currentBlock.type === "prepend") {
1008
+ prepend.push(blockContent);
1009
+ } else if (currentBlock.type === "append") {
1010
+ append.push(blockContent);
1011
+ } else {
1012
+ inject.push(blockContent);
1013
+ }
1014
+ }
1015
+ lastIndex = tagEnd;
1016
+ currentBlock = null;
1017
+ } else {
1018
+ if (currentBlock !== null) {
1019
+ logger.warn(`Nested tags not allowed: found <${tagName}> inside <${currentBlock.type}>`);
1020
+ return null;
1021
+ }
1022
+ const inlinePart = content.slice(lastIndex, tagStart);
1023
+ inline += inlinePart;
1024
+ currentBlock = { type: tagName, startIndex: tagStart, contentStart: tagEnd };
1025
+ }
1026
+ match = tagPattern.exec(content);
1027
+ }
1028
+ if (currentBlock !== null) {
1029
+ const blockContent = content.slice(currentBlock.contentStart).trim();
1030
+ if (blockContent) {
1031
+ if (currentBlock.type === "prepend") {
1032
+ prepend.push(blockContent);
1033
+ } else if (currentBlock.type === "append") {
1034
+ append.push(blockContent);
1035
+ } else {
1036
+ inject.push(blockContent);
1037
+ }
1038
+ }
1039
+ } else {
1040
+ inline += content.slice(lastIndex);
1041
+ }
1042
+ return {
1043
+ inline: inline.trim(),
1044
+ prepend,
1045
+ append,
1046
+ inject
1047
+ };
1048
+ }
1049
+ function expandHashtags(text, registry, expansionCounts = new Map, options = {}) {
1050
+ const collector = createCollector();
1051
+ const expanded = expandText(text, registry, expansionCounts, collector, options);
1052
+ return {
1053
+ text: expanded,
1054
+ prepend: collector.prepend.map((block) => block.content),
1055
+ append: collector.append.map((block) => block.content),
1056
+ inject: collector.inject.map((block) => block.content)
1057
+ };
1058
+ }
1059
+ function assembleMessage(result) {
1060
+ const parts = [];
1061
+ if (result.prepend.length > 0) {
1062
+ parts.push(result.prepend.join(`
1063
+
1064
+ `));
1065
+ }
1066
+ if (result.text.trim()) {
1067
+ parts.push(result.text);
1068
+ }
1069
+ if (result.append.length > 0) {
1070
+ parts.push(result.append.join(`
1071
+
1072
+ `));
1073
+ }
1074
+ return parts.join(`
1075
+
1076
+ `);
1077
+ }
1078
+
1079
+ // src/injection-manager.ts
1080
+ class InjectionManager {
1081
+ activeInjections = new Map;
1082
+ nextOrder = 0;
1083
+ touchInjections(sessionID, injections) {
1084
+ if (injections.length === 0)
1085
+ return false;
1086
+ const session = this.getOrCreateSession(sessionID);
1087
+ let hasNew = false;
1088
+ for (const injection of injections) {
1089
+ const key = this.getInjectionKey(injection);
1090
+ const existing = session.get(key);
1091
+ if (existing) {
1092
+ existing.snippetName = injection.snippetName;
1093
+ existing.content = injection.content;
1094
+ continue;
1095
+ }
1096
+ session.set(key, {
1097
+ ...injection,
1098
+ key,
1099
+ order: this.nextOrder++
1100
+ });
1101
+ hasNew = true;
1102
+ }
1103
+ return hasNew;
1104
+ }
1105
+ getRenderableInjections(sessionID, messageCount, recencyWindow) {
1106
+ const session = this.activeInjections.get(sessionID);
1107
+ if (!session || session.size === 0) {
1108
+ return { injections: [], newlyRegistered: [] };
1109
+ }
1110
+ const window = Math.max(1, recencyWindow);
1111
+ const targetPosition = Math.max(0, messageCount - window);
1112
+ const injections = [...session.values()].sort((a, b) => a.order - b.order).map((injection) => ({
1113
+ ...injection,
1114
+ targetPosition
1115
+ }));
1116
+ return { injections, newlyRegistered: [] };
1117
+ }
1118
+ registerAndGetNew(sessionID, descriptors) {
1119
+ if (descriptors.length === 0)
1120
+ return [];
1121
+ const session = this.getOrCreateSession(sessionID);
1122
+ const newOnes = [];
1123
+ for (const desc of descriptors) {
1124
+ const key = this.getInjectionKey(desc);
1125
+ const existing = session.get(key);
1126
+ if (existing) {
1127
+ existing.snippetName = desc.snippetName;
1128
+ existing.content = desc.content;
1129
+ continue;
1130
+ }
1131
+ const injection = {
1132
+ ...desc,
1133
+ key,
1134
+ order: this.nextOrder++
1135
+ };
1136
+ session.set(key, injection);
1137
+ newOnes.push(injection);
1138
+ }
1139
+ return newOnes;
1140
+ }
1141
+ clearSession(sessionID) {
1142
+ if (this.activeInjections.has(sessionID)) {
1143
+ this.activeInjections.delete(sessionID);
1144
+ logger.debug("Cleared active injections", { sessionID });
1145
+ }
1146
+ }
1147
+ getOrCreateSession(sessionID) {
1148
+ let session = this.activeInjections.get(sessionID);
1149
+ if (!session) {
1150
+ session = new Map;
1151
+ this.activeInjections.set(sessionID, session);
1152
+ }
1153
+ return session;
1154
+ }
1155
+ getInjectionKey(injection) {
1156
+ return `${injection.snippetName}\x00${injection.content}`;
1157
+ }
1158
+ }
1159
+
1160
+ // src/pending-drafts.ts
1161
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
1162
+ import { join as join4 } from "node:path";
1163
+ function statePath() {
1164
+ return join4(PATHS.CONFIG_DIR, "state", "pending-drafts.json");
1165
+ }
1166
+ function scopeKey(workspaceDir) {
1167
+ return workspaceDir || "__global__";
1168
+ }
1169
+ function normalizeNames(names) {
1170
+ return [...new Set(names.map((name) => name.trim().toLowerCase()).filter(Boolean))];
1171
+ }
1172
+ function normalizeState(value) {
1173
+ if (!value || typeof value !== "object" || Array.isArray(value))
1174
+ return {};
1175
+ return Object.fromEntries(Object.entries(value).filter(([, names]) => Array.isArray(names)).map(([key, names]) => [
1176
+ key,
1177
+ normalizeNames(names.filter((name) => typeof name === "string"))
1178
+ ]).filter(([, names]) => names.length > 0));
1179
+ }
1180
+ async function readState() {
1181
+ try {
1182
+ return normalizeState(JSON.parse(await readFile2(statePath(), "utf8")));
1183
+ } catch (error) {
1184
+ if (error.code === "ENOENT")
1185
+ return {};
1186
+ logger.warn("Failed to read pending draft state", {
1187
+ error: error instanceof Error ? error.message : String(error),
1188
+ path: statePath()
1189
+ });
1190
+ return {};
1191
+ }
1192
+ }
1193
+ async function writeState(state) {
1194
+ await mkdir2(join4(PATHS.CONFIG_DIR, "state"), { recursive: true });
1195
+ await writeFile2(statePath(), `${JSON.stringify(state, null, 2)}
1196
+ `, "utf8");
1197
+ }
1198
+ function usedHashtags(text) {
1199
+ const used = new Set;
1200
+ const pattern = new RegExp(PATTERNS.HASHTAG);
1201
+ for (const match of text.matchAll(pattern)) {
1202
+ const token = match[0] || "";
1203
+ const name = match[1]?.toLowerCase();
1204
+ const index = match.index ?? -1;
1205
+ if (!name || index < 0)
1206
+ continue;
1207
+ if (name === "skill" && text[index + token.length] === "(")
1208
+ continue;
1209
+ used.add(name);
1210
+ }
1211
+ return used;
1212
+ }
1213
+ async function getPendingDrafts(workspaceDir) {
1214
+ const state = await readState();
1215
+ return state[scopeKey(workspaceDir)] || [];
1216
+ }
1217
+ async function removePendingDrafts(workspaceDir, names) {
1218
+ const key = scopeKey(workspaceDir);
1219
+ const remove = new Set(normalizeNames(names));
1220
+ if (remove.size === 0)
1221
+ return;
1222
+ const state = await readState();
1223
+ const next = (state[key] || []).filter((name) => !remove.has(name));
1224
+ if (next.length > 0) {
1225
+ state[key] = next;
1226
+ } else {
1227
+ delete state[key];
1228
+ }
1229
+ await writeState(state);
1230
+ }
1231
+ async function refreshPendingDraftsForText(text, registry, workspaceDir, reload) {
1232
+ const pending = await getPendingDrafts(workspaceDir);
1233
+ if (pending.length === 0)
1234
+ return;
1235
+ const used = usedHashtags(text);
1236
+ const matched = pending.filter((name) => used.has(name));
1237
+ if (matched.length === 0)
1238
+ return;
1239
+ await reload();
1240
+ const resolved = matched.filter((name) => {
1241
+ const snippet = registry.get(name);
1242
+ return !!snippet?.content.trim();
1243
+ });
1244
+ if (resolved.length === 0)
1245
+ return;
1246
+ await removePendingDrafts(workspaceDir, resolved);
1247
+ }
1248
+
1249
+ // src/reload-signal.ts
1250
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
1251
+ import { join as join5 } from "node:path";
1252
+ function statePath2() {
1253
+ return join5(PATHS.CONFIG_DIR, "state", "snippet-reload.json");
1254
+ }
1255
+ function scopeKey2(workspaceDir) {
1256
+ return workspaceDir || "__global__";
1257
+ }
1258
+ function normalizeState2(value) {
1259
+ if (!value || typeof value !== "object" || Array.isArray(value))
1260
+ return {};
1261
+ return Object.fromEntries(Object.entries(value).filter(([, stamp]) => typeof stamp === "number" && Number.isFinite(stamp)).map(([key, stamp]) => [key, stamp]));
1262
+ }
1263
+ async function readState2() {
1264
+ try {
1265
+ return normalizeState2(JSON.parse(await readFile3(statePath2(), "utf8")));
1266
+ } catch (error) {
1267
+ if (error.code === "ENOENT")
1268
+ return {};
1269
+ logger.warn("Failed to read snippet reload signal", {
1270
+ error: error instanceof Error ? error.message : String(error),
1271
+ path: statePath2()
1272
+ });
1273
+ return {};
1274
+ }
1275
+ }
1276
+ async function writeState2(state) {
1277
+ await mkdir3(join5(PATHS.CONFIG_DIR, "state"), { recursive: true });
1278
+ await writeFile3(statePath2(), `${JSON.stringify(state, null, 2)}
1279
+ `, "utf8");
1280
+ }
1281
+ async function consumeSnippetReloadRequest(workspaceDir) {
1282
+ const key = scopeKey2(workspaceDir);
1283
+ const state = await readState2();
1284
+ if (typeof state[key] !== "number")
1285
+ return false;
1286
+ delete state[key];
1287
+ await writeState2(state);
1288
+ return true;
1289
+ }
1290
+
1291
+ // src/shell.ts
1292
+ import { exec } from "node:child_process";
1293
+ import { promisify } from "node:util";
1294
+ var execAsync = promisify(exec);
1295
+ async function executeShellCommands(text, ctx) {
1296
+ let result = text;
1297
+ PATTERNS.SHELL_COMMAND.lastIndex = 0;
1298
+ const matches = [...text.matchAll(PATTERNS.SHELL_COMMAND)];
1299
+ for (const match of matches) {
1300
+ const showCommand = match[1] === "!>";
1301
+ const cmd = match[2];
1302
+ const _placeholder = match[0];
1303
+ try {
1304
+ const output = await execAsync(cmd, { cwd: ctx.directory });
1305
+ const text2 = `${output.stdout}${output.stderr}`.trim();
1306
+ const replacement = showCommand ? `$ ${cmd}
1307
+ --> ${text2}` : text2;
1308
+ result = result.replace(_placeholder, replacement);
1309
+ } catch (error) {
1310
+ logger.warn("Shell command execution failed", {
1311
+ command: cmd,
1312
+ error: error instanceof Error ? error.message : String(error)
1313
+ });
1314
+ }
1315
+ }
1316
+ return result;
1317
+ }
1318
+
1319
+ // src/skill-load-manager.ts
1320
+ class SkillLoadManager {
1321
+ loads = new Map;
1322
+ pending = new Map;
1323
+ sessionPayloads = new Map;
1324
+ register(sessionID, messageID, payloads) {
1325
+ const session = this.getOrCreateSession(sessionID);
1326
+ session.set(messageID, [...payloads]);
1327
+ }
1328
+ queue(sessionID, payloads, messageID) {
1329
+ const session = this.pending.get(sessionID) || [];
1330
+ session.push({ messageID, payloads: [...payloads] });
1331
+ this.pending.set(sessionID, session);
1332
+ }
1333
+ get(sessionID, messageID) {
1334
+ return [...this.loads.get(sessionID)?.get(messageID) || []];
1335
+ }
1336
+ rememberForSession(sessionID, payloads) {
1337
+ const existing = this.sessionPayloads.get(sessionID) || [];
1338
+ const seen = new Set(existing);
1339
+ const merged = [...existing];
1340
+ for (const payload of payloads) {
1341
+ if (seen.has(payload))
1342
+ continue;
1343
+ seen.add(payload);
1344
+ merged.push(payload);
1345
+ }
1346
+ this.sessionPayloads.set(sessionID, merged);
1347
+ }
1348
+ getSessionPayloads(sessionID) {
1349
+ return [...this.sessionPayloads.get(sessionID) || []];
1350
+ }
1351
+ clearSession(sessionID) {
1352
+ this.loads.delete(sessionID);
1353
+ this.pending.delete(sessionID);
1354
+ this.sessionPayloads.delete(sessionID);
1355
+ }
1356
+ drainPending(sessionID) {
1357
+ const queued = this.pending.get(sessionID) || [];
1358
+ this.pending.delete(sessionID);
1359
+ return queued.map((entry) => ({
1360
+ messageID: entry.messageID,
1361
+ payloads: [...entry.payloads]
1362
+ }));
1363
+ }
1364
+ getOrCreateSession(sessionID) {
1365
+ const existing = this.loads.get(sessionID);
1366
+ if (existing)
1367
+ return existing;
1368
+ const created = new Map;
1369
+ this.loads.set(sessionID, created);
1370
+ return created;
1371
+ }
1372
+ }
1373
+
1374
+ // src/skill-loader.ts
1375
+ import { readdir as readdir2, readFile as readFile4, stat } from "node:fs/promises";
1376
+ import { homedir as homedir2 } from "node:os";
1377
+ import { dirname, join as join6, resolve } from "node:path";
1378
+ import { fileURLToPath } from "node:url";
1379
+ var matter2 = await importCjs("gray-matter");
1380
+ var __filename2 = fileURLToPath(import.meta.url);
1381
+ var __dirname2 = dirname(__filename2);
1382
+ function getGlobalSkillDirs(homeDir = homedir2()) {
1383
+ return [
1384
+ join6(homeDir, ".config", "opencode", "skill"),
1385
+ join6(homeDir, ".config", "opencode", "skills"),
1386
+ join6(homeDir, ".claude", "skills"),
1387
+ join6(homeDir, ".agents", "skills")
1388
+ ];
1389
+ }
1390
+ function getProjectSkillDirs(projectDir) {
1391
+ return [
1392
+ join6(projectDir, ".opencode", "skill"),
1393
+ join6(projectDir, ".opencode", "skills"),
1394
+ join6(projectDir, ".claude", "skills"),
1395
+ join6(projectDir, ".agents", "skills")
1396
+ ];
1397
+ }
1398
+ function getBundledSkillDirs() {
1399
+ return [join6(__dirname2, "..", "..", "skill")];
1400
+ }
1401
+ function uniqueDirs(dirs) {
1402
+ const seen = new Set;
1403
+ const result = [];
1404
+ for (const dir of dirs) {
1405
+ const key = resolve(dir);
1406
+ if (seen.has(key))
1407
+ continue;
1408
+ seen.add(key);
1409
+ result.push(dir);
1410
+ }
1411
+ return result;
1412
+ }
1413
+ async function exists(path) {
1414
+ try {
1415
+ await stat(path);
1416
+ return true;
1417
+ } catch {
1418
+ return false;
1419
+ }
1420
+ }
1421
+ async function getProjectSearchRoots(projectDir) {
1422
+ const roots = [];
1423
+ let dir = resolve(projectDir);
1424
+ while (true) {
1425
+ roots.push(dir);
1426
+ if (await exists(join6(dir, ".git"))) {
1427
+ break;
1428
+ }
1429
+ const parent = dirname(dir);
1430
+ if (parent === dir) {
1431
+ break;
1432
+ }
1433
+ dir = parent;
1434
+ }
1435
+ return roots.reverse();
1436
+ }
1437
+ async function loadSkills(projectDir, options = {}) {
1438
+ const skills = new Map;
1439
+ const bundledDirs = options.bundledSkillDirs || getBundledSkillDirs();
1440
+ for (const dir of uniqueDirs([...bundledDirs, ...options.opencodeSkillDirs || []])) {
1441
+ await loadFromDirectory2(dir, skills, "global");
1442
+ }
1443
+ if (options.opencodeSkillDirs && options.opencodeSkillDirs.length > 0) {
1444
+ logger.debug("Loaded OpenCode-exposed skill directories", { paths: options.opencodeSkillDirs });
1445
+ }
1446
+ for (const dir of getGlobalSkillDirs(options.homeDir)) {
1447
+ await loadFromDirectory2(dir, skills, "global");
1448
+ }
1449
+ if (projectDir) {
1450
+ for (const root of await getProjectSearchRoots(projectDir)) {
1451
+ for (const dir of getProjectSkillDirs(root)) {
1452
+ await loadFromDirectory2(dir, skills, "project");
1453
+ }
1454
+ }
1455
+ }
1456
+ logger.debug("Skills loaded", { count: skills.size });
1457
+ return skills;
1458
+ }
1459
+ async function loadFromDirectory2(dir, registry, source) {
1460
+ try {
1461
+ const entries = await readdir2(dir, { withFileTypes: true });
1462
+ for (const entry of entries) {
1463
+ if (!entry.isDirectory())
1464
+ continue;
1465
+ const skill = await loadSkill(dir, entry.name, source);
1466
+ if (skill) {
1467
+ registry.set(skill.name.toLowerCase(), skill);
1468
+ }
1469
+ }
1470
+ logger.debug(`Loaded skills from ${source} directory`, { path: dir });
1471
+ } catch {
1472
+ logger.debug(`${source} skill directory not found`, { path: dir });
1473
+ }
1474
+ }
1475
+ async function loadSkill(baseDir, skillName, source) {
1476
+ const filePath = join6(baseDir, skillName, "SKILL.md");
1477
+ try {
1478
+ const fileContent = await readFile4(filePath, "utf8");
1479
+ const parsed = matter2(fileContent);
1480
+ const content = parsed.content.trim();
1481
+ const frontmatter = parsed.data;
1482
+ const name = frontmatter.name || skillName;
1483
+ return {
1484
+ name,
1485
+ content,
1486
+ description: frontmatter.description,
1487
+ source,
1488
+ filePath
212
1489
  };
213
- const buildMarkerMessageId = (messages, targetPosition, index) => {
214
- const realMessages = messages.filter((message) => !isIgnoredMessage(message));
215
- const pivot = realMessages[Math.max(0, Math.min(realMessages.length - 1, targetPosition - 1))];
216
- if (!pivot?.info.id)
217
- return undefined;
218
- const prefix = messageIdPrefix(pivot.info.id);
219
- if (!prefix)
220
- return undefined;
221
- return `msg_${prefix}${markerSuffix(targetPosition, index)}`;
1490
+ } catch (error) {
1491
+ logger.warn("Failed to load skill", {
1492
+ skillName,
1493
+ error: error instanceof Error ? error.message : String(error)
1494
+ });
1495
+ return null;
1496
+ }
1497
+ }
1498
+ function getSkill(registry, name) {
1499
+ return registry.get(name.toLowerCase());
1500
+ }
1501
+
1502
+ // src/skill-loading.ts
1503
+ import { readdir as readdir3 } from "node:fs/promises";
1504
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
1505
+ import { pathToFileURL } from "node:url";
1506
+
1507
+ // src/skill-renderer.ts
1508
+ function expandSkillTags(text, registry) {
1509
+ let expanded = text;
1510
+ PATTERNS.SKILL_TAG_SELF_CLOSING.lastIndex = 0;
1511
+ expanded = expanded.replace(PATTERNS.SKILL_TAG_SELF_CLOSING, (match, name) => {
1512
+ const key = name.trim().toLowerCase();
1513
+ const skill = registry.get(key);
1514
+ if (!skill) {
1515
+ logger.warn(`Skill not found: '${name}', leaving tag unchanged`);
1516
+ return match;
1517
+ }
1518
+ logger.debug(`Expanded skill tag: ${name}`, { source: skill.source });
1519
+ return skill.content;
1520
+ });
1521
+ PATTERNS.SKILL_TAG_BLOCK.lastIndex = 0;
1522
+ expanded = expanded.replace(PATTERNS.SKILL_TAG_BLOCK, (match, name) => {
1523
+ const key = name.trim().toLowerCase();
1524
+ const skill = registry.get(key);
1525
+ if (!skill) {
1526
+ logger.warn(`Skill not found: '${name}', leaving tag unchanged`);
1527
+ return match;
1528
+ }
1529
+ logger.debug(`Expanded skill tag: ${name}`, { source: skill.source });
1530
+ return skill.content;
1531
+ });
1532
+ return expanded;
1533
+ }
1534
+
1535
+ // src/skill-loading.ts
1536
+ var SKILL_FILE_LIMIT = 10;
1537
+ function visibleSkillLoad(skill) {
1538
+ return `↳ Loaded ${skill.name}`;
1539
+ }
1540
+ function pluginNote(skill, marker) {
1541
+ return `Plugin note: \`${marker}\` is not instruction. Do not call \`skill\` again for ${skill.name}.`;
1542
+ }
1543
+ async function expandSkillLoads(text, registry, snippets, options) {
1544
+ PATTERNS.SKILL_LOAD.lastIndex = 0;
1545
+ const matches = [...text.matchAll(PATTERNS.SKILL_LOAD)];
1546
+ if (matches.length === 0) {
1547
+ return { text, payloads: [] };
1548
+ }
1549
+ let result = "";
1550
+ let lastIndex = 0;
1551
+ const payloads = [];
1552
+ for (const match of matches) {
1553
+ const index = match.index ?? 0;
1554
+ const token = match[0];
1555
+ const parsed = parseSkillName(match[1]);
1556
+ result += text.slice(lastIndex, index);
1557
+ lastIndex = index + token.length;
1558
+ if (!parsed) {
1559
+ result += token;
1560
+ continue;
1561
+ }
1562
+ const skill = getSkill(registry, parsed);
1563
+ if (!skill) {
1564
+ logger.warn(`Skill not found: '${parsed}', leaving syntax unchanged`);
1565
+ result += token;
1566
+ continue;
1567
+ }
1568
+ const marker = visibleSkillLoad(skill);
1569
+ payloads.push(await buildSkillPayload(skill, registry, snippets, marker, options));
1570
+ result += marker;
1571
+ }
1572
+ result += text.slice(lastIndex);
1573
+ return { text: result, payloads };
1574
+ }
1575
+ async function buildSkillPayloadsFromVisibleText(text, registry, snippets, options) {
1576
+ if (!text.includes("↳ Loaded ")) {
1577
+ return [];
1578
+ }
1579
+ const matches = [];
1580
+ const skills = [...registry.values()].map((skill) => ({ skill, marker: visibleSkillLoad(skill) })).toSorted((a, b) => b.marker.length - a.marker.length);
1581
+ for (const entry of skills) {
1582
+ let from = 0;
1583
+ while (from < text.length) {
1584
+ const start = text.indexOf(entry.marker, from);
1585
+ if (start === -1) {
1586
+ break;
1587
+ }
1588
+ const end = start + entry.marker.length;
1589
+ const overlaps = matches.some((match) => start < match.end && end > match.start);
1590
+ if (!overlaps) {
1591
+ matches.push({ start, end, skill: entry.skill, marker: entry.marker });
1592
+ break;
1593
+ }
1594
+ from = start + 1;
1595
+ }
1596
+ }
1597
+ if (matches.length === 0) {
1598
+ return [];
1599
+ }
1600
+ matches.sort((a, b) => a.start - b.start);
1601
+ return Promise.all(matches.map((match) => buildSkillPayload(match.skill, registry, snippets, match.marker, options)));
1602
+ }
1603
+ function parseSkillName(input) {
1604
+ if (!input)
1605
+ return null;
1606
+ const trimmed = input.trim();
1607
+ if (!trimmed)
1608
+ return null;
1609
+ const quote = trimmed[0];
1610
+ if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
1611
+ const inner = trimmed.slice(1, -1).trim();
1612
+ return inner || null;
1613
+ }
1614
+ return trimmed;
1615
+ }
1616
+ async function buildSkillPayload(skill, registry, _snippets, marker, options) {
1617
+ const dir = dirname2(skill.filePath);
1618
+ const base = pathToFileURL(dir).href;
1619
+ const files = await listSkillFiles(dir, SKILL_FILE_LIMIT);
1620
+ const content = renderSkillContent(skill.content, registry, options);
1621
+ return [
1622
+ `<skill_content name="${skill.name}">`,
1623
+ pluginNote(skill, marker),
1624
+ "",
1625
+ `# Skill: ${skill.name}`,
1626
+ "",
1627
+ content,
1628
+ "",
1629
+ `Base directory for this skill: ${base}`,
1630
+ "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
1631
+ "Note: file list is sampled.",
1632
+ "",
1633
+ "<skill_files>",
1634
+ files.map((file) => `<file>${file}</file>`).join(`
1635
+ `),
1636
+ "</skill_files>",
1637
+ "</skill_content>"
1638
+ ].join(`
1639
+ `);
1640
+ }
1641
+ function renderSkillContent(content, registry, options) {
1642
+ let processed = content;
1643
+ if (options.expandSkillTagsInContent) {
1644
+ processed = expandSkillTags(processed, registry);
1645
+ }
1646
+ return processed;
1647
+ }
1648
+ async function listSkillFiles(dir, limit) {
1649
+ const files = [];
1650
+ await walkSkillFiles(dir, files, limit);
1651
+ return files;
1652
+ }
1653
+ async function walkSkillFiles(dir, files, limit) {
1654
+ if (files.length >= limit)
1655
+ return;
1656
+ const entries = await readdir3(dir, { withFileTypes: true, encoding: "utf8" }).catch(() => null);
1657
+ if (!entries)
1658
+ return;
1659
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
1660
+ if (files.length >= limit)
1661
+ return;
1662
+ const filePath = resolve2(dir, entry.name);
1663
+ if (entry.isDirectory()) {
1664
+ await walkSkillFiles(filePath, files, limit);
1665
+ continue;
1666
+ }
1667
+ if (!entry.isFile())
1668
+ continue;
1669
+ if (filePath.includes("SKILL.md"))
1670
+ continue;
1671
+ files.push(filePath);
1672
+ }
1673
+ }
1674
+
1675
+ // index.ts
1676
+ var __filename3 = fileURLToPath2(import.meta.url);
1677
+ var __dirname3 = dirname3(__filename3);
1678
+ var PLUGIN_ROOT = join7(__dirname3, "..");
1679
+ var SKILL_DIR = join7(PLUGIN_ROOT, "skill");
1680
+ var MARKER_ID_RANDOM_FILL = "0000000000";
1681
+ async function cleanupLegacySkillInstall() {
1682
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1683
+ if (!home)
1684
+ return;
1685
+ const legacySkillDir = join7(home, ".config", "opencode", "skill", "snippets");
1686
+ const legacySkillPath = join7(legacySkillDir, "SKILL.md");
1687
+ try {
1688
+ await access2(legacySkillPath);
1689
+ await unlink2(legacySkillPath);
1690
+ logger.debug("Cleaned up legacy skill file", { path: legacySkillPath });
1691
+ await rmdir(legacySkillDir).catch(() => {});
1692
+ } catch (err) {
1693
+ logger.debug("Failed to cleanup legacy skill", { error: String(err) });
1694
+ }
1695
+ }
1696
+ var SnippetsPlugin = async (ctx) => {
1697
+ const config = loadConfig(ctx.directory);
1698
+ logger.debugEnabled = config.logging.debug;
1699
+ cleanupLegacySkillInstall();
1700
+ const startupStart = performance.now();
1701
+ const snippets = await loadSnippets(ctx.directory);
1702
+ const opencodeSkillDirs = [];
1703
+ const loadRuntimeSkills = () => loadSkills(ctx.directory, { opencodeSkillDirs });
1704
+ let skills = new Map;
1705
+ if (config.experimental.skillRendering || config.experimental.skillLoading) {
1706
+ skills = await loadRuntimeSkills();
1707
+ }
1708
+ const startupTime = performance.now() - startupStart;
1709
+ logger.debug("Plugin startup complete", {
1710
+ startupTimeMs: startupTime.toFixed(2),
1711
+ snippetCount: snippets.size,
1712
+ skillCount: skills.size,
1713
+ skillRenderingEnabled: config.experimental.skillRendering,
1714
+ skillLoadingEnabled: config.experimental.skillLoading,
1715
+ injectBlocksEnabled: config.experimental.injectBlocks,
1716
+ debugLogging: config.logging.debug
1717
+ });
1718
+ const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory);
1719
+ const injectionManager = new InjectionManager;
1720
+ const skillLoadManager = new SkillLoadManager;
1721
+ const injectionMarkerIdsBySession = new Map;
1722
+ const injectionMarkerRenderQueueBySession = new Map;
1723
+ const processTextParts = async (parts) => {
1724
+ const messageStart = performance.now();
1725
+ let expandTimeTotal = 0;
1726
+ let skillTimeTotal = 0;
1727
+ let shellTimeTotal = 0;
1728
+ let processedParts = 0;
1729
+ const allInjected = [];
1730
+ const expandOptions = {
1731
+ extractInject: config.experimental.injectBlocks,
1732
+ onInjectBlock: (block) => {
1733
+ allInjected.push(block);
1734
+ }
222
1735
  };
223
- const getPersistedMessages = async (sessionID) => {
224
- try {
225
- const response = await ctx.client.session.messages({ path: { id: sessionID } });
226
- return (response.data || []).map((message) => ({
227
- info: message.info,
228
- parts: message.parts,
229
- }));
1736
+ for (const part of parts) {
1737
+ if (part.type === "text" && part.text) {
1738
+ if (config.experimental.skillRendering && skills.size > 0) {
1739
+ const skillStart = performance.now();
1740
+ part.text = expandSkillTags(part.text, skills);
1741
+ skillTimeTotal += performance.now() - skillStart;
230
1742
  }
231
- catch (error) {
232
- logger.debug("Failed to load session messages for injection markers", {
233
- sessionID,
234
- error: error instanceof Error ? error.message : String(error),
235
- });
236
- return [];
237
- }
238
- };
239
- const renderInjectionMarkers = async (sessionID, messages, injections) => {
240
- if (injections.length === 0)
1743
+ const skillPayloads = [];
1744
+ const loadSkills2 = async () => {
1745
+ if (!config.experimental.skillLoading || skills.size === 0)
241
1746
  return;
242
- const next = new Set();
243
- const pending = [];
244
- injections.forEach((injection, index) => {
245
- const id = buildMarkerMessageId(messages, injection.targetPosition, index);
246
- if (!id)
247
- return;
248
- next.add(id);
249
- pending.push({ id, text: `↳ Injected ${injection.snippetName}` });
250
- });
251
- const persistedMarkerIds = messages
252
- .filter(isInjectionMarkerMessage)
253
- .map((message) => message.info.id)
254
- .filter((id) => !!id);
255
- const persistedMarkerPartIds = injectionMarkerPartIdsByMessage(messages);
256
- const previous = new Set([
257
- ...persistedMarkerIds,
258
- ...(injectionMarkerIdsBySession.get(sessionID) || []),
259
- ]);
260
- const kept = new Set();
261
- const current = new Set();
262
- logger.debug("Rendering injection markers", {
263
- sessionID,
264
- messageCount: countConversationMessages(messages),
265
- injectionCount: injections.length,
266
- nextMarkerIds: [...next],
267
- persistedMarkerIds,
268
- persistedMarkerPartIds: Object.fromEntries(persistedMarkerPartIds),
269
- previousMarkerIds: [...previous],
270
- });
271
- for (const id of previous) {
272
- if (!next.has(id)) {
273
- let deleted = true;
274
- for (const partId of persistedMarkerPartIds.get(id) || []) {
275
- const partDeleted = await deleteSessionPart(ctx.client, ctx.serverUrl, sessionID, id, partId);
276
- if (!partDeleted)
277
- deleted = false;
278
- }
279
- const messageDeleted = await deleteSessionMessage(ctx.client, ctx.serverUrl, sessionID, id);
280
- if (!messageDeleted)
281
- deleted = false;
282
- if (!deleted)
283
- kept.add(id);
284
- }
1747
+ const skillLoadResult = await expandSkillLoads(part.text || "", skills, snippets, {
1748
+ expandSkillTagsInContent: config.experimental.skillRendering,
1749
+ extractInject: config.experimental.injectBlocks
1750
+ });
1751
+ part.text = skillLoadResult.text;
1752
+ skillPayloads.push(...skillLoadResult.payloads);
1753
+ };
1754
+ if (config.experimental.skillLoading && skills.size > 0) {
1755
+ const skillStart = performance.now();
1756
+ await loadSkills2();
1757
+ skillTimeTotal += performance.now() - skillStart;
285
1758
  }
286
- if (kept.size > 0) {
287
- logger.debug("Skipping injection marker creation until stale markers delete", {
288
- sessionID,
289
- keptMarkerIds: [...kept],
290
- nextMarkerIds: [...next],
291
- });
292
- injectionMarkerIdsBySession.set(sessionID, kept);
293
- return;
1759
+ const expandStart = performance.now();
1760
+ if (await consumeSnippetReloadRequest(ctx.directory)) {
1761
+ const fresh = await loadSnippets(ctx.directory);
1762
+ snippets.clear();
1763
+ for (const [key, value] of fresh) {
1764
+ snippets.set(key, value);
1765
+ }
294
1766
  }
295
- for (const marker of pending) {
296
- current.add(marker.id);
297
- if (!previous.has(marker.id)) {
298
- await sendIgnoredMessage(ctx.client, sessionID, marker.text, marker.id);
1767
+ await refreshPendingDraftsForText(part.text, snippets, ctx.directory, async () => {
1768
+ await loadSnippets(ctx.directory).then((fresh) => {
1769
+ snippets.clear();
1770
+ for (const [key, value] of fresh) {
1771
+ snippets.set(key, value);
299
1772
  }
1773
+ });
1774
+ });
1775
+ const expansionResult = expandHashtags(part.text, snippets, new Map, expandOptions);
1776
+ part.text = assembleMessage(expansionResult);
1777
+ expandTimeTotal += performance.now() - expandStart;
1778
+ if (config.experimental.skillLoading && skills.size > 0) {
1779
+ const skillStart = performance.now();
1780
+ await loadSkills2();
1781
+ part.skillLoads = skillPayloads;
1782
+ skillTimeTotal += performance.now() - skillStart;
300
1783
  }
301
- injectionMarkerIdsBySession.set(sessionID, current);
302
- };
303
- const scheduleInjectionMarkerRender = (sessionID, messages, injections) => {
304
- const previous = injectionMarkerRenderQueueBySession.get(sessionID) || Promise.resolve();
305
- const next = previous
306
- .catch(() => undefined)
307
- .then(() => new Promise((resolve) => {
308
- // User requirement: marker maintenance must not block the real prompt.
309
- setTimeout(() => {
310
- renderInjectionMarkers(sessionID, messages, injections).then(resolve, (error) => {
311
- logger.debug("Failed to render injection markers", {
312
- sessionID,
313
- error: error instanceof Error ? error.message : String(error),
314
- });
315
- resolve();
316
- });
317
- }, 0);
318
- }));
319
- injectionMarkerRenderQueueBySession.set(sessionID, next);
320
- };
321
- const insertInjectionsIntoMessages = (messages, injections) => {
322
- if (injections.length === 0)
323
- return messages;
324
- const totalRealMessages = countConversationMessages(messages);
325
- const buckets = new Map();
326
- for (const injection of injections) {
327
- const position = Math.max(0, Math.min(totalRealMessages, injection.targetPosition));
328
- const existing = buckets.get(position) || [];
329
- existing.push(injection);
330
- buckets.set(position, existing);
1784
+ const shellStart = performance.now();
1785
+ part.text = await executeShellCommands(part.text, { directory: ctx.directory });
1786
+ shellTimeTotal += performance.now() - shellStart;
1787
+ processedParts += 1;
1788
+ }
1789
+ }
1790
+ if (processedParts > 0) {
1791
+ const totalTime = performance.now() - messageStart;
1792
+ logger.debug("Text parts processing complete", {
1793
+ totalTimeMs: totalTime.toFixed(2),
1794
+ skillTimeMs: skillTimeTotal.toFixed(2),
1795
+ snippetExpandTimeMs: expandTimeTotal.toFixed(2),
1796
+ shellTimeMs: shellTimeTotal.toFixed(2),
1797
+ processedParts,
1798
+ injectedCount: allInjected.length
1799
+ });
1800
+ }
1801
+ return allInjected;
1802
+ };
1803
+ const isIgnoredMessage = (message) => message.parts.some((part) => part.ignored);
1804
+ const isSkillContentMessage = (message) => message.parts.some((part) => part.type === "text" && (part.text || "").includes("<skill_content name="));
1805
+ const isInjectionMarkerMessage = (message) => isIgnoredMessage(message) && message.parts.some((part) => part.type === "text" && part.text?.startsWith("↳ Injected "));
1806
+ const injectionMarkerPartIdsByMessage = (messages) => {
1807
+ const ids = new Map;
1808
+ for (const message of messages) {
1809
+ if (!message.info.id || !isInjectionMarkerMessage(message))
1810
+ continue;
1811
+ const partIds = message.parts.filter((part) => part.type === "text" && part.text?.startsWith("↳ Injected ")).map((part) => part.id).filter((id) => !!id);
1812
+ if (partIds.length > 0)
1813
+ ids.set(message.info.id, partIds);
1814
+ }
1815
+ return ids;
1816
+ };
1817
+ const countConversationMessages = (messages) => messages.filter((message) => !isIgnoredMessage(message)).length;
1818
+ const messageIdPrefix = (messageId) => {
1819
+ const match = /^msg_([0-9a-f]{12})/.exec(messageId);
1820
+ return match?.[1];
1821
+ };
1822
+ const markerSuffix = (targetPosition, index) => {
1823
+ const suffix = `000${(index + 1).toString(36)}`.slice(-3);
1824
+ if (targetPosition === 0)
1825
+ return `${MARKER_ID_RANDOM_FILL}${suffix}`;
1826
+ return `zzzzzzzzzzz${suffix}`;
1827
+ };
1828
+ const buildMarkerMessageId = (messages, targetPosition, index) => {
1829
+ const realMessages = messages.filter((message) => !isIgnoredMessage(message));
1830
+ const pivot = realMessages[Math.max(0, Math.min(realMessages.length - 1, targetPosition - 1))];
1831
+ if (!pivot?.info.id)
1832
+ return;
1833
+ const prefix = messageIdPrefix(pivot.info.id);
1834
+ if (!prefix)
1835
+ return;
1836
+ return `msg_${prefix}${markerSuffix(targetPosition, index)}`;
1837
+ };
1838
+ const getPersistedMessages = async (sessionID) => {
1839
+ try {
1840
+ const response = await ctx.client.session.messages({ path: { id: sessionID } });
1841
+ return (response.data || []).map((message) => ({
1842
+ info: message.info,
1843
+ parts: message.parts
1844
+ }));
1845
+ } catch (error) {
1846
+ logger.debug("Failed to load session messages for injection markers", {
1847
+ sessionID,
1848
+ error: error instanceof Error ? error.message : String(error)
1849
+ });
1850
+ return [];
1851
+ }
1852
+ };
1853
+ const renderInjectionMarkers = async (sessionID, messages, injections) => {
1854
+ if (injections.length === 0)
1855
+ return;
1856
+ const next = new Set;
1857
+ const pending = [];
1858
+ injections.forEach((injection, index) => {
1859
+ const id = buildMarkerMessageId(messages, injection.targetPosition, index);
1860
+ if (!id)
1861
+ return;
1862
+ next.add(id);
1863
+ pending.push({ id, text: `↳ Injected ${injection.snippetName}` });
1864
+ });
1865
+ const persistedMarkerIds = messages.filter(isInjectionMarkerMessage).map((message) => message.info.id).filter((id) => !!id);
1866
+ const persistedMarkerPartIds = injectionMarkerPartIdsByMessage(messages);
1867
+ const previous = new Set([
1868
+ ...persistedMarkerIds,
1869
+ ...injectionMarkerIdsBySession.get(sessionID) || []
1870
+ ]);
1871
+ const kept = new Set;
1872
+ const current = new Set;
1873
+ logger.debug("Rendering injection markers", {
1874
+ sessionID,
1875
+ messageCount: countConversationMessages(messages),
1876
+ injectionCount: injections.length,
1877
+ nextMarkerIds: [...next],
1878
+ persistedMarkerIds,
1879
+ persistedMarkerPartIds: Object.fromEntries(persistedMarkerPartIds),
1880
+ previousMarkerIds: [...previous]
1881
+ });
1882
+ for (const id of previous) {
1883
+ if (!next.has(id)) {
1884
+ let deleted = true;
1885
+ for (const partId of persistedMarkerPartIds.get(id) || []) {
1886
+ const partDeleted = await deleteSessionPart(ctx.client, ctx.serverUrl, sessionID, id, partId);
1887
+ if (!partDeleted)
1888
+ deleted = false;
331
1889
  }
332
- const result = [];
333
- const prepend = buckets.get(0) || [];
334
- for (const injection of prepend) {
335
- result.push({
336
- info: { role: "user" },
337
- parts: [{ type: "text", text: injection.content }],
338
- });
1890
+ const messageDeleted = await deleteSessionMessage(ctx.client, ctx.serverUrl, sessionID, id);
1891
+ if (!messageDeleted)
1892
+ deleted = false;
1893
+ if (!deleted)
1894
+ kept.add(id);
1895
+ }
1896
+ }
1897
+ if (kept.size > 0) {
1898
+ logger.debug("Skipping injection marker creation until stale markers delete", {
1899
+ sessionID,
1900
+ keptMarkerIds: [...kept],
1901
+ nextMarkerIds: [...next]
1902
+ });
1903
+ injectionMarkerIdsBySession.set(sessionID, kept);
1904
+ return;
1905
+ }
1906
+ for (const marker of pending) {
1907
+ current.add(marker.id);
1908
+ if (!previous.has(marker.id)) {
1909
+ await sendIgnoredMessage(ctx.client, sessionID, marker.text, marker.id);
1910
+ }
1911
+ }
1912
+ injectionMarkerIdsBySession.set(sessionID, current);
1913
+ };
1914
+ const scheduleInjectionMarkerRender = (sessionID, messages, injections) => {
1915
+ const previous = injectionMarkerRenderQueueBySession.get(sessionID) || Promise.resolve();
1916
+ const next = previous.catch(() => {
1917
+ return;
1918
+ }).then(() => new Promise((resolve3) => {
1919
+ setTimeout(() => {
1920
+ renderInjectionMarkers(sessionID, messages, injections).then(resolve3, (error) => {
1921
+ logger.debug("Failed to render injection markers", {
1922
+ sessionID,
1923
+ error: error instanceof Error ? error.message : String(error)
1924
+ });
1925
+ resolve3();
1926
+ });
1927
+ }, 0);
1928
+ }));
1929
+ injectionMarkerRenderQueueBySession.set(sessionID, next);
1930
+ };
1931
+ const insertInjectionsIntoMessages = (messages, injections) => {
1932
+ if (injections.length === 0)
1933
+ return messages;
1934
+ const totalRealMessages = countConversationMessages(messages);
1935
+ const buckets = new Map;
1936
+ for (const injection of injections) {
1937
+ const position = Math.max(0, Math.min(totalRealMessages, injection.targetPosition));
1938
+ const existing = buckets.get(position) || [];
1939
+ existing.push(injection);
1940
+ buckets.set(position, existing);
1941
+ }
1942
+ const result = [];
1943
+ const prepend = buckets.get(0) || [];
1944
+ for (const injection of prepend) {
1945
+ result.push({
1946
+ info: { role: "user" },
1947
+ parts: [{ type: "text", text: injection.content }]
1948
+ });
1949
+ }
1950
+ let seenRealMessages = 0;
1951
+ messages.forEach((message) => {
1952
+ result.push(message);
1953
+ if (isIgnoredMessage(message))
1954
+ return;
1955
+ seenRealMessages += 1;
1956
+ const positioned = buckets.get(seenRealMessages) || [];
1957
+ for (const injection of positioned) {
1958
+ result.push({
1959
+ info: { role: "user", sessionID: message.info.sessionID },
1960
+ parts: [{ type: "text", text: injection.content }]
1961
+ });
1962
+ }
1963
+ });
1964
+ return result;
1965
+ };
1966
+ const getPartSkillLoads = (parts) => parts.flatMap((part) => part.skillLoads || []);
1967
+ const getMessageSkillLoads = async (sessionID, message) => {
1968
+ if (isSkillContentMessage(message)) {
1969
+ logger.debug("Skipping skill-content message during load resolution", {
1970
+ sessionID,
1971
+ messageID: message.info.id
1972
+ });
1973
+ return [];
1974
+ }
1975
+ const direct = getPartSkillLoads(message.parts);
1976
+ if (direct.length > 0) {
1977
+ logger.debug("Resolved skill loads from direct part metadata", {
1978
+ sessionID,
1979
+ messageID: message.info.id,
1980
+ payloadCount: direct.length
1981
+ });
1982
+ return direct;
1983
+ }
1984
+ if (message.info.id) {
1985
+ const stored = skillLoadManager.get(sessionID, message.info.id);
1986
+ if (stored.length > 0) {
1987
+ logger.debug("Resolved skill loads from message-id registry", {
1988
+ sessionID,
1989
+ messageID: message.info.id,
1990
+ payloadCount: stored.length
1991
+ });
1992
+ return stored;
1993
+ }
1994
+ }
1995
+ if (!config.experimental.skillLoading || skills.size === 0)
1996
+ return [];
1997
+ const text = message.parts.filter((part) => part.type === "text").map((part) => part.text || "").join(`
1998
+
1999
+ `);
2000
+ const recovered = await buildSkillPayloadsFromVisibleText(text, skills, snippets, {
2001
+ expandSkillTagsInContent: config.experimental.skillRendering,
2002
+ extractInject: config.experimental.injectBlocks
2003
+ });
2004
+ if (recovered.length > 0) {
2005
+ logger.debug("Recovered hidden skill payloads from visible markers", {
2006
+ sessionID,
2007
+ messageID: message.info.id,
2008
+ payloadCount: recovered.length
2009
+ });
2010
+ }
2011
+ if (recovered.length === 0 && text.includes("↳ Loaded ")) {
2012
+ logger.debug("Visible skill markers found but no hidden payloads recovered", {
2013
+ sessionID,
2014
+ messageID: message.info.id,
2015
+ text
2016
+ });
2017
+ }
2018
+ return recovered;
2019
+ };
2020
+ const insertSkillLoadsIntoMessages = async (sessionID, messages) => {
2021
+ const pendingRaw = skillLoadManager.drainPending(sessionID);
2022
+ const result = [];
2023
+ const resolvedLoads = await Promise.all(messages.map(async (message) => {
2024
+ if (message.info.role !== "user" || isIgnoredMessage(message)) {
2025
+ return [];
2026
+ }
2027
+ return getMessageSkillLoads(sessionID, message);
2028
+ }));
2029
+ logger.debug("Resolved skill loads for transform messages", {
2030
+ sessionID,
2031
+ messages: messages.map((message, i) => ({
2032
+ messageID: message.info.id,
2033
+ role: message.info.role,
2034
+ synthetic: message.parts.some((part) => part.synthetic),
2035
+ snippetsProcessed: message.parts.some((part) => part.snippetsProcessed),
2036
+ text: message.parts.filter((part) => part.type === "text").map((part) => (part.text || "").slice(0, 120)).join(" | "),
2037
+ resolvedLoadCount: (resolvedLoads[i] || []).length
2038
+ }))
2039
+ });
2040
+ const directPayloadStrings = new Set;
2041
+ for (const [i, message] of messages.entries()) {
2042
+ if (message.info.role !== "user" || isIgnoredMessage(message))
2043
+ continue;
2044
+ for (const payload of resolvedLoads[i] || []) {
2045
+ directPayloadStrings.add(payload);
2046
+ }
2047
+ }
2048
+ const pending = pendingRaw.filter((entry) => {
2049
+ if (entry.payloads.length === 0)
2050
+ return false;
2051
+ return entry.payloads.some((p) => !directPayloadStrings.has(p));
2052
+ });
2053
+ if (pending.length !== pendingRaw.length) {
2054
+ logger.debug("Dropped duplicate queued skill payloads", {
2055
+ sessionID,
2056
+ before: pendingRaw.length,
2057
+ after: pending.length
2058
+ });
2059
+ }
2060
+ const fallbackByIndex = new Map;
2061
+ if (pending.length > 0) {
2062
+ for (let i = messages.length - 1;i >= 0; i -= 1) {
2063
+ const message = messages[i];
2064
+ if (message.info.role !== "user" || isIgnoredMessage(message))
2065
+ continue;
2066
+ if ((resolvedLoads[i] || []).length > 0)
2067
+ continue;
2068
+ const next = pending.at(-1);
2069
+ if (!next)
2070
+ break;
2071
+ if (next.messageID && message.info.id && next.messageID !== message.info.id) {
2072
+ continue;
339
2073
  }
340
- let seenRealMessages = 0;
341
- messages.forEach((message) => {
342
- result.push(message);
343
- if (isIgnoredMessage(message))
344
- return;
345
- seenRealMessages += 1;
346
- const positioned = buckets.get(seenRealMessages) || [];
347
- for (const injection of positioned) {
348
- result.push({
349
- info: { role: "user", sessionID: message.info.sessionID },
350
- parts: [{ type: "text", text: injection.content }],
351
- });
352
- }
2074
+ pending.pop();
2075
+ fallbackByIndex.set(i, next.payloads);
2076
+ }
2077
+ while (pending.length > 0) {
2078
+ const next = pending.pop();
2079
+ if (!next)
2080
+ break;
2081
+ for (let i = messages.length - 1;i >= 0; i -= 1) {
2082
+ const message = messages[i];
2083
+ if (message.info.role !== "user" || isIgnoredMessage(message))
2084
+ continue;
2085
+ if ((resolvedLoads[i] || []).length > 0)
2086
+ continue;
2087
+ if (fallbackByIndex.has(i))
2088
+ continue;
2089
+ fallbackByIndex.set(i, next.payloads);
2090
+ break;
2091
+ }
2092
+ }
2093
+ }
2094
+ for (const [i, message] of messages.entries()) {
2095
+ if (message.info.role !== "user" || isIgnoredMessage(message)) {
2096
+ result.push(message);
2097
+ continue;
2098
+ }
2099
+ const direct = resolvedLoads[i] || [];
2100
+ const payloads = direct.length > 0 ? direct : fallbackByIndex.get(i) || [];
2101
+ if (payloads.length === 0) {
2102
+ result.push(message);
2103
+ continue;
2104
+ }
2105
+ skillLoadManager.rememberForSession(sessionID, payloads);
2106
+ const hiddenText = payloads.join(`
2107
+
2108
+ `);
2109
+ if (message.parts.some((part) => part.type === "text" && part.synthetic && (part.text || "") === hiddenText)) {
2110
+ logger.debug("Hidden skill payload already attached to user message", {
2111
+ sessionID,
2112
+ messageID: message.info.id,
2113
+ hiddenLength: hiddenText.length
353
2114
  });
354
- return result;
355
- };
356
- const getPartSkillLoads = (parts) => parts.flatMap((part) => part.skillLoads || []);
357
- const getMessageSkillLoads = async (sessionID, message) => {
358
- if (isSkillContentMessage(message)) {
359
- logger.debug("Skipping skill-content message during load resolution", {
360
- sessionID,
361
- messageID: message.info.id,
362
- });
363
- return [];
2115
+ result.push(message);
2116
+ continue;
2117
+ }
2118
+ logger.debug("Appended hidden skill payload to user message", {
2119
+ sessionID,
2120
+ messageID: message.info.id,
2121
+ partCountBefore: message.parts.length,
2122
+ partCountAfter: message.parts.length + 1,
2123
+ hiddenLength: hiddenText.length
2124
+ });
2125
+ result.push({
2126
+ ...message,
2127
+ parts: [{ type: "text", text: hiddenText, synthetic: true }, ...message.parts]
2128
+ });
2129
+ }
2130
+ return result;
2131
+ };
2132
+ return {
2133
+ config: async (opencodeConfig) => {
2134
+ const cfg = opencodeConfig;
2135
+ cfg.skills ??= {};
2136
+ cfg.skills.paths ??= [];
2137
+ cfg.skills.paths.push(SKILL_DIR);
2138
+ opencodeSkillDirs.length = 0;
2139
+ opencodeSkillDirs.push(...cfg.skills.paths);
2140
+ if (config.experimental.skillRendering || config.experimental.skillLoading) {
2141
+ skills = await loadRuntimeSkills();
2142
+ }
2143
+ opencodeConfig.command ??= {};
2144
+ opencodeConfig.command.snippets = {
2145
+ template: "",
2146
+ description: "Manage text snippets (add, delete, list, help)"
2147
+ };
2148
+ opencodeConfig.command["snippets:reload"] = {
2149
+ template: "",
2150
+ description: "Reload snippet files from disk"
2151
+ };
2152
+ },
2153
+ "command.execute.before": commandHandler,
2154
+ "chat.message": async (input, output) => {
2155
+ if (output.message.role !== "user")
2156
+ return;
2157
+ if (output.parts.some((part) => part.ignored))
2158
+ return;
2159
+ const injected = await processTextParts(output.parts);
2160
+ logger.debug("chat.message processed user parts", {
2161
+ sessionID: input.sessionID,
2162
+ messageID: input.messageID,
2163
+ texts: output.parts.filter((part) => part.type === "text").map((part) => ({
2164
+ text: (part.text || "").slice(0, 200),
2165
+ skillLoads: part.skillLoads?.length || 0
2166
+ }))
2167
+ });
2168
+ output.parts.forEach((part) => {
2169
+ if (part.type === "text") {
2170
+ part.snippetsProcessed = true;
364
2171
  }
365
- const direct = getPartSkillLoads(message.parts);
366
- if (direct.length > 0) {
367
- logger.debug("Resolved skill loads from direct part metadata", {
368
- sessionID,
369
- messageID: message.info.id,
370
- payloadCount: direct.length,
371
- });
372
- return direct;
2172
+ });
2173
+ if (input.messageID) {
2174
+ const payloads = getPartSkillLoads(output.parts);
2175
+ if (payloads.length > 0) {
2176
+ skillLoadManager.register(input.sessionID, input.messageID, payloads);
2177
+ }
2178
+ } else {
2179
+ const payloads = getPartSkillLoads(output.parts);
2180
+ if (payloads.length > 0) {
2181
+ skillLoadManager.queue(input.sessionID, payloads, input.messageID);
373
2182
  }
374
- if (message.info.id) {
375
- const stored = skillLoadManager.get(sessionID, message.info.id);
376
- if (stored.length > 0) {
377
- logger.debug("Resolved skill loads from message-id registry", {
378
- sessionID,
379
- messageID: message.info.id,
380
- payloadCount: stored.length,
381
- });
382
- return stored;
2183
+ }
2184
+ injectionManager.registerAndGetNew(input.sessionID, injected);
2185
+ if (config.experimental.injectBlocks) {
2186
+ const persisted = await getPersistedMessages(input.sessionID);
2187
+ const current = {
2188
+ info: {
2189
+ id: output.message.id,
2190
+ role: output.message.role,
2191
+ sessionID: output.message.sessionID || input.sessionID
2192
+ },
2193
+ parts: output.parts
2194
+ };
2195
+ const messages = [...persisted, current];
2196
+ const messageCount = countConversationMessages(messages);
2197
+ const { injections } = injectionManager.getRenderableInjections(input.sessionID, messageCount, config.injectRecencyMessages);
2198
+ scheduleInjectionMarkerRender(input.sessionID, messages, injections);
2199
+ }
2200
+ },
2201
+ "experimental.chat.messages.transform": async (input, output) => {
2202
+ const sessionID = input.sessionID || input.session?.id || output.messages[0]?.info?.sessionID;
2203
+ logger.debug("Transform hook called", {
2204
+ inputSessionID: input.sessionID,
2205
+ extractedSessionID: sessionID,
2206
+ messageCount: output.messages.length,
2207
+ hasSessionID: !!sessionID
2208
+ });
2209
+ for (const message of output.messages) {
2210
+ if (message.info.role === "user") {
2211
+ if (message.parts.some((part) => part.snippetsProcessed))
2212
+ continue;
2213
+ if (message.parts.some((part) => part.ignored))
2214
+ continue;
2215
+ if (message.parts.some((part) => part.synthetic))
2216
+ continue;
2217
+ const injected = await processTextParts(message.parts);
2218
+ if (injected.length > 0 && sessionID) {
2219
+ injectionManager.registerAndGetNew(sessionID, injected);
2220
+ }
2221
+ if (sessionID && message.info.id) {
2222
+ const payloads = getPartSkillLoads(message.parts);
2223
+ if (payloads.length > 0) {
2224
+ skillLoadManager.register(sessionID, message.info.id, payloads);
383
2225
  }
2226
+ }
384
2227
  }
385
- if (!config.experimental.skillLoading || skills.size === 0)
386
- return [];
387
- const text = message.parts
388
- .filter((part) => part.type === "text")
389
- .map((part) => part.text || "")
390
- .join("\n\n");
391
- const recovered = await buildSkillPayloadsFromVisibleText(text, skills, snippets, {
392
- expandSkillTagsInContent: config.experimental.skillRendering,
393
- extractInject: config.experimental.injectBlocks,
2228
+ }
2229
+ if (sessionID) {
2230
+ const messageCount = countConversationMessages(output.messages);
2231
+ const { injections } = injectionManager.getRenderableInjections(sessionID, messageCount, config.injectRecencyMessages);
2232
+ logger.debug("Transform hook - checking for injections", {
2233
+ sessionID,
2234
+ hasInjections: injections.length > 0,
2235
+ injectionCount: injections.length,
2236
+ messageTexts: output.messages.map((m) => ({
2237
+ role: m.info.role,
2238
+ text: m.parts.filter((p) => p.type === "text").map((p) => (p.text || "").slice(0, 50)).join(" | "),
2239
+ snippetsProcessed: m.parts.some((p) => p.snippetsProcessed)
2240
+ }))
394
2241
  });
395
- if (recovered.length > 0) {
396
- logger.debug("Recovered hidden skill payloads from visible markers", {
397
- sessionID,
398
- messageID: message.info.id,
399
- payloadCount: recovered.length,
400
- });
2242
+ if (injections.length > 0) {
2243
+ const beforeCount = output.messages.length;
2244
+ output.messages = insertInjectionsIntoMessages(output.messages, injections);
2245
+ logger.debug("Injected ephemeral user messages", {
2246
+ sessionID,
2247
+ injectionCount: injections.length,
2248
+ messagesBefore: beforeCount,
2249
+ messagesAfter: output.messages.length
2250
+ });
401
2251
  }
402
- if (recovered.length === 0 && text.includes("↳ Loaded ")) {
403
- logger.debug("Visible skill markers found but no hidden payloads recovered", {
404
- sessionID,
405
- messageID: message.info.id,
406
- text,
2252
+ if (config.experimental.skillLoading) {
2253
+ const beforeSkillLoads = output.messages.length;
2254
+ output.messages = await insertSkillLoadsIntoMessages(sessionID, output.messages);
2255
+ if (output.messages.length > beforeSkillLoads) {
2256
+ logger.debug("Injected skill load context messages", {
2257
+ sessionID,
2258
+ messagesBefore: beforeSkillLoads,
2259
+ messagesAfter: output.messages.length
407
2260
  });
2261
+ }
408
2262
  }
409
- return recovered;
410
- };
411
- const insertSkillLoadsIntoMessages = async (sessionID, messages) => {
412
- const pendingRaw = skillLoadManager.drainPending(sessionID);
413
- const result = [];
414
- const resolvedLoads = await Promise.all(messages.map(async (message) => {
415
- if (message.info.role !== "user" || isIgnoredMessage(message)) {
416
- return [];
417
- }
418
- return getMessageSkillLoads(sessionID, message);
419
- }));
420
- logger.debug("Resolved skill loads for transform messages", {
421
- sessionID,
422
- messages: messages.map((message, i) => ({
423
- messageID: message.info.id,
424
- role: message.info.role,
425
- synthetic: message.parts.some((part) => part.synthetic),
426
- snippetsProcessed: message.parts.some((part) => part.snippetsProcessed),
427
- text: message.parts
428
- .filter((part) => part.type === "text")
429
- .map((part) => (part.text || "").slice(0, 120))
430
- .join(" | "),
431
- resolvedLoadCount: (resolvedLoads[i] || []).length,
432
- })),
2263
+ }
2264
+ },
2265
+ "experimental.chat.system.transform": async (input, output) => {
2266
+ if (!config.experimental.skillLoading)
2267
+ return;
2268
+ if (!input.sessionID)
2269
+ return;
2270
+ const payloads = skillLoadManager.getSessionPayloads(input.sessionID);
2271
+ if (payloads.length === 0)
2272
+ return;
2273
+ const hiddenText = payloads.join(`
2274
+
2275
+ `);
2276
+ if (output.system.includes(hiddenText)) {
2277
+ logger.debug("Hidden skill payloads already present in system prompt", {
2278
+ sessionID: input.sessionID,
2279
+ payloadCount: payloads.length,
2280
+ hiddenLength: hiddenText.length
433
2281
  });
434
- // Dedup requirement: if a queued payload string already exists as a direct
435
- // skillLoad on any user message (because chat.message attached it to part.skillLoads),
436
- // a synthetic for that message will be pushed via the `direct` path below.
437
- // We must NOT also push a fallback synthetic for the same payload, otherwise
438
- // OpenCode's fresh-session flow (which seems to fire chat.message both with and
439
- // without messageID) ends up with the skill_content duplicated and stuck onto
440
- // an unrelated user message like beads-context.
441
- const directPayloadStrings = new Set();
442
- for (const [i, message] of messages.entries()) {
443
- if (message.info.role !== "user" || isIgnoredMessage(message))
444
- continue;
445
- for (const payload of resolvedLoads[i] || []) {
446
- directPayloadStrings.add(payload);
447
- }
2282
+ return;
2283
+ }
2284
+ output.system.push(hiddenText);
2285
+ logger.debug("Mirrored hidden skill payloads into system prompt", {
2286
+ sessionID: input.sessionID,
2287
+ payloadCount: payloads.length,
2288
+ hiddenLength: hiddenText.length,
2289
+ systemEntryCount: output.system.length
2290
+ });
2291
+ },
2292
+ "tool.execute.after": async (input, output) => {
2293
+ if (input.tool !== "skill")
2294
+ return;
2295
+ if (typeof output.output === "string" && output.output.trim()) {
2296
+ let processed = output.output;
2297
+ if (config.experimental.skillRendering && skills.size > 0) {
2298
+ processed = expandSkillTags(processed, skills);
448
2299
  }
449
- const pending = pendingRaw.filter((entry) => {
450
- if (entry.payloads.length === 0)
451
- return false;
452
- // If every payload string in this entry is already attached directly to
453
- // some user message, the synthetic will be emitted there. Drop the dup.
454
- return entry.payloads.some((p) => !directPayloadStrings.has(p));
2300
+ const expandOptions = {
2301
+ extractInject: config.experimental.injectBlocks
2302
+ };
2303
+ const expansionResult = expandHashtags(processed, snippets, new Map, expandOptions);
2304
+ output.output = assembleMessage(expansionResult);
2305
+ logger.debug("Skill content expanded", {
2306
+ tool: input.tool,
2307
+ callID: input.callID
455
2308
  });
456
- if (pending.length !== pendingRaw.length) {
457
- logger.debug("Dropped duplicate queued skill payloads", {
458
- sessionID,
459
- before: pendingRaw.length,
460
- after: pending.length,
461
- });
462
- }
463
- const fallbackByIndex = new Map();
464
- if (pending.length > 0) {
465
- for (let i = messages.length - 1; i >= 0; i -= 1) {
466
- const message = messages[i];
467
- if (message.info.role !== "user" || isIgnoredMessage(message))
468
- continue;
469
- if ((resolvedLoads[i] || []).length > 0)
470
- continue;
471
- const next = pending.at(-1);
472
- if (!next)
473
- break;
474
- if (next.messageID && message.info.id && next.messageID !== message.info.id) {
475
- continue;
476
- }
477
- pending.pop();
478
- fallbackByIndex.set(i, next.payloads);
479
- }
480
- while (pending.length > 0) {
481
- const next = pending.pop();
482
- if (!next)
483
- break;
484
- for (let i = messages.length - 1; i >= 0; i -= 1) {
485
- const message = messages[i];
486
- if (message.info.role !== "user" || isIgnoredMessage(message))
487
- continue;
488
- if ((resolvedLoads[i] || []).length > 0)
489
- continue;
490
- if (fallbackByIndex.has(i))
491
- continue;
492
- fallbackByIndex.set(i, next.payloads);
493
- break;
494
- }
495
- }
496
- }
497
- for (const [i, message] of messages.entries()) {
498
- if (message.info.role !== "user" || isIgnoredMessage(message)) {
499
- result.push(message);
500
- continue;
501
- }
502
- const direct = resolvedLoads[i] || [];
503
- const payloads = direct.length > 0 ? direct : fallbackByIndex.get(i) || [];
504
- if (payloads.length === 0) {
505
- result.push(message);
506
- continue;
507
- }
508
- skillLoadManager.rememberForSession(sessionID, payloads);
509
- const hiddenText = payloads.join("\n\n");
510
- if (message.parts.some((part) => part.type === "text" && part.synthetic && (part.text || "") === hiddenText)) {
511
- logger.debug("Hidden skill payload already attached to user message", {
512
- sessionID,
513
- messageID: message.info.id,
514
- hiddenLength: hiddenText.length,
515
- });
516
- result.push(message);
517
- continue;
518
- }
519
- // Regression guard from the PTY repro:
520
- // 1. skill content must stay hidden from the user,
521
- // 2. skill content must be injected immediately below the visible user message and reach the LLM,
522
- // 3. the agent must not call `skill` a second time for an already-loaded skill.
523
- logger.debug("Appended hidden skill payload to user message", {
524
- sessionID,
525
- messageID: message.info.id,
526
- partCountBefore: message.parts.length,
527
- partCountAfter: message.parts.length + 1,
528
- hiddenLength: hiddenText.length,
529
- });
530
- result.push({
531
- ...message,
532
- parts: [{ type: "text", text: hiddenText, synthetic: true }, ...message.parts],
533
- });
534
- }
535
- return result;
536
- };
537
- return {
538
- // Register /snippets commands and skill path
539
- config: async (opencodeConfig) => {
540
- // Register skill folder path for automatic discovery
541
- const cfg = opencodeConfig;
542
- cfg.skills ??= {};
543
- cfg.skills.paths ??= [];
544
- cfg.skills.paths.push(SKILL_DIR);
545
- opencodeSkillDirs.length = 0;
546
- opencodeSkillDirs.push(...cfg.skills.paths);
547
- if (config.experimental.skillRendering || config.experimental.skillLoading) {
548
- skills = await loadRuntimeSkills();
549
- }
550
- // Register /snippets commands
551
- opencodeConfig.command ??= {};
552
- opencodeConfig.command.snippets = {
553
- template: "",
554
- description: "Manage text snippets (add, delete, list, help)",
555
- };
556
- opencodeConfig.command["snippets:reload"] = {
557
- template: "",
558
- description: "Reload snippet files from disk",
559
- };
560
- },
561
- "command.execute.before": commandHandler,
562
- "chat.message": async (input, output) => {
563
- if (output.message.role !== "user")
564
- return;
565
- if (output.parts.some((part) => part.ignored))
566
- return;
567
- const injected = await processTextParts(output.parts);
568
- logger.debug("chat.message processed user parts", {
569
- sessionID: input.sessionID,
570
- messageID: input.messageID,
571
- texts: output.parts
572
- .filter((part) => part.type === "text")
573
- .map((part) => ({
574
- text: (part.text || "").slice(0, 200),
575
- skillLoads: part.skillLoads?.length || 0,
576
- })),
577
- });
578
- output.parts.forEach((part) => {
579
- if (part.type === "text") {
580
- part.snippetsProcessed = true;
581
- }
582
- });
583
- if (input.messageID) {
584
- const payloads = getPartSkillLoads(output.parts);
585
- if (payloads.length > 0) {
586
- skillLoadManager.register(input.sessionID, input.messageID, payloads);
587
- }
588
- }
589
- else {
590
- const payloads = getPartSkillLoads(output.parts);
591
- if (payloads.length > 0) {
592
- skillLoadManager.queue(input.sessionID, payloads, input.messageID);
593
- }
594
- }
595
- injectionManager.registerAndGetNew(input.sessionID, injected);
596
- if (config.experimental.injectBlocks) {
597
- const persisted = await getPersistedMessages(input.sessionID);
598
- const current = {
599
- info: {
600
- id: output.message.id,
601
- role: output.message.role,
602
- sessionID: output.message.sessionID || input.sessionID,
603
- },
604
- parts: output.parts,
605
- };
606
- const messages = [...persisted, current];
607
- const messageCount = countConversationMessages(messages);
608
- const { injections } = injectionManager.getRenderableInjections(input.sessionID, messageCount, config.injectRecencyMessages);
609
- scheduleInjectionMarkerRender(input.sessionID, messages, injections);
610
- }
611
- },
612
- "experimental.chat.messages.transform": async (input, output) => {
613
- const sessionID = input.sessionID || input.session?.id || output.messages[0]?.info?.sessionID;
614
- logger.debug("Transform hook called", {
615
- inputSessionID: input.sessionID,
616
- extractedSessionID: sessionID,
617
- messageCount: output.messages.length,
618
- hasSessionID: !!sessionID,
619
- });
620
- for (const message of output.messages) {
621
- if (message.info.role === "user") {
622
- if (message.parts.some((part) => part.snippetsProcessed))
623
- continue;
624
- if (message.parts.some((part) => part.ignored))
625
- continue;
626
- if (message.parts.some((part) => part.synthetic))
627
- continue;
628
- const injected = await processTextParts(message.parts);
629
- if (injected.length > 0 && sessionID) {
630
- injectionManager.registerAndGetNew(sessionID, injected);
631
- }
632
- if (sessionID && message.info.id) {
633
- const payloads = getPartSkillLoads(message.parts);
634
- if (payloads.length > 0) {
635
- skillLoadManager.register(sessionID, message.info.id, payloads);
636
- }
637
- }
638
- }
639
- }
640
- if (sessionID) {
641
- const messageCount = countConversationMessages(output.messages);
642
- const { injections } = injectionManager.getRenderableInjections(sessionID, messageCount, config.injectRecencyMessages);
643
- logger.debug("Transform hook - checking for injections", {
644
- sessionID,
645
- hasInjections: injections.length > 0,
646
- injectionCount: injections.length,
647
- messageTexts: output.messages.map((m) => ({
648
- role: m.info.role,
649
- text: m.parts
650
- .filter((p) => p.type === "text")
651
- .map((p) => (p.text || "").slice(0, 50))
652
- .join(" | "),
653
- snippetsProcessed: m.parts.some((p) => p.snippetsProcessed),
654
- })),
655
- });
656
- if (injections.length > 0) {
657
- const beforeCount = output.messages.length;
658
- output.messages = insertInjectionsIntoMessages(output.messages, injections);
659
- logger.debug("Injected ephemeral user messages", {
660
- sessionID,
661
- injectionCount: injections.length,
662
- messagesBefore: beforeCount,
663
- messagesAfter: output.messages.length,
664
- });
665
- }
666
- if (config.experimental.skillLoading) {
667
- const beforeSkillLoads = output.messages.length;
668
- output.messages = await insertSkillLoadsIntoMessages(sessionID, output.messages);
669
- if (output.messages.length > beforeSkillLoads) {
670
- logger.debug("Injected skill load context messages", {
671
- sessionID,
672
- messagesBefore: beforeSkillLoads,
673
- messagesAfter: output.messages.length,
674
- });
675
- }
676
- }
677
- }
678
- },
679
- "experimental.chat.system.transform": async (input, output) => {
680
- if (!config.experimental.skillLoading)
681
- return;
682
- if (!input.sessionID)
683
- return;
684
- const payloads = skillLoadManager.getSessionPayloads(input.sessionID);
685
- if (payloads.length === 0)
686
- return;
687
- const hiddenText = payloads.join("\n\n");
688
- if (output.system.includes(hiddenText)) {
689
- logger.debug("Hidden skill payloads already present in system prompt", {
690
- sessionID: input.sessionID,
691
- payloadCount: payloads.length,
692
- hiddenLength: hiddenText.length,
693
- });
694
- return;
695
- }
696
- output.system.push(hiddenText);
697
- logger.debug("Mirrored hidden skill payloads into system prompt", {
698
- sessionID: input.sessionID,
699
- payloadCount: payloads.length,
700
- hiddenLength: hiddenText.length,
701
- systemEntryCount: output.system.length,
702
- });
703
- },
704
- // Process skill tool output to expand snippets and skill tags in skill content
705
- "tool.execute.after": async (input, output) => {
706
- if (input.tool !== "skill")
707
- return;
708
- // The skill tool returns markdown content in its output
709
- // Expand skill tags and hashtags in the skill content
710
- if (typeof output.output === "string" && output.output.trim()) {
711
- let processed = output.output;
712
- // First expand skill tags if enabled
713
- if (config.experimental.skillRendering && skills.size > 0) {
714
- processed = expandSkillTags(processed, skills);
715
- }
716
- // Then expand hashtag snippets
717
- const expandOptions = {
718
- extractInject: config.experimental.injectBlocks,
719
- };
720
- const expansionResult = expandHashtags(processed, snippets, new Map(), expandOptions);
721
- output.output = assembleMessage(expansionResult);
722
- logger.debug("Skill content expanded", {
723
- tool: input.tool,
724
- callID: input.callID,
725
- });
726
- }
727
- },
728
- };
2309
+ }
2310
+ }
2311
+ };
2312
+ };
2313
+ var plugin = {
2314
+ id: "opencode-snippets",
2315
+ server: SnippetsPlugin
729
2316
  };
730
- const plugin = {
731
- id: "opencode-snippets",
732
- server: SnippetsPlugin,
2317
+ var server = SnippetsPlugin;
2318
+ var opencode_snippets_default = plugin;
2319
+ export {
2320
+ server,
2321
+ opencode_snippets_default as default,
2322
+ SnippetsPlugin
733
2323
  };
734
- export const server = SnippetsPlugin;
735
- export default plugin;
736
- //# sourceMappingURL=index.js.map