reeboot 1.0.0 → 1.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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Skill Manager Extension
3
+ *
4
+ * Manages permanent and ephemeral skills for the reeboot agent:
5
+ * - Permanent skills: registered via resources_discover at startup
6
+ * - Ephemeral skills: loaded on demand via load_skill tool, TTL-based expiry,
7
+ * injected into system prompt via before_agent_start
8
+ * - Persistence: active ephemeral skills stored in ~/.reeboot/active-skills.json
9
+ * - Catalog: resolves skill names from bundled catalog and extended catalog
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { homedir } from 'os';
15
+ import { fileURLToPath } from 'url';
16
+ import { Type } from '@sinclair/typebox';
17
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
18
+ import type { Config } from '../src/config.js';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ // Resolve package root (same logic as loader.ts: dist/extensions → dist/ → reeboot/)
23
+ const PACKAGE_ROOT = join(__dirname, '../../');
24
+ const BUNDLED_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
25
+
26
+ // Default persist path
27
+ const DEFAULT_PERSIST_PATH = join(homedir(), '.reeboot', 'active-skills.json');
28
+
29
+ // ─── Types ────────────────────────────────────────────────────────────────────
30
+
31
+ export interface ActiveSkill {
32
+ name: string;
33
+ skillDir: string;
34
+ description: string;
35
+ expiresAt: number; // Date.now() + ttlMs
36
+ }
37
+
38
+ interface SkillMeta {
39
+ name: string;
40
+ description: string;
41
+ }
42
+
43
+ // ─── Catalog Resolution ───────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Search each root for <root>/<name>/SKILL.md.
47
+ * Returns the skill directory path (not SKILL.md) or null.
48
+ * Case-insensitive name match.
49
+ */
50
+ export function findSkill(name: string, roots: string[]): string | null {
51
+ const lowerName = name.toLowerCase();
52
+ for (const root of roots) {
53
+ if (!existsSync(root)) continue;
54
+ const skillDir = join(root, name);
55
+ if (existsSync(join(skillDir, 'SKILL.md'))) {
56
+ return skillDir;
57
+ }
58
+ // Try case-insensitive scan
59
+ try {
60
+ const entries = readdirSync(root, { withFileTypes: true });
61
+ for (const entry of entries) {
62
+ if (entry.isDirectory() && entry.name.toLowerCase() === lowerName) {
63
+ const candidate = join(root, entry.name);
64
+ if (existsSync(join(candidate, 'SKILL.md'))) {
65
+ return candidate;
66
+ }
67
+ }
68
+ }
69
+ } catch {
70
+ // Root not accessible — skip
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Read SKILL.md frontmatter and parse name + description.
78
+ * Uses simple regex; does not require a YAML library.
79
+ * Returns null on any parse error.
80
+ */
81
+ export function readSkillMeta(skillDir: string): SkillMeta | null {
82
+ const skillMdPath = join(skillDir, 'SKILL.md');
83
+ if (!existsSync(skillMdPath)) return null;
84
+ try {
85
+ const content = readFileSync(skillMdPath, 'utf-8');
86
+ // Extract frontmatter between --- delimiters
87
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
88
+ if (!fmMatch) return null;
89
+ const frontmatter = fmMatch[1];
90
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
91
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
92
+ if (!nameMatch || !descMatch) return null;
93
+ return {
94
+ name: nameMatch[1].trim(),
95
+ description: descMatch[1].trim(),
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Returns the list of catalog root directories to search, in order:
104
+ * 1. Bundled catalog: <package_root>/skills/
105
+ * 2. Extended catalog: config.skills.catalog_path or ~/.reeboot/skills-catalog/
106
+ */
107
+ function resolveCatalogRoots(config: Config): string[] {
108
+ const roots: string[] = [];
109
+
110
+ // 1. Bundled catalog (may not exist yet — handled gracefully)
111
+ roots.push(BUNDLED_SKILLS_DIR);
112
+
113
+ // 2. Extended catalog
114
+ const skillsConfig = config?.skills;
115
+ if (skillsConfig?.catalog_path) {
116
+ roots.push(skillsConfig.catalog_path);
117
+ } else {
118
+ const defaultCatalog = join(homedir(), '.reeboot', 'skills-catalog');
119
+ if (existsSync(defaultCatalog)) {
120
+ roots.push(defaultCatalog);
121
+ }
122
+ }
123
+
124
+ return roots;
125
+ }
126
+
127
+ // ─── ActiveSkillStore ─────────────────────────────────────────────────────────
128
+
129
+ export class ActiveSkillStore {
130
+ private _skills: Map<string, ActiveSkill> = new Map();
131
+
132
+ load(name: string, skillDir: string, description: string, ttlMs: number): void {
133
+ const expiresAt = Date.now() + ttlMs;
134
+ this._skills.set(name, { name, skillDir, description, expiresAt });
135
+ }
136
+
137
+ unload(name: string): boolean {
138
+ return this._skills.delete(name);
139
+ }
140
+
141
+ /** Returns only non-expired skills (does not mutate). */
142
+ getActive(): ActiveSkill[] {
143
+ const now = Date.now();
144
+ return Array.from(this._skills.values()).filter((s) => s.expiresAt > now);
145
+ }
146
+
147
+ /** Removes expired skills and returns their names. */
148
+ pruneExpired(): string[] {
149
+ const now = Date.now();
150
+ const removed: string[] = [];
151
+ for (const [name, skill] of this._skills.entries()) {
152
+ if (skill.expiresAt <= now) {
153
+ this._skills.delete(name);
154
+ removed.push(name);
155
+ }
156
+ }
157
+ return removed;
158
+ }
159
+
160
+ /** Replace entire store from an array (used on restore). */
161
+ restoreFrom(skills: ActiveSkill[]): void {
162
+ this._skills.clear();
163
+ for (const s of skills) {
164
+ this._skills.set(s.name, s);
165
+ }
166
+ }
167
+
168
+ toArray(): ActiveSkill[] {
169
+ return Array.from(this._skills.values());
170
+ }
171
+ }
172
+
173
+ // ─── Persistence ─────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Write active skills to disk as JSON array.
177
+ * Creates parent directories if needed.
178
+ */
179
+ function persistStore(store: ActiveSkillStore, path: string): void {
180
+ try {
181
+ mkdirSync(dirname(path), { recursive: true });
182
+ const data = store.getActive();
183
+ writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8');
184
+ } catch (err) {
185
+ console.warn('[skill-manager] failed to persist active skills:', err);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Read active skills from disk.
191
+ * Discards expired entries (expiresAt <= now).
192
+ * Returns empty map on missing/corrupted file.
193
+ */
194
+ function restoreStore(path: string, now: number): ActiveSkill[] {
195
+ if (!existsSync(path)) return [];
196
+ try {
197
+ const raw = readFileSync(path, 'utf-8');
198
+ const data: ActiveSkill[] = JSON.parse(raw);
199
+ if (!Array.isArray(data)) return [];
200
+ return data.filter((s) => s.expiresAt > now);
201
+ } catch (err) {
202
+ console.warn('[skill-manager] failed to restore active skills from disk:', err);
203
+ return [];
204
+ }
205
+ }
206
+
207
+ // ─── Extension Default Export ─────────────────────────────────────────────────
208
+
209
+ export function skillManagerExtension(
210
+ pi: ExtensionAPI,
211
+ config: Config,
212
+ persistPath: string = DEFAULT_PERSIST_PATH
213
+ ): void {
214
+ const skillsConfig = config?.skills ?? {
215
+ permanent: [],
216
+ ephemeral_ttl_minutes: 60,
217
+ catalog_path: '',
218
+ };
219
+
220
+ const store = new ActiveSkillStore();
221
+ const catalogRoots = resolveCatalogRoots(config);
222
+
223
+ // ── Restore persisted active skills on startup ────────────────────────────
224
+ const restored = restoreStore(persistPath, Date.now());
225
+ if (restored.length > 0) {
226
+ store.restoreFrom(restored);
227
+ }
228
+
229
+ // ── Permanent Skills — resources_discover ─────────────────────────────────
230
+ pi.on('resources_discover', async () => {
231
+ const skillPaths: string[] = [];
232
+ for (const name of skillsConfig.permanent ?? []) {
233
+ const dir = findSkill(name, catalogRoots);
234
+ if (dir) {
235
+ skillPaths.push(dir);
236
+ } else {
237
+ console.warn(`[skill-manager] permanent skill not found in catalog: ${name}`);
238
+ }
239
+ }
240
+ return { skillPaths };
241
+ });
242
+
243
+ // ── Ephemeral Skills — before_agent_start injection ───────────────────────
244
+ pi.on('before_agent_start', async (event: any) => {
245
+ const active = store.getActive();
246
+ if (active.length === 0) return undefined;
247
+ const now = Date.now();
248
+ const xml = [
249
+ '\n<active_skills>',
250
+ ...active.map((s) => {
251
+ const minsLeft = Math.max(1, Math.round((s.expiresAt - now) / 60_000));
252
+ return ` <skill name="${s.name}">\n <description>${s.description}</description>\n <expires_in>${minsLeft} minutes</expires_in>\n </skill>`;
253
+ }),
254
+ '</active_skills>',
255
+ ].join('\n');
256
+ return { systemPrompt: event.systemPrompt + xml };
257
+ });
258
+
259
+ // ── TTL Expiry Loop ───────────────────────────────────────────────────────
260
+ const loop = setInterval(() => {
261
+ const removed = store.pruneExpired();
262
+ if (removed.length > 0) {
263
+ persistStore(store, persistPath);
264
+ }
265
+ }, 60_000);
266
+
267
+ pi.on('session_shutdown', async () => {
268
+ clearInterval(loop);
269
+ });
270
+
271
+ // ── load_skill tool ───────────────────────────────────────────────────────
272
+ pi.registerTool({
273
+ name: 'load_skill',
274
+ label: 'Load Skill',
275
+ description:
276
+ 'Load a skill from the catalog into active context for a limited time.',
277
+ parameters: Type.Object({
278
+ name: Type.String({ description: 'Skill name to load' }),
279
+ ttl_minutes: Type.Optional(
280
+ Type.Number({
281
+ description: 'How long to keep skill active (minutes). Defaults to config value.',
282
+ })
283
+ ),
284
+ }),
285
+ async execute(_id: string, params: any, _signal: any, _onUpdate: any, _ctx: any) {
286
+ const skillName: string = params.name;
287
+ const ttl = params.ttl_minutes ?? skillsConfig.ephemeral_ttl_minutes ?? 60;
288
+ const ttlMs = ttl * 60 * 1000;
289
+
290
+ const skillDir = findSkill(skillName, catalogRoots);
291
+ if (!skillDir) {
292
+ return {
293
+ content: [
294
+ {
295
+ type: 'text',
296
+ text: `skill "${skillName}" not found in catalog`,
297
+ },
298
+ ],
299
+ };
300
+ }
301
+
302
+ const meta = readSkillMeta(skillDir);
303
+ const description = meta?.description ?? skillName;
304
+
305
+ store.load(skillName, skillDir, description, ttlMs);
306
+ persistStore(store, persistPath);
307
+
308
+ const expiresAt = Date.now() + ttlMs;
309
+ return {
310
+ content: [
311
+ {
312
+ type: 'text',
313
+ text: `Loaded skill "${skillName}" for ${ttl} minutes.`,
314
+ },
315
+ ],
316
+ details: {
317
+ name: skillName,
318
+ expiresAt: new Date(expiresAt).toISOString(),
319
+ },
320
+ };
321
+ },
322
+ });
323
+
324
+ // ── unload_skill tool ─────────────────────────────────────────────────────
325
+ pi.registerTool({
326
+ name: 'unload_skill',
327
+ label: 'Unload Skill',
328
+ description: 'Remove an active ephemeral skill from context immediately.',
329
+ parameters: Type.Object({
330
+ name: Type.String({ description: 'Skill name to unload' }),
331
+ }),
332
+ async execute(_id: string, params: any, _signal: any, _onUpdate: any, _ctx: any) {
333
+ const skillName: string = params.name;
334
+
335
+ // Check if active before unload
336
+ const active = store.getActive();
337
+ const isActive = active.some((s) => s.name === skillName);
338
+ if (!isActive) {
339
+ return {
340
+ content: [
341
+ {
342
+ type: 'text',
343
+ text: `skill "${skillName}" is not active`,
344
+ },
345
+ ],
346
+ };
347
+ }
348
+
349
+ store.unload(skillName);
350
+ persistStore(store, persistPath);
351
+
352
+ return {
353
+ content: [
354
+ {
355
+ type: 'text',
356
+ text: `Unloaded skill "${skillName}".`,
357
+ },
358
+ ],
359
+ };
360
+ },
361
+ });
362
+
363
+ // ── list_available_skills tool ────────────────────────────────────────────
364
+ pi.registerTool({
365
+ name: 'list_available_skills',
366
+ label: 'List Available Skills',
367
+ description:
368
+ 'List all skills available in the catalog. Optionally filter by keyword.',
369
+ parameters: Type.Object({
370
+ query: Type.Optional(
371
+ Type.String({
372
+ description: 'Optional keyword filter (case-insensitive substring match on name or description)',
373
+ })
374
+ ),
375
+ }),
376
+ async execute(_id: string, params: any, _signal: any, _onUpdate: any, _ctx: any) {
377
+ const query: string | undefined = params.query;
378
+
379
+ // Scan all catalog roots for skills
380
+ const allSkills: Array<{ name: string; description: string }> = [];
381
+ const seen = new Set<string>();
382
+
383
+ for (const root of catalogRoots) {
384
+ if (!existsSync(root)) continue;
385
+ try {
386
+ const entries = readdirSync(root, { withFileTypes: true });
387
+ for (const entry of entries) {
388
+ if (!entry.isDirectory()) continue;
389
+ const skillDir = join(root, entry.name);
390
+ const skillMdPath = join(skillDir, 'SKILL.md');
391
+ if (!existsSync(skillMdPath)) continue;
392
+ const meta = readSkillMeta(skillDir);
393
+ if (!meta) continue;
394
+ if (seen.has(meta.name)) continue; // bundled takes priority
395
+ seen.add(meta.name);
396
+ allSkills.push({ name: meta.name, description: meta.description });
397
+ }
398
+ } catch {
399
+ // Root not accessible — skip
400
+ }
401
+ }
402
+
403
+ // Apply keyword filter if provided
404
+ let results = allSkills;
405
+ if (query && query.trim()) {
406
+ const lowerQuery = query.toLowerCase();
407
+ results = allSkills.filter(
408
+ (s) =>
409
+ s.name.toLowerCase().includes(lowerQuery) ||
410
+ s.description.toLowerCase().includes(lowerQuery)
411
+ );
412
+ }
413
+
414
+ return {
415
+ content: [{ type: 'text', text: JSON.stringify(results) }],
416
+ };
417
+ },
418
+ });
419
+ }
420
+
421
+ export default skillManagerExtension;