diff --git a/src/services/memory/cache.js b/src/services/memory/cache.js new file mode 100644 index 0000000..4ba047a --- /dev/null +++ b/src/services/memory/cache.js @@ -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(); + }, + }; +} diff --git a/src/services/memory/embedder.js b/src/services/memory/embedder.js new file mode 100644 index 0000000..94b8aa8 --- /dev/null +++ b/src/services/memory/embedder.js @@ -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[] }} [options] + * @returns {Promise} + */ +export async function embedText(text, options = {}) { + if (typeof options.embedText === 'function') { + return options.embedText(text); + } + return fallbackEmbedding(text); +} diff --git a/src/services/memory/index.js b/src/services/memory/index.js new file mode 100644 index 0000000..df50bf5 --- /dev/null +++ b/src/services/memory/index.js @@ -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[] }} [options] - Optional ingest tuning. + * @returns {Promise} 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} 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; +} diff --git a/src/services/memory/ingestor.js b/src/services/memory/ingestor.js new file mode 100644 index 0000000..2217e29 --- /dev/null +++ b/src/services/memory/ingestor.js @@ -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); +} diff --git a/src/services/memory/pruner.js b/src/services/memory/pruner.js new file mode 100644 index 0000000..81af09e --- /dev/null +++ b/src/services/memory/pruner.js @@ -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); +} diff --git a/src/services/memory/retriever.js b/src/services/memory/retriever.js new file mode 100644 index 0000000..9f52787 --- /dev/null +++ b/src/services/memory/retriever.js @@ -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); +} diff --git a/src/services/memory/scheduler.js b/src/services/memory/scheduler.js new file mode 100644 index 0000000..a494565 --- /dev/null +++ b/src/services/memory/scheduler.js @@ -0,0 +1,16 @@ +/** + * @param {() => Promise | 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); + }, + }; +} diff --git a/src/services/memory/schema.js b/src/services/memory/schema.js new file mode 100644 index 0000000..1e5f10a --- /dev/null +++ b/src/services/memory/schema.js @@ -0,0 +1,55 @@ +/** + * @typedef {Object} MemoryEvent + * @property {string} agent_id + * @property {string} content + * @property {number} [timestamp] + * @property {string[]} [tags] + * @property {Record} [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} 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} 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 : {}, + }; +} diff --git a/src/services/memory/summarizer.js b/src/services/memory/summarizer.js new file mode 100644 index 0000000..a529448 --- /dev/null +++ b/src/services/memory/summarizer.js @@ -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; +}