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