assistme 0.2.9 → 0.3.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.
@@ -78,6 +78,7 @@ export interface Skill {
78
78
  source: "bundled" | "manual" | "external" | "auto_extracted" | "auto_improved" | "job_generated";
79
79
  dbId?: string;
80
80
  sourceSkillId?: string;
81
+ invocationCount: number;
81
82
  }
82
83
 
83
84
  function parseDbMetadata(raw: unknown): SkillMetadata {
@@ -101,6 +102,9 @@ export class SkillManager {
101
102
  private idfCache: Map<string, number> = new Map();
102
103
  private userId: string | null = null;
103
104
 
105
+ /** Cache for findRelevant() — keyed by prompt, invalidated on skill changes */
106
+ private relevanceCache: Map<string, { results: Skill[]; maxResults: number }> = new Map();
107
+
104
108
  private readonly DESCRIPTION_BUDGET_CHARS = 16_000;
105
109
 
106
110
  setUserId(userId: string): void {
@@ -146,9 +150,16 @@ export class SkillManager {
146
150
  source: (row.source as Skill["source"]) || "manual",
147
151
  dbId: row.id as string,
148
152
  sourceSkillId: (row.source_skill_id as string) || undefined,
153
+ invocationCount: (row.invocation_count as number) || 0,
149
154
  };
150
155
  }
151
156
 
157
+ /** Invalidate caches when skills change (create, add, update, remove). */
158
+ private invalidateCaches(): void {
159
+ this.relevanceCache.clear();
160
+ this.buildIdfCache();
161
+ }
162
+
152
163
  private buildIdfCache(): void {
153
164
  this.idfCache.clear();
154
165
  const docFreq = new Map<string, number>();
@@ -174,7 +185,14 @@ export class SkillManager {
174
185
  }
175
186
 
176
187
  findRelevant(prompt: string, maxResults = 3): Skill[] {
177
- const lower = prompt.toLowerCase();
188
+ // Check cache (hit if same prompt and at least as many results cached)
189
+ const cacheKey = prompt.toLowerCase();
190
+ const cached = this.relevanceCache.get(cacheKey);
191
+ if (cached && cached.maxResults >= maxResults) {
192
+ return cached.results.slice(0, maxResults);
193
+ }
194
+
195
+ const lower = cacheKey;
178
196
  const promptTokens = tokenize(lower);
179
197
  const promptTokenSet = new Set(promptTokens);
180
198
  const idf = (word: string): number => this.idfCache.get(word) || 1;
@@ -203,26 +221,53 @@ export class SkillManager {
203
221
  if (score > 0) scored.push({ skill, score });
204
222
  }
205
223
 
206
- return scored
224
+ const results = scored
207
225
  .sort((a, b) => b.score - a.score)
208
226
  .slice(0, maxResults)
209
227
  .map((s) => s.skill);
228
+
229
+ // Cache the result
230
+ this.relevanceCache.set(cacheKey, { results, maxResults });
231
+ return results;
210
232
  }
211
233
 
212
- buildSkillDescriptions(): string {
213
- const skills = this.getAll()
214
- .filter((s) => !s.disableModelInvocation)
215
- .sort((a, b) => {
216
- if (a.metadata.always && !b.metadata.always) return -1;
217
- if (!a.metadata.always && b.metadata.always) return 1;
218
- return a.description.length - b.description.length;
219
- });
234
+ /**
235
+ * Build lightweight skill descriptions for the system prompt.
236
+ * When a taskPrompt is provided, relevant skills are prioritized to the top;
237
+ * remaining skills are sorted by usage frequency (invocationCount).
238
+ */
239
+ buildSkillDescriptions(taskPrompt?: string): string {
240
+ const all = this.getAll().filter((s) => !s.disableModelInvocation);
241
+ if (all.length === 0) return "";
242
+
243
+ // Split into "always" skills and the rest
244
+ const alwaysSkills = all.filter((s) => s.metadata.always);
245
+ const rest = all.filter((s) => !s.metadata.always);
246
+
247
+ // If a task prompt is provided, find relevant skills and prioritize them
248
+ let relevantNames: Set<string> | null = null;
249
+ if (taskPrompt) {
250
+ const relevant = this.findRelevant(taskPrompt, 10);
251
+ relevantNames = new Set(relevant.map((s) => s.name));
252
+ }
253
+
254
+ // Sort: relevant skills first (by match score, already sorted), then by invocation count
255
+ const sorted = rest.sort((a, b) => {
256
+ if (relevantNames) {
257
+ const aRelevant = relevantNames.has(a.name);
258
+ const bRelevant = relevantNames.has(b.name);
259
+ if (aRelevant && !bRelevant) return -1;
260
+ if (!aRelevant && bRelevant) return 1;
261
+ }
262
+ return (b.invocationCount || 0) - (a.invocationCount || 0);
263
+ });
220
264
 
221
- if (skills.length === 0) return "";
265
+ const skills = [...alwaysSkills, ...sorted];
222
266
 
223
267
  let budget = this.DESCRIPTION_BUDGET_CHARS;
224
268
  let prompt = "\n\n## Your Skills\n";
225
- prompt += "These are your approved skills. Use skill_invoke to load full instructions when a task matches.\n\n";
269
+ prompt += "These are your approved skills. Use skill_invoke to load full instructions when a task matches.\n";
270
+ prompt += "If no skill matches but the task is a reusable pattern, consider creating one with skill_create.\n\n";
226
271
  let included = 0;
227
272
 
228
273
  for (const skill of skills) {
@@ -236,7 +281,7 @@ export class SkillManager {
236
281
  }
237
282
 
238
283
  if (included < skills.length) {
239
- prompt += `\n_(${skills.length - included} additional skills available — use skill_invoke to explore)_\n`;
284
+ prompt += `\n_(${skills.length - included} additional skills available — use skill_search to find more)_\n`;
240
285
  }
241
286
 
242
287
  return prompt;
@@ -294,6 +339,26 @@ export class SkillManager {
294
339
  const id = (row.out_id || row.id) as string;
295
340
  const skillName = (row.out_name || row.name) as string;
296
341
 
342
+ // Add to in-memory map so the skill is immediately searchable
343
+ this.skills.set(skillName, {
344
+ name: skillName,
345
+ description,
346
+ version: "1.0.0",
347
+ userInvocable: true,
348
+ disableModelInvocation: false,
349
+ keywords: options?.keywords || [],
350
+ allowedTools: [],
351
+ argumentHint: "",
352
+ metadata: options?.emoji ? { emoji: options.emoji } : {},
353
+ homepage: "",
354
+ content,
355
+ filePath: "",
356
+ source: (options?.source as Skill["source"]) || "manual",
357
+ dbId: id,
358
+ invocationCount: 0,
359
+ });
360
+ this.invalidateCaches();
361
+
297
362
  log.info(`Skill "${skillName}" created in skills table (pending approval)`);
298
363
  return { id, name: skillName };
299
364
  } catch (err) {
@@ -323,7 +388,7 @@ export class SkillManager {
323
388
  source_skill_id: skillId,
324
389
  });
325
390
  this.skills.set(skill.name, skill);
326
- this.buildIdfCache();
391
+ this.invalidateCaches();
327
392
 
328
393
  log.info(`Skill "${row.name}" added to user's collection`);
329
394
  return skill;
@@ -337,6 +402,7 @@ export class SkillManager {
337
402
  const skill = this.skills.get(name);
338
403
  if (!skill) return false;
339
404
  this.skills.delete(name);
405
+ this.invalidateCaches();
340
406
  this.removeFromDb(name).catch(() => {});
341
407
  return true;
342
408
  }
@@ -387,6 +453,7 @@ export class SkillManager {
387
453
  skill.content = newContent;
388
454
  skill.description = newDescription;
389
455
  skill.version = newVersion;
456
+ this.invalidateCaches();
390
457
 
391
458
  this.syncToAgentSkills(name, newDescription, newContent, newVersion, {
392
459
  source: "auto_improved",
@@ -604,6 +671,35 @@ export class SkillManager {
604
671
 
605
672
  // ── Exported Utility Functions ─────────────────────────────────────
606
673
 
674
+ /**
675
+ * Validate a skill name against the Agent Skills spec:
676
+ * 1-64 chars, lowercase kebab-case (a-z, 0-9, hyphens),
677
+ * no leading/trailing/consecutive hyphens.
678
+ * Returns null if valid, or an error message if invalid.
679
+ */
680
+ export function validateSkillName(name: string): string | null {
681
+ if (!name || name.length === 0) return "name is empty";
682
+ if (name.length > 64) return `name too long (${name.length}/64 chars)`;
683
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
684
+ return `name must be lowercase kebab-case (a-z, 0-9, hyphens), no leading/trailing/consecutive hyphens. Got: "${name}"`;
685
+ }
686
+ return null;
687
+ }
688
+
689
+ /**
690
+ * Normalize a skill name to valid kebab-case.
691
+ * Converts to lowercase, replaces non-alphanumeric with hyphens,
692
+ * collapses consecutive hyphens, trims, and truncates to 64 chars.
693
+ */
694
+ export function normalizeSkillName(name: string): string {
695
+ return name
696
+ .toLowerCase()
697
+ .replace(/[^a-z0-9]+/g, "-")
698
+ .replace(/^-+|-+$/g, "")
699
+ .replace(/-{2,}/g, "-")
700
+ .slice(0, 64);
701
+ }
702
+
607
703
  export function substituteArguments(content: string, args: string): string {
608
704
  const parts = args.split(/\s+/);
609
705
  content = content.replace(/\$ARGUMENTS/g, args);