assistme 0.2.8 → 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.
@@ -1,6 +1,6 @@
1
1
  import { execSync } from "child_process";
2
2
  import { log } from "../utils/logger.js";
3
- import { getSupabase } from "../db/supabase.js";
3
+ import { callMcpHandler } from "../db/api-client.js";
4
4
 
5
5
  // ── Text Processing Helpers ─────────────────────────────────────────
6
6
 
@@ -25,12 +25,10 @@ const STOP_WORDS = new Set([
25
25
  * Supports both English (space-delimited) and Chinese (character bigrams).
26
26
  */
27
27
  function tokenize(text: string): string[] {
28
- // English tokens: split on delimiters, filter stop words
29
28
  const englishTokens = text
30
29
  .split(/[\s\-_/.,;:!?()[\]{}'"]+/)
31
30
  .filter((w) => w.length > 2 && !STOP_WORDS.has(w));
32
31
 
33
- // Chinese character bigrams for CJK text
34
32
  const chineseChars = text.replace(/[^\u4e00-\u9fff]/g, "");
35
33
  const chineseBigrams: string[] = [];
36
34
  for (let i = 0; i < chineseChars.length - 1; i++) {
@@ -40,9 +38,6 @@ function tokenize(text: string): string[] {
40
38
  return [...englishTokens, ...chineseBigrams];
41
39
  }
42
40
 
43
- /**
44
- * Generate bigrams (consecutive word pairs) from a token list.
45
- */
46
41
  function bigrams(tokens: string[]): Set<string> {
47
42
  const result = new Set<string>();
48
43
  for (let i = 0; i < tokens.length - 1; i++) {
@@ -67,26 +62,6 @@ export interface SkillMetadata {
67
62
  skillKey?: string;
68
63
  }
69
64
 
70
- /**
71
- * A variable that a skill requires from the user.
72
- * Variables are defined in the skill template (e.g. `{{github_repos}}`)
73
- * and resolved with user-specific values stored in agent_skills.config.
74
- */
75
- export interface SkillVariable {
76
- /** Variable name used in templates, e.g. "github_repos" */
77
- name: string;
78
- /** Human-readable description, e.g. "GitHub repositories to monitor" */
79
- description: string;
80
- /** Data type of the variable */
81
- type: "string" | "string[]" | "number" | "boolean";
82
- /** Whether the skill cannot function without this variable */
83
- required: boolean;
84
- /** Default value if not configured by user */
85
- default?: string;
86
- /** Example value to guide the user, e.g. "octocat/hello-world, myorg/myrepo" */
87
- example?: string;
88
- }
89
-
90
65
  export interface Skill {
91
66
  name: string;
92
67
  description: string;
@@ -98,20 +73,14 @@ export interface Skill {
98
73
  argumentHint: string;
99
74
  metadata: SkillMetadata;
100
75
  homepage: string;
101
- content: string; // The markdown body (instructions)
102
- filePath: string; // Empty for DB-only skills
76
+ content: string;
77
+ filePath: string;
103
78
  source: "bundled" | "manual" | "external" | "auto_extracted" | "auto_improved" | "job_generated";
104
- dbId?: string; // UUID from agent_skills table
105
- sourceSkillId?: string; // UUID from skills table (origin)
106
- /** Variable definitions declared by the skill template */
107
- variables?: SkillVariable[];
108
- /** User-specific variable values (from agent_skills.config) */
109
- config?: Record<string, unknown>;
79
+ dbId?: string;
80
+ sourceSkillId?: string;
81
+ invocationCount: number;
110
82
  }
111
83
 
112
- /**
113
- * Parse metadata JSONB from DB row into SkillMetadata.
114
- */
115
84
  function parseDbMetadata(raw: unknown): SkillMetadata {
116
85
  if (!raw || typeof raw !== "object") return {};
117
86
  const obj = raw as Record<string, unknown>;
@@ -127,63 +96,33 @@ function parseDbMetadata(raw: unknown): SkillMetadata {
127
96
  }
128
97
 
129
98
  // ── Skill Manager ───────────────────────────────────────────────────
130
- // Architecture:
131
- // skills table = repository of all skill definitions (like npm)
132
- // agent_skills = user's active collection (approved/added skills)
133
- // in-memory map = loaded from agent_skills for fast access
134
- //
135
- // Flow:
136
- // create() → saves to skills table only (draft, is_public: false)
137
- // addSkill() → copies from skills to agent_skills + in-memory map
138
- // publish() → sets skills.is_public = true
139
99
 
140
100
  export class SkillManager {
141
101
  private skills: Map<string, Skill> = new Map();
142
102
  private idfCache: Map<string, number> = new Map();
143
103
  private userId: string | null = null;
144
104
 
145
- /** Budget for skill descriptions in system prompt (characters). */
105
+ /** Cache for findRelevant() keyed by prompt, invalidated on skill changes */
106
+ private relevanceCache: Map<string, { results: Skill[]; maxResults: number }> = new Map();
107
+
146
108
  private readonly DESCRIPTION_BUDGET_CHARS = 16_000;
147
109
 
148
- /**
149
- * Set the user ID for DB operations.
150
- * Called after authentication; enables DB-backed skill storage.
151
- */
152
110
  setUserId(userId: string): void {
153
111
  this.userId = userId;
154
112
  }
155
113
 
156
- /**
157
- * Load skills from DB (user's agent_skills collection).
158
- * This is the only loading mechanism — no file-based loading.
159
- */
160
114
  async loadFromDb(): Promise<void> {
161
115
  if (!this.userId) return;
162
116
 
163
117
  try {
164
- const sb = getSupabase();
165
-
166
- // Load only the user's active skills from agent_skills
167
- const { data, error } = await sb
168
- .from("agent_skills")
169
- .select("*")
170
- .eq("user_id", this.userId)
171
- .eq("is_active", true);
172
-
173
- if (error) {
174
- log.debug(`DB skill load failed: ${error.message}`);
175
- return;
176
- }
118
+ const data = await callMcpHandler<Record<string, unknown>[]>("skill.load");
177
119
 
178
120
  this.skills.clear();
179
- const dbRows = (data || []) as Record<string, unknown>[];
180
-
181
- for (const row of dbRows) {
121
+ for (const row of data || []) {
182
122
  const skill = this.rowToSkill(row);
183
123
  this.skills.set(skill.name, skill);
184
124
  }
185
125
 
186
- // Build IDF cache for relevance matching
187
126
  this.buildIdfCache();
188
127
 
189
128
  if (this.skills.size > 0) {
@@ -194,19 +133,7 @@ export class SkillManager {
194
133
  }
195
134
  }
196
135
 
197
- /**
198
- * Convert a DB row to a Skill object.
199
- */
200
136
  private rowToSkill(row: Record<string, unknown>): Skill {
201
- // Parse variables from metadata.variables or top-level variables column
202
- const meta = (row.metadata || {}) as Record<string, unknown>;
203
- const rawVars = (row.variables || meta.variables) as SkillVariable[] | undefined;
204
- const variables = Array.isArray(rawVars) ? rawVars : undefined;
205
-
206
- // Parse user config (variable values) from config column
207
- const rawConfig = row.config as Record<string, unknown> | undefined;
208
- const config = rawConfig && typeof rawConfig === "object" ? rawConfig : undefined;
209
-
210
137
  return {
211
138
  name: row.name as string,
212
139
  description: (row.description as string) || "",
@@ -223,15 +150,16 @@ export class SkillManager {
223
150
  source: (row.source as Skill["source"]) || "manual",
224
151
  dbId: row.id as string,
225
152
  sourceSkillId: (row.source_skill_id as string) || undefined,
226
- variables,
227
- config,
153
+ invocationCount: (row.invocation_count as number) || 0,
228
154
  };
229
155
  }
230
156
 
231
- /**
232
- * Pre-compute IDF (Inverse Document Frequency) for all tokens across skills.
233
- * Called once after loadFromDb(), avoids recomputing on every findRelevant() call.
234
- */
157
+ /** Invalidate caches when skills change (create, add, update, remove). */
158
+ private invalidateCaches(): void {
159
+ this.relevanceCache.clear();
160
+ this.buildIdfCache();
161
+ }
162
+
235
163
  private buildIdfCache(): void {
236
164
  this.idfCache.clear();
237
165
  const docFreq = new Map<string, number>();
@@ -248,102 +176,98 @@ export class SkillManager {
248
176
  }
249
177
  }
250
178
 
251
- /**
252
- * Get all loaded skills (from user's agent_skills collection).
253
- */
254
179
  getAll(): Skill[] {
255
180
  return Array.from(this.skills.values());
256
181
  }
257
182
 
258
- /**
259
- * Get a skill by name.
260
- */
261
183
  get(name: string): Skill | undefined {
262
184
  return this.skills.get(name);
263
185
  }
264
186
 
265
- /**
266
- * Find skills relevant to a given task prompt.
267
- * Uses a TF-IDF-inspired scoring approach with pre-computed IDF cache.
268
- */
269
187
  findRelevant(prompt: string, maxResults = 3): Skill[] {
270
- 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;
271
196
  const promptTokens = tokenize(lower);
272
197
  const promptTokenSet = new Set(promptTokens);
273
-
274
198
  const idf = (word: string): number => this.idfCache.get(word) || 1;
275
-
276
199
  const scored: Array<{ skill: Skill; score: number }> = [];
277
200
 
278
201
  for (const skill of this.skills.values()) {
279
202
  if (skill.disableModelInvocation) continue;
280
-
281
203
  let score = 0;
282
-
283
- // Name match (strongest signal)
284
204
  if (lower.includes(skill.name.toLowerCase())) score += 10;
285
-
286
- // Keywords match (explicit frontmatter keywords — high signal)
287
205
  for (const kw of skill.keywords) {
288
- if (lower.includes(kw.toLowerCase())) {
289
- score += 8;
290
- }
206
+ if (lower.includes(kw.toLowerCase())) score += 8;
291
207
  }
292
-
293
- // Description word overlap with IDF weighting
294
208
  const descTokens = tokenize(skill.description.toLowerCase());
295
209
  for (const word of descTokens) {
296
- if (promptTokenSet.has(word)) {
297
- score += 3 * idf(word);
298
- }
210
+ if (promptTokenSet.has(word)) score += 3 * idf(word);
299
211
  }
300
-
301
- // Content keyword matches with IDF weighting (lighter)
302
212
  const contentTokens = tokenize(skill.content.toLowerCase());
303
213
  for (const word of contentTokens) {
304
- if (promptTokenSet.has(word)) {
305
- score += 0.5 * idf(word);
306
- }
214
+ if (promptTokenSet.has(word)) score += 0.5 * idf(word);
307
215
  }
308
-
309
- // Bigram matching: consecutive word pairs
310
216
  const promptBigrams = bigrams(promptTokens);
311
217
  const descBigrams = bigrams(descTokens);
312
218
  for (const bg of descBigrams) {
313
219
  if (promptBigrams.has(bg)) score += 5;
314
220
  }
315
-
316
- if (score > 0) {
317
- scored.push({ skill, score });
318
- }
221
+ if (score > 0) scored.push({ skill, score });
319
222
  }
320
223
 
321
- return scored
224
+ const results = scored
322
225
  .sort((a, b) => b.score - a.score)
323
226
  .slice(0, maxResults)
324
227
  .map((s) => s.skill);
228
+
229
+ // Cache the result
230
+ this.relevanceCache.set(cacheKey, { results, maxResults });
231
+ return results;
325
232
  }
326
233
 
327
234
  /**
328
- * Build a lightweight prompt section with skill descriptions only.
329
- * Full skill content is loaded on-demand via the skill_invoke tool.
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).
330
238
  */
331
- buildSkillDescriptions(): string {
332
- const skills = this.getAll()
333
- .filter((s) => !s.disableModelInvocation)
334
- .sort((a, b) => {
335
- // always=true skills first
336
- if (a.metadata.always && !b.metadata.always) return -1;
337
- if (!a.metadata.always && b.metadata.always) return 1;
338
- // Then shorter descriptions first (fit more skills in budget)
339
- return a.description.length - b.description.length;
340
- });
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
+ }
341
253
 
342
- if (skills.length === 0) return "";
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
+ });
264
+
265
+ const skills = [...alwaysSkills, ...sorted];
343
266
 
344
267
  let budget = this.DESCRIPTION_BUDGET_CHARS;
345
268
  let prompt = "\n\n## Your Skills\n";
346
- 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";
347
271
  let included = 0;
348
272
 
349
273
  for (const skill of skills) {
@@ -357,22 +281,19 @@ export class SkillManager {
357
281
  }
358
282
 
359
283
  if (included < skills.length) {
360
- 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`;
361
285
  }
362
286
 
363
287
  return prompt;
364
288
  }
365
289
 
366
- /**
367
- * @deprecated Use buildSkillDescriptions() + skill_invoke tool instead.
368
- */
290
+ /** @deprecated Use buildSkillDescriptions() + skill_invoke tool instead. */
369
291
  buildSkillPrompt(taskPrompt: string): string {
370
292
  const relevant = this.findRelevant(taskPrompt);
371
293
  if (relevant.length === 0) return "";
372
294
 
373
295
  let prompt = "\n\n## Available Skills\n";
374
- prompt +=
375
- "The following skills provide detailed instructions for this type of task:\n\n";
296
+ prompt += "The following skills provide detailed instructions for this type of task:\n\n";
376
297
 
377
298
  for (const skill of relevant) {
378
299
  const emoji = skill.metadata.emoji || "";
@@ -384,48 +305,30 @@ export class SkillManager {
384
305
  return prompt;
385
306
  }
386
307
 
387
- /**
388
- * Create a new skill. Saves to `skills` table only (as private draft).
389
- * The skill is NOT added to user's active collection (agent_skills) until
390
- * the user approves it via addSkill().
391
- *
392
- * Returns { id, name } of the created skill, or null on failure.
393
- */
394
308
  async create(
395
309
  name: string,
396
310
  description: string,
397
311
  content: string,
398
- options?: { source?: string; emoji?: string; keywords?: string[]; variables?: SkillVariable[] }
312
+ options?: { source?: string; emoji?: string; keywords?: string[] }
399
313
  ): Promise<{ id: string; name: string } | null> {
400
314
  if (!this.userId) return null;
401
315
 
402
316
  try {
403
- const sb = getSupabase();
404
- const source = options?.source || "manual";
405
- const metadata: Record<string, unknown> = {};
406
- if (options?.emoji) {
407
- metadata.openclaw = { emoji: options.emoji };
408
- }
409
- if (options?.variables && options.variables.length > 0) {
410
- metadata.variables = options.variables;
411
- }
412
-
413
- const { data, error } = await sb.rpc("create_skill", {
414
- p_user_id: this.userId,
415
- p_name: name,
416
- p_description: description,
417
- p_content: content,
418
- p_version: "1.0.0",
419
- p_source: source,
420
- p_emoji: options?.emoji || null,
421
- p_keywords: options?.keywords || [],
422
- p_metadata: metadata,
423
- });
424
-
425
- if (error) {
426
- log.debug(`Skill create failed for "${name}": ${error.message}`);
427
- return null;
428
- }
317
+ const metadata = options?.emoji ? { openclaw: { emoji: options.emoji } } : {};
318
+
319
+ const data = await callMcpHandler<Record<string, unknown> | Record<string, unknown>[]>(
320
+ "skill.create",
321
+ {
322
+ name,
323
+ description,
324
+ content,
325
+ version: "1.0.0",
326
+ source: options?.source || "manual",
327
+ emoji: options?.emoji || null,
328
+ keywords: options?.keywords || [],
329
+ metadata,
330
+ },
331
+ );
429
332
 
430
333
  const row = (Array.isArray(data) ? data[0] : data) as Record<string, unknown> | null;
431
334
  if (!row) {
@@ -433,10 +336,29 @@ export class SkillManager {
433
336
  return null;
434
337
  }
435
338
 
436
- // RPC returns out_id/out_name to avoid column ambiguity
437
339
  const id = (row.out_id || row.id) as string;
438
340
  const skillName = (row.out_name || row.name) as string;
439
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
+
440
362
  log.info(`Skill "${skillName}" created in skills table (pending approval)`);
441
363
  return { id, name: skillName };
442
364
  } catch (err) {
@@ -445,63 +367,19 @@ export class SkillManager {
445
367
  }
446
368
  }
447
369
 
448
- /**
449
- * Add a skill to the user's active collection (agent_skills).
450
- * Copies content from the skills table.
451
- * This is the "approval" step — after this, the skill is usable.
452
- */
453
370
  async addSkill(skillId: string): Promise<Skill | null> {
454
371
  if (!this.userId) return null;
455
372
 
456
373
  try {
457
- const sb = getSupabase();
458
-
459
- // Fetch from skills table
460
- const { data: pub, error: fetchErr } = await sb
461
- .from("skills")
462
- .select("*")
463
- .eq("id", skillId)
464
- .eq("is_active", true)
465
- .single();
466
-
467
- if (fetchErr || !pub) {
468
- log.debug(`Skill not found: ${skillId}`);
469
- return null;
470
- }
471
-
472
- const row = pub as Record<string, unknown>;
473
-
474
- // Add to user's agent_skills via upsert
475
- const { data, error } = await sb.rpc("upsert_agent_skill", {
476
- p_user_id: this.userId,
477
- p_name: row.name as string,
478
- p_description: (row.description as string) || "",
479
- p_content: row.content as string,
480
- p_version: (row.version as string) || "1.0.0",
481
- p_source: (row.source as string) || "manual",
482
- p_emoji: (row.emoji as string) || null,
483
- p_keywords: (row.keywords as string[]) || [],
484
- p_source_skill_id: skillId,
485
- });
486
-
487
- if (error) {
488
- log.debug(`addSkill failed: ${error.message}`);
489
- return null;
490
- }
491
-
492
- // Update install count on skills table (non-critical)
493
- try {
494
- await sb.rpc("increment_install_count", { p_skill_id: skillId });
495
- } catch {
496
- try {
497
- await sb.from("skills")
498
- .update({ install_count: (row.install_count as number || 0) + 1 })
499
- .eq("id", skillId);
500
- } catch { /* ignore */ }
501
- }
502
-
503
- // Add to in-memory map
504
- const agentSkillRow = (data && typeof data === "object" ? data : row) as Record<string, unknown>;
374
+ const result = await callMcpHandler<{ skill: Record<string, unknown>; agent_skill: unknown }>(
375
+ "skill.fetch_and_add",
376
+ { skill_id: skillId },
377
+ );
378
+
379
+ const row = result.skill;
380
+ const agentSkillRow = (result.agent_skill && typeof result.agent_skill === "object"
381
+ ? result.agent_skill
382
+ : row) as Record<string, unknown>;
505
383
  const skill = this.rowToSkill({
506
384
  ...agentSkillRow,
507
385
  name: row.name,
@@ -510,7 +388,7 @@ export class SkillManager {
510
388
  source_skill_id: skillId,
511
389
  });
512
390
  this.skills.set(skill.name, skill);
513
- this.buildIdfCache();
391
+ this.invalidateCaches();
514
392
 
515
393
  log.info(`Skill "${row.name}" added to user's collection`);
516
394
  return skill;
@@ -520,28 +398,18 @@ export class SkillManager {
520
398
  }
521
399
  }
522
400
 
523
- /**
524
- * Remove a skill from user's collection (soft-delete in agent_skills).
525
- */
526
401
  remove(name: string): boolean {
527
402
  const skill = this.skills.get(name);
528
403
  if (!skill) return false;
529
-
530
404
  this.skills.delete(name);
531
-
532
- // Mark inactive in DB (fire-and-forget)
405
+ this.invalidateCaches();
533
406
  this.removeFromDb(name).catch(() => {});
534
407
  return true;
535
408
  }
536
409
 
537
- /**
538
- * List skills with summary info.
539
- */
540
410
  listFormatted(): string {
541
411
  const skills = this.getAll();
542
- if (skills.length === 0) {
543
- return "No skills in your collection.";
544
- }
412
+ if (skills.length === 0) return "No skills in your collection.";
545
413
 
546
414
  return skills
547
415
  .map((s) => {
@@ -551,29 +419,15 @@ export class SkillManager {
551
419
  .join("\n\n");
552
420
  }
553
421
 
554
- /**
555
- * Check if a skill with a similar name already exists in user's collection.
556
- */
557
422
  findSimilar(name: string): Skill | null {
558
- // Exact match
559
423
  if (this.skills.has(name)) return this.skills.get(name)!;
560
424
 
561
- // Fuzzy match: check if names overlap significantly
562
425
  const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, "");
563
426
  for (const [existingName, skill] of this.skills) {
564
- const normalizedExisting = existingName
565
- .toLowerCase()
566
- .replace(/[^a-z0-9]/g, "");
567
-
568
- // One contains the other
569
- if (
570
- normalizedName.includes(normalizedExisting) ||
571
- normalizedExisting.includes(normalizedName)
572
- ) {
427
+ const normalizedExisting = existingName.toLowerCase().replace(/[^a-z0-9]/g, "");
428
+ if (normalizedName.includes(normalizedExisting) || normalizedExisting.includes(normalizedName)) {
573
429
  return skill;
574
430
  }
575
-
576
- // High word overlap
577
431
  const nameWords = new Set(name.split("-"));
578
432
  const existingWords = new Set(existingName.split("-"));
579
433
  let overlap = 0;
@@ -584,45 +438,34 @@ export class SkillManager {
584
438
  return skill;
585
439
  }
586
440
  }
587
-
588
441
  return null;
589
442
  }
590
443
 
591
- /**
592
- * Update an existing skill in user's collection.
593
- * Bumps the patch version automatically. Also updates skills table if user is author.
594
- */
595
444
  update(name: string, newContent: string, description?: string): boolean {
596
445
  const skill = this.skills.get(name);
597
446
  if (!skill) return false;
598
447
 
599
- // Bump patch version
600
448
  const versionParts = skill.version.split(".").map(Number);
601
449
  versionParts[2] = (versionParts[2] || 0) + 1;
602
450
  const newVersion = versionParts.join(".");
603
451
  const newDescription = description || skill.description;
604
452
 
605
- // Update in-memory
606
453
  skill.content = newContent;
607
454
  skill.description = newDescription;
608
455
  skill.version = newVersion;
456
+ this.invalidateCaches();
609
457
 
610
- // Persist to agent_skills (fire-and-forget)
611
458
  this.syncToAgentSkills(name, newDescription, newContent, newVersion, {
612
459
  source: "auto_improved",
613
460
  }).catch(() => {});
614
461
 
615
- // Also update skills table if this skill has a source (fire-and-forget)
616
462
  if (skill.sourceSkillId && this.userId) {
617
- const sb = getSupabase();
618
- void (async () => {
619
- try {
620
- await sb.from("skills")
621
- .update({ content: newContent, description: newDescription, version: newVersion })
622
- .eq("id", skill.sourceSkillId!)
623
- .eq("author_id", this.userId!);
624
- } catch { /* non-critical */ }
625
- })();
463
+ callMcpHandler("skill.update_source", {
464
+ source_skill_id: skill.sourceSkillId,
465
+ content: newContent,
466
+ description: newDescription,
467
+ version: newVersion,
468
+ }).catch(() => {});
626
469
  }
627
470
 
628
471
  log.info(`Skill "${name}" updated to v${newVersion}`);
@@ -631,10 +474,6 @@ export class SkillManager {
631
474
 
632
475
  // ── DB Integration ─────────────────────────────────────────────────
633
476
 
634
- /**
635
- * Persist a skill to agent_skills via upsert_agent_skill().
636
- * Fire-and-forget: failures are logged but don't block.
637
- */
638
477
  async syncToAgentSkills(
639
478
  name: string,
640
479
  description: string,
@@ -646,46 +485,26 @@ export class SkillManager {
646
485
  keywords?: string[];
647
486
  changeSummary?: string;
648
487
  sourceSkillId?: string;
649
- variables?: SkillVariable[];
650
488
  }
651
489
  ): Promise<void> {
652
490
  if (!this.userId) return;
653
491
 
654
492
  try {
655
- const sb = getSupabase();
656
-
657
- // Store variables in metadata for agent_skills
658
- const metadata: Record<string, unknown> = {};
659
- if (options?.variables && options.variables.length > 0) {
660
- metadata.variables = options.variables;
661
- }
662
-
663
- const { data, error } = await sb.rpc("upsert_agent_skill", {
664
- p_user_id: this.userId,
665
- p_name: name,
666
- p_description: description,
667
- p_content: content,
668
- p_version: version,
669
- p_source: options?.source || "manual",
670
- p_emoji: options?.emoji || null,
671
- p_keywords: options?.keywords || [],
672
- p_change_summary: options?.changeSummary || null,
673
- p_source_skill_id: options?.sourceSkillId || null,
674
- p_metadata: Object.keys(metadata).length > 0 ? metadata : null,
493
+ const data = await callMcpHandler<Record<string, unknown> | null>("skill.upsert", {
494
+ name,
495
+ description,
496
+ content,
497
+ version,
498
+ source: options?.source || "manual",
499
+ emoji: options?.emoji || null,
500
+ keywords: options?.keywords || [],
501
+ change_summary: options?.changeSummary || null,
502
+ source_skill_id: options?.sourceSkillId || null,
675
503
  });
676
504
 
677
- if (error) {
678
- log.debug(`DB skill sync failed for "${name}": ${error.message}`);
679
- return;
680
- }
681
-
682
- // Update in-memory skill with dbId and variables
683
505
  const skill = this.skills.get(name);
684
- if (skill && data && typeof data === "object" && "id" in (data as Record<string, unknown>)) {
685
- skill.dbId = (data as Record<string, unknown>).id as string;
686
- }
687
- if (skill && options?.variables) {
688
- skill.variables = options.variables;
506
+ if (skill && data && typeof data === "object" && "id" in data) {
507
+ skill.dbId = data.id as string;
689
508
  }
690
509
 
691
510
  log.debug(`Skill "${name}" synced to agent_skills`);
@@ -694,10 +513,6 @@ export class SkillManager {
694
513
  }
695
514
  }
696
515
 
697
- /**
698
- * Log a skill invocation to agent_skill_invocations.
699
- * Fire-and-forget: failures don't block task execution.
700
- */
701
516
  async logInvocation(
702
517
  skillName: string,
703
518
  options?: {
@@ -718,29 +533,19 @@ export class SkillManager {
718
533
  }
719
534
 
720
535
  try {
721
- const sb = getSupabase();
722
- const { error } = await sb.from("agent_skill_invocations").insert({
536
+ await callMcpHandler("skill.log_invocation", {
723
537
  skill_id: skillDbId,
724
- user_id: this.userId,
725
538
  message_id: options?.messageId || null,
726
539
  session_id: options?.sessionId || null,
727
540
  task_prompt: options?.taskPrompt?.slice(0, 500) || null,
728
541
  arguments: options?.arguments || null,
729
542
  success: options?.success ?? null,
730
543
  });
731
-
732
- if (error) {
733
- log.debug(`Invocation log failed: ${error.message}`);
734
- }
735
544
  } catch (err) {
736
545
  log.debug(`Invocation log error: ${err}`);
737
546
  }
738
547
  }
739
548
 
740
- /**
741
- * Search user's skills in DB using full-text search.
742
- * Falls back to in-memory findRelevant() if DB is unavailable.
743
- */
744
549
  async searchDb(query: string, limit = 10): Promise<Array<{
745
550
  name: string;
746
551
  description: string;
@@ -748,19 +553,11 @@ export class SkillManager {
748
553
  source: string;
749
554
  invocationCount: number;
750
555
  }>> {
751
- // Try DB search first
752
556
  if (this.userId) {
753
557
  try {
754
- const sb = getSupabase();
755
- const { data, error } = await sb.rpc("search_agent_skills", {
756
- p_user_id: this.userId,
757
- p_query: query,
758
- p_limit: limit,
759
- });
760
-
761
- if (!error && data) {
762
- const rows = data as Record<string, unknown>[];
763
- return rows.map((row) => ({
558
+ const data = await callMcpHandler<Record<string, unknown>[]>("skill.search", { query, limit });
559
+ if (data) {
560
+ return data.map((row) => ({
764
561
  name: row.name as string,
765
562
  description: (row.description as string) || "",
766
563
  emoji: (row.emoji as string) || "",
@@ -768,12 +565,9 @@ export class SkillManager {
768
565
  invocationCount: (row.invocation_count as number) || 0,
769
566
  }));
770
567
  }
771
- } catch {
772
- // Fall through to in-memory search
773
- }
568
+ } catch { /* Fall through to in-memory search */ }
774
569
  }
775
570
 
776
- // Fallback: in-memory search
777
571
  const results = this.findRelevant(query, limit);
778
572
  return results.map((s) => ({
779
573
  name: s.name,
@@ -784,88 +578,52 @@ export class SkillManager {
784
578
  }));
785
579
  }
786
580
 
787
- /**
788
- * Remove a skill from DB (soft-delete).
789
- */
790
581
  async removeFromDb(name: string): Promise<void> {
791
582
  if (!this.userId) return;
792
-
793
583
  try {
794
- const sb = getSupabase();
795
- await sb
796
- .from("agent_skills")
797
- .update({ is_active: false })
798
- .eq("user_id", this.userId)
799
- .eq("name", name);
800
- } catch {
801
- // Non-critical
802
- }
584
+ await callMcpHandler("skill.remove", { name });
585
+ } catch { /* Non-critical */ }
803
586
  }
804
587
 
805
588
  // ── Marketplace ────────────────────────────────────────────────────
806
589
 
807
- /**
808
- * Publish a skill to the marketplace (sets is_public = true in skills table).
809
- */
810
590
  async publish(
811
591
  name: string,
812
592
  options?: { category?: string; authorName?: string }
813
593
  ): Promise<{ id: string } | null> {
814
594
  if (!this.userId) return null;
815
-
816
595
  const skill = this.skills.get(name);
817
596
  if (!skill) return null;
818
-
819
597
  if (skill.source === "external") {
820
598
  log.debug(`Cannot publish external skill "${name}"`);
821
599
  return null;
822
600
  }
823
601
 
824
602
  try {
825
- const sb = getSupabase();
826
-
827
- // Upsert to skills table with is_public = true
828
- const { data, error } = await sb
829
- .from("skills")
830
- .upsert(
831
- {
832
- name: skill.name,
833
- description: skill.description,
834
- version: skill.version,
835
- emoji: skill.metadata.emoji || null,
836
- content: skill.content,
837
- argument_hint: skill.argumentHint || null,
838
- keywords: skill.keywords,
839
- allowed_tools: skill.allowedTools,
840
- author_id: this.userId,
841
- author_name: options?.authorName || null,
842
- metadata: skill.metadata,
843
- homepage: skill.homepage || null,
844
- category: options?.category || null,
845
- source: skill.source,
846
- is_public: true,
847
- },
848
- { onConflict: "author_id,name" }
849
- )
850
- .select("id")
851
- .single();
852
-
853
- if (error) {
854
- log.debug(`Publish failed for "${name}": ${error.message}`);
855
- return null;
856
- }
603
+ const data = await callMcpHandler<{ id: string }>("skill.publish", {
604
+ name: skill.name,
605
+ description: skill.description,
606
+ version: skill.version,
607
+ emoji: skill.metadata.emoji || null,
608
+ content: skill.content,
609
+ argument_hint: skill.argumentHint || null,
610
+ keywords: skill.keywords,
611
+ allowed_tools: skill.allowedTools,
612
+ author_name: options?.authorName || null,
613
+ metadata: skill.metadata,
614
+ homepage: skill.homepage || null,
615
+ category: options?.category || null,
616
+ source: skill.source,
617
+ });
857
618
 
858
619
  log.info(`Skill "${name}" published to marketplace`);
859
- return data as { id: string };
620
+ return data;
860
621
  } catch (err) {
861
622
  log.debug(`Publish error: ${err}`);
862
623
  return null;
863
624
  }
864
625
  }
865
626
 
866
- /**
867
- * Browse marketplace skills (public + active).
868
- */
869
627
  async browse(options?: {
870
628
  query?: string;
871
629
  category?: string;
@@ -885,18 +643,15 @@ export class SkillManager {
885
643
  ratingCount: number;
886
644
  }>> {
887
645
  try {
888
- const sb = getSupabase();
889
- const { data, error } = await sb.rpc("browse_skills", {
890
- p_query: options?.query || null,
891
- p_category: options?.category || null,
892
- p_sort: options?.sort || "popular",
893
- p_limit: options?.limit || 20,
894
- p_offset: options?.offset || 0,
646
+ const data = await callMcpHandler<Record<string, unknown>[]>("skill.browse", {
647
+ query: options?.query || null,
648
+ category: options?.category || null,
649
+ sort: options?.sort || "popular",
650
+ limit: options?.limit || 20,
651
+ offset: options?.offset || 0,
895
652
  });
896
653
 
897
- if (error || !data) return [];
898
-
899
- return (data as Record<string, unknown>[]).map((r) => ({
654
+ return (data || []).map((r) => ({
900
655
  id: r.id as string,
901
656
  name: r.name as string,
902
657
  description: (r.description as string) || "",
@@ -912,93 +667,39 @@ export class SkillManager {
912
667
  return [];
913
668
  }
914
669
  }
915
-
916
- // ── Variable Configuration ──────────────────────────────────────────
917
-
918
- /**
919
- * Update user-specific variable values (config) for a skill.
920
- * Persists to agent_skills.config in DB and updates in-memory.
921
- */
922
- async updateConfig(
923
- skillName: string,
924
- config: Record<string, unknown>
925
- ): Promise<boolean> {
926
- const skill = this.skills.get(skillName);
927
- if (!skill) return false;
928
-
929
- // Merge with existing config
930
- const merged = { ...(skill.config || {}), ...config };
931
- skill.config = merged;
932
-
933
- // Persist to DB
934
- if (this.userId && skill.dbId) {
935
- try {
936
- const sb = getSupabase();
937
- await sb
938
- .from("agent_skills")
939
- .update({ config: merged })
940
- .eq("id", skill.dbId)
941
- .eq("user_id", this.userId);
942
- log.debug(`Config updated for skill "${skillName}"`);
943
- } catch (err) {
944
- log.debug(`Config update failed for "${skillName}": ${err}`);
945
- }
946
- }
947
-
948
- return true;
949
- }
950
-
951
- /**
952
- * Get the unresolved (unconfigured) variables for a skill.
953
- * Returns variables that are required but have no value in config.
954
- */
955
- getUnconfiguredVariables(skillName: string): SkillVariable[] {
956
- const skill = this.skills.get(skillName);
957
- if (!skill?.variables) return [];
958
-
959
- return skill.variables.filter((v) => {
960
- if (!v.required) return false;
961
- const value = skill.config?.[v.name];
962
- return value === undefined || value === null || value === "";
963
- });
964
- }
965
670
  }
966
671
 
967
672
  // ── Exported Utility Functions ─────────────────────────────────────
968
673
 
969
674
  /**
970
- * Substitute {{variable_name}} placeholders in skill content with user config values.
971
- * Also handles array values by joining with ", ".
972
- *
973
- * Unresolved variables are left as-is with a note: {{variable_name: [NOT CONFIGURED]}}
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.
974
679
  */
975
- export function substituteVariables(
976
- content: string,
977
- config: Record<string, unknown> | undefined,
978
- variables?: SkillVariable[]
979
- ): string {
980
- if (!content.includes("{{")) return content;
981
-
982
- return content.replace(/\{\{(\w+)\}\}/g, (match, varName: string) => {
983
- const value = config?.[varName];
984
-
985
- if (value !== undefined && value !== null) {
986
- if (Array.isArray(value)) return value.join(", ");
987
- return String(value);
988
- }
989
-
990
- // Check for default value in variable definitions
991
- const varDef = variables?.find((v) => v.name === varName);
992
- if (varDef?.default !== undefined) return varDef.default;
993
-
994
- // Leave as-is with warning for unresolved
995
- return `{{${varName}: [NOT CONFIGURED]}}`;
996
- });
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;
997
687
  }
998
688
 
999
689
  /**
1000
- * Substitute $ARGUMENTS, $ARGUMENTS[N], and $N placeholders in skill content.
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.
1001
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
+
1002
703
  export function substituteArguments(content: string, args: string): string {
1003
704
  const parts = args.split(/\s+/);
1004
705
  content = content.replace(/\$ARGUMENTS/g, args);
@@ -1007,10 +708,6 @@ export function substituteArguments(content: string, args: string): string {
1007
708
  return content;
1008
709
  }
1009
710
 
1010
- /**
1011
- * Preprocess dynamic context in skill content.
1012
- * Replaces !`command` patterns with the command's stdout.
1013
- */
1014
711
  export function preprocessDynamicContext(content: string, cwd?: string): string {
1015
712
  return content.replace(/!`([^`]+)`/g, (_, cmd) => {
1016
713
  try {