src / history.ts
src / history.ts
// Reading the chat history that the preprocessor receives: what the user asked on each turn,
// which tools ran, and which pages were actually read.
import type { Chat } from "@lmstudio/sdk";
import { stripThroughLineBlocks } from "./text";
export interface Turn {
userText: string;
toolCalls: Array<{ id?: string; name: string; args: Record<string, unknown> }>;
toolResults: Map<string, string>;
}
// Tools that run on most turns regardless of the task, so they do not mark a turn as work.
const HOUSEKEEPING = new Set(["read_memory", "search_memory", "get_current_time", "get_context_usage", "search_self_test"]);
// The user's own words in a stored user message, without anything a plugin wrapped around them.
export function ownText(stored: string): string {
let t = stored;
const q = t.lastIndexOf("User Query:");
if (q >= 0) t = t.slice(q + "User Query:".length);
return stripThroughLineBlocks(t).trim();
}
// Splits the history into turns: each user message and everything up to the next one.
// The message being processed now is not part of the history, so every turn here is complete.
export function turnsOf(history: Chat): Turn[] {
const turns: Turn[] = [];
for (const m of history.getMessagesArray()) {
const role = m.getRole();
if (role === "user") {
turns.push({ userText: ownText(m.getText()), toolCalls: [], toolResults: new Map() });
continue;
}
const t = turns.at(-1);
if (!t) continue;
for (const r of m.getToolCallRequests()) t.toolCalls.push({ id: r.id, name: r.name, args: r.arguments ?? {} });
for (const r of m.getToolCallResults()) if (r.toolCallId) t.toolResults.set(r.toolCallId, r.content);
}
return turns;
}
export function workCalls(t: Turn): number {
return t.toolCalls.filter((c) => !HOUSEKEEPING.has(c.name) && !c.name.startsWith("ledger_")).length;
}
const FAILED = /status: (error|captcha|budget_exhausted|browser_unavailable|parser_failed|no_results)\b/;
const READ_TOOL = /read|visit|fetch|browse|open_?(page|url)|get_?page/i;
// A URL reduced to a comparable form: no scheme, no "www.", no fragment, query or trailing slash.
export function normalizeUrl(u: string): string | null {
const m = /^(?:https?:\/\/)?(?:www\.)?([a-z0-9.-]+\.[a-z]{2,})(\/[^\s?#]*)?/i.exec(u.trim().replace(/[)\].,;'"]+$/, ""));
if (!m) return null;
return (m[1].toLowerCase() + (m[2] ?? "")).replace(/\/+$/, "");
}
// The first URL in a finding's source, if the source names one.
export function urlInSource(source: string): string | null {
const m = /(https?:\/\/[^\s)\]]+|(?:www\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\/[^\s)\]]*)/i.exec(source);
return m ? normalizeUrl(m[1]) : null;
}
// Pages read successfully anywhere in this chat's stored history, normalized.
export function readUrls(turns: Turn[]): Set<string> {
const urls = new Set<string>();
for (const t of turns) {
for (const c of t.toolCalls) {
if (!READ_TOOL.test(c.name)) continue;
const result = c.id ? t.toolResults.get(c.id) : undefined;
if (result !== undefined && FAILED.test(result.slice(0, 400))) continue;
for (const v of Object.values(c.args)) {
if (typeof v !== "string") continue;
const n = /^(https?:\/\/|www\.)/i.test(v) ? normalizeUrl(v) : null;
if (n) urls.add(n);
}
// Where the page actually ended up after redirects, as the reading tool reports it in a
// "url:" line; the model often cites that address rather than the one it asked for.
if (result !== undefined) {
for (const m of result.slice(0, 2000).matchAll(/(?:^|\n|\\n)url: (https?:\/\/[^\s"\\]+)/g)) {
const n = normalizeUrl(m[1]);
if (n) urls.add(n);
}
}
}
}
return urls;
}
// Reading the chat history that the preprocessor receives: what the user asked on each turn,
// which tools ran, and which pages were actually read.
import type { Chat } from "@lmstudio/sdk";
import { stripThroughLineBlocks } from "./text";
export interface Turn {
userText: string;
toolCalls: Array<{ id?: string; name: string; args: Record<string, unknown> }>;
toolResults: Map<string, string>;
}
// Tools that run on most turns regardless of the task, so they do not mark a turn as work.
const HOUSEKEEPING = new Set(["read_memory", "search_memory", "get_current_time", "get_context_usage", "search_self_test"]);
// The user's own words in a stored user message, without anything a plugin wrapped around them.
export function ownText(stored: string): string {
let t = stored;
const q = t.lastIndexOf("User Query:");
if (q >= 0) t = t.slice(q + "User Query:".length);
return stripThroughLineBlocks(t).trim();
}
// Splits the history into turns: each user message and everything up to the next one.
// The message being processed now is not part of the history, so every turn here is complete.
export function turnsOf(history: Chat): Turn[] {
const turns: Turn[] = [];
for (const m of history.getMessagesArray()) {
const role = m.getRole();
if (role === "user") {
turns.push({ userText: ownText(m.getText()), toolCalls: [], toolResults: new Map() });
continue;
}
const t = turns.at(-1);
if (!t) continue;
for (const r of m.getToolCallRequests()) t.toolCalls.push({ id: r.id, name: r.name, args: r.arguments ?? {} });
for (const r of m.getToolCallResults()) if (r.toolCallId) t.toolResults.set(r.toolCallId, r.content);
}
return turns;
}
export function workCalls(t: Turn): number {
return t.toolCalls.filter((c) => !HOUSEKEEPING.has(c.name) && !c.name.startsWith("ledger_")).length;
}
const FAILED = /status: (error|captcha|budget_exhausted|browser_unavailable|parser_failed|no_results)\b/;
const READ_TOOL = /read|visit|fetch|browse|open_?(page|url)|get_?page/i;
// A URL reduced to a comparable form: no scheme, no "www.", no fragment, query or trailing slash.
export function normalizeUrl(u: string): string | null {
const m = /^(?:https?:\/\/)?(?:www\.)?([a-z0-9.-]+\.[a-z]{2,})(\/[^\s?#]*)?/i.exec(u.trim().replace(/[)\].,;'"]+$/, ""));
if (!m) return null;
return (m[1].toLowerCase() + (m[2] ?? "")).replace(/\/+$/, "");
}
// The first URL in a finding's source, if the source names one.
export function urlInSource(source: string): string | null {
const m = /(https?:\/\/[^\s)\]]+|(?:www\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\/[^\s)\]]*)/i.exec(source);
return m ? normalizeUrl(m[1]) : null;
}
// Pages read successfully anywhere in this chat's stored history, normalized.
export function readUrls(turns: Turn[]): Set<string> {
const urls = new Set<string>();
for (const t of turns) {
for (const c of t.toolCalls) {
if (!READ_TOOL.test(c.name)) continue;
const result = c.id ? t.toolResults.get(c.id) : undefined;
if (result !== undefined && FAILED.test(result.slice(0, 400))) continue;
for (const v of Object.values(c.args)) {
if (typeof v !== "string") continue;
const n = /^(https?:\/\/|www\.)/i.test(v) ? normalizeUrl(v) : null;
if (n) urls.add(n);
}
// Where the page actually ended up after redirects, as the reading tool reports it in a
// "url:" line; the model often cites that address rather than the one it asked for.
if (result !== undefined) {
for (const m of result.slice(0, 2000).matchAll(/(?:^|\n|\\n)url: (https?:\/\/[^\s"\\]+)/g)) {
const n = normalizeUrl(m[1]);
if (n) urls.add(n);
}
}
}
}
return urls;
}