clementine-agent 1.18.111 → 1.18.112

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.
@@ -279,6 +279,13 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
279
279
  }
280
280
  if (prepared.length === 0)
281
281
  return { text: '', applied, missing };
282
+ // Folder-form bundled-file budget. Anthropic skill spec says the body
283
+ // should be ≤500 lines; bundled files (templates/, reference docs)
284
+ // load on top. We cap aggregate inlined bundle bytes so a skill with a
285
+ // huge templates/ tree doesn't blow the context window — anything over
286
+ // the cap is left on disk and the LLM can Read it via the cron's cwd.
287
+ const BUNDLE_FILE_CAP = 5;
288
+ const BUNDLE_BYTES_CAP = 12000;
282
289
  const skillLines = prepared.map(s => {
283
290
  recordSkillUse(s.name);
284
291
  memoryStore?.logSkillUse?.({
@@ -292,7 +299,62 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
292
299
  let block = `### ${s.title}${s.source === 'pinned' ? ' _(pinned)_' : ''}\n${s.content}`;
293
300
  if (s.toolsUsed.length > 0)
294
301
  block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
295
- if (s.attachments.length > 0) {
302
+ // Folder-form skills (post-migration default): inline sibling .md
303
+ // files (templates/intro.md, reference.md, etc.) so the cron prompt
304
+ // actually sees them. SKILL.md itself is the body above. scripts/
305
+ // and other non-.md assets stay on disk — the cron's cwd has Bash
306
+ // access to them via the runtime working directory.
307
+ const folderPath = path.join(s.skillDir, s.name);
308
+ const skillEntry = path.join(folderPath, 'SKILL.md');
309
+ if (fs.existsSync(skillEntry)) {
310
+ let bytesUsed = 0;
311
+ let filesUsed = 0;
312
+ const collectMd = (subDir, label) => {
313
+ if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
314
+ return;
315
+ let entries;
316
+ try {
317
+ entries = fs.readdirSync(subDir).sort();
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ for (const entry of entries) {
323
+ if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
324
+ break;
325
+ if (entry === 'SKILL.md')
326
+ continue;
327
+ if (!entry.endsWith('.md'))
328
+ continue;
329
+ const full = path.join(subDir, entry);
330
+ try {
331
+ const content = fs.readFileSync(full, 'utf-8');
332
+ const remaining = BUNDLE_BYTES_CAP - bytesUsed;
333
+ const slice = content.slice(0, remaining);
334
+ const labeled = label ? `${label}/${entry}` : entry;
335
+ block += `\n\n#### ${labeled}\n${slice}`;
336
+ bytesUsed += slice.length;
337
+ filesUsed++;
338
+ }
339
+ catch { /* skip unreadable */ }
340
+ }
341
+ };
342
+ // Top-level bundled .md files (reference.md, etc.)
343
+ collectMd(folderPath, '');
344
+ // Common sub-dirs: templates/, references/. One level deep only —
345
+ // we don't recurse to keep the budget predictable.
346
+ for (const sub of ['templates', 'references']) {
347
+ if (filesUsed >= BUNDLE_FILE_CAP || bytesUsed >= BUNDLE_BYTES_CAP)
348
+ break;
349
+ const subPath = path.join(folderPath, sub);
350
+ if (fs.existsSync(subPath) && fs.statSync(subPath).isDirectory()) {
351
+ collectMd(subPath, sub);
352
+ }
353
+ }
354
+ }
355
+ else if (s.attachments.length > 0) {
356
+ // Legacy flat form: attachments live under <skill>.files/ alongside
357
+ // the skill .md. Kept for backward compat with un-migrated skills.
296
358
  const attDir = path.join(s.skillDir, s.name + '.files');
297
359
  for (const attName of s.attachments.slice(0, 3)) {
298
360
  const attPath = path.join(attDir, attName);
@@ -74,7 +74,10 @@ export declare function searchSkills(query: string, limit?: number, agentSlug?:
74
74
  export declare function loadSkillByName(name: string, agentSlug?: string, opts?: {
75
75
  suppressedNames?: Set<string>;
76
76
  }): SkillMatch | null;
77
- /** Record that a skill was used (bump use count). */
77
+ /** Record that a skill was used (bump use count). Handles both flat and
78
+ * folder-form skills. For folder form the counter lives under
79
+ * `clementine.useCount` (Anthropic-canonical frontmatter keeps top-level
80
+ * reserved for `name`/`description`). */
78
81
  export declare function recordSkillUse(skillName: string, agentSlug?: string): void;
79
82
  /** List all active skills (global + all agent-scoped). */
80
83
  export declare function listSkills(agentSlug?: string): Array<{
@@ -325,11 +325,39 @@ async function mergeSkill(assistant, existing, incoming) {
325
325
  */
326
326
  const skillEmbeddingCache = new Map();
327
327
  /**
328
- * Recursively list every .md skill file under `dir`. Returns absolute
328
+ * Compute the canonical skill slug from a relative path.
329
+ *
330
+ * Two layouts are supported:
331
+ * - Folder form (Anthropic spec): `<name>/SKILL.md` → `<name>`
332
+ * (sibling .md files in that folder are bundled docs, not standalone skills)
333
+ * - Flat form (legacy): `<name>.md` → `<name>`
334
+ * (auto-generated MCP skills like `auto/discord/send.md` → `auto-discord-send`)
335
+ *
336
+ * NOTE: this is the single source of truth for "what slug should this file be
337
+ * loaded under." Both walkSkillFiles and loadSkillByName route through it so
338
+ * the dedupe/naming stays consistent.
339
+ */
340
+ function slugFromRel(relPath) {
341
+ // Folder form: <a>/<b>/SKILL.md → <a>-<b>
342
+ if (/(?:^|[\\/])SKILL\.md$/.test(relPath)) {
343
+ const parts = relPath.replace(/[\\/]SKILL\.md$/, '').split(/[\\/]/);
344
+ return parts.join('-');
345
+ }
346
+ // Flat form: any other .md file
347
+ return relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
348
+ }
349
+ /**
350
+ * Recursively list every skill file under `dir`. Returns absolute
329
351
  * paths, relative paths (for dedupe/naming), and an `isAuto` flag set
330
- * when the file lives under an `auto/` subtree. Used so auto-generated
331
- * MCP skills under `skills/auto/<server>/<tool>.md` surface in search
332
- * while user-authored top-level skills win on score tiebreak.
352
+ * when the file lives under an `auto/` subtree.
353
+ *
354
+ * Folder-form skills (`<name>/SKILL.md`) emit ONLY the SKILL.md and do
355
+ * not recurse — sibling .md files (templates/, reference docs) and
356
+ * subdirectories (scripts/) are bundled assets, not standalone skills.
357
+ *
358
+ * Flat-form skills (`<name>.md`, including auto-generated MCP skills
359
+ * under `auto/<server>/<tool>.md`) keep the legacy behavior so existing
360
+ * unmigrated installs don't regress.
333
361
  */
334
362
  function walkSkillFiles(root) {
335
363
  const out = [];
@@ -341,6 +369,20 @@ function walkSkillFiles(root) {
341
369
  catch {
342
370
  return;
343
371
  }
372
+ // Folder-form short-circuit: a sub-directory with a SKILL.md is a bundled
373
+ // skill folder. Emit just the SKILL.md and skip everything else inside —
374
+ // bundled docs/scripts are not separate skills.
375
+ if (rel) {
376
+ const skillEntry = entries.find(e => e.isFile() && e.name === 'SKILL.md');
377
+ if (skillEntry) {
378
+ out.push({
379
+ filePath: path.join(dir, skillEntry.name),
380
+ relPath: path.join(rel, skillEntry.name),
381
+ isAuto: rel.split(path.sep)[0] === 'auto',
382
+ });
383
+ return;
384
+ }
385
+ }
344
386
  for (const ent of entries) {
345
387
  const name = ent.name;
346
388
  // Skip backup files and hidden files
@@ -401,9 +443,10 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
401
443
  // tiebreak even when both match the query.
402
444
  const files = walkSkillFiles(dir);
403
445
  for (const { filePath, relPath, isAuto } of files) {
404
- // Use relPath (no .md, slashes → dashes) so same-name skills in
405
- // different subdirs don't collide in the dedupe set.
406
- const name = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
446
+ // Use slugFromRel so folder-form skills (`<name>/SKILL.md`) get
447
+ // their canonical name `<name>` and not `<name>-SKILL`. Same dedupe
448
+ // contract: distinct files cannot share a name.
449
+ const name = slugFromRel(relPath);
407
450
  if (seen.has(name))
408
451
  continue;
409
452
  seen.add(name);
@@ -521,18 +564,61 @@ export function loadSkillByName(name, agentSlug, opts) {
521
564
  if (opts?.suppressedNames?.has(name))
522
565
  return null;
523
566
  for (const dir of dirs) {
567
+ // Fast path: folder form. `<dir>/<name>/SKILL.md` is the canonical
568
+ // Anthropic layout post-migration. Try it first so a slug like
569
+ // `morning-briefing` resolves to `morning-briefing/SKILL.md`.
570
+ const folderEntry = path.join(dir, name, 'SKILL.md');
571
+ if (existsSync(folderEntry)) {
572
+ try {
573
+ const raw = readFileSync(folderEntry, 'utf-8');
574
+ const parsed = matter(raw);
575
+ const fmTools = parsed.data?.clementine?.tools?.allow ?? parsed.data?.toolsUsed ?? [];
576
+ return {
577
+ name,
578
+ title: parsed.data.title ?? parsed.data.name ?? name,
579
+ content: parsed.content, // full body — the runtime cap is gone
580
+ score: 0,
581
+ toolsUsed: Array.isArray(fmTools) ? fmTools : [],
582
+ attachments: parsed.data.attachments ?? [],
583
+ skillDir: dir,
584
+ };
585
+ }
586
+ catch {
587
+ return null;
588
+ }
589
+ }
590
+ // Fast path: flat form. `<dir>/<name>.md` (legacy + auto-discovery).
591
+ const flatEntry = path.join(dir, `${name}.md`);
592
+ if (existsSync(flatEntry)) {
593
+ try {
594
+ const raw = readFileSync(flatEntry, 'utf-8');
595
+ const parsed = matter(raw);
596
+ return {
597
+ name,
598
+ title: parsed.data.title ?? name,
599
+ content: parsed.content, // full body — the runtime cap is gone
600
+ score: 0,
601
+ toolsUsed: parsed.data.toolsUsed ?? [],
602
+ attachments: parsed.data.attachments ?? [],
603
+ skillDir: dir,
604
+ };
605
+ }
606
+ catch {
607
+ return null;
608
+ }
609
+ }
610
+ // Fallback: walk for nested forms (e.g. `auto/discord/send` slug).
524
611
  const files = walkSkillFiles(dir);
525
612
  for (const { filePath, relPath } of files) {
526
- const slug = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
527
- if (slug !== name)
613
+ if (slugFromRel(relPath) !== name)
528
614
  continue;
529
615
  try {
530
616
  const raw = readFileSync(filePath, 'utf-8');
531
617
  const parsed = matter(raw);
532
618
  return {
533
- name: slug,
534
- title: parsed.data.title ?? slug,
535
- content: parsed.content.slice(0, 1500),
619
+ name,
620
+ title: parsed.data.title ?? name,
621
+ content: parsed.content, // full body — the runtime cap is gone
536
622
  score: 0,
537
623
  toolsUsed: parsed.data.toolsUsed ?? [],
538
624
  attachments: parsed.data.attachments ?? [],
@@ -546,21 +632,37 @@ export function loadSkillByName(name, agentSlug, opts) {
546
632
  }
547
633
  return null;
548
634
  }
549
- /** Record that a skill was used (bump use count). */
635
+ /** Record that a skill was used (bump use count). Handles both flat and
636
+ * folder-form skills. For folder form the counter lives under
637
+ * `clementine.useCount` (Anthropic-canonical frontmatter keeps top-level
638
+ * reserved for `name`/`description`). */
550
639
  export function recordSkillUse(skillName, agentSlug) {
551
640
  try {
552
- // Check agent dir first, then global
553
641
  const dirs = agentSlug ? [agentSkillsDir(agentSlug), GLOBAL_SKILLS_DIR] : [GLOBAL_SKILLS_DIR];
554
642
  for (const dir of dirs) {
555
- const filePath = path.join(dir, `${skillName}.md`);
556
- if (!existsSync(filePath))
557
- continue;
558
- const raw = readFileSync(filePath, 'utf-8');
559
- const parsed = matter(raw);
560
- parsed.data.useCount = (parsed.data.useCount ?? 0) + 1;
561
- parsed.data.lastUsed = new Date().toISOString();
562
- writeFileSync(filePath, matter.stringify(parsed.content, parsed.data));
563
- return;
643
+ // Folder form: <dir>/<skillName>/SKILL.md
644
+ const folderEntry = path.join(dir, skillName, 'SKILL.md');
645
+ if (existsSync(folderEntry)) {
646
+ const raw = readFileSync(folderEntry, 'utf-8');
647
+ const parsed = matter(raw);
648
+ parsed.data.clementine = (parsed.data.clementine && typeof parsed.data.clementine === 'object')
649
+ ? parsed.data.clementine
650
+ : {};
651
+ parsed.data.clementine.useCount = (parsed.data.clementine.useCount ?? 0) + 1;
652
+ parsed.data.clementine.lastUsed = new Date().toISOString();
653
+ writeFileSync(folderEntry, matter.stringify(parsed.content, parsed.data));
654
+ return;
655
+ }
656
+ // Flat form: <dir>/<skillName>.md
657
+ const flatEntry = path.join(dir, `${skillName}.md`);
658
+ if (existsSync(flatEntry)) {
659
+ const raw = readFileSync(flatEntry, 'utf-8');
660
+ const parsed = matter(raw);
661
+ parsed.data.useCount = (parsed.data.useCount ?? 0) + 1;
662
+ parsed.data.lastUsed = new Date().toISOString();
663
+ writeFileSync(flatEntry, matter.stringify(parsed.content, parsed.data));
664
+ return;
665
+ }
564
666
  }
565
667
  }
566
668
  catch { /* non-fatal */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.111",
3
+ "version": "1.18.112",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",