@agenticmail/core 0.9.24 → 0.9.25

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.
@@ -0,0 +1,724 @@
1
+ // src/skills/registry.ts
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
3
+ import { join, dirname, basename } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { homedir } from "os";
6
+
7
+ // src/memory/text-search.ts
8
+ var BM25_K1 = 1.2;
9
+ var BM25_B = 0.75;
10
+ var FIELD_WEIGHT_TITLE = 3;
11
+ var FIELD_WEIGHT_TAGS = 2;
12
+ var FIELD_WEIGHT_CONTENT = 1;
13
+ var PREFIX_MATCH_PENALTY = 0.7;
14
+ var STOP_WORDS = /* @__PURE__ */ new Set([
15
+ "a",
16
+ "about",
17
+ "above",
18
+ "after",
19
+ "again",
20
+ "against",
21
+ "all",
22
+ "am",
23
+ "an",
24
+ "and",
25
+ "any",
26
+ "are",
27
+ "as",
28
+ "at",
29
+ "be",
30
+ "because",
31
+ "been",
32
+ "before",
33
+ "being",
34
+ "below",
35
+ "between",
36
+ "both",
37
+ "but",
38
+ "by",
39
+ "can",
40
+ "could",
41
+ "did",
42
+ "do",
43
+ "does",
44
+ "doing",
45
+ "down",
46
+ "during",
47
+ "each",
48
+ "either",
49
+ "every",
50
+ "few",
51
+ "for",
52
+ "from",
53
+ "further",
54
+ "get",
55
+ "got",
56
+ "had",
57
+ "has",
58
+ "have",
59
+ "having",
60
+ "he",
61
+ "her",
62
+ "here",
63
+ "hers",
64
+ "herself",
65
+ "him",
66
+ "himself",
67
+ "his",
68
+ "how",
69
+ "i",
70
+ "if",
71
+ "in",
72
+ "into",
73
+ "is",
74
+ "it",
75
+ "its",
76
+ "itself",
77
+ "just",
78
+ "may",
79
+ "me",
80
+ "might",
81
+ "more",
82
+ "most",
83
+ "must",
84
+ "my",
85
+ "myself",
86
+ "neither",
87
+ "no",
88
+ "nor",
89
+ "not",
90
+ "now",
91
+ "of",
92
+ "off",
93
+ "on",
94
+ "once",
95
+ "only",
96
+ "or",
97
+ "other",
98
+ "ought",
99
+ "our",
100
+ "ours",
101
+ "ourselves",
102
+ "out",
103
+ "over",
104
+ "own",
105
+ "same",
106
+ "shall",
107
+ "she",
108
+ "should",
109
+ "so",
110
+ "some",
111
+ "such",
112
+ "than",
113
+ "that",
114
+ "the",
115
+ "their",
116
+ "theirs",
117
+ "them",
118
+ "themselves",
119
+ "then",
120
+ "there",
121
+ "these",
122
+ "they",
123
+ "this",
124
+ "those",
125
+ "through",
126
+ "to",
127
+ "too",
128
+ "under",
129
+ "until",
130
+ "up",
131
+ "us",
132
+ "very",
133
+ "was",
134
+ "we",
135
+ "were",
136
+ "what",
137
+ "when",
138
+ "where",
139
+ "which",
140
+ "while",
141
+ "who",
142
+ "whom",
143
+ "why",
144
+ "will",
145
+ "with",
146
+ "would",
147
+ "yet",
148
+ "you",
149
+ "your",
150
+ "yours",
151
+ "yourself",
152
+ "yourselves"
153
+ ]);
154
+ var STEM_RULES = [
155
+ // Step 1: plurals and past participles
156
+ [/ies$/, "i", 3],
157
+ // policies → polici,eries → eri
158
+ [/sses$/, "ss", 4],
159
+ // addresses → address
160
+ [/([^s])s$/, "$1", 3],
161
+ // items → item, but not "ss"
162
+ [/eed$/, "ee", 4],
163
+ // agreed → agree
164
+ [/ed$/, "", 3],
165
+ // configured → configur, but min length 3
166
+ [/ing$/, "", 4],
167
+ // running → runn → run (handled below)
168
+ // Step 2: derivational suffixes
169
+ [/ational$/, "ate", 6],
170
+ // relational → relate
171
+ [/tion$/, "t", 5],
172
+ // adoption → adopt
173
+ [/ness$/, "", 5],
174
+ // awareness → aware
175
+ [/ment$/, "", 5],
176
+ // deployment → deploy
177
+ [/able$/, "", 5],
178
+ // configurable → configur
179
+ [/ible$/, "", 5],
180
+ // accessible → access
181
+ [/ful$/, "", 5],
182
+ // powerful → power
183
+ [/ous$/, "", 5],
184
+ // dangerous → danger
185
+ [/ive$/, "", 5],
186
+ // interactive → interact
187
+ [/ize$/, "", 4],
188
+ // normalize → normal
189
+ [/ise$/, "", 4],
190
+ // organise → organ
191
+ [/ally$/, "", 5],
192
+ // automatically → automat
193
+ [/ly$/, "", 4],
194
+ // quickly → quick
195
+ [/er$/, "", 4]
196
+ // handler → handl
197
+ ];
198
+ var DOUBLE_CONSONANT = /([^aeiou])\1$/;
199
+ function stem(word) {
200
+ if (word.length < 3) return word;
201
+ let stemmed = word;
202
+ for (const [pattern, replacement, minLen] of STEM_RULES) {
203
+ if (stemmed.length >= minLen && pattern.test(stemmed)) {
204
+ stemmed = stemmed.replace(pattern, replacement);
205
+ break;
206
+ }
207
+ }
208
+ if (stemmed.length > 2 && DOUBLE_CONSONANT.test(stemmed)) {
209
+ stemmed = stemmed.slice(0, -1);
210
+ }
211
+ return stemmed;
212
+ }
213
+ function tokenize(text) {
214
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 1 && !STOP_WORDS.has(t)).map(stem);
215
+ }
216
+ var MemorySearchIndex = class {
217
+ /** Posting lists: stemmed term → Set of memory IDs containing it */
218
+ postings = /* @__PURE__ */ new Map();
219
+ /** Per-document metadata for BM25 scoring */
220
+ docs = /* @__PURE__ */ new Map();
221
+ /** Pre-computed IDF values. Stale flag triggers lazy recomputation. */
222
+ idf = /* @__PURE__ */ new Map();
223
+ idfStale = true;
224
+ /** 3-character prefix map for prefix matching: prefix → Set of full stems */
225
+ prefixMap = /* @__PURE__ */ new Map();
226
+ /** Total weighted document length (for computing average) */
227
+ totalWeightedLen = 0;
228
+ get docCount() {
229
+ return this.docs.size;
230
+ }
231
+ get avgDocLen() {
232
+ return this.docs.size > 0 ? this.totalWeightedLen / this.docs.size : 1;
233
+ }
234
+ /**
235
+ * Index a memory entry. Extracts stems from title, content, and tags
236
+ * with field-specific weighting and builds posting lists.
237
+ */
238
+ addDocument(id, entry) {
239
+ if (this.docs.has(id)) this.removeDocument(id);
240
+ const titleTokens = tokenize(entry.title);
241
+ const contentTokens = tokenize(entry.content);
242
+ const tagTokens = entry.tags.flatMap((t) => tokenize(t));
243
+ const weightedTf = /* @__PURE__ */ new Map();
244
+ for (const t of titleTokens) weightedTf.set(t, (weightedTf.get(t) || 0) + FIELD_WEIGHT_TITLE);
245
+ for (const t of tagTokens) weightedTf.set(t, (weightedTf.get(t) || 0) + FIELD_WEIGHT_TAGS);
246
+ for (const t of contentTokens) weightedTf.set(t, (weightedTf.get(t) || 0) + FIELD_WEIGHT_CONTENT);
247
+ const weightedLen = titleTokens.length * FIELD_WEIGHT_TITLE + tagTokens.length * FIELD_WEIGHT_TAGS + contentTokens.length * FIELD_WEIGHT_CONTENT;
248
+ const allStems = /* @__PURE__ */ new Set();
249
+ for (const t of weightedTf.keys()) allStems.add(t);
250
+ const stemSequence = [...titleTokens, ...contentTokens];
251
+ const docRecord = { weightedTf, weightedLen, allStems, stemSequence };
252
+ this.docs.set(id, docRecord);
253
+ this.totalWeightedLen += weightedLen;
254
+ for (const term of allStems) {
255
+ let posting = this.postings.get(term);
256
+ if (!posting) {
257
+ posting = /* @__PURE__ */ new Set();
258
+ this.postings.set(term, posting);
259
+ }
260
+ posting.add(id);
261
+ if (term.length >= 3) {
262
+ const prefix = term.slice(0, 3);
263
+ let prefixSet = this.prefixMap.get(prefix);
264
+ if (!prefixSet) {
265
+ prefixSet = /* @__PURE__ */ new Set();
266
+ this.prefixMap.set(prefix, prefixSet);
267
+ }
268
+ prefixSet.add(term);
269
+ }
270
+ }
271
+ this.idfStale = true;
272
+ }
273
+ /** Remove a document from the index. */
274
+ removeDocument(id) {
275
+ const doc = this.docs.get(id);
276
+ if (!doc) return;
277
+ this.totalWeightedLen -= doc.weightedLen;
278
+ this.docs.delete(id);
279
+ for (const term of doc.allStems) {
280
+ const posting = this.postings.get(term);
281
+ if (posting) {
282
+ posting.delete(id);
283
+ if (posting.size === 0) {
284
+ this.postings.delete(term);
285
+ if (term.length >= 3) {
286
+ const prefixSet = this.prefixMap.get(term.slice(0, 3));
287
+ if (prefixSet) {
288
+ prefixSet.delete(term);
289
+ if (prefixSet.size === 0) this.prefixMap.delete(term.slice(0, 3));
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ this.idfStale = true;
296
+ }
297
+ /** Recompute IDF values for all terms. Called lazily before search. */
298
+ refreshIdf() {
299
+ if (!this.idfStale) return;
300
+ const N = this.docs.size;
301
+ this.idf.clear();
302
+ for (const [term, posting] of this.postings) {
303
+ const df = posting.size;
304
+ this.idf.set(term, Math.log((N - df + 0.5) / (df + 0.5) + 1));
305
+ }
306
+ this.idfStale = false;
307
+ }
308
+ /**
309
+ * Expand query terms with prefix matches.
310
+ * "deploy" → ["deploy", "deployment", "deploying", ...] (if they exist in the index)
311
+ */
312
+ expandQueryTerms(queryStems) {
313
+ const expanded = /* @__PURE__ */ new Map();
314
+ for (const qs of queryStems) {
315
+ if (this.postings.has(qs)) {
316
+ expanded.set(qs, Math.max(expanded.get(qs) || 0, 1));
317
+ }
318
+ if (qs.length >= 3) {
319
+ const prefix = qs.slice(0, 3);
320
+ const candidates = this.prefixMap.get(prefix);
321
+ if (candidates) {
322
+ for (const candidate of candidates) {
323
+ if (candidate !== qs && candidate.startsWith(qs)) {
324
+ expanded.set(candidate, Math.max(expanded.get(candidate) || 0, PREFIX_MATCH_PENALTY));
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return expanded;
331
+ }
332
+ /**
333
+ * Compute bigram proximity boost: if two query terms appear adjacent
334
+ * in the document's stem sequence, boost the score.
335
+ */
336
+ bigramProximityBoost(docId, queryStems) {
337
+ if (queryStems.length < 2) return 0;
338
+ const doc = this.docs.get(docId);
339
+ if (!doc || doc.stemSequence.length < 2) return 0;
340
+ let boost = 0;
341
+ const seq = doc.stemSequence;
342
+ const querySet = new Set(queryStems);
343
+ for (let i = 0; i < seq.length - 1; i++) {
344
+ if (querySet.has(seq[i]) && querySet.has(seq[i + 1]) && seq[i] !== seq[i + 1]) {
345
+ boost += 0.5;
346
+ }
347
+ }
348
+ return Math.min(boost, 2);
349
+ }
350
+ /**
351
+ * Search the index for documents matching a query.
352
+ * Returns scored results sorted by BM25F relevance.
353
+ *
354
+ * @param query - Raw query string
355
+ * @param candidateIds - Optional: only score these document IDs (for agent-scoped search)
356
+ * @returns Array of { id, score } sorted by descending score
357
+ */
358
+ search(query, candidateIds) {
359
+ const queryStems = tokenize(query);
360
+ if (queryStems.length === 0) return [];
361
+ this.refreshIdf();
362
+ const expandedTerms = this.expandQueryTerms(queryStems);
363
+ if (expandedTerms.size === 0) return [];
364
+ const avgDl = this.avgDocLen;
365
+ const candidates = /* @__PURE__ */ new Set();
366
+ for (const term of expandedTerms.keys()) {
367
+ const posting = this.postings.get(term);
368
+ if (posting) {
369
+ for (const docId of posting) {
370
+ if (!candidateIds || candidateIds.has(docId)) candidates.add(docId);
371
+ }
372
+ }
373
+ }
374
+ const results = [];
375
+ for (const docId of candidates) {
376
+ const doc = this.docs.get(docId);
377
+ if (!doc) continue;
378
+ let score = 0;
379
+ for (const [term, weight] of expandedTerms) {
380
+ const tf = doc.weightedTf.get(term) || 0;
381
+ if (tf === 0) continue;
382
+ const termIdf = this.idf.get(term) || 0;
383
+ const numerator = tf * (BM25_K1 + 1);
384
+ const denominator = tf + BM25_K1 * (1 - BM25_B + BM25_B * (doc.weightedLen / avgDl));
385
+ score += termIdf * (numerator / denominator) * weight;
386
+ }
387
+ score += this.bigramProximityBoost(docId, queryStems);
388
+ if (score > 0) results.push({ id: docId, score });
389
+ }
390
+ results.sort((a, b) => b.score - a.score);
391
+ return results;
392
+ }
393
+ /** Check if a document exists in the index. */
394
+ has(id) {
395
+ return this.docs.has(id);
396
+ }
397
+ };
398
+
399
+ // src/skills/registry.ts
400
+ function builtInDir() {
401
+ const here = dirname(fileURLToPath(import.meta.url));
402
+ return join(here, "built-in");
403
+ }
404
+ function userDir() {
405
+ const dir = join(homedir(), ".agenticmail", "skills");
406
+ try {
407
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
408
+ } catch {
409
+ }
410
+ return dir;
411
+ }
412
+ var cache = { ts: 0, byId: null, index: null };
413
+ var CACHE_TTL_MS = 5e3;
414
+ function skillToIndexDoc(s) {
415
+ const contentParts = [
416
+ s.description,
417
+ s.context?.when_to_use ?? "",
418
+ ...s.principles ?? [],
419
+ ...Object.values(s.phrases ?? {}),
420
+ ...(s.tactics ?? []).flatMap((t) => [t.name, t.when, t.script]),
421
+ ...s.success_signals ?? [],
422
+ ...s.failure_signals ?? []
423
+ ];
424
+ return {
425
+ title: s.name,
426
+ // Include category as a tag for free — a query of "negotiation"
427
+ // hits both literal `negotiation`-category skills AND skills
428
+ // that tagged themselves "negotiation".
429
+ tags: [...s.tags ?? [], s.category],
430
+ content: contentParts.filter(Boolean).join(" ")
431
+ };
432
+ }
433
+ function loadAllSkillsFromDisk() {
434
+ const all = /* @__PURE__ */ new Map();
435
+ const dirs = [builtInDir(), userDir()];
436
+ for (const dir of dirs) {
437
+ if (!existsSync(dir)) continue;
438
+ let entries;
439
+ try {
440
+ entries = readdirSync(dir);
441
+ } catch {
442
+ continue;
443
+ }
444
+ for (const entry of entries) {
445
+ if (!entry.endsWith(".json")) continue;
446
+ const fullPath = join(dir, entry);
447
+ try {
448
+ const st = statSync(fullPath);
449
+ if (!st.isFile()) continue;
450
+ const raw = readFileSync(fullPath, "utf-8");
451
+ const parsed = JSON.parse(raw);
452
+ const errors = validateSkill(parsed);
453
+ if (errors.length > 0) {
454
+ console.warn(`[skills] ${entry} invalid, skipping: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`);
455
+ continue;
456
+ }
457
+ all.set(parsed.id, parsed);
458
+ } catch (err) {
459
+ console.warn(`[skills] could not load ${fullPath}: ${err.message}`);
460
+ }
461
+ }
462
+ }
463
+ return all;
464
+ }
465
+ function ensureLoaded() {
466
+ const now = Date.now();
467
+ if (cache.byId && cache.index && now - cache.ts < CACHE_TTL_MS) {
468
+ return { byId: cache.byId, index: cache.index };
469
+ }
470
+ const fresh = loadAllSkillsFromDisk();
471
+ const index = new MemorySearchIndex();
472
+ for (const [id, skill] of fresh) {
473
+ try {
474
+ index.addDocument(id, skillToIndexDoc(skill));
475
+ } catch {
476
+ }
477
+ }
478
+ cache.byId = fresh;
479
+ cache.index = index;
480
+ cache.ts = now;
481
+ return { byId: fresh, index };
482
+ }
483
+ function invalidateSkillCache() {
484
+ cache.byId = null;
485
+ cache.index = null;
486
+ cache.ts = 0;
487
+ }
488
+ function validateSkill(s) {
489
+ const errs = [];
490
+ if (!s || typeof s !== "object" || Array.isArray(s)) {
491
+ return [{ path: "$", message: "skill must be a JSON object" }];
492
+ }
493
+ const sk = s;
494
+ const requireString = (key, minLen = 1) => {
495
+ const v = sk[key];
496
+ if (typeof v !== "string" || v.length < minLen) {
497
+ errs.push({ path: key, message: `must be a non-empty string` });
498
+ }
499
+ };
500
+ const requireArray = (key, minLen = 1) => {
501
+ const v = sk[key];
502
+ if (!Array.isArray(v) || v.length < minLen) {
503
+ errs.push({ path: key, message: `must be a non-empty array` });
504
+ }
505
+ };
506
+ requireString("id");
507
+ if (typeof sk.id === "string" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(sk.id)) {
508
+ errs.push({ path: "id", message: "must be lowercase-hyphenated slug (a-z, 0-9, -)" });
509
+ }
510
+ requireString("name");
511
+ requireString("version");
512
+ requireString("description");
513
+ requireString("category");
514
+ const validCategories = [
515
+ "negotiation",
516
+ "customer-service",
517
+ "reservations",
518
+ "medical-admin",
519
+ "legal-admin",
520
+ "finance-admin",
521
+ "real-estate",
522
+ "travel",
523
+ "subscription",
524
+ "home-services",
525
+ "social",
526
+ "civic",
527
+ "employment",
528
+ "debt-collection",
529
+ "other"
530
+ ];
531
+ if (typeof sk.category === "string" && !validCategories.includes(sk.category)) {
532
+ errs.push({ path: "category", message: `unknown category "${sk.category}"; must be one of: ${validCategories.join(", ")}` });
533
+ }
534
+ if (!Array.isArray(sk.tags)) errs.push({ path: "tags", message: "must be an array of strings" });
535
+ if (sk.disclaimer !== null && typeof sk.disclaimer !== "string") {
536
+ errs.push({ path: "disclaimer", message: "must be a string or null" });
537
+ }
538
+ if (!sk.context || typeof sk.context !== "object") {
539
+ errs.push({ path: "context", message: "must be an object" });
540
+ } else {
541
+ const ctx = sk.context;
542
+ if (typeof ctx.when_to_use !== "string") errs.push({ path: "context.when_to_use", message: "must be a string" });
543
+ if (!Array.isArray(ctx.preconditions)) errs.push({ path: "context.preconditions", message: "must be an array" });
544
+ if (typeof ctx.estimated_call_duration_minutes !== "number") errs.push({ path: "context.estimated_call_duration_minutes", message: "must be a number" });
545
+ }
546
+ requireArray("principles", 2);
547
+ if (!sk.phrases || typeof sk.phrases !== "object") errs.push({ path: "phrases", message: "must be an object of {key: phrase}" });
548
+ if (!Array.isArray(sk.tactics) || sk.tactics.length === 0) {
549
+ errs.push({ path: "tactics", message: "must be a non-empty array" });
550
+ } else {
551
+ sk.tactics.forEach((t, i) => {
552
+ if (!t || typeof t !== "object") {
553
+ errs.push({ path: `tactics[${i}]`, message: "must be an object" });
554
+ return;
555
+ }
556
+ const tactic = t;
557
+ if (typeof tactic.name !== "string") errs.push({ path: `tactics[${i}].name`, message: "must be a string" });
558
+ if (typeof tactic.when !== "string") errs.push({ path: `tactics[${i}].when`, message: "must be a string" });
559
+ if (typeof tactic.script !== "string" || tactic.script.length < 5) {
560
+ errs.push({ path: `tactics[${i}].script`, message: "must be a non-empty string (>= 5 chars)" });
561
+ }
562
+ });
563
+ }
564
+ requireArray("boundaries", 1);
565
+ if (!Array.isArray(sk.success_signals)) errs.push({ path: "success_signals", message: "must be an array" });
566
+ if (!Array.isArray(sk.failure_signals)) errs.push({ path: "failure_signals", message: "must be an array" });
567
+ if (!sk.exit_strategy || typeof sk.exit_strategy !== "object") {
568
+ errs.push({ path: "exit_strategy", message: "must be an object" });
569
+ } else {
570
+ const xs = sk.exit_strategy;
571
+ if (typeof xs.on_success !== "string") errs.push({ path: "exit_strategy.on_success", message: "must be a string" });
572
+ if (typeof xs.on_failure !== "string") errs.push({ path: "exit_strategy.on_failure", message: "must be a string" });
573
+ }
574
+ if (!Array.isArray(sk.required_user_info)) errs.push({ path: "required_user_info", message: "must be an array" });
575
+ if (typeof sk.contributed_by !== "string") errs.push({ path: "contributed_by", message: "must be a string" });
576
+ return errs;
577
+ }
578
+ function summarize(s) {
579
+ return {
580
+ id: s.id,
581
+ name: s.name,
582
+ category: s.category,
583
+ tags: s.tags,
584
+ description: s.description,
585
+ version: s.version,
586
+ disclaimer_required: !!s.disclaimer,
587
+ estimated_call_duration_minutes: s.context.estimated_call_duration_minutes
588
+ };
589
+ }
590
+ function listSkills(opts = {}) {
591
+ const all = Array.from(ensureLoaded().byId.values());
592
+ const filtered = all.filter((s) => {
593
+ if (opts.category && s.category !== opts.category) return false;
594
+ if (opts.tag && !s.tags.includes(opts.tag.toLowerCase())) return false;
595
+ return true;
596
+ });
597
+ return filtered.sort((a, b) => a.name.localeCompare(b.name)).map(summarize);
598
+ }
599
+ function searchSkills(query, limit = 20) {
600
+ const q = query.trim();
601
+ if (!q) return [];
602
+ const { byId, index } = ensureLoaded();
603
+ const ranked = index.search(q);
604
+ if (ranked.length === 0) {
605
+ const qLow = q.toLowerCase();
606
+ const fallback = [];
607
+ for (const s of byId.values()) {
608
+ if (s.id.toLowerCase().includes(qLow) || s.name.toLowerCase().includes(qLow) || s.tags.some((t) => t.toLowerCase().includes(qLow))) {
609
+ fallback.push(summarize(s));
610
+ if (fallback.length >= limit) break;
611
+ }
612
+ }
613
+ return fallback;
614
+ }
615
+ const out = [];
616
+ for (const { id } of ranked) {
617
+ const skill = byId.get(id);
618
+ if (!skill) continue;
619
+ out.push(summarize(skill));
620
+ if (out.length >= limit) break;
621
+ }
622
+ return out;
623
+ }
624
+ function loadSkill(id) {
625
+ return ensureLoaded().byId.get(id) ?? null;
626
+ }
627
+ function saveUserSkill(skill) {
628
+ const errors = validateSkill(skill);
629
+ if (errors.length > 0) {
630
+ throw new Error(`skill validation failed: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`);
631
+ }
632
+ const dir = userDir();
633
+ const path = join(dir, `${skill.id}.json`);
634
+ const now = (/* @__PURE__ */ new Date()).toISOString();
635
+ const out = {
636
+ ...skill,
637
+ created_at: skill.created_at ?? now,
638
+ updated_at: now
639
+ };
640
+ writeFileSync(path, JSON.stringify(out, null, 2), "utf-8");
641
+ invalidateSkillCache();
642
+ return { path };
643
+ }
644
+ function skillFilename(id) {
645
+ return `${basename(id)}.json`;
646
+ }
647
+ function userSkillsDir() {
648
+ return userDir();
649
+ }
650
+
651
+ // src/skills/index.ts
652
+ function renderSkillAsPrompt(skill) {
653
+ const lines = [];
654
+ lines.push(`=== SKILL LOADED: ${skill.name} (v${skill.version}) ===`);
655
+ lines.push(`Category: ${skill.category} Tags: ${skill.tags.join(", ")}`);
656
+ lines.push("");
657
+ lines.push(skill.description);
658
+ lines.push("");
659
+ if (skill.disclaimer) {
660
+ lines.push("REQUIRED DISCLAIMER (recite at start of the substantive turn):");
661
+ lines.push(` "${skill.disclaimer}"`);
662
+ lines.push("");
663
+ }
664
+ lines.push("WHEN TO USE THIS:");
665
+ lines.push(` ${skill.context.when_to_use}`);
666
+ if (skill.context.preconditions.length > 0) {
667
+ lines.push("Preconditions:");
668
+ for (const p of skill.context.preconditions) lines.push(` - ${p}`);
669
+ }
670
+ lines.push("");
671
+ lines.push("PRINCIPLES:");
672
+ for (const p of skill.principles) lines.push(` - ${p}`);
673
+ lines.push("");
674
+ if (Object.keys(skill.phrases).length > 0) {
675
+ lines.push("PHRASES (paraphrase to match your voice; keep the structural move):");
676
+ for (const [k, v] of Object.entries(skill.phrases)) lines.push(` [${k}] "${v}"`);
677
+ lines.push("");
678
+ }
679
+ if (skill.tactics.length > 0) {
680
+ lines.push("TACTICS (try in order; fall back to next on failure):");
681
+ skill.tactics.forEach((t, i) => {
682
+ lines.push(` ${i + 1}. ${t.name}`);
683
+ lines.push(` When: ${t.when}`);
684
+ lines.push(` Script: "${t.script}"`);
685
+ });
686
+ lines.push("");
687
+ }
688
+ if (skill.boundaries.length > 0) {
689
+ lines.push("HARD BOUNDARIES \u2014 do not cross:");
690
+ for (const b of skill.boundaries) lines.push(` - ${b}`);
691
+ lines.push("");
692
+ }
693
+ lines.push("SUCCESS SIGNALS:");
694
+ for (const s of skill.success_signals) lines.push(` - ${s}`);
695
+ lines.push("");
696
+ lines.push("FAILURE SIGNALS \u2014 when these appear, escalate or exit:");
697
+ for (const s of skill.failure_signals) lines.push(` - ${s}`);
698
+ lines.push("");
699
+ lines.push("EXIT:");
700
+ lines.push(` On success: ${skill.exit_strategy.on_success}`);
701
+ lines.push(` On failure: ${skill.exit_strategy.on_failure}`);
702
+ if (skill.exit_strategy.follow_ups && skill.exit_strategy.follow_ups.length > 0) {
703
+ lines.push(" Follow-ups (after the call):");
704
+ for (const f of skill.exit_strategy.follow_ups) lines.push(` - ${f}`);
705
+ }
706
+ lines.push("");
707
+ lines.push("=== END SKILL ===");
708
+ return lines.join("\n");
709
+ }
710
+
711
+ export {
712
+ stem,
713
+ tokenize,
714
+ MemorySearchIndex,
715
+ invalidateSkillCache,
716
+ validateSkill,
717
+ listSkills,
718
+ searchSkills,
719
+ loadSkill,
720
+ saveUserSkill,
721
+ skillFilename,
722
+ userSkillsDir,
723
+ renderSkillAsPrompt
724
+ };