245 lines
6.5 KiB
TypeScript
245 lines
6.5 KiB
TypeScript
import type { Summarizer, SummarizerOpts } from "./types.ts";
|
|
|
|
const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/;
|
|
const INTENT_TRIGGERS =
|
|
/^(?:request|fix|implement|add|remove|update|refactor|create|delete|change|build)\s*:/i;
|
|
const FILE_EXTENSIONS = new Set([
|
|
"ts",
|
|
"tsx",
|
|
"js",
|
|
"jsx",
|
|
"py",
|
|
"md",
|
|
"json",
|
|
"sql",
|
|
"css",
|
|
"html",
|
|
"yaml",
|
|
"yml",
|
|
"sh",
|
|
"rb",
|
|
"go",
|
|
"rs",
|
|
"java",
|
|
"c",
|
|
"cpp",
|
|
"h",
|
|
"hpp",
|
|
]);
|
|
|
|
function stripTokenPunctuation(token: string): string {
|
|
let start = 0;
|
|
let end = token.length;
|
|
const leading = new Set(["'", '"', "`", "(", "[", "{"]);
|
|
const trailing = new Set(["'", '"', "`", ")", "]", "}", ",", ";", ":"]);
|
|
|
|
while (start < end && leading.has(token[start])) start++;
|
|
while (end > start && trailing.has(token[end - 1])) end--;
|
|
|
|
return token.slice(start, end);
|
|
}
|
|
|
|
function getKnownFilePathToken(token: string): string | null {
|
|
const clean = stripTokenPunctuation(token);
|
|
const lastDot = clean.lastIndexOf(".");
|
|
if (lastDot <= 0 || lastDot === clean.length - 1) return null;
|
|
|
|
const extension = clean.slice(lastDot + 1).toLowerCase();
|
|
if (!FILE_EXTENSIONS.has(extension)) return null;
|
|
if (!clean.includes("/") && !clean.includes(".")) return null;
|
|
|
|
return clean;
|
|
}
|
|
|
|
function extractFilePathTokens(text: string): string[] {
|
|
const paths: string[] = [];
|
|
for (const token of text.split(/\s+/)) {
|
|
const filePath = getKnownFilePathToken(token);
|
|
if (filePath) paths.push(filePath);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function extractErrorSnippets(text: string): string[] {
|
|
const snippets: string[] = [];
|
|
for (const segment of text.split(/[.\n]/)) {
|
|
const trimmed = segment.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const lower = trimmed.toLowerCase();
|
|
if (
|
|
trimmed.includes("TypeError:") ||
|
|
trimmed.includes("ReferenceError:") ||
|
|
trimmed.includes("SyntaxError:") ||
|
|
trimmed.includes("RangeError:") ||
|
|
trimmed.includes("URIError:") ||
|
|
trimmed.includes("EvalError:") ||
|
|
trimmed.includes("Error:") ||
|
|
trimmed.includes("Exception:") ||
|
|
lower.includes("error ts")
|
|
) {
|
|
snippets.push(trimmed);
|
|
}
|
|
}
|
|
return snippets;
|
|
}
|
|
|
|
function extractIntents(messages: Array<{ role: string; content?: string | unknown[] }>): string[] {
|
|
const intents: string[] = [];
|
|
for (const msg of messages) {
|
|
if (msg.role !== "user") continue;
|
|
const text = extractText(msg.content);
|
|
if (!text) continue;
|
|
const firstLine = text.split("\n")[0]?.trim();
|
|
if (firstLine && INTENT_TRIGGERS.test(firstLine)) {
|
|
intents.push(firstLine.slice(0, 120));
|
|
} else if (intents.length === 0 && firstLine) {
|
|
intents.push(firstLine.slice(0, 120));
|
|
}
|
|
}
|
|
return intents;
|
|
}
|
|
|
|
function extractFilePaths(
|
|
messages: Array<{ role: string; content?: string | unknown[] }>
|
|
): string[] {
|
|
const paths = new Set<string>();
|
|
for (const msg of messages) {
|
|
const text = extractText(msg.content);
|
|
if (!text) continue;
|
|
const matches = extractFilePathTokens(text);
|
|
for (const m of matches) {
|
|
paths.add(m);
|
|
}
|
|
}
|
|
return [...paths].slice(0, 20);
|
|
}
|
|
|
|
function extractErrors(messages: Array<{ role: string; content?: string | unknown[] }>): string[] {
|
|
const errors: string[] = [];
|
|
for (const msg of messages) {
|
|
const text = extractText(msg.content);
|
|
if (!text) continue;
|
|
for (const match of extractErrorSnippets(text)) errors.push(match.slice(0, 150));
|
|
}
|
|
return errors.slice(0, 10);
|
|
}
|
|
|
|
function extractLastDecision(
|
|
messages: Array<{ role: string; content?: string | unknown[] }>
|
|
): string {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].role === "assistant") {
|
|
const text = extractText(messages[i].content);
|
|
if (text) return text;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function trimCodeFences(text: string): string {
|
|
let output = "";
|
|
let cursor = 0;
|
|
|
|
while (cursor < text.length) {
|
|
const start = text.indexOf("```", cursor);
|
|
if (start === -1) {
|
|
output += text.slice(cursor);
|
|
break;
|
|
}
|
|
|
|
const openingLineEnd = text.indexOf("\n", start + 3);
|
|
if (openingLineEnd === -1) {
|
|
output += text.slice(cursor);
|
|
break;
|
|
}
|
|
|
|
const closeStart = text.indexOf("\n```", openingLineEnd + 1);
|
|
if (closeStart === -1) {
|
|
output += text.slice(cursor);
|
|
break;
|
|
}
|
|
|
|
const closeEnd = closeStart + 4;
|
|
const opening = text.slice(start, openingLineEnd);
|
|
const code = text.slice(openingLineEnd + 1, closeStart);
|
|
const lines = code.split("\n");
|
|
|
|
output += text.slice(cursor, start);
|
|
if (lines.length <= 4) {
|
|
output += text.slice(start, closeEnd);
|
|
} else {
|
|
const head = lines.slice(0, 3).join("\n");
|
|
const tail = lines[lines.length - 1];
|
|
output += `${opening}\n${head}\n…\n${tail}\n\`\`\``;
|
|
}
|
|
|
|
cursor = closeEnd;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function extractText(content?: string | unknown[]): string {
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter(
|
|
(p): p is { type: string; text?: string } =>
|
|
typeof p === "object" && p !== null && "text" in p
|
|
)
|
|
.map((p) => p.text ?? "")
|
|
.join("\n");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export class RuleBasedSummarizer implements Summarizer {
|
|
summarize(messages: unknown[], opts?: SummarizerOpts): string {
|
|
const maxLen = opts?.maxLen ?? 2000;
|
|
const preserveCode = opts?.preserveCode ?? true;
|
|
|
|
const typed = messages as Array<{ role: string; content?: string | unknown[] }>;
|
|
|
|
const filtered = typed.filter((msg) => {
|
|
const text = extractText(msg.content);
|
|
if (!text || text.trim().length === 0) return false;
|
|
return !COMPRESSED_MARKER_RE.test(text);
|
|
});
|
|
|
|
if (filtered.length === 0) return "";
|
|
|
|
const intents = extractIntents(filtered);
|
|
const files = extractFilePaths(filtered);
|
|
const errors = extractErrors(filtered);
|
|
const decision = extractLastDecision(filtered);
|
|
|
|
const parts: string[] = ["[COMPRESSED:summary]"];
|
|
|
|
if (intents.length > 0) {
|
|
parts.push(`Intents: ${intents.join("; ")}.`);
|
|
}
|
|
if (files.length > 0) {
|
|
parts.push(`Files touched: ${files.join(", ")}.`);
|
|
}
|
|
if (errors.length > 0) {
|
|
parts.push(`Errors: ${errors.join("; ")}.`);
|
|
}
|
|
if (decision) {
|
|
const processed = preserveCode ? trimCodeFences(decision) : decision;
|
|
parts.push(`Last decision: ${processed.slice(0, 200)}.`);
|
|
}
|
|
|
|
let result = parts.join(" ");
|
|
if (result.length > maxLen) {
|
|
result = result.slice(0, maxLen - 3) + "...";
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export function createSummarizer(): Summarizer {
|
|
return new RuleBasedSummarizer();
|
|
}
|