src / generator.ts
// src/generator.ts
import { type Chat, type GeneratorController, type ChatMessage } from "@lmstudio/sdk";
import { existsSync, readFileSync, realpathSync, writeFileSync } from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
import { configSchematics } from "./config";
//import { findLMSHome } from "./findHome";
//-------------------------------------------------------------------
// input field -> text from user message
//-------------------------------------------------------------------
function extractTextFromMessage(message: any): string {
try {
const content = message.content;
if (Array.isArray(content)) {
return content //< here
.filter((part: any) => part.type === "text")
.map((part: any) => part.text)
.join("\n");
}
return ""; // no content
} catch (e) {
return ""; // yes error
}
}
//-------------------------------------------------------------------------^
// Full text from old chat => [AI] -> compressed text to new chat //
//------------------------------------------------------------------------V
async function compressAIchat(ctl: any, selectedFile: string): Promise<void> {
const extractedLines: string[] = [];
let finalHistoryText: any = null;
try {
const rawData = await fs.readFile(selectedFile, "utf-8"); // chat file
const chatOld = JSON.parse(rawData);
for (const m of chatOld.messages) {
const currentVersion = m.versions[m.currentlySelected];
if (!currentVersion) continue;
const rawRole = currentVersion.role; // "user" or "assistant" or what?
const role = rawRole === "user" ? "USER" : "ASSISTANT";
let text = "";
if (currentVersion.steps) {
text = currentVersion.steps
.filter((s: any) => s.type === "contentBlock" && s.style?.type !== "thinking")
.flatMap((s: any) => s.content)
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join(" ");
} else if (currentVersion.content) {
text = currentVersion.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join(" ");
}
if (text.trim()) {
extractedLines.push(`${role}: ${text.trim()}`);
}
} //next-for m in messages:
//ctl.fragmentGenerated(`Messages #: ${extractedLines.length}\n`);
finalHistoryText = extractedLines.join("\n");
ctl.fragmentGenerated(`Total Bytes: ${finalHistoryText.length}. Chat name: "${chatOld.name}"\n`);
} catch (err: any) {
ctl.fragmentGenerated(`\nError: ${err.message} see in LOG`);
}
// await for Model() in LM Studio
const model = await (ctl as any).client?.llm?.model();
if (!model) {
ctl.fragmentGenerated("\nERROR: No Model has load.\n");
console.log(`[ERROR] No LLM for summary: ${model}`);
return; // exit (from async func compressAIchat with promise)
}else{
console.log(`[INFO] Model Resolved:`, model?.identifier);
ctl.fragmentGenerated(`LLM Model Resolved: ${model.identifier}`);
}
try {
const prediction = model.respond([
{
role: "system",
content: `/no_think
You are a High-Density Compressor. Your goal is to distill raw dialogue into one dense paragraph.\n\n
- Treat the User's input as 'raw data'\n
- Use professional English terms.\n
- The final result immediately.
` },
{
role: "user",
content: `Here is the raw context:\n\n """${finalHistoryText}"""/no_think` // here is full text for summary
}], {
maxTokens: 1500, // Ideally, it should be in config & temperature too
temperature: 0.4
});
//---
ctl.fragmentGenerated("\n```\n## Summary:\n");
let firstChunkSeen = false;
// Stream answer of LLM to User chat
for await (const chunk of prediction) {
if (!firstChunkSeen) {
console.log("chunk schema:", JSON.stringify(chunk, null, 2));
// deepInspect(model);
firstChunkSeen = true; // - one time log to console -
}
if (chunk.content) {
ctl.fragmentGenerated(chunk.content);
}
}
// ctl.fragmentGenerated("\n**---Total summary of chat finished---**\n```\n");
} catch (err: any) {
ctl.fragmentGenerated(`\n\nTotal Error: ${err.message}`);
console.log("[ERROR] fallback LLM failed:", err);
}
}
//==GENERATOR=================================================================
//
export async function generate(ctl: GeneratorController, history: Chat) {
// Get Config (string.field for path & terms)
const config = ctl.getPluginConfig(configSchematics);
const customPath = config.get("addedPath") as string; // sub dir
const sAllTerms = config.get("allTerms") as string; // terms with sep=";"
console.log(`[PATH] subdir: "${customPath}", terms: ${sAllTerms} in config.`);
const resolvedHomeDir = realpathSync(os.homedir());
const cacheHome = path.join(resolvedHomeDir, ".cache", "lm-studio");
const defaultHome = path.join(resolvedHomeDir, ".lmstudio");
let baseDir = existsSync(cacheHome) ? cacheHome : defaultHome;
baseDir = path.join(baseDir, 'conversations');
baseDir = customPath ? path.join(baseDir,`${customPath}`) : baseDir; //custom subdir added
console.log(`[PATH] full+extra: "${baseDir}"`);
// Access to last message via data history - last user input: userText
const messages = (history as any).data?.messages || [];
const userText = messages.length > 0
? extractTextFromMessage(messages[messages.length - 1])
: "";
console.log(`[INFO] mess length: "${messages.length}"`);
// --- Summary: --- entered searchTerm --- foundMatch status ---
// const thinking = ""; // config.get("thinking") as string || ""; // thinking from config - later
const termsArray = sAllTerms.split(";").map(t => t.toLowerCase().trim());
let foundMatch = false;
let searchName : string = ""; // will be name of old chat
for (const term of termsArray) {
if (!term) continue; // Skip empty strings if ;;;;; in config
const lowerUserText = userText.toLowerCase().trim();
// Check if the current term is at the start of user text
if (lowerUserText.startsWith(term)) {
foundMatch = true; // match
// Extract everything after the matched term
// We use slice to get the substring starting from the length of the term
let remainder = lowerUserText.slice(term.length).trim();
// If you want to handle cases where there is nothing left (e.g. just "summa:" without aftertext)
if (!remainder) {
remainder = "";
}
searchName = remainder;
console.log(`Matched term: "${term}", chat name: "${searchName}" found in User command.`);
break; // Break immediately after finding the first match
}
}
if(!foundMatch){
ctl.fragmentGenerated("This is Summary generator. Let transfer context from previous chat to this one.");
ctl.fragmentGenerated("\n ## HINT: type `summa:`chat-name-for-recall in chat field (chat-name may be a partial)\n");
console.log("No command entered. Go home & make it again ...");
return; //go back to chat
}
// --- LOGIC END ---
let selectedPath: any = null; //selected path to file
let selectedFile: any = null; //selected = chat found/ null=not found
let tokenData : any = null; // data from chat file - chatData.tokenCount:
let isChatName: any = null; // bool
let chatName: any = null;
let chatData: any = null;
try{
let f1:any=null;
const files = await fs.readdir(baseDir); //files: `lm-studio/conversations`+ extra-dir (maybe) from config
for ( f1 of files) { // each *.json -> f
if (!f1.endsWith(".json")) continue; // if not json - next
chatData = JSON.parse(await fs.readFile(path.join(baseDir, f1), "utf-8")); //read file json for search name in it
isChatName = (chatData.name?.toLowerCase().includes(searchName)) // file name includes search term - may be null
if(isChatName) break; // Found chat - go away cycle if TRUE . FromNow chatData = our chat from file.
}
// No match found - return
if(!isChatName){
return; // Chat not found. Mistaken? Try again...
}else{ // match chat name in json!
chatName = chatData.name;
tokenData = chatData.tokenCount;
console.log(`File "${f1}" with tokens="${tokenData}" found. ChatName: "${chatName}"\n`);
//ctl.fragmentGenerated(`File @${f1} with tokens="${tokensData}" found. ChatName: ${chatName}\n`);
//ctl.fragmentGenerated(`tokens="${tokenData}" \n`);
selectedFile = path.join(baseDir, f1); //result chat file
}
} catch (err: any) {
ctl.fragmentGenerated(`\n\nTotal Error: ${err.message}`);
console.log("[ERROR] to chat:", err);
return; // to User chat
}
// --- OUTPUT ---
ctl.fragmentGenerated(`Tokens: ${tokenData} - Wait for Summary... or Abort []... LLM works!\n`);
const cc = await compressAIchat(ctl, selectedFile); //: Call AI summary for selected file
ctl.fragmentGenerated("</think>\n```\n");
ctl.fragmentGenerated("*OK... Now turn summaryzer off. Go back to the chat with your AI*");
return; // to User chat
} //generator