blob: fa831cd6a8945fffcf6970c7e14a09542909cb7b [file]
/**
* `/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] };
}