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/README.md +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2292 -705
- package/dist/index.js.map +1 -1
- package/dist/src/config.js +1 -1
- package/dist/src/expander.d.ts.map +1 -1
- package/dist/src/expander.js +38 -13
- package/dist/src/expander.js.map +1 -1
- package/dist/src/loader.js +3 -3
- package/dist/src/loader.js.map +1 -1
- package/dist/src/logger.d.ts +8 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +15 -3
- package/dist/src/logger.js.map +1 -1
- package/dist/src/pending-drafts.d.ts.map +1 -1
- package/dist/src/pending-drafts.js +5 -6
- package/dist/src/pending-drafts.js.map +1 -1
- package/dist/src/reload-signal.d.ts.map +1 -1
- package/dist/src/reload-signal.js +5 -6
- package/dist/src/reload-signal.js.map +1 -1
- package/dist/src/shell.d.ts +2 -13
- package/dist/src/shell.d.ts.map +1 -1
- package/dist/src/shell.js +5 -2
- package/dist/src/shell.js.map +1 -1
- package/dist/src/skill-loader.d.ts.map +1 -1
- package/dist/src/skill-loader.js +2 -6
- package/dist/src/skill-loader.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +1639 -0
- package/dist/tui.jsx +19 -13
- package/dist/tui.jsx.map +1 -1
- package/package.json +5 -6
- package/skill/snippets/SKILL.md +6 -25
- package/skill/snippets/references/creating-snippets.md +86 -0
package/dist/index.js
CHANGED
|
@@ -1,736 +1,2323 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|