src / preprocessor.ts
/**
* @file preprocessor.ts
* Prompt preprocessor — silently injects relevant memories into the
* system context before each user message reaches the model.
*
* This is what makes the memory system "feel" persistent:
* the user never has to say "recall" — the model just knows.
*
* Flow:
* 1. User types a message
* 2. Preprocessor fires, extracts key terms from the message
* 3. Retrieves top-N memories by composite score
* 4. Formats them and prepends to the system prompt
* 5. Model sees the memories as part of its context
*/
import { configSchematics } from "./config";
import { getSharedInstances } from "./toolsProvider";
import { extractFacts } from "./processing/ai";
import { MAX_INJECTED_CONTEXT_CHARS, MEMORY_SEPARATOR } from "./constants";
import type { PluginController } from "./pluginTypes";
import type { ScoredMemory, MemoryRecord } from "./types";
function readConfig(ctl: PluginController) {
const c = ctl.getPluginConfig(configSchematics);
return {
autoInject: c.get("autoInjectMemories") === "on",
contextCount: c.get("contextMemoryCount") || 5,
decayDays: c.get("decayHalfLifeDays") || 30,
storagePath: c.get("memoryStoragePath") || "",
enableAI: c.get("enableAIExtraction") === "on",
activeProject: c.get("activeProject") || "",
};
}
/** Format memories into a clean context block for injection. */
function formatMemories(memories: Array<ScoredMemory | MemoryRecord>): string {
if (memories.length === 0) return "";
const lines: string[] = [];
let totalChars = 0;
for (const mem of memories) {
const scopeTag =
mem.scope !== "global"
? ` [${mem.scope}${mem.project ? `:${mem.project}` : ""}]`
: "";
const line = `${mem.content} [${mem.category}${mem.tags.length > 0 ? `, tags: ${mem.tags.join(", ")}` : ""}${scopeTag}]`;
if (totalChars + line.length > MAX_INJECTED_CONTEXT_CHARS) break;
lines.push(line);
totalChars += line.length;
}
if (lines.length === 0) return "";
return [
`[Persistent Memory — ${lines.length} relevant memories]`,
`You have the following knowledge about this user from previous conversations:`,
MEMORY_SEPARATOR + lines.join(MEMORY_SEPARATOR),
``,
`Use this knowledge naturally. Do not mention the memory system unless asked.`,
].join("\n");
}
export async function promptPreprocessor(
ctl: PluginController,
userMessage: string,
): Promise<string> {
const cfg = readConfig(ctl);
if (!cfg.autoInject) return userMessage;
if (userMessage.length < 10) return userMessage;
try {
const { engine, db } = await getSharedInstances(cfg.storagePath);
const result = engine.retrieve(
userMessage,
cfg.contextCount,
cfg.decayDays,
false,
);
let allMemories: Array<ScoredMemory | MemoryRecord> = [...result.memories];
if (cfg.activeProject) {
const projectMems = db.getByProject(cfg.activeProject, cfg.contextCount);
const existingIds = new Set(result.memories.map((m) => m.id));
for (const pm of projectMems) {
if (!existingIds.has(pm.id)) allMemories.push(pm);
}
allMemories = allMemories.slice(0, cfg.contextCount);
}
if (cfg.enableAI && userMessage.length >= 30) {
const existingSummary = result.memories.map((m) => m.content).join("; ");
extractFacts(userMessage, existingSummary)
.then((facts) => {
for (const fact of facts) {
try {
const id = db.store(
fact.content,
fact.category,
fact.tags,
fact.confidence,
"ai-extracted",
);
engine.indexMemory(id, fact.content, fact.tags, fact.category);
} catch {
}
}
})
.catch(() => {
});
}
if (allMemories.length === 0) return userMessage;
const context = formatMemories(allMemories);
if (!context) return userMessage;
return `${context}\n\n---\n\n${userMessage}`;
} catch {
return userMessage;
}
}