Files
lam-agent-vicinae/src/lib/code.ts
T

471 lines
14 KiB
TypeScript

/**
* Fixups for code the model wrote as markdown.
*
* Vicinae renders fences through `MdCodeBlock.qml`, which gives a dark panel, a
* header showing the language, a Copy button, and syntax colouring resolved by
* KSyntaxHighlighting. That last part is why the label matters beyond
* cosmetics: `syntax::highlight` looks the language up with
* `definitionForName(language)` and then `definitionForFileName("file." + language)`,
* so `ts` resolves through `file.ts` and `bash` through its definition name,
* while an unrecognised string such as `terminal` still renders as a label but
* produces no highlighting at all. A wrong label is therefore worse than none,
* and inference stays deliberately conservative.
*/
/** First words that mark a block as shell rather than a programming language. */
const SHELL_COMMANDS = new Set([
"apt",
"brew",
"bun",
"cat",
"cd",
"chmod",
"chown",
"code",
"cp",
"curl",
"df",
"docker",
"du",
"echo",
"export",
"find",
"gh",
"git",
"grep",
"htop",
"jq",
"kill",
"kubectl",
"ln",
"ls",
"make",
"man",
"mkdir",
"mv",
"nano",
"node",
"npm",
"npx",
"open",
"pacman",
"pip",
"pnpm",
"ps",
"psql",
"pwd",
"python",
"rm",
"rsync",
"scp",
"sed",
"source",
"ssh",
"sudo",
"systemctl",
"tar",
"top",
"touch",
"unzip",
"vim",
"wget",
"which",
"yarn",
"zip",
]);
const FENCE = "```";
/**
* An opening fence, with an optional language.
*
* The language has to be part of this pattern. A closing ``` and an opening
* bare ``` are the same two lines apart from context, so a pattern that only
* matched bare fences treated every closing fence as a new opening one and
* swallowed the rest of the answer.
*/
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
/** A closing fence is the marker and nothing else. */
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
/**
* A line that is nothing but a bare http(s) URL, give or take a bullet.
*
* Vicinae only makes a URL clickable when markdown presents it as a link. Its
* parser attaches the `table` and `strikethrough` GFM extensions and nothing
* else, so the `autolink` extension is off: a bare URL renders as plain dead
* text. `MarkdownModel` turns `CMARK_NODE_LINK` into `<a href=...>`, and the
* selection controller opens that anchor on release, so `[label](url)` is the
* one form the user can actually click.
*/
const BARE_URL = /^https?:\/\/\S+$/;
/** Sentence punctuation a model appends to a URL that is not part of it. */
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/;
/** One inline code span that holds nothing but a URL. */
const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
/** A bullet or numbered-list marker, kept so a list of links stays a list. */
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
/** A GFM table row: it opens and closes with a pipe. */
const TABLE_ROW = /^\s*\|.*\|\s*$/;
/**
* The delimiter row under a header.
*
* The test is not how many dashes there are, because GFM wants only one per
* column. It is that the line could be nothing else: pipes, dashes, colons and
* spaces, with at least one dash to tell it apart from a row of empty cells.
*/
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
function significantLines(code: string): string[] {
return code
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("//"));
}
function looksLikeShell(code: string): boolean {
const lines = significantLines(code);
if (lines.length === 0) return false;
// An explicit prompt marker, or a leading `~` path, is about as clear as it gets.
if (lines.some((line) => /^[$>] /.test(line) || line.startsWith("~/"))) return true;
// A shell command as the first word of the first meaningful line.
const [firstWord] = lines[0].split(/\s+/);
if (SHELL_COMMANDS.has(firstWord)) return true;
// Failing that, a pipeline or chained command between two known commands.
if (/[|&]{1,2}/.test(code)) {
return lines.some((line) => {
const [word] = line.split(/\s+/);
return SHELL_COMMANDS.has(word.replace(/[|&].*$/, ""));
});
}
return false;
}
function looksLikeJson(code: string): boolean {
const trimmed = code.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
const parsed: unknown = JSON.parse(trimmed);
return typeof parsed === "object" && parsed !== null;
} catch {
return false;
}
}
/** TypeScript-specific syntax. Plain JS is left unlabelled rather than guessed at. */
function looksLikeTypeScript(code: string): boolean {
return (
/\binterface\s+\w+\s*\{/.test(code) ||
/\btype\s+\w+\s*=/.test(code) ||
/\bas\s+const\b/.test(code) ||
/:\s*(string|number|boolean|void|unknown|never)\b/.test(code) ||
/<\w+>(\s*\(|\s*=|\s*=>)/.test(code) ||
/\b(implements|readonly)\s+/.test(code)
);
}
function looksLikePython(code: string): boolean {
return (
/^\s*def\s+\w+\s*\(/m.test(code) ||
/^\s*from\s+[\w.]+\s+import\s+/m.test(code) ||
/^\s*import\s+\w+$/m.test(code) ||
/\bself\./.test(code) ||
/^\s*(async\s+)?def\s+/m.test(code)
);
}
/**
* Best-effort language tag, or `undefined` when nothing is clear enough.
* Order matters: a shell pipeline can contain what look like code fragments.
*/
export function inferLanguage(code: string): string | undefined {
if (code.trim().length === 0) return undefined;
if (looksLikeShell(code)) return "bash";
if (looksLikeJson(code)) return "json";
if (looksLikeTypeScript(code)) return "ts";
if (looksLikePython(code)) return "python";
return undefined;
}
/** Copies a fenced block through verbatim, returning the index just past it. */
function copyFence(lines: string[], out: string[], start: number): number {
let index = start;
out.push(lines[index]);
index += 1;
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
out.push(lines[index]);
index += 1;
}
if (index < lines.length) {
out.push(lines[index]);
index += 1;
}
return index;
}
/**
* Turns a paragraph that is nothing but one inline code span into a fence.
*
* Models reliably fence multi-line code and just as reliably answer "what is the
* command for X" with a lone `` `pwd` ``. A single inline chip is the worst
* presentation for that answer: no Copy button, no language, no colouring. The
* rule is narrow on purpose, a whole line that is exactly one span, so
* ``run `pwd` to check`` is left alone while a standalone command is promoted.
*
* Existing fences are copied through untouched, so a span-like line inside a
* code block is never rewritten.
*/
export function promoteStandaloneCodeSpans(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
const fence = OPENING_FENCE.exec(lines[index]);
if (fence) {
index = copyFence(lines, out, index);
continue;
}
const span = STANDALONE_CODE_SPAN.exec(lines[index]);
if (span) {
const code = span[1].trim();
out.push(FENCE + (inferLanguage(code) ?? ""), code, FENCE);
index += 1;
continue;
}
out.push(lines[index]);
index += 1;
}
return out.join("\n");
}
/**
* Adds a language to fences that have none, leaving labelled fences untouched.
*
* Only bare opening fences are rewritten, so the model's own choice always wins.
*/
export function labelCodeFences(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
const openingLine = lines[index];
const opener = OPENING_FENCE.exec(openingLine);
if (!opener) {
out.push(openingLine);
index += 1;
continue;
}
const [, indent, marker, existing] = opener;
const body: string[] = [];
index += 1;
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
body.push(lines[index]);
index += 1;
}
const closing = index < lines.length ? lines[index] : "";
if (closing) index += 1;
// A fence the model already labelled is left exactly as written.
if (existing) out.push(openingLine);
else {
const language = inferLanguage(body.join("\n"));
out.push(language ? `${indent}${marker}${language}` : openingLine);
}
out.push(...body);
if (closing) out.push(closing);
}
return out.join("\n");
}
/** A URL with the sentence punctuation after it removed. */
function asBareUrl(candidate: string): string | undefined {
const trimmed = candidate.trim();
if (!BARE_URL.test(trimmed)) return undefined;
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
}
/**
* Wraps a URL as a markdown link, using the address itself as the label so the
* destination stays readable rather than turning into a generic word.
*
* A destination containing parentheses (plenty of Wikipedia articles) has to
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
* the destination and leaves the rest as trailing prose.
*/
function asLink(url: string): string {
// Square brackets in the *label* would end the link text early, which is why
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
// tolerates brackets, but not parentheses, which is what the pointy form is
// for.
const label = url.replace(/[[\]]/g, "\\$&");
const destination = /[()]/.test(url) ? `<${url}>` : url;
return `[${label}](${destination})`;
}
/**
* The URLs of a fence whose every non-empty line is one, or `undefined` when
* the block is real code.
*
* Deliberately narrow: one line that is not a URL disqualifies the whole block,
* so a `curl` invocation that happens to contain an address is never touched.
*/
function urlOnlyBlock(lines: string[]): string[] | undefined {
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const url = asBareUrl(trimmed);
if (!url) return undefined;
urls.push(url);
}
return urls.length > 0 ? urls : undefined;
}
/** Rewrites a line that is only a URL, or only a span holding one, into a link. */
function linkifyLine(line: string): string {
const marker = LIST_MARKER.exec(line);
const prefix = marker ? marker[1] : (line.match(/^[ \t]*/)?.[0] ?? "");
const rest = marker ? marker[2] : line.slice(prefix.length);
const span = STANDALONE_URL_SPAN.exec(rest.trim());
const url = asBareUrl(span ? span[1] : rest);
return url ? `${prefix}${asLink(url)}` : line;
}
/**
* Turns URLs the model wrote as code back into links.
*
* This is the reported bug: asked for a few GitHub projects, the model answers
* with a fenced block of bare addresses. A fence renders through
* `MdCodeBlock.qml` as a dark panel with a Copy button and no link handling at
* all, so the one thing the user wants to do with a URL is the one thing they
* cannot. The same applies to a lone `` `https://...` `` span.
*
* Three shapes are recovered, and only these:
*
* 1. A fence whose entire body is URLs.
* 2. A line that is exactly one inline span holding a URL.
* 3. A line that is exactly a URL, bulleted or not.
*
* Existing fences holding anything else are copied through untouched, so real
* code is never rewritten.
*/
export function restoreUrlLinks(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (!OPENING_FENCE.test(lines[index])) {
out.push(linkifyLine(lines[index]));
index += 1;
continue;
}
const body: string[] = [];
let cursor = index + 1;
while (cursor < lines.length && !CLOSING_FENCE.test(lines[cursor])) {
body.push(lines[cursor]);
cursor += 1;
}
const closing = cursor < lines.length;
const urls = urlOnlyBlock(body);
if (urls) out.push(...urls.map(asLink));
else {
out.push(lines[index], ...body);
if (closing) out.push(lines[cursor]);
}
index = cursor + (closing ? 1 : 0);
}
return out.join("\n");
}
/**
* Inserts the blank line a table needs to start at all.
*
* A GFM table cannot interrupt a paragraph, so a table written straight after a
* line of prose is not a table: it renders as a paragraph of pipes and dashes,
* which is how a perfectly good answer turns into noise. The blank line looks
* unnecessary, so models leave it out.
*
* Only a real table start is touched, which is a row followed by a delimiter
* row, so prose that happens to contain a pipe is left alone. Fences are copied
* through untouched for the same reason, since a table shown as example code
* must stay exactly as written.
*/
export function separateTables(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (OPENING_FENCE.test(lines[index])) {
index = copyFence(lines, out, index);
continue;
}
const startsTable =
TABLE_ROW.test(lines[index]) && index + 1 < lines.length && TABLE_DELIMITER_ROW.test(lines[index + 1]);
// Only when something would otherwise swallow it. A table already at a block
// boundary, after a blank line or after a fenced block, needs nothing: the
// fence has already ended the preceding block.
const previous = out[out.length - 1];
const atBlockBoundary =
previous === undefined || previous.trim().length === 0 || CLOSING_FENCE.test(previous);
if (startsTable && !atBlockBoundary) out.push("");
out.push(lines[index]);
index += 1;
}
return out.join("\n");
}
/**
* The full pre-render pass, in order.
*
* Tables are separated first because they change block boundaries: a blank line
* has to exist before anything else looks at the line. Links are restored next
* so a recovered URL leaves no span for the promoter to fence again. Promotion
* then runs before labelling, so a promoted block is seen as an ordinary fence
* by the labeller, which is also harmless when promotion already inferred a
* language.
*/
export function repairCodeBlocks(markdown: string): string {
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
}