Project Files
src / memory / 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,
L0_MAX_CHARS,
L1_MAX_CHARS,
L1_ESSENTIAL_COUNT,
} from "./constants";
import {
buildProjectMemoryTags,
resolveProjectMemoryTarget,
} from "./projectMemory";
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") || "",
injectionCategories: (c.get("memoryInjectionCategories") || "")
.split(",")
.map((s: string) => s.trim().toLowerCase())
.filter(Boolean),
};
}
/** Format a single memory into a one-line string. */
function formatMemoryLine(mem: ScoredMemory | MemoryRecord): string {
const scopeTag =
mem.scope !== "global"
? ` [${mem.scope}${mem.project ? `:${mem.project}` : ""}]`
: "";
return `${mem.content} [${mem.category}${mem.tags.length > 0 ? `, tags: ${mem.tags.join(", ")}` : ""}${scopeTag}]`;
}
/**
* Build tiered memory context:
* - L0: Identity memories (always loaded, ~400 chars)
* - L1: Essential memories (always loaded, ~1600 chars)
* - L2: Query-relevant memories (on-demand)
*/
function buildTieredContext(
identityMems: MemoryRecord[],
essentialMems: MemoryRecord[],
queryMems: Array<ScoredMemory | MemoryRecord>,
): string {
const sections: string[] = [];
const usedIds = new Set<string>();
// L0: Identity layer
if (identityMems.length > 0) {
const l0Lines: string[] = [];
let l0Chars = 0;
for (const mem of identityMems) {
const line = mem.content;
if (l0Chars + line.length > L0_MAX_CHARS) break;
l0Lines.push(line);
l0Chars += line.length;
usedIds.add(mem.id);
}
if (l0Lines.length > 0) {
sections.push(`[User Identity]${MEMORY_SEPARATOR}${l0Lines.join(MEMORY_SEPARATOR)}`);
}
}
// L1: Essential layer
if (essentialMems.length > 0) {
const l1Lines: string[] = [];
let l1Chars = 0;
for (const mem of essentialMems) {
if (usedIds.has(mem.id)) continue;
const line = formatMemoryLine(mem);
if (l1Chars + line.length > L1_MAX_CHARS) break;
l1Lines.push(line);
l1Chars += line.length;
usedIds.add(mem.id);
}
if (l1Lines.length > 0) {
sections.push(`[Essential Knowledge]${MEMORY_SEPARATOR}${l1Lines.join(MEMORY_SEPARATOR)}`);
}
}
// L2: Query-relevant layer (dedup against L0/L1)
const remainingBudget = MAX_INJECTED_CONTEXT_CHARS - sections.join("\n").length;
if (queryMems.length > 0 && remainingBudget > 100) {
const l2Lines: string[] = [];
let l2Chars = 0;
for (const mem of queryMems) {
if (usedIds.has(mem.id)) continue;
const line = formatMemoryLine(mem);
if (l2Chars + line.length > remainingBudget) break;
l2Lines.push(line);
l2Chars += line.length;
usedIds.add(mem.id);
}
if (l2Lines.length > 0) {
sections.push(`[Relevant Context]${MEMORY_SEPARATOR}${l2Lines.join(MEMORY_SEPARATOR)}`);
}
}
if (sections.length === 0) return "";
const totalCount = usedIds.size;
return [
`[Persistent Memory — ${totalCount} memories loaded]`,
...sections,
``,
`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);
// L0: Identity memories (always loaded)
const identityMems = db.getIdentityMemories();
// L1: Essential memories (always loaded — most important/accessed)
const essentialMems = db.getEssentialMemories(L1_ESSENTIAL_COUNT);
// L2: Query-relevant memories (on-demand based on user message)
const result = await engine.retrieve(
userMessage,
cfg.contextCount,
cfg.decayDays,
false,
);
let queryMemories: 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)) queryMemories.push(pm);
}
}
// Apply category filter if configured
if (cfg.injectionCategories.length > 0) {
queryMemories = queryMemories.filter((m) =>
cfg.injectionCategories.includes(m.category.toLowerCase()),
);
}
queryMemories = queryMemories.slice(0, cfg.contextCount);
if (cfg.enableAI && userMessage.length >= 100) {
const existingSummary = result.memories.map((m) => m.content).join("; ");
extractFacts(userMessage, existingSummary)
.then((facts) => {
for (const fact of facts) {
try {
const target = resolveProjectMemoryTarget(cfg, {
forceActiveProject: true,
});
const normalizedTags =
target.scope === "project" && target.project
? buildProjectMemoryTags(fact.tags, target.project)
: fact.tags;
const id = db.store(
fact.content,
fact.category,
normalizedTags,
fact.confidence,
"ai-extracted",
null,
target.scope,
target.project,
);
engine.indexMemory(
id,
fact.content,
normalizedTags,
fact.category,
);
} catch {
}
}
})
.catch(() => {
});
}
// Build tiered context (L0 + L1 + L2)
const hasAnyMemories = identityMems.length > 0 || essentialMems.length > 0 || queryMemories.length > 0;
// Identity onboarding: only when NO memories exist at all (true first conversation)
if (!hasAnyMemories) {
return `[Memory System: No memories found. If the user hasn't introduced themselves yet, naturally ask their name, role, and preferred language early in the conversation, then store with Remember(content, "identity"). This makes future conversations personalized.]\n\n${userMessage}`;
}
const context = buildTieredContext(identityMems, essentialMems, queryMemories);
if (!context) return userMessage;
return `${context}\n\n---\n\n${userMessage}`;
} catch {
return userMessage;
}
}