Add modular memory service API scaffold

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 14:33:23 -05:00
1 parent 8055132f32
commit 9c2524540c
9 files changed
+311

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
/**
* @returns {{ get: (key: string) => unknown, set: (key: string, value: unknown) => void, delete: (key: string) => boolean, clear: () => void }}
*/
export function createMemoryCache() {
const store = new Map();
return {
get(key) {
return store.get(key);
},
set(key, value) {
store.set(key, value);
},
delete(key) {
return store.delete(key);
},
clear() {
store.clear();
},
};
}
+22
View File
@@ -0,0 +1,22 @@
/**
* @param {string} text
* @returns {number[]}
*/
function fallbackEmbedding(text) {
const chars = [...text];
if (chars.length === 0) return [0];
const mean = chars.reduce((sum, char) => sum + char.codePointAt(0), 0) / chars.length;
return [Number(mean.toFixed(4)), chars.length];
}
/**
* @param {string} text
* @param {{ embedText?: (value: string) => Promise<number[]> | number[] }} [options]
* @returns {Promise<number[]>}
*/
export async function embedText(text, options = {}) {
if (typeof options.embedText === 'function') {
return options.embedText(text);
}
return fallbackEmbedding(text);
}
+123
View File
@@ -0,0 +1,123 @@
import { createMemoryCache } from './cache.js';
import { embedText } from './embedder.js';
import { normalizeEvents } from './ingestor.js';
import { selectPrunableMemories } from './pruner.js';
import { filterAndRankMemories } from './retriever.js';
import { startScheduler } from './scheduler.js';
import { createMemoryRecord } from './schema.js';
import { summarizeEvents } from './summarizer.js';
const memoryStore = new Map();
const cache = createMemoryCache();
/**
* Ingests agent events and stores them as memory records.
*
* @param {import('./schema.js').MemoryEvent[]} events - Ordered list of raw events to persist.
* @param {{ maxSummaryLength?: number, embedText?: (value: string) => Promise<number[]> | number[] }} [options] - Optional ingest tuning.
* @returns {Promise<import('./schema.js').MemoryRecord[]>} Stored memory records with generated IDs/embeddings.
*/
export async function ingestEvents(events, options = {}) {
const normalizedEvents = normalizeEvents(events);
const records = [];
for (const event of normalizedEvents) {
const summary = summarizeEvents([event], options);
const embedding = await embedText(`${summary}\n${event.content}`, options);
const record = createMemoryRecord({
agent_id: event.agent_id,
content: event.content,
summary,
tags: event.tags,
metadata: event.metadata,
embedding,
created_at: event.timestamp,
updated_at: event.timestamp,
});
memoryStore.set(record.id, record);
records.push(record);
}
cache.clear();
return records;
}
/**
* Retrieves top-k memories for an agent query.
*
* @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number }} params - Retrieval filter and ranking inputs.
* @returns {Promise<import('./schema.js').MemoryRecord[]>} Sorted list of relevant memories.
*/
export async function getRelevantMemories(params) {
const cacheKey = JSON.stringify(params);
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const ranked = filterAndRankMemories([...memoryStore.values()], params);
cache.set(cacheKey, ranked);
return ranked;
}
/**
* Runs a memory pruning pass and removes unpinned stale records.
*
* @param {{ retentionMs?: number, now?: number, scheduleEveryMs?: number }} [options] - Prune configuration; if scheduleEveryMs is set, returns a scheduler handle.
* @returns {Promise<{ pruned: import('./schema.js').MemoryRecord[], scheduler?: { stop: () => void } }>} Pruned records and optional scheduler handle.
*/
export async function runPruneCycle(options = {}) {
const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30);
const prunable = selectPrunableMemories([...memoryStore.values()], {
retentionMs,
now: options.now,
});
for (const memory of prunable) {
memoryStore.delete(memory.id);
}
cache.clear();
const result = { pruned: prunable };
if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) {
result.scheduler = startScheduler(() => runPruneCycle({ retentionMs }), {
intervalMs: options.scheduleEveryMs,
});
}
return result;
}
/**
* Pins a memory to prevent pruning and boost retrieval ranking.
*
* @param {string} id - Memory record identifier.
* @returns {import('./schema.js').MemoryRecord | null} Updated memory record or null when not found.
*/
export function pinMemory(id) {
const memory = memoryStore.get(id);
if (!memory) return null;
const updated = { ...memory, pinned: true, updated_at: Date.now() };
memoryStore.set(id, updated);
cache.clear();
return updated;
}
/**
* Removes a pin from a memory so it can be pruned normally.
*
* @param {string} id - Memory record identifier.
* @returns {import('./schema.js').MemoryRecord | null} Updated memory record or null when not found.
*/
export function unpinMemory(id) {
const memory = memoryStore.get(id);
if (!memory) return null;
const updated = { ...memory, pinned: false, updated_at: Date.now() };
memoryStore.set(id, updated);
cache.clear();
return updated;
}
+9
View File
@@ -0,0 +1,9 @@
import { normalizeEvent } from './schema.js';
/**
* @param {import('./schema.js').MemoryEvent[]} events
* @returns {import('./schema.js').MemoryEvent[]}
*/
export function normalizeEvents(events) {
return events.map(normalizeEvent);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ retentionMs: number, now?: number }} options
* @returns {import('./schema.js').MemoryRecord[]}
*/
export function selectPrunableMemories(memories, options) {
const now = options.now ?? Date.now();
const cutoff = now - options.retentionMs;
return memories.filter((memory) => !memory.pinned && memory.updated_at < cutoff);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number }} params
* @returns {import('./schema.js').MemoryRecord[]}
*/
export function filterAndRankMemories(memories, params) {
const { agent_id, query = '', tags = [], timeframe, k = 10 } = params;
const loweredQuery = query.toLowerCase();
const filtered = memories.filter((memory) => {
if (memory.agent_id !== agent_id) return false;
const inTimeframe = !timeframe
|| ((timeframe.from == null || memory.created_at >= timeframe.from)
&& (timeframe.to == null || memory.created_at <= timeframe.to));
if (!inTimeframe) return false;
if (tags.length > 0) {
const memoryTags = new Set(memory.tags);
const matchesAllTags = tags.every((tag) => memoryTags.has(tag));
if (!matchesAllTags) return false;
}
return true;
});
return filtered
.map((memory) => {
const haystack = `${memory.summary} ${memory.content}`.toLowerCase();
const queryScore = loweredQuery && haystack.includes(loweredQuery) ? 1 : 0;
const pinBoost = memory.pinned ? 1 : 0;
const recencyScore = memory.updated_at;
return {
memory,
score: (queryScore * 10) + (pinBoost * 5) + recencyScore,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, k)
.map((entry) => entry.memory);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @param {() => Promise<unknown> | unknown} task
* @param {{ intervalMs: number }} options
* @returns {{ stop: () => void }}
*/
export function startScheduler(task, options) {
const handle = setInterval(() => {
void task();
}, options.intervalMs);
return {
stop() {
clearInterval(handle);
},
};
}
+55
View File
@@ -0,0 +1,55 @@
/**
* @typedef {Object} MemoryEvent
* @property {string} agent_id
* @property {string} content
* @property {number} [timestamp]
* @property {string[]} [tags]
* @property {Record<string, unknown>} [metadata]
*/
/**
* @typedef {Object} MemoryRecord
* @property {string} id
* @property {string} agent_id
* @property {string} summary
* @property {string} content
* @property {number} created_at
* @property {number} updated_at
* @property {string[]} tags
* @property {number[] | null} embedding
* @property {boolean} pinned
* @property {Record<string, unknown>} metadata
*/
/**
* @param {MemoryEvent} event
* @returns {MemoryEvent}
*/
export function normalizeEvent(event) {
return {
...event,
timestamp: Number.isFinite(event?.timestamp) ? event.timestamp : Date.now(),
tags: Array.isArray(event?.tags) ? event.tags : [],
metadata: event?.metadata && typeof event.metadata === 'object' ? event.metadata : {},
};
}
/**
* @param {Partial<MemoryRecord>} record
* @returns {MemoryRecord}
*/
export function createMemoryRecord(record) {
const now = Date.now();
return {
id: record.id || crypto.randomUUID(),
agent_id: record.agent_id || '',
summary: record.summary || '',
content: record.content || '',
created_at: Number.isFinite(record.created_at) ? record.created_at : now,
updated_at: Number.isFinite(record.updated_at) ? record.updated_at : now,
tags: Array.isArray(record.tags) ? record.tags : [],
embedding: Array.isArray(record.embedding) ? record.embedding : null,
pinned: Boolean(record.pinned),
metadata: record.metadata && typeof record.metadata === 'object' ? record.metadata : {},
};
}
+11
View File
@@ -0,0 +1,11 @@
/**
* @param {import('./schema.js').MemoryEvent[]} events
* @param {{ maxSummaryLength?: number }} [options]
* @returns {string}
*/
export function summarizeEvents(events, options = {}) {
const maxSummaryLength = options.maxSummaryLength ?? 280;
const joined = events.map((event) => event.content).join(' ').trim();
if (!joined) return '';
return joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
}