bloby-bot 0.69.5 → 0.70.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.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/supervisor/channels/whatsapp.ts +25 -22
  4. package/supervisor/chat/bloby-main.tsx +1 -1
  5. package/supervisor/harnesses/claude.ts +24 -0
  6. package/supervisor/harnesses/codex.ts +2 -27
  7. package/supervisor/harnesses/pi/index.ts +6 -0
  8. package/supervisor/harnesses/skills.ts +133 -0
  9. package/supervisor/index.ts +230 -20
  10. package/supervisor/public/morphy/headphones-idle.webp +0 -0
  11. package/supervisor/public/morphy/headphones.json +4 -4
  12. package/supervisor/public/morphy/headphones.webp +0 -0
  13. package/supervisor/public/morphy/teleporting.json +2 -2
  14. package/supervisor/public/morphy/teleporting.webp +0 -0
  15. package/supervisor/shell.ts +53 -0
  16. package/supervisor/vite-dev.ts +28 -13
  17. package/supervisor/widget.js +124 -24
  18. package/supervisor/workspace-guard.js +33 -0
  19. package/vite.config.ts +26 -1
  20. package/workspace/client/index.html +6 -1
  21. package/workspace/client/public/morphy/headphones-idle.webp +0 -0
  22. package/workspace/client/public/morphy/headphones.json +4 -4
  23. package/workspace/client/public/morphy/headphones.webp +0 -0
  24. package/workspace/client/public/morphy/teleporting.json +2 -2
  25. package/workspace/client/public/morphy/teleporting.webp +0 -0
  26. package/workspace/client/public/sw.js +25 -2
  27. package/workspace/skills/create-skill/SKILL.md +188 -0
  28. package/workspace/skills/create-skill/references/patterns.md +126 -0
  29. package/workspace/skills/mac/skill.json +15 -2
  30. package/workspace/client/public/arrow.png +0 -0
  31. package/workspace/client/public/bloby_happy.mov +0 -0
  32. package/workspace/client/public/bloby_happy.webm +0 -0
  33. package/workspace/client/public/bloby_happy_reappearing.mov +0 -0
  34. package/workspace/client/public/bloby_happy_reappearing.webm +0 -0
  35. package/workspace/client/public/bloby_say_hi.mov +0 -0
  36. package/workspace/client/public/bloby_say_hi.webm +0 -0
  37. package/workspace/client/public/bloby_tilts.webm +0 -0
  38. package/workspace/client/public/headphones_spritesheet.webp +0 -0
  39. package/workspace/client/public/spritesheet.webp +0 -0
  40. package/workspace/skills/telegram/.claude-plugin/plugin.json +0 -6
  41. package/workspace/skills/whatsapp/.claude-plugin/plugin.json +0 -6
package/README.md CHANGED
@@ -133,7 +133,7 @@ workspace/
133
133
  PULSE.json Periodic wake-up config (interval, quiet hours)
134
134
  CRONS.json Scheduled tasks with cron expressions
135
135
  memory/ Daily notes (YYYY-MM-DD.md files, append-only)
136
- skills/ Plugin directories with .claude-plugin/plugin.json
136
+ skills/ Skill folders (SKILL.md with name+description frontmatter)
137
137
  MCP.json MCP server configuration (optional)
138
138
  files/ Attachment storage (audio, images, documents)
139
139
  ```
@@ -193,9 +193,9 @@ When a cron or pulse fires:
193
193
 
194
194
  ---
195
195
 
196
- ## Skills & Plugins
196
+ ## Skills
197
197
 
198
- The agent auto-discovers skills in `workspace/skills/`. Each skill is a folder containing `.claude-plugin/plugin.json`. These are loaded as tool plugins when the agent starts a query.
198
+ The agent auto-discovers skills in `workspace/skills/`. Each skill is a folder containing a `SKILL.md` whose YAML frontmatter carries `name` (= folder name) and `description`. All three harnesses surface that metadata in context and load the body on demand: the Claude harness mirrors skills into `workspace/.claude/skills` and enables them via the Agent SDK's `skills` option, the Codex harness mirrors them into `workspace/.codex/skills` and primes them via `skills/list`, and the Pi harness injects a name+description index into the system prompt (see `supervisor/harnesses/skills.ts`).
199
199
 
200
200
  MCP servers can be configured in `workspace/MCP.json`. The agent loads them at query time and logs which servers are active.
201
201
 
@@ -568,7 +568,7 @@ workspace/
568
568
  PULSE.json Periodic wake-up configuration
569
569
  CRONS.json Scheduled task definitions
570
570
  memory/ Daily notes (YYYY-MM-DD.md)
571
- skills/ Plugin directories (.claude-plugin/plugin.json)
571
+ skills/ Skill folders (SKILL.md with frontmatter)
572
572
  MCP.json MCP server configuration (optional)
573
573
  files/ Uploaded file storage (audio/, images/, documents/)
574
574
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.69.5",
3
+ "version": "0.70.0",
4
4
  "releaseNotes": [
5
5
  "1. Fix: agent self-update ",
6
6
  "1",
@@ -481,6 +481,7 @@ export class WhatsAppChannel implements ChannelProvider {
481
481
  if (connection === 'open') {
482
482
  this.connected = true;
483
483
  this.reconnectAttempts = 0;
484
+ this.pairingRetries = 0;
484
485
  this.qrData = null;
485
486
  this.qrSvg = null;
486
487
  this.buildLidMap();
@@ -516,38 +517,40 @@ export class WhatsAppChannel implements ChannelProvider {
516
517
  return;
517
518
  }
518
519
 
519
- // Never paired (pair-success sets creds.registered before the post-pairing 515,
520
- // so a registered=false close means the pairing window expired unused).
521
- if (!state.creds.registered) {
522
- if (state.creds.me) {
523
- // Phantom identity from an uncompleted pairing-code attempt (Baileys sets
524
- // creds.me as a placeholder when the code is requested). Reconnecting would
525
- // try to LOG IN with an identity the server never registered → guaranteed
526
- // 401 wipe. Reset to a clean unpaired state instead.
527
- log.info('[whatsapp] Pairing code expired before confirmation — resetting credentials; connect again to retry');
528
- await this.deleteCredentials();
529
- } else if (this.pairingRetries < 2) {
530
- // Fresh QR windows for a while (the link page is likely still open),
531
- // then stop instead of cycling QR codes in the background forever.
520
+ // restartRequired (515) is the server's reconnect-now handoff. In the QR flow it
521
+ // arrives right after pair-success, while creds.registered is STILL false (that
522
+ // flag is only set in the pairing-code flow, messages-recv link_code_pairing_ref)
523
+ // so it MUST be handled before any unpaired/phantom logic, or a successful
524
+ // scan gets thrown away. Backoff guards a pathological repeated-515 loop.
525
+ if (statusCode === DisconnectReason.restartRequired) {
526
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 60_000);
527
+ log.info(`[whatsapp] Restart required (normal after pairing) reconnecting in ${delay}ms...`);
528
+ this.emitStatus();
529
+ this.scheduleReconnect(delay);
530
+ return;
531
+ }
532
+
533
+ // Pure QR wait expired without anyone claiming an identity — regenerate a fresh
534
+ // QR a couple of times (the link page is likely still open), then stop instead
535
+ // of cycling QR codes in the background forever.
536
+ if (!state.creds.registered && !state.creds.me) {
537
+ if (this.pairingRetries < 2) {
532
538
  this.pairingRetries++;
533
539
  log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
534
540
  this.emitStatus();
535
541
  this.scheduleReconnect(2000);
536
- return;
537
542
  } else {
538
543
  log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
544
+ this.emitStatus();
539
545
  }
540
- this.emitStatus();
541
546
  return;
542
547
  }
543
548
 
544
- // restartRequired (515) is the normal post-pairing handoff reconnect right
545
- // away, but only the first time (a repeated-515 loop is a known Baileys failure
546
- // mode; let it back off). Everything else backs off 5s 60s so an offline
547
- // network doesn't hot-loop.
548
- const fastRestart = statusCode === DisconnectReason.restartRequired && this.reconnectAttempts === 0;
549
- const delay = fastRestart ? 1000 : Math.min(5000 * 2 ** this.reconnectAttempts, 60_000);
550
- this.reconnectAttempts++;
549
+ // Anything else reconnects with backoff (5s 60s). This includes the
550
+ // me-set-but-unregistered states: a real post-pairing identity completes its
551
+ // login on reconnect, and a stale pairing-code placeholder gets a 401 from the
552
+ // server which lands in the loggedOut branch above and wipes it cleanly.
553
+ const delay = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
551
554
  log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
552
555
  this.emitStatus();
553
556
  this.scheduleReconnect(delay);
@@ -631,7 +631,7 @@ function BlobyApp() {
631
631
  <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/70 backdrop-blur-[2px]">
632
632
  <span className="flex items-center gap-2 text-[12px] text-muted-foreground bg-white/[0.05] border border-white/[0.08] rounded-full px-3.5 py-1.5 shadow-lg">
633
633
  <span className={`h-1.5 w-1.5 rounded-full animate-pulse ${updating ? 'bg-amber-400' : 'bg-red-500'}`} />
634
- {updating ? 'Updating back in a moment…' : 'Offline waiting for connection…'}
634
+ {updating ? 'Morphy is Updating…' : 'Morphy is Offline'}
635
635
  </span>
636
636
  </div>
637
637
  )}
@@ -20,6 +20,7 @@ import { getClaudeAccessToken } from '../../worker/claude-auth.js';
20
20
  import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
21
21
  import { buildAgents } from '../agents/index.js';
22
22
  import { preWarm, claimWarmup, discardWarmup } from '../cli-warmup.js';
23
+ import { mirrorSkillsInto } from './skills.js';
23
24
 
24
25
  // ── Types ──────────────────────────────────────────────────────────────────
25
26
 
@@ -128,6 +129,20 @@ function formatConversationHistory(messages: RecentMessage[]): string {
128
129
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
129
130
  }
130
131
 
132
+ // The Agent SDK discovers project-scope skills under `<cwd>/.claude/skills`
133
+ // (name+description listed in context, body lazy-loaded via the Skill tool).
134
+ // Bloby keeps the canonical skills in `workspace/skills/<name>`, so we mirror
135
+ // each one into `.claude/skills/<name>` as a symlink — same single-source
136
+ // pattern as the codex harness's `.codex/skills` mirror. The returned names
137
+ // feed the `skills` option as an explicit allowlist: only Bloby's workspace
138
+ // skills are enabled, so the human's personal `~/.claude/skills` never leak
139
+ // into the agent and the option hash stays deterministic for the pre-warmer.
140
+ const CLAUDE_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.claude', 'skills');
141
+
142
+ function syncClaudeSkills(): string[] {
143
+ return mirrorSkillsInto(CLAUDE_SKILLS_ROOT, 'claude');
144
+ }
145
+
131
146
  /** Load MCP server config from workspace/MCP.json */
132
147
  function loadMcpServers(): Record<string, any> | undefined {
133
148
  try {
@@ -210,6 +225,7 @@ async function buildConversationOptions(
210
225
 
211
226
  const agents = buildAgents();
212
227
  const mcpServers = loadMcpServers();
228
+ const skills = syncClaudeSkills();
213
229
 
214
230
  return {
215
231
  model,
@@ -224,6 +240,7 @@ async function buildConversationOptions(
224
240
  systemPrompt,
225
241
  mcpServers,
226
242
  agents,
243
+ skills,
227
244
  agentProgressSummaries: true,
228
245
  // Auto-compaction: the live conversation is a single long-lived query() whose
229
246
  // context grows every turn (messages + tool results + sub-agent transcripts).
@@ -294,6 +311,8 @@ export async function startConversation(
294
311
  if (baseOptions.mcpServers) {
295
312
  log.info(`[conversation] MCP servers: ${Object.keys(baseOptions.mcpServers).join(', ')}`);
296
313
  }
314
+ const skillNames = Array.isArray(baseOptions.skills) ? baseOptions.skills : [];
315
+ log.info(`[conversation] Skills: ${skillNames.length ? skillNames.join(', ') : 'none'}`);
297
316
 
298
317
  // Try to claim a pre-warmed subprocess — its abortController is the one
299
318
  // baked into the warm query and must be reused for end/abort to reach it.
@@ -667,6 +686,10 @@ export async function startBlobyAgentQuery(
667
686
  maxTurns: effectiveMaxTurns,
668
687
  abortController,
669
688
  systemPrompt: enrichedPrompt,
689
+ // Customer-facing runs (supportPrompt) get no skills — SCRIPT.md alone
690
+ // governs that persona and the listing would just leak ops docs into
691
+ // customer context. Owner runs (pulse/cron) get the full workspace set.
692
+ skills: supportPrompt ? [] : syncClaudeSkills(),
670
693
  mcpServers,
671
694
  stderr: (chunk: string) => { stderrBuf += chunk; },
672
695
  env: {
@@ -781,6 +804,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
781
804
  maxTurns,
782
805
  abortController,
783
806
  systemPrompt: systemPrompt as any,
807
+ skills: syncClaudeSkills(),
784
808
  ...(req.sessionId ? { resume: req.sessionId } : {}),
785
809
  stderr: (chunk: string) => { stderrBuf += chunk; },
786
810
  env: {
@@ -35,6 +35,7 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
35
35
  import type { SavedFile } from '../file-saver.js';
36
36
  import { getCodexAccessToken } from '../../worker/codex-auth.js';
37
37
  import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
38
+ import { mirrorSkillsInto } from './skills.js';
38
39
  import type { OnAgentMessage, RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
39
40
  export type { RecentMessage, AgentAttachment };
40
41
 
@@ -771,7 +772,6 @@ async function spawnAndInitialize(
771
772
  }
772
773
  }
773
774
 
774
- const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
775
775
  // Codex discovers "repo"-scope skills under `<cwd>/.codex/skills` (verified
776
776
  // against 0.135.0 — a bare `<cwd>/skills` is NOT scanned, and `skills/list`
777
777
  // has no extra-root param). Bloby keeps the canonical skills in
@@ -780,33 +780,8 @@ const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
780
780
  // (Each SKILL.md needs YAML frontmatter or codex rejects it — see SKILL_FORMAT_MIGRATION.md.)
781
781
  const CODEX_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.codex', 'skills');
782
782
 
783
- /** Mirror workspace/skills/<name> → workspace/.codex/skills/<name> as symlinks (idempotent). */
784
- function syncCodexSkillRoot(): void {
785
- let names: string[] = [];
786
- try {
787
- names = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
788
- .filter((e) => e.isDirectory() || e.isSymbolicLink())
789
- .map((e) => e.name);
790
- } catch {
791
- return; // no skills dir — nothing to mirror
792
- }
793
- try { fs.mkdirSync(CODEX_SKILLS_ROOT, { recursive: true }); } catch {}
794
- for (const name of names) {
795
- const target = path.join(SKILLS_DIR, name);
796
- const link = path.join(CODEX_SKILLS_ROOT, name);
797
- try {
798
- const cur = fs.existsSync(link) ? fs.realpathSync(link) : null;
799
- if (cur === fs.realpathSync(target)) continue; // already correct
800
- try { fs.rmSync(link, { recursive: true, force: true }); } catch {}
801
- fs.symlinkSync(target, link, 'dir');
802
- } catch (err: any) {
803
- log.warn(`[codex] could not mirror skill "${name}" into .codex/skills: ${err.message}`);
804
- }
805
- }
806
- }
807
-
808
783
  function primeWorkspaceSkills(rpc: CodexRpc): void {
809
- syncCodexSkillRoot();
784
+ mirrorSkillsInto(CODEX_SKILLS_ROOT, 'codex');
810
785
  rpc.request('skills/list', {
811
786
  cwds: [WORKSPACE_DIR],
812
787
  forceReload: true,
@@ -24,6 +24,7 @@ import type {
24
24
  } from '../types.js';
25
25
  export type { RecentMessage, AgentAttachment };
26
26
 
27
+ import { buildSkillsIndex } from '../skills.js';
27
28
  import { createAsyncQueue, type AsyncQueue } from './async-queue.js';
28
29
  import { createPiSession, type PiSessionEvent } from './session.js';
29
30
  import { getPiSubProvider } from './sub-providers.js';
@@ -110,6 +111,11 @@ async function buildSystemPrompt(
110
111
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'pi');
111
112
  let systemPrompt = basePrompt;
112
113
  systemPrompt += LIVE_CONVERSATION_HINT;
114
+ // Pi has no native skill machinery (Claude's SDK and Codex discover skills
115
+ // themselves), so inject the name+description index here — the agent reads
116
+ // skills/<name>/SKILL.md on demand. Customer-facing supportPrompt runs skip
117
+ // this builder entirely, so they never see the index.
118
+ systemPrompt += buildSkillsIndex();
113
119
  systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
114
120
 
115
121
  try {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared skill plumbing for all three harnesses.
3
+ *
4
+ * Canonical on-disk layout (single source of truth):
5
+ * workspace/skills/<name>/SKILL.md — YAML frontmatter with `name` (= folder
6
+ * name) and `description`. See SKILL_FORMAT_MIGRATION.md.
7
+ *
8
+ * Each harness consumes that one layout its own way:
9
+ * - claude: mirrored into `workspace/.claude/skills` (the Agent SDK's
10
+ * project-skill discovery root) and enabled via the `skills` option —
11
+ * the SDK then lists name+description in context and lazy-loads bodies
12
+ * through its native Skill tool.
13
+ * - codex: mirrored into `workspace/.codex/skills` (codex's repo-scope
14
+ * root) and primed via `skills/list` — codex's own router takes over.
15
+ * - pi: no native skill machinery, so `buildSkillsIndex()` appends a
16
+ * name+description index to the system prompt and the agent reads
17
+ * `skills/<name>/SKILL.md` on demand (hermes-style progressive
18
+ * disclosure: metadata always in context, body only when used).
19
+ */
20
+
21
+ import fs from 'fs';
22
+ import path from 'path';
23
+ import { log } from '../../shared/logger.js';
24
+ import { WORKSPACE_DIR } from '../../shared/paths.js';
25
+
26
+ export const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
27
+
28
+ /** Sorted names of installed skill folders (dirs or symlinks under skills/). */
29
+ export function listSkillNames(): string[] {
30
+ try {
31
+ return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
32
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith('.'))
33
+ .map((e) => e.name)
34
+ .sort();
35
+ } catch {
36
+ return []; // no skills dir — nothing installed
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Mirror workspace/skills/<name> → <mirrorRoot>/<name> as symlinks
42
+ * (idempotent), and prune symlinks for skills that were uninstalled.
43
+ * Returns the sorted list of mirrored skill names.
44
+ *
45
+ * Only symlinks are ever pruned — a real directory someone dropped into the
46
+ * mirror root is left alone.
47
+ */
48
+ export function mirrorSkillsInto(mirrorRoot: string, label: string): string[] {
49
+ const names = listSkillNames();
50
+ if (names.length) {
51
+ try { fs.mkdirSync(mirrorRoot, { recursive: true }); } catch {}
52
+ }
53
+
54
+ const mirrored: string[] = [];
55
+ for (const name of names) {
56
+ const target = path.join(SKILLS_DIR, name);
57
+ const link = path.join(mirrorRoot, name);
58
+ try {
59
+ const cur = fs.existsSync(link) ? fs.realpathSync(link) : null;
60
+ if (cur !== fs.realpathSync(target)) {
61
+ try { fs.rmSync(link, { recursive: true, force: true }); } catch {}
62
+ fs.symlinkSync(target, link, 'dir');
63
+ }
64
+ mirrored.push(name);
65
+ } catch (err: any) {
66
+ log.warn(`[${label}] could not mirror skill "${name}" into ${path.basename(path.dirname(mirrorRoot))}/skills: ${err.message}`);
67
+ }
68
+ }
69
+
70
+ // Prune stale symlinks (skill uninstalled) so dead links never reach the harness.
71
+ try {
72
+ for (const entry of fs.readdirSync(mirrorRoot, { withFileTypes: true })) {
73
+ if (!entry.isSymbolicLink() || names.includes(entry.name)) continue;
74
+ try { fs.unlinkSync(path.join(mirrorRoot, entry.name)); } catch {}
75
+ }
76
+ } catch {}
77
+
78
+ return mirrored;
79
+ }
80
+
81
+ /**
82
+ * Parse `name` and `description` from a SKILL.md YAML frontmatter block.
83
+ * Handles plain, quoted, and folded/literal (`>-`, `|`) scalar styles —
84
+ * enough for the two mandated keys without pulling in a YAML dependency.
85
+ */
86
+ export function parseSkillFrontmatter(skillMdPath: string): { name?: string; description?: string } {
87
+ let raw: string;
88
+ try {
89
+ raw = fs.readFileSync(skillMdPath, 'utf-8');
90
+ } catch {
91
+ return {};
92
+ }
93
+ if (!raw.startsWith('---')) return {};
94
+ const end = raw.indexOf('\n---', 3);
95
+ if (end === -1) return {};
96
+ const lines = raw.slice(raw.indexOf('\n') + 1, end).split('\n');
97
+
98
+ const out: Record<string, string> = {};
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const m = lines[i].match(/^(name|description):\s*(.*)$/);
101
+ if (!m) continue;
102
+ let value = m[2].trim();
103
+ if (/^[>|][+-]?$/.test(value)) {
104
+ // Block scalar — collect indented continuation lines, fold with spaces.
105
+ const parts: string[] = [];
106
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) {
107
+ i++;
108
+ if (lines[i].trim()) parts.push(lines[i].trim());
109
+ }
110
+ value = parts.join(' ');
111
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
112
+ value = value.slice(1, -1).replace(/\\"/g, '"');
113
+ }
114
+ out[m[1]] = value;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Compact installed-skills index for system-prompt injection (pi harness).
121
+ * One name+description line per skill — the body stays on disk until the
122
+ * agent actually opens it. Returns '' when no skills are installed.
123
+ */
124
+ export function buildSkillsIndex(): string {
125
+ const entries: string[] = [];
126
+ for (const name of listSkillNames()) {
127
+ const fm = parseSkillFrontmatter(path.join(SKILLS_DIR, name, 'SKILL.md'));
128
+ const description = fm.description || '(no description — open the SKILL.md)';
129
+ entries.push(`- **${fm.name || name}** — ${description}`);
130
+ }
131
+ if (!entries.length) return '';
132
+ return `\n\n---\n# Installed Skills\n\nScan this list on every request. When a request matches a skill — even partially — read \`skills/<name>/SKILL.md\` before acting and follow it.\n\n${entries.join('\n')}`;
133
+ }