src / memory / MemoryService.ts
const STOPWORDS = new Set([
"about", "after", "again", "also", "another", "answer", "because", "before",
"between", "could", "detail", "details", "discuss", "explain", "from", "have",
"how", "into", "more", "other", "please", "question", "should", "show", "some",
"that", "their", "there", "these", "this", "those", "what", "when", "where",
"which", "with", "would", "your",
]);
const NOISE_WORDS = new Set([
"code", "function", "class", "file", "data", "system", "user", "app",
"web", "api", "server", "client", "page", "view", "model", "config",
"type", "value", "key", "name", "result", "error", "message", "state",
]);
export interface MemoryEntry {
topic: string;
summary: string;
timestamp: number;
}
const MAX_TOPIC_CACHE = 256;
const MAX_ENTRIES_PER_TOPIC = 20;
const MAX_RETRIEVE_ENTRIES = 5;
const TOPIC_CACHE_MAX_AGE_MS = 3600_000; // 1 hour
export class MemoryService {
private static memory = new Map<string, MemoryEntry[]>();
private static topicCache = new Map<string, { topic: string; ts: number }>();
private static cacheOps = 0;
public static save(topic: string, summary: string): void {
const cleanedTopic = this.normalizeTopic(topic);
if (!cleanedTopic) return;
let entries = this.memory.get(cleanedTopic);
if (!entries) {
entries = [];
this.memory.set(cleanedTopic, entries);
}
entries.push({ topic: cleanedTopic, summary, timestamp: Date.now() });
if (entries.length > MAX_ENTRIES_PER_TOPIC) {
this.memory.set(cleanedTopic, entries.slice(-MAX_ENTRIES_PER_TOPIC));
}
this.pruneTopicCacheIfNeeded();
}
public static retrieve(topic: string): string {
const cleanedTopic = this.normalizeTopic(topic);
const entries = this.memory.get(cleanedTopic);
if (!entries?.length) return "";
return entries.slice(-MAX_RETRIEVE_ENTRIES).map((e) => `- ${e.summary}`).join("\n");
}
/**
* Extract a topic from a prompt for memory lookup.
*
* OPTIMIZATION: Uses a hash of the prompt as the cache key instead of the
* raw prompt string. Raw prompts can be long and contain unicode, causing
* excessive memory pressure in the topicCache Map. Hashing reduces key size
* from O(n) to O(1) (32-char hex string) while preserving cache hit rate.
*/
public static extractTopic(prompt: string): string {
const promptHash = stableHash(prompt);
const cached = this.topicCache.get(promptHash);
if (cached !== undefined && Date.now() - cached.ts < TOPIC_CACHE_MAX_AGE_MS) {
return cached.topic;
}
const words = prompt
.split(/[^a-zA-Z0-9_]+/)
.map((w) => w.toLowerCase())
.filter(
(w) => w.length >= 4 && !STOPWORDS.has(w) && !NOISE_WORDS.has(w),
);
const topic = words.length >= 2 ? words.slice(0, 4).join(" ") : "";
if (topic) {
this.topicCache.set(promptHash, { topic, ts: Date.now() });
this.cacheOps++;
this.pruneTopicCacheIfNeeded();
}
return topic;
}
private static normalizeTopic(topic: string): string {
return topic.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 80);
}
private static pruneTopicCacheIfNeeded(): void {
if (this.topicCache.size <= MAX_TOPIC_CACHE) return;
const now = Date.now();
for (const [key, entry] of this.topicCache) {
if (now - entry.ts > TOPIC_CACHE_MAX_AGE_MS) {
this.topicCache.delete(key);
}
}
if (this.topicCache.size <= MAX_TOPIC_CACHE) return;
const toRemove = this.topicCache.size - MAX_TOPIC_CACHE;
let removed = 0;
for (const key of this.topicCache.keys()) {
if (++removed >= toRemove) break;
this.topicCache.delete(key);
}
}
}
function stableHash(input: string): string {
let h = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
if (code > 0xFFFF) {
h = Math.imul(h ^ code, 16777619) >>> 0;
i++;
continue;
}
h = Math.imul(h ^ code, 16777619) >>> 0;
}
return h.toString(16);
}
src / memory / MemoryService.ts
const STOPWORDS = new Set([
"about", "after", "again", "also", "another", "answer", "because", "before",
"between", "could", "detail", "details", "discuss", "explain", "from", "have",
"how", "into", "more", "other", "please", "question", "should", "show", "some",
"that", "their", "there", "these", "this", "those", "what", "when", "where",
"which", "with", "would", "your",
]);
const NOISE_WORDS = new Set([
"code", "function", "class", "file", "data", "system", "user", "app",
"web", "api", "server", "client", "page", "view", "model", "config",
"type", "value", "key", "name", "result", "error", "message", "state",
]);
export interface MemoryEntry {
topic: string;
summary: string;
timestamp: number;
}
const MAX_TOPIC_CACHE = 256;
const MAX_ENTRIES_PER_TOPIC = 20;
const MAX_RETRIEVE_ENTRIES = 5;
const TOPIC_CACHE_MAX_AGE_MS = 3600_000; // 1 hour
export class MemoryService {
private static memory = new Map<string, MemoryEntry[]>();
private static topicCache = new Map<string, { topic: string; ts: number }>();
private static cacheOps = 0;
public static save(topic: string, summary: string): void {
const cleanedTopic = this.normalizeTopic(topic);
if (!cleanedTopic) return;
let entries = this.memory.get(cleanedTopic);
if (!entries) {
entries = [];
this.memory.set(cleanedTopic, entries);
}
entries.push({ topic: cleanedTopic, summary, timestamp: Date.now() });
if (entries.length > MAX_ENTRIES_PER_TOPIC) {
this.memory.set(cleanedTopic, entries.slice(-MAX_ENTRIES_PER_TOPIC));
}
this.pruneTopicCacheIfNeeded();
}
public static retrieve(topic: string): string {
const cleanedTopic = this.normalizeTopic(topic);
const entries = this.memory.get(cleanedTopic);
if (!entries?.length) return "";
return entries.slice(-MAX_RETRIEVE_ENTRIES).map((e) => `- ${e.summary}`).join("\n");
}
/**
* Extract a topic from a prompt for memory lookup.
*
* OPTIMIZATION: Uses a hash of the prompt as the cache key instead of the
* raw prompt string. Raw prompts can be long and contain unicode, causing
* excessive memory pressure in the topicCache Map. Hashing reduces key size
* from O(n) to O(1) (32-char hex string) while preserving cache hit rate.
*/
public static extractTopic(prompt: string): string {
const promptHash = stableHash(prompt);
const cached = this.topicCache.get(promptHash);
if (cached !== undefined && Date.now() - cached.ts < TOPIC_CACHE_MAX_AGE_MS) {
return cached.topic;
}
const words = prompt
.split(/[^a-zA-Z0-9_]+/)
.map((w) => w.toLowerCase())
.filter(
(w) => w.length >= 4 && !STOPWORDS.has(w) && !NOISE_WORDS.has(w),
);
const topic = words.length >= 2 ? words.slice(0, 4).join(" ") : "";
if (topic) {
this.topicCache.set(promptHash, { topic, ts: Date.now() });
this.cacheOps++;
this.pruneTopicCacheIfNeeded();
}
return topic;
}
private static normalizeTopic(topic: string): string {
return topic.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 80);
}
private static pruneTopicCacheIfNeeded(): void {
if (this.topicCache.size <= MAX_TOPIC_CACHE) return;
const now = Date.now();
for (const [key, entry] of this.topicCache) {
if (now - entry.ts > TOPIC_CACHE_MAX_AGE_MS) {
this.topicCache.delete(key);
}
}
if (this.topicCache.size <= MAX_TOPIC_CACHE) return;
const toRemove = this.topicCache.size - MAX_TOPIC_CACHE;
let removed = 0;
for (const key of this.topicCache.keys()) {
if (++removed >= toRemove) break;
this.topicCache.delete(key);
}
}
}
function stableHash(input: string): string {
let h = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
if (code > 0xFFFF) {
h = Math.imul(h ^ code, 16777619) >>> 0;
i++;
continue;
}
h = Math.imul(h ^ code, 16777619) >>> 0;
}
return h.toString(16);
}