opencode-plugin-preload-skills 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -7,6 +7,47 @@ var path = require('path');
7
7
  var os = require('os');
8
8
 
9
9
  // src/index.ts
10
+ function estimateTokens(text) {
11
+ return Math.ceil(text.length / 4);
12
+ }
13
+ function matchGlobPattern(filePath, pattern) {
14
+ let regexPattern = pattern.replace(/\*\*\//g, "\0DOUBLESTARSLASH\0").replace(/\*\*/g, "\0DOUBLESTAR\0").replace(/\*/g, "\0SINGLESTAR\0").replace(/\?/g, "\0QUESTION\0");
15
+ regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
16
+ regexPattern = regexPattern.replace(/\x00DOUBLESTARSLASH\x00/g, "(?:[^/]+/)*").replace(/\x00DOUBLESTAR\x00/g, ".*").replace(/\x00SINGLESTAR\x00/g, "[^/]*").replace(/\x00QUESTION\x00/g, "[^/]");
17
+ const regex = new RegExp(`^${regexPattern}$`);
18
+ return regex.test(filePath);
19
+ }
20
+ function checkCondition(condition, projectDir) {
21
+ if (condition.fileExists) {
22
+ const fullPath = path.join(projectDir, condition.fileExists);
23
+ if (!fs.existsSync(fullPath)) return false;
24
+ }
25
+ if (condition.packageHasDependency) {
26
+ const packageJsonPath = path.join(projectDir, "package.json");
27
+ if (!fs.existsSync(packageJsonPath)) return false;
28
+ try {
29
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
30
+ const deps = {
31
+ ...packageJson.dependencies,
32
+ ...packageJson.devDependencies,
33
+ ...packageJson.peerDependencies
34
+ };
35
+ if (!deps[condition.packageHasDependency]) return false;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ if (condition.envVar) {
41
+ if (!process.env[condition.envVar]) return false;
42
+ }
43
+ return true;
44
+ }
45
+ function textContainsKeyword(text, keywords) {
46
+ const lowerText = text.toLowerCase();
47
+ return keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
48
+ }
49
+
50
+ // src/skill-loader.ts
10
51
  var SKILL_FILENAME = "SKILL.md";
11
52
  var SKILL_SEARCH_PATHS = [
12
53
  (dir) => path.join(dir, ".opencode", "skills"),
@@ -39,8 +80,19 @@ function parseFrontmatter(content) {
39
80
  if (descMatch?.[1]) {
40
81
  result.description = descMatch[1].trim();
41
82
  }
83
+ const summaryMatch = frontmatter.match(/^summary:\s*(.+)$/m);
84
+ if (summaryMatch?.[1]) {
85
+ result.summary = summaryMatch[1].trim();
86
+ }
42
87
  return result;
43
88
  }
89
+ function extractAutoSummary(content, maxLength = 500) {
90
+ const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
91
+ const firstSection = withoutFrontmatter.split(/\n##\s/)[0] ?? "";
92
+ const cleaned = firstSection.replace(/^#\s+.+\n?/, "").replace(/\n+/g, " ").trim();
93
+ if (cleaned.length <= maxLength) return cleaned;
94
+ return cleaned.slice(0, maxLength).replace(/\s+\S*$/, "") + "...";
95
+ }
44
96
  function loadSkill(skillName, projectDir) {
45
97
  const filePath = findSkillFile(skillName, projectDir);
46
98
  if (!filePath) {
@@ -48,12 +100,14 @@ function loadSkill(skillName, projectDir) {
48
100
  }
49
101
  try {
50
102
  const content = fs.readFileSync(filePath, "utf-8");
51
- const { name, description } = parseFrontmatter(content);
103
+ const { name, description, summary } = parseFrontmatter(content);
52
104
  return {
53
105
  name: name ?? skillName,
54
106
  description: description ?? "",
107
+ summary: summary ?? extractAutoSummary(content),
55
108
  content,
56
- filePath
109
+ filePath,
110
+ tokenCount: estimateTokens(content)
57
111
  };
58
112
  } catch {
59
113
  return null;
@@ -72,28 +126,58 @@ function loadSkills(skillNames, projectDir) {
72
126
  }
73
127
  return skills;
74
128
  }
75
- function formatSkillsForInjection(skills) {
129
+ function formatSkillsForInjection(skills, options = false) {
76
130
  if (!Array.isArray(skills) || skills.length === 0) {
77
131
  return "";
78
132
  }
79
- const parts = skills.map(
80
- (skill) => `<preloaded-skill name="${skill.name}">
81
- ${skill.content}
82
- </preloaded-skill>`
83
- );
133
+ const opts = typeof options === "boolean" ? { useSummaries: options } : options;
134
+ const globalUseSummaries = opts.useSummaries ?? false;
135
+ const skillSettings = opts.skillSettings ?? {};
136
+ const parts = skills.map((skill) => {
137
+ const perSkillSetting = skillSettings[skill.name]?.useSummary;
138
+ const shouldUseSummary = perSkillSetting ?? globalUseSummaries;
139
+ const content = shouldUseSummary && skill.summary ? skill.summary : skill.content;
140
+ return `<preloaded-skill name="${skill.name}">
141
+ ${content}
142
+ </preloaded-skill>`;
143
+ });
84
144
  return `<preloaded-skills>
85
145
  The following skills have been automatically loaded for this session:
86
146
 
87
147
  ${parts.join("\n\n")}
88
148
  </preloaded-skills>`;
89
149
  }
150
+ function calculateTotalTokens(skills) {
151
+ return skills.reduce((sum, skill) => sum + skill.tokenCount, 0);
152
+ }
153
+ function filterSkillsByTokenBudget(skills, maxTokens) {
154
+ const result = [];
155
+ let totalTokens = 0;
156
+ for (const skill of skills) {
157
+ if (totalTokens + skill.tokenCount <= maxTokens) {
158
+ result.push(skill);
159
+ totalTokens += skill.tokenCount;
160
+ }
161
+ }
162
+ return result;
163
+ }
90
164
 
91
165
  // src/index.ts
92
166
  var CONFIG_FILENAME = "preload-skills.json";
167
+ var ANALYTICS_FILENAME = "preload-skills-analytics.json";
93
168
  var FILE_TOOLS = ["read", "edit", "write", "glob", "grep"];
94
169
  var DEFAULT_CONFIG = {
95
170
  skills: [],
96
171
  fileTypeSkills: {},
172
+ agentSkills: {},
173
+ pathPatterns: {},
174
+ contentTriggers: {},
175
+ groups: {},
176
+ conditionalSkills: [],
177
+ skillSettings: {},
178
+ maxTokens: void 0,
179
+ useSummaries: false,
180
+ analytics: false,
97
181
  persistAfterCompaction: true,
98
182
  debug: false
99
183
  };
@@ -110,7 +194,7 @@ function findConfigFile(projectDir) {
110
194
  }
111
195
  return null;
112
196
  }
113
- function parseFileTypeSkills(raw) {
197
+ function parseStringArrayRecord(raw) {
114
198
  if (!raw || typeof raw !== "object") return {};
115
199
  const result = {};
116
200
  for (const [key, value] of Object.entries(raw)) {
@@ -120,6 +204,28 @@ function parseFileTypeSkills(raw) {
120
204
  }
121
205
  return result;
122
206
  }
207
+ function parseConditionalSkills(raw) {
208
+ if (!Array.isArray(raw)) return [];
209
+ return raw.filter(
210
+ (item) => typeof item === "object" && item !== null && typeof item.skill === "string" && typeof item.if === "object"
211
+ );
212
+ }
213
+ function parseSkillSettings(raw) {
214
+ if (!raw || typeof raw !== "object") return {};
215
+ const result = {};
216
+ for (const [skillName, settings] of Object.entries(raw)) {
217
+ if (typeof settings === "object" && settings !== null) {
218
+ const parsed = {};
219
+ if ("useSummary" in settings && typeof settings.useSummary === "boolean") {
220
+ parsed.useSummary = settings.useSummary;
221
+ }
222
+ if (Object.keys(parsed).length > 0) {
223
+ result[skillName] = parsed;
224
+ }
225
+ }
226
+ }
227
+ return result;
228
+ }
123
229
  function loadConfigFile(projectDir) {
124
230
  const configPath = findConfigFile(projectDir);
125
231
  if (!configPath) {
@@ -130,7 +236,16 @@ function loadConfigFile(projectDir) {
130
236
  const parsed = JSON.parse(content);
131
237
  return {
132
238
  skills: Array.isArray(parsed.skills) ? parsed.skills : [],
133
- fileTypeSkills: parseFileTypeSkills(parsed.fileTypeSkills),
239
+ fileTypeSkills: parseStringArrayRecord(parsed.fileTypeSkills),
240
+ agentSkills: parseStringArrayRecord(parsed.agentSkills),
241
+ pathPatterns: parseStringArrayRecord(parsed.pathPatterns),
242
+ contentTriggers: parseStringArrayRecord(parsed.contentTriggers),
243
+ groups: parseStringArrayRecord(parsed.groups),
244
+ conditionalSkills: parseConditionalSkills(parsed.conditionalSkills),
245
+ skillSettings: parseSkillSettings(parsed.skillSettings),
246
+ maxTokens: typeof parsed.maxTokens === "number" ? parsed.maxTokens : void 0,
247
+ useSummaries: typeof parsed.useSummaries === "boolean" ? parsed.useSummaries : void 0,
248
+ analytics: typeof parsed.analytics === "boolean" ? parsed.analytics : void 0,
134
249
  persistAfterCompaction: typeof parsed.persistAfterCompaction === "boolean" ? parsed.persistAfterCompaction : void 0,
135
250
  debug: typeof parsed.debug === "boolean" ? parsed.debug : void 0
136
251
  };
@@ -154,8 +269,29 @@ function getSkillsForExtension(ext, fileTypeSkills) {
154
269
  }
155
270
  return [...new Set(skills)];
156
271
  }
272
+ function getSkillsForPath(filePath, pathPatterns) {
273
+ const skills = [];
274
+ for (const [pattern, skillNames] of Object.entries(pathPatterns)) {
275
+ if (matchGlobPattern(filePath, pattern)) {
276
+ skills.push(...skillNames);
277
+ }
278
+ }
279
+ return [...new Set(skills)];
280
+ }
281
+ function resolveSkillGroups(skillNames, groups) {
282
+ const resolved = [];
283
+ for (const name of skillNames) {
284
+ if (name.startsWith("@") && groups[name.slice(1)]) {
285
+ resolved.push(...groups[name.slice(1)]);
286
+ } else {
287
+ resolved.push(name);
288
+ }
289
+ }
290
+ return [...new Set(resolved)];
291
+ }
157
292
  var PreloadSkillsPlugin = async (ctx) => {
158
293
  const sessionStates = /* @__PURE__ */ new Map();
294
+ const analyticsData = /* @__PURE__ */ new Map();
159
295
  const fileConfig = loadConfigFile(ctx.directory);
160
296
  const config = {
161
297
  ...DEFAULT_CONFIG,
@@ -172,57 +308,176 @@ var PreloadSkillsPlugin = async (ctx) => {
172
308
  }
173
309
  });
174
310
  };
311
+ const trackSkillUsage = (sessionID, skillName, triggerType) => {
312
+ if (!config.analytics) return;
313
+ if (!analyticsData.has(sessionID)) {
314
+ analyticsData.set(sessionID, {
315
+ sessionId: sessionID,
316
+ skillUsage: /* @__PURE__ */ new Map()
317
+ });
318
+ }
319
+ const data = analyticsData.get(sessionID);
320
+ const now = Date.now();
321
+ if (data.skillUsage.has(skillName)) {
322
+ const stats = data.skillUsage.get(skillName);
323
+ stats.loadCount++;
324
+ stats.lastLoaded = now;
325
+ } else {
326
+ data.skillUsage.set(skillName, {
327
+ skillName,
328
+ loadCount: 1,
329
+ triggerType,
330
+ firstLoaded: now,
331
+ lastLoaded: now
332
+ });
333
+ }
334
+ };
335
+ const saveAnalytics = () => {
336
+ if (!config.analytics) return;
337
+ try {
338
+ const analyticsPath = path.join(ctx.directory, ".opencode", ANALYTICS_FILENAME);
339
+ const dir = path.dirname(analyticsPath);
340
+ if (!fs.existsSync(dir)) {
341
+ fs.mkdirSync(dir, { recursive: true });
342
+ }
343
+ const serializable = {};
344
+ for (const [sessionId, data] of analyticsData) {
345
+ serializable[sessionId] = {
346
+ sessionId: data.sessionId,
347
+ skillUsage: Object.fromEntries(data.skillUsage)
348
+ };
349
+ }
350
+ fs.writeFileSync(analyticsPath, JSON.stringify(serializable, null, 2));
351
+ } catch {
352
+ log("warn", "Failed to save analytics");
353
+ }
354
+ };
175
355
  const skillCache = /* @__PURE__ */ new Map();
176
- const getOrLoadSkill = (skillName) => {
177
- if (skillCache.has(skillName)) {
178
- return skillCache.get(skillName) ?? null;
356
+ const loadSkillsWithBudget = (skillNames, currentTokens, triggerType, sessionID) => {
357
+ const resolved = resolveSkillGroups(skillNames, config.groups ?? {});
358
+ let skills = loadSkills(resolved, ctx.directory);
359
+ for (const skill of skills) {
360
+ skillCache.set(skill.name, skill);
179
361
  }
180
- const skill = loadSkill(skillName, ctx.directory);
181
- if (skill) {
182
- skillCache.set(skillName, skill);
362
+ if (config.maxTokens) {
363
+ const remainingBudget = config.maxTokens - currentTokens;
364
+ skills = filterSkillsByTokenBudget(skills, remainingBudget);
365
+ }
366
+ for (const skill of skills) {
367
+ trackSkillUsage(sessionID, skill.name, triggerType);
183
368
  }
184
- return skill;
369
+ return {
370
+ skills,
371
+ tokensUsed: calculateTotalTokens(skills)
372
+ };
373
+ };
374
+ const resolveConditionalSkills = () => {
375
+ if (!config.conditionalSkills?.length) return [];
376
+ const resolved = [];
377
+ for (const { skill, if: condition } of config.conditionalSkills) {
378
+ if (checkCondition(condition, ctx.directory)) {
379
+ resolved.push(skill);
380
+ }
381
+ }
382
+ return resolved;
185
383
  };
186
384
  let initialSkills = [];
187
385
  let initialFormattedContent = "";
188
- if (config.skills.length > 0) {
189
- initialSkills = loadSkills(config.skills, ctx.directory);
190
- initialFormattedContent = formatSkillsForInjection(initialSkills);
191
- for (const skill of initialSkills) {
192
- skillCache.set(skill.name, skill);
193
- }
386
+ let initialTokensUsed = 0;
387
+ const allInitialSkillNames = [
388
+ ...config.skills,
389
+ ...resolveConditionalSkills()
390
+ ];
391
+ if (allInitialSkillNames.length > 0) {
392
+ const result = loadSkillsWithBudget(
393
+ allInitialSkillNames,
394
+ 0,
395
+ "initial",
396
+ "__init__"
397
+ );
398
+ initialSkills = result.skills;
399
+ initialTokensUsed = result.tokensUsed;
400
+ initialFormattedContent = formatSkillsForInjection(
401
+ initialSkills,
402
+ { useSummaries: config.useSummaries, skillSettings: config.skillSettings }
403
+ );
194
404
  const loadedNames = initialSkills.map((s) => s.name);
195
- const missingNames = config.skills.filter((s) => !loadedNames.includes(s));
196
- log("info", `Loaded ${initialSkills.length} skills for preloading`, {
405
+ const missingNames = allInitialSkillNames.filter(
406
+ (s) => !loadedNames.includes(s) && !s.startsWith("@")
407
+ );
408
+ log("info", `Loaded ${initialSkills.length} initial skills`, {
197
409
  loaded: loadedNames,
410
+ tokens: initialTokensUsed,
198
411
  missing: missingNames.length > 0 ? missingNames : void 0
199
412
  });
200
- if (missingNames.length > 0) {
201
- log("warn", "Some configured skills were not found", {
202
- missing: missingNames
203
- });
204
- }
205
413
  }
206
- const hasFileTypeSkills = Object.keys(config.fileTypeSkills ?? {}).length > 0;
207
- if (config.skills.length === 0 && !hasFileTypeSkills) {
414
+ const hasTriggeredSkills = Object.keys(config.fileTypeSkills ?? {}).length > 0 || Object.keys(config.agentSkills ?? {}).length > 0 || Object.keys(config.pathPatterns ?? {}).length > 0 || Object.keys(config.contentTriggers ?? {}).length > 0;
415
+ if (allInitialSkillNames.length === 0 && !hasTriggeredSkills) {
208
416
  log("warn", "No skills configured. Create .opencode/preload-skills.json");
209
417
  }
210
418
  const getSessionState = (sessionID) => {
211
419
  if (!sessionStates.has(sessionID)) {
212
420
  sessionStates.set(sessionID, {
213
421
  initialSkillsInjected: false,
214
- loadedSkills: new Set(initialSkills.map((s) => s.name))
422
+ loadedSkills: new Set(initialSkills.map((s) => s.name)),
423
+ totalTokensUsed: initialTokensUsed
215
424
  });
216
425
  }
217
426
  return sessionStates.get(sessionID);
218
427
  };
219
428
  const pendingSkillInjections = /* @__PURE__ */ new Map();
429
+ const queueSkillsForInjection = (sessionID, skillNames, triggerType, state) => {
430
+ const newSkillNames = skillNames.filter((name) => !state.loadedSkills.has(name));
431
+ if (newSkillNames.length === 0) return;
432
+ const result = loadSkillsWithBudget(
433
+ newSkillNames,
434
+ state.totalTokensUsed,
435
+ triggerType,
436
+ sessionID
437
+ );
438
+ if (result.skills.length > 0) {
439
+ for (const skill of result.skills) {
440
+ state.loadedSkills.add(skill.name);
441
+ }
442
+ state.totalTokensUsed += result.tokensUsed;
443
+ const existing = pendingSkillInjections.get(sessionID) ?? [];
444
+ pendingSkillInjections.set(sessionID, [...existing, ...result.skills]);
445
+ log("debug", `Queued ${triggerType} skills for injection`, {
446
+ sessionID,
447
+ skills: result.skills.map((s) => s.name),
448
+ tokens: result.tokensUsed
449
+ });
450
+ }
451
+ };
220
452
  return {
221
453
  "chat.message": async (input, output) => {
222
454
  if (!input.sessionID) return;
223
455
  const state = getSessionState(input.sessionID);
224
456
  const firstTextPart = output.parts.find((p) => p.type === "text");
225
457
  if (!firstTextPart || !("text" in firstTextPart)) return;
458
+ const messageText = firstTextPart.text;
459
+ if (input.agent && config.agentSkills?.[input.agent]) {
460
+ queueSkillsForInjection(
461
+ input.sessionID,
462
+ config.agentSkills[input.agent],
463
+ "agent",
464
+ state
465
+ );
466
+ }
467
+ if (config.contentTriggers) {
468
+ for (const [keyword, skillNames] of Object.entries(
469
+ config.contentTriggers
470
+ )) {
471
+ if (textContainsKeyword(messageText, [keyword])) {
472
+ queueSkillsForInjection(
473
+ input.sessionID,
474
+ skillNames,
475
+ "content",
476
+ state
477
+ );
478
+ }
479
+ }
480
+ }
226
481
  const contentToInject = [];
227
482
  if (!state.initialSkillsInjected && initialFormattedContent) {
228
483
  contentToInject.push(initialFormattedContent);
@@ -234,10 +489,10 @@ var PreloadSkillsPlugin = async (ctx) => {
234
489
  }
235
490
  const pending = pendingSkillInjections.get(input.sessionID);
236
491
  if (pending && pending.length > 0) {
237
- const formatted = formatSkillsForInjection(pending);
492
+ const formatted = formatSkillsForInjection(pending, { useSummaries: config.useSummaries, skillSettings: config.skillSettings });
238
493
  if (formatted) {
239
494
  contentToInject.push(formatted);
240
- log("info", "Injected file-type triggered skills", {
495
+ log("info", "Injected triggered skills", {
241
496
  sessionID: input.sessionID,
242
497
  skills: pending.map((s) => s.name)
243
498
  });
@@ -253,7 +508,6 @@ ${firstTextPart.text}`;
253
508
  }
254
509
  },
255
510
  "tool.execute.after": async (input, _output) => {
256
- if (!hasFileTypeSkills) return;
257
511
  if (!FILE_TOOLS.includes(input.tool)) return;
258
512
  if (!input.sessionID) return;
259
513
  const state = getSessionState(input.sessionID);
@@ -262,27 +516,17 @@ ${firstTextPart.text}`;
262
516
  const filePath = getFilePathFromArgs(toolArgs);
263
517
  if (!filePath) return;
264
518
  const ext = path.extname(filePath);
265
- if (!ext) return;
266
- const skillNames = getSkillsForExtension(ext, config.fileTypeSkills ?? {});
267
- if (skillNames.length === 0) return;
268
- const newSkills = [];
269
- for (const name of skillNames) {
270
- if (state.loadedSkills.has(name)) continue;
271
- const skill = getOrLoadSkill(name);
272
- if (skill) {
273
- newSkills.push(skill);
274
- state.loadedSkills.add(name);
519
+ if (ext && config.fileTypeSkills) {
520
+ const extSkills = getSkillsForExtension(ext, config.fileTypeSkills);
521
+ if (extSkills.length > 0) {
522
+ queueSkillsForInjection(input.sessionID, extSkills, "fileType", state);
275
523
  }
276
524
  }
277
- if (newSkills.length > 0) {
278
- const existing = pendingSkillInjections.get(input.sessionID) ?? [];
279
- pendingSkillInjections.set(input.sessionID, [...existing, ...newSkills]);
280
- log("debug", "Queued file-type skills for injection", {
281
- sessionID: input.sessionID,
282
- filePath,
283
- extension: ext,
284
- skills: newSkills.map((s) => s.name)
285
- });
525
+ if (config.pathPatterns) {
526
+ const pathSkills = getSkillsForPath(filePath, config.pathPatterns);
527
+ if (pathSkills.length > 0) {
528
+ queueSkillsForInjection(input.sessionID, pathSkills, "path", state);
529
+ }
286
530
  }
287
531
  },
288
532
  "experimental.session.compacting": async (input, output) => {
@@ -295,7 +539,10 @@ ${firstTextPart.text}`;
295
539
  if (skill) allLoadedSkills.push(skill);
296
540
  }
297
541
  if (allLoadedSkills.length === 0) return;
298
- const formatted = formatSkillsForInjection(allLoadedSkills);
542
+ const formatted = formatSkillsForInjection(
543
+ allLoadedSkills,
544
+ { useSummaries: config.useSummaries, skillSettings: config.skillSettings }
545
+ );
299
546
  output.context.push(
300
547
  `## Preloaded Skills
301
548
 
@@ -308,13 +555,16 @@ ${formatted}`
308
555
  sessionID: input.sessionID,
309
556
  skillCount: allLoadedSkills.length
310
557
  });
558
+ saveAnalytics();
311
559
  },
312
560
  event: async ({ event }) => {
313
561
  if (event.type === "session.deleted" && "sessionID" in event.properties) {
314
562
  const sessionID = event.properties.sessionID;
315
563
  sessionStates.delete(sessionID);
316
564
  pendingSkillInjections.delete(sessionID);
565
+ analyticsData.delete(sessionID);
317
566
  log("debug", "Cleaned up session state", { sessionID });
567
+ saveAnalytics();
318
568
  }
319
569
  }
320
570
  };