Skill Tracer
Skills › gstack › gstack › voice-triggers.ts

voice-triggers.ts

61 lines · scripts/resolvers/voice-triggers.ts at 3f79d81

1/**
2 * Voice triggers: speech-to-text aliases a template lists under
3 * `voice-triggers:` in its frontmatter.
4 *
5 * Every host folds them into the skill's description, but Claude's catalog trim
6 * keeps only the lead sentence. {{VOICE_TRIGGER_ROUTING}} gives them a cheap home
7 * in the root router's routing block instead: one routing line per skill.
8 */
9
10import * as fs from 'fs';
11import * as path from 'path';
12import type { TemplateContext } from './types';
13import { discoverTemplates, includesSkill } from '../discover-skills';
14import { getHostConfig } from '../../hosts/index';
16const ROOT = path.resolve(import.meta.dir, '..', '..');
18/**
19 * Extract voice-triggers YAML list from frontmatter.
20 * Returns an array of trigger strings, or [] if no voice-triggers field.
21 */
22export function extractVoiceTriggers(content: string): string[] {
23 const fmStart = content.indexOf('---\n');
24 if (fmStart !== 0) return [];
25 const fmEnd = content.indexOf('\n---', fmStart + 4);
26 if (fmEnd === -1) return [];
27 const frontmatter = content.slice(fmStart + 4, fmEnd);
29 const triggers: string[] = [];
30 let inVoice = false;
31 for (const line of frontmatter.split('\n')) {
32 if (/^voice-triggers:/.test(line)) { inVoice = true; continue; }
33 if (inVoice) {
34 const m = line.match(/^\s+-\s+"(.+)"$/);
35 if (m) triggers.push(m[1]);
36 else if (!/^\s/.test(line)) break;
37 }
38 }
39 return triggers;
42/**
43 * Claude only: other hosts keep the voice triggers in each skill's description.
44 * The template places this at the end of the last routing line, so each line
45 * starts with its own newline and an empty result leaves the list unchanged.
46 */
47export function generateVoiceTriggerRouting(ctx: TemplateContext): string {
48 if (ctx.host !== 'claude') return '';
49 const hostConfig = getHostConfig(ctx.host);
50 let out = '';
51 // Sorted so the render doesn't depend on filesystem iteration order.
52 const skillDirs = discoverTemplates(ROOT).map(t => path.dirname(t.tmpl)).sort();
53 for (const skillDir of skillDirs) {
54 if (skillDir === '.' || !includesSkill(hostConfig, skillDir)) continue;
55 const content = fs.readFileSync(path.join(ROOT, skillDir, 'SKILL.md.tmpl'), 'utf-8').replace(/\r\n/g, '\n');
56 const triggers = extractVoiceTriggers(content);
57 if (triggers.length === 0) continue;
58 out += `\n- Voice trigger ${triggers.map(t => `"${t}"`).join(', ')} → invoke \`/${skillDir}\``;
59 }
60 return out;