| /** |
| * `/skill:<name>` invocation tokens (issue #1148) — the TUI's serialization |
| * of an explicit skill invocation. A token is valid anywhere in the input as |
| * long as it starts the text or follows whitespace (so paths and URLs never |
| * produce false positives); `<name>` uses the skill id charset, and |
| * resolution downstream matches by id first, then by display name. |
| * |
| * This module owns ONLY the token syntax: parsing for submit-time injection, |
| * stripping for message composition, and the shared line pattern used by the |
| * editor highlighter and autocomplete. Loading/gating/composing lives in |
| * `@maka/runtime`'s skill-invocation module. |
| */ |
| |
| export interface SkillInvocationToken { |
| /** The id-or-name captured after the `/skill:` prefix, exactly as typed. */ |
| name: string; |
| /** Start offset of the full token (including the prefix) in the source text. */ |
| start: number; |
| /** End offset (exclusive) of the full token in the source text. */ |
| end: number; |
| } |
| |
| /** |
| * Matches one token per line evaluation. Exported for consumers that run |
| * per-line (editor highlight, autocomplete prefix detection) — always |
| * construct a fresh RegExp from the source when a stateful `g` flag is used |
| * across calls. |
| */ |
| export const SKILL_INVOCATION_TOKEN_SOURCE = String.raw`(?:^|(?<=\s))\/skill:([A-Za-z0-9._-]+)`; |
| |
| const TOKEN_PATTERN = new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'); |
| |
| /** |
| * Parse the distinct invocation tokens in `text`, in first-appearance order, |
| * deduped case-insensitively by name. Positions point at the first |
| * occurrence of each name. |
| */ |
| export function parseSkillInvocationTokens(text: string): SkillInvocationToken[] { |
| const tokens: SkillInvocationToken[] = []; |
| const seen = new Set<string>(); |
| for (const match of text.matchAll(TOKEN_PATTERN)) { |
| const name = match[1]; |
| const key = name.toLowerCase(); |
| if (seen.has(key)) continue; |
| seen.add(key); |
| const start = match.index; |
| tokens.push({ name, start, end: start + match[0].length }); |
| } |
| return tokens; |
| } |
| |
| /** |
| * Remove every occurrence of the named tokens from `text`. Only lines that |
| * actually contained a removed token are tidied (adjacent whitespace |
| * collapsed around the hole; the line is dropped if left empty) — every |
| * other line passes through byte-identical, so code blocks and intentional |
| * spacing elsewhere are untouched. The result is NOT global-trimmed: leading |
| * or trailing whitespace that is not itself a removed-token line is kept so |
| * indented code/YAML after a token-only line survives. |
| */ |
| export function stripSkillInvocationTokens(text: string, names: ReadonlySet<string>): string { |
| const pattern = new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'); |
| const lines = text.split('\n'); |
| const out: string[] = []; |
| for (const line of lines) { |
| let touched = false; |
| const stripped = line.replace(pattern, (whole, name: string) => { |
| if (!names.has(name.toLowerCase())) return whole; |
| touched = true; |
| return ''; |
| }); |
| if (!touched) { |
| out.push(line); |
| continue; |
| } |
| // Collapse spaces left by the token hole on this line only. Untouched |
| // lines (including indented code after a token-only line) stay byte-identical |
| // because we never global-trim the joined result. |
| const tidied = stripped.replace(/[ \t]+/g, ' ').trim(); |
| if (tidied.length > 0) out.push(tidied); |
| } |
| return out.join('\n'); |
| } |
| |
| /** |
| * The token prefix directly before the cursor on the cursor's own line, if |
| * any — the autocomplete trigger shape. `query` is the partial name typed so |
| * far (may be empty); `prefix` is the full `/skill:<query>` span to replace. |
| */ |
| export function skillInvocationPrefixAt( |
| lines: string[], |
| cursorLine: number, |
| cursorCol: number, |
| ): { prefix: string; query: string } | null { |
| const currentLine = lines[cursorLine] || ''; |
| const beforeCursor = currentLine.slice(0, cursorCol); |
| const match = /(?:^|\s)(\/skill:([A-Za-z0-9._-]*))$/.exec(beforeCursor); |
| if (!match) return null; |
| return { prefix: match[1], query: match[2] }; |
| } |