Skip to content

Commit 6905221

Browse files
committed
feat: Add context-aware memory enhancements
Adds provider-agnostic enhancements to memory system to support advanced features like token budgeting, message editing, caching, and priority-based selection. ## New Features ### Enhanced Message Metadata (Optional) - status: 'active' | 'archived' | 'deleted' for filtering - importance: 0-1 score for priority-based selection - tokenCount: Provider-calculated token count - cacheControl: Cache configuration with expiry - version: Message versioning for edit tracking - editedFrom: Original message ID for edit history ### Context Window Management - ContextWindowConfig: maxTokens, strategy, retainRecentMessages - Strategies: fifo, priority-based, token-aware - Token budget-aware message retrieval ### New Storage Methods (Optional) - updateMessage: Edit existing messages - getMessageHistory: Get message version history - getThreadTokenCount: Calculate total tokens - getMessagesByTokenBudget: Retrieve within token limit - getCachedMessages: Get messages with active cache - invalidateCache: Expire cached messages - getMessagesByStatus: Filter by status - getMessagesByImportance: Filter by importance score ### Memory Class Enhancements - updateMessage: Edit messages with versioning - getMessageVersions: Get edit history - getThreadTokenCount: Get token usage - getMessagesWithinBudget: Token-aware retrieval - getCachedMessages: Cache-aware retrieval - invalidateCache: Cache management - getMessagesByStatus: Status filtering - getMessagesByImportance: Priority filtering ## Design Principles - Backward compatible: All new fields/methods are optional - Provider agnostic: No specific API dependencies - Graceful degradation: Falls back when storage doesn't support features - Type safe: Full TypeScript support with proper types ## Implementation - All optional methods implemented in InMemoryStorage - Memory class checks for method availability before calling - New types exported: CacheControl, ContextWindowConfig - All existing tests pass (10/10)
1 parent a894fbb commit 6905221

File tree

6 files changed

+437
-8
lines changed

6 files changed

+437
-8
lines changed

src/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@ export { ReusableReadableStream } from "./lib/reusable-stream.js";
1313
// Memory system exports
1414
export { Memory, InMemoryStorage } from "./lib/memory/index.js";
1515
export type {
16-
MemoryStorage,
16+
CacheControl,
17+
ContextWindowConfig,
18+
GetMessagesOptions,
1719
MemoryConfig,
18-
Thread,
19-
Resource,
2020
MemoryMessage,
21-
ThreadWorkingMemory,
21+
MemoryStorage,
22+
Resource,
2223
ResourceWorkingMemory,
23-
WorkingMemoryData,
2424
SerializedMemoryState,
2525
SerializedThreadState,
26-
GetMessagesOptions,
26+
Thread,
27+
ThreadWorkingMemory,
28+
WorkingMemoryData,
2729
} from "./lib/memory/index.js";
2830
// #endregion
2931
export * from "./sdk/sdk.js";

src/lib/memory/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type { MemoryStorage } from "./storage/interface.js";
1212

1313
// Types
1414
export type {
15+
CacheControl,
16+
ContextWindowConfig,
1517
GetMessagesOptions,
1618
MemoryConfig,
1719
MemoryMessage,

src/lib/memory/memory.ts

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ import type {
1616
WorkingMemoryData,
1717
} from "./types.js";
1818

19+
/**
20+
* Resolved configuration with all defaults applied
21+
*/
22+
type ResolvedMemoryConfig = Required<Pick<MemoryConfig, 'maxHistoryMessages' | 'autoInject' | 'autoSave' | 'trackTokenUsage'>> & Pick<MemoryConfig, 'contextWindow'>;
23+
1924
/**
2025
* Memory class for managing conversation history, threads, and working memory
2126
*/
2227
export class Memory {
2328
private storage: MemoryStorage;
24-
private config: Required<MemoryConfig>;
29+
private config: ResolvedMemoryConfig;
2530

2631
/**
2732
* Create a new Memory instance
@@ -34,13 +39,15 @@ export class Memory {
3439
maxHistoryMessages: config.maxHistoryMessages ?? 10,
3540
autoInject: config.autoInject ?? true,
3641
autoSave: config.autoSave ?? true,
42+
...(config.contextWindow !== undefined && { contextWindow: config.contextWindow }),
43+
trackTokenUsage: config.trackTokenUsage ?? false,
3744
};
3845
}
3946

4047
/**
4148
* Get the current memory configuration
4249
*/
43-
getConfig(): Required<MemoryConfig> {
50+
getConfig(): ResolvedMemoryConfig {
4451
return { ...this.config };
4552
}
4653

@@ -259,6 +266,152 @@ export class Memory {
259266
return await this.storage.getResourceWorkingMemory(resourceId);
260267
}
261268

269+
// ===== Enhanced Message Operations =====
270+
271+
/**
272+
* Update an existing message
273+
* @param messageId The message ID
274+
* @param updates Partial message updates
275+
* @returns The updated message, or null if storage doesn't support updates
276+
*/
277+
async updateMessage(
278+
messageId: string,
279+
updates: Partial<Message>,
280+
): Promise<MemoryMessage | null> {
281+
if (!this.storage.updateMessage) {
282+
return null;
283+
}
284+
285+
return await this.storage.updateMessage(messageId, {
286+
message: updates as Message,
287+
} as Partial<MemoryMessage>);
288+
}
289+
290+
/**
291+
* Get edit history for a message
292+
* @param messageId The message ID
293+
* @returns Array of message versions, or empty if storage doesn't support history
294+
*/
295+
async getMessageVersions(messageId: string): Promise<MemoryMessage[]> {
296+
if (!this.storage.getMessageHistory) {
297+
return [];
298+
}
299+
300+
return await this.storage.getMessageHistory(messageId);
301+
}
302+
303+
// ===== Token-Aware Operations =====
304+
305+
/**
306+
* Get total token count for a thread
307+
* @param threadId The thread ID
308+
* @returns Token count, or 0 if storage doesn't support token counting
309+
*/
310+
async getThreadTokenCount(threadId: string): Promise<number> {
311+
if (!this.storage.getThreadTokenCount) {
312+
return 0;
313+
}
314+
315+
return await this.storage.getThreadTokenCount(threadId);
316+
}
317+
318+
/**
319+
* Get messages within a token budget
320+
* Uses contextWindow config if available, otherwise falls back to maxHistoryMessages
321+
* @param threadId The thread ID
322+
* @param maxTokens Optional max tokens (uses config if not provided)
323+
* @returns Array of messages within token budget
324+
*/
325+
async getMessagesWithinBudget(
326+
threadId: string,
327+
maxTokens?: number,
328+
): Promise<Message[]> {
329+
const tokenLimit =
330+
maxTokens || this.config.contextWindow?.maxTokens;
331+
332+
if (!tokenLimit || !this.storage.getMessagesByTokenBudget) {
333+
// Fall back to regular getRecentMessages
334+
return await this.getRecentMessages(threadId);
335+
}
336+
337+
const memoryMessages = await this.storage.getMessagesByTokenBudget(
338+
threadId,
339+
tokenLimit,
340+
);
341+
342+
return memoryMessages.map((mm) => mm.message);
343+
}
344+
345+
// ===== Cache Management =====
346+
347+
/**
348+
* Get messages with active cache
349+
* @param threadId The thread ID
350+
* @returns Array of cached messages, or empty if storage doesn't support caching
351+
*/
352+
async getCachedMessages(threadId: string): Promise<MemoryMessage[]> {
353+
if (!this.storage.getCachedMessages) {
354+
return [];
355+
}
356+
357+
return await this.storage.getCachedMessages(threadId);
358+
}
359+
360+
/**
361+
* Invalidate cache for messages
362+
* @param threadId The thread ID
363+
* @param beforeDate Optional date - invalidate cache before this date
364+
*/
365+
async invalidateCache(threadId: string, beforeDate?: Date): Promise<void> {
366+
if (!this.storage.invalidateCache) {
367+
return;
368+
}
369+
370+
await this.storage.invalidateCache(threadId, beforeDate);
371+
}
372+
373+
// ===== Filtered Retrieval =====
374+
375+
/**
376+
* Get messages by status
377+
* @param threadId The thread ID
378+
* @param status The status to filter by
379+
* @returns Array of messages with matching status
380+
*/
381+
async getMessagesByStatus(
382+
threadId: string,
383+
status: "active" | "archived" | "deleted",
384+
): Promise<MemoryMessage[]> {
385+
if (!this.storage.getMessagesByStatus) {
386+
// Fall back to getting all messages and filtering
387+
const all = await this.storage.getMessages(threadId);
388+
return all.filter((m) => m.status === status);
389+
}
390+
391+
return await this.storage.getMessagesByStatus(threadId, status);
392+
}
393+
394+
/**
395+
* Get messages by importance threshold
396+
* @param threadId The thread ID
397+
* @param minImportance Minimum importance score (0-1)
398+
* @returns Array of messages meeting importance threshold
399+
*/
400+
async getMessagesByImportance(
401+
threadId: string,
402+
minImportance: number,
403+
): Promise<MemoryMessage[]> {
404+
if (!this.storage.getMessagesByImportance) {
405+
// Fall back to getting all messages and filtering
406+
const all = await this.storage.getMessages(threadId);
407+
return all.filter(
408+
(m) => m.importance !== undefined && m.importance >= minImportance,
409+
);
410+
}
411+
412+
return await this.storage.getMessagesByImportance(threadId, minImportance);
413+
}
414+
262415
// ===== Utility Methods =====
263416

264417
/**

src/lib/memory/storage/in-memory.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export class InMemoryStorage implements MemoryStorage {
2828
private threadWorkingMemories: Map<string, ThreadWorkingMemory> = new Map();
2929
private resourceWorkingMemories: Map<string, ResourceWorkingMemory> =
3030
new Map();
31+
// Message version history tracking
32+
private messageHistory: Map<string, MemoryMessage[]> = new Map();
3133

3234
// ===== Thread Operations =====
3335

@@ -277,4 +279,139 @@ export class InMemoryStorage implements MemoryStorage {
277279
);
278280
}
279281
}
282+
283+
// ===== Enhanced Message Operations =====
284+
285+
async updateMessage(
286+
messageId: string,
287+
updates: Partial<MemoryMessage>,
288+
): Promise<MemoryMessage> {
289+
const existing = this.messages.get(messageId);
290+
if (!existing) {
291+
throw new Error(`Message ${messageId} not found`);
292+
}
293+
294+
// Save current version to history
295+
if (!this.messageHistory.has(messageId)) {
296+
this.messageHistory.set(messageId, []);
297+
}
298+
this.messageHistory.get(messageId)!.push({ ...existing });
299+
300+
// Create updated message with incremented version
301+
const updated: MemoryMessage = {
302+
...existing,
303+
...updates,
304+
version: (existing.version || 1) + 1,
305+
editedFrom: existing.editedFrom || existing.id,
306+
};
307+
308+
this.messages.set(messageId, updated);
309+
return updated;
310+
}
311+
312+
async getMessageHistory(messageId: string): Promise<MemoryMessage[]> {
313+
const history = this.messageHistory.get(messageId) || [];
314+
const current = this.messages.get(messageId);
315+
316+
// Return history + current (oldest to newest)
317+
return current ? [...history, current] : history;
318+
}
319+
320+
// ===== Token-Aware Operations =====
321+
322+
async getThreadTokenCount(threadId: string): Promise<number> {
323+
const messages = await this.getMessages(threadId);
324+
return messages.reduce((sum, msg) => sum + (msg.tokenCount || 0), 0);
325+
}
326+
327+
async getMessagesByTokenBudget(
328+
threadId: string,
329+
maxTokens: number,
330+
options: GetMessagesOptions = {},
331+
): Promise<MemoryMessage[]> {
332+
const messages = await this.getMessages(threadId, options);
333+
334+
// Filter to only active messages with token counts
335+
const activeMessages = messages.filter(
336+
(m) => (!m.status || m.status === "active") && m.tokenCount !== undefined,
337+
);
338+
339+
// Sort by importance (desc) then recency (desc)
340+
activeMessages.sort((a, b) => {
341+
const importanceA = a.importance || 0;
342+
const importanceB = b.importance || 0;
343+
if (importanceA !== importanceB) {
344+
return importanceB - importanceA; // Higher importance first
345+
}
346+
return b.createdAt.getTime() - a.createdAt.getTime(); // More recent first
347+
});
348+
349+
// Select messages within token budget
350+
const selected: MemoryMessage[] = [];
351+
let tokenCount = 0;
352+
353+
for (const message of activeMessages) {
354+
const messageTokens = message.tokenCount || 0;
355+
if (tokenCount + messageTokens <= maxTokens) {
356+
selected.push(message);
357+
tokenCount += messageTokens;
358+
}
359+
}
360+
361+
// Sort selected messages chronologically for conversation flow
362+
return selected.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
363+
}
364+
365+
// ===== Cache Management Operations =====
366+
367+
async getCachedMessages(threadId: string): Promise<MemoryMessage[]> {
368+
const messages = await this.getMessages(threadId);
369+
const now = new Date();
370+
371+
return messages.filter((msg) => {
372+
if (!msg.cacheControl?.enabled) return false;
373+
if (!msg.cacheControl.expiresAt) return true;
374+
return msg.cacheControl.expiresAt > now;
375+
});
376+
}
377+
378+
async invalidateCache(threadId: string, beforeDate?: Date): Promise<void> {
379+
const messages = await this.getMessages(threadId);
380+
const cutoff = beforeDate || new Date();
381+
382+
for (const message of messages) {
383+
if (message.cacheControl?.enabled) {
384+
// Update cache control to mark as expired
385+
const updated = {
386+
...message,
387+
cacheControl: {
388+
...message.cacheControl,
389+
enabled: false,
390+
expiresAt: cutoff,
391+
},
392+
};
393+
this.messages.set(message.id, updated);
394+
}
395+
}
396+
}
397+
398+
// ===== Filtered Retrieval Operations =====
399+
400+
async getMessagesByStatus(
401+
threadId: string,
402+
status: string,
403+
): Promise<MemoryMessage[]> {
404+
const messages = await this.getMessages(threadId);
405+
return messages.filter((m) => m.status === status);
406+
}
407+
408+
async getMessagesByImportance(
409+
threadId: string,
410+
minImportance: number,
411+
): Promise<MemoryMessage[]> {
412+
const messages = await this.getMessages(threadId);
413+
return messages.filter(
414+
(m) => m.importance !== undefined && m.importance >= minImportance,
415+
);
416+
}
280417
}

0 commit comments

Comments
 (0)