mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* test(harness): pin the GSTACK_SESSION_KIND=spawned marker and its readers (GSTA-135) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(harness): trigger-first skill descriptions, hybrid OpenClaw layout, opt-in --context block (GSTA-135) Router lines and Hermes leads open with the skill's triggers; OpenClaw defaults to hybrid (five task skills plus the router); --context writes the routing block only into a named file, refusing SOUL.md and any new Hermes rules file that would outrank one Hermes loads now. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(harness): skill-use layout, --context and HARNESS_CONTEXT_REFUSED (GSTA-135) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
86 lines
4.3 KiB
TypeScript
86 lines
4.3 KiB
TypeScript
/**
|
|
* Trigger-first skill wording for harness listings (GSTA-135 skill use).
|
|
*
|
|
* Frozen rule, built only from each skill's own frontmatter, and the trigger
|
|
* part always comes first:
|
|
* - Router lines (lib/harness-register.ts, 160 chars): the description's own
|
|
* trigger sentence (its first sentence that starts "Use when", "Use to",
|
|
* "Use for", ...), followed by the description's first sentence when both
|
|
* fit. A description with no trigger sentence (the catalog-trimmed Claude
|
|
* render) uses the triggers list below instead.
|
|
* - Hermes descriptions (scripts/gen-skill-docs.ts, hosts/hermes.ts): a lead
|
|
* of at most 57 chars, the part Hermes's skills index shows: "Use for: "
|
|
* and the skill's `triggers:` list in frontmatter order, as many whole
|
|
* entries as fit; without triggers, the trigger sentence (or first
|
|
* sentence) cut at a word. The description's own trigger sentence, which
|
|
* the list repeats, is dropped, so a render does not grow.
|
|
*/
|
|
|
|
const TRIGGER_SENTENCE = /^Use (when|whenever|to|for|after|before|on|if)\b/;
|
|
|
|
export const normalizeDescription = (description: string) => description.replace(/\s*\(gstack\)\s*$/, '').replace(/\s+/g, ' ').trim();
|
|
|
|
export const sentences = (description: string): string[] => normalizeDescription(description).split(/(?<=[.!?])\s+/).filter(Boolean);
|
|
|
|
/** Cuts at the last word boundary that leaves room for "...". */
|
|
export function cutWords(text: string, max: number): string {
|
|
if (text.length <= max) return text;
|
|
const cut = text.slice(0, max - 3);
|
|
const space = cut.lastIndexOf(' ');
|
|
return (space > max / 3 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '') + '...';
|
|
}
|
|
|
|
/** The description's own trigger sentence, or ''. */
|
|
export const triggerSentence = (description: string) => sentences(description).find(s => TRIGGER_SENTENCE.test(s)) ?? '';
|
|
|
|
/** "Use for: a, b, c." from a skill's triggers list, as many whole entries as fit in max chars ('' without triggers). */
|
|
export function triggerList(triggers: string[], max: number): string {
|
|
const items = triggers.map(t => t.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
if (!items.length) return '';
|
|
let lead = 'Use for: ' + items[0] + '.';
|
|
for (const t of items.slice(1)) {
|
|
const next = lead.slice(0, -1) + ', ' + t + '.';
|
|
if (next.length > max) break;
|
|
lead = next;
|
|
}
|
|
return cutWords(lead, max);
|
|
}
|
|
|
|
/** A trigger-first lead of at most max chars: the triggers list, else the trigger sentence or first sentence cut at a word. */
|
|
export function triggerLead(description: string, triggers: string[], max: number): string {
|
|
return triggerList(triggers, max) || cutWords(triggerSentence(description) || sentences(description)[0] || '', max);
|
|
}
|
|
|
|
/** A description led by triggerLead on its own first line; the description's trigger sentence is dropped when the lead comes from the triggers list, which that sentence repeats. */
|
|
export function withTriggerLead(description: string, triggers: string[], max: number): string {
|
|
const lead = triggerLead(description, triggers, max);
|
|
const sentence = triggerSentence(description);
|
|
if (!sentence || !triggerList(triggers, max)) return lead + '\n' + description;
|
|
const tag = /\(gstack\)\s*$/.test(description) ? ' (gstack)' : '';
|
|
return lead + '\n' + sentences(description).filter(s => s !== sentence).join(' ') + tag;
|
|
}
|
|
|
|
/** A router line of at most max chars: the trigger part first, then the description's first sentence when both fit. */
|
|
export function routerLine(description: string, triggers: string[], max = 160): string {
|
|
const first = sentences(description)[0] ?? '';
|
|
const trigger = triggerSentence(description) || triggerList(triggers, max);
|
|
if (!trigger) return cutWords(first, max);
|
|
const both = trigger + ' ' + first;
|
|
return first && first !== trigger && both.length <= max ? both : cutWords(trigger, max);
|
|
}
|
|
|
|
/** The values of a YAML block list field (`field:` then ` - value` lines) in a SKILL.md's frontmatter. */
|
|
export function frontmatterList(md: string, field: string): string[] {
|
|
const fm = md.startsWith('---\n') ? md.slice(4, md.indexOf('\n---', 4)) : '';
|
|
const lines = fm.split('\n');
|
|
const i = lines.findIndex(l => l.trimEnd() === field + ':');
|
|
if (i < 0) return [];
|
|
const out: string[] = [];
|
|
for (const l of lines.slice(i + 1)) {
|
|
const m = /^\s*-\s+(.*)$/.exec(l);
|
|
if (!m) break;
|
|
out.push(m[1].trim().replace(/^["']|["']$/g, ''));
|
|
}
|
|
return out;
|
|
}
|