Add structured memory system for persistent agent context

Implements `StructuredMemoryManager` to track conversation summaries,
user preferences, and learned facts across sessions. Includes:
- Configurable storage limits with automatic enforcement
- Heuristic extraction of preferences and facts from messages
- Memory context injection into system prompts
- Full test coverage for all manager operations
This commit is contained in:
2026-05-20 22:18:33 +02:00
parent 9d4eb9a62a
commit fbb744ba6b
7 changed files with 1201 additions and 39 deletions
+57 -5
View File
@@ -25,6 +25,7 @@ import {
} from './types';
import { ConversationStateManager } from './conversation-state';
import { ErrorHandler } from './error-handler';
import { StructuredMemoryManager } from './structured-memory';
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
@@ -42,7 +43,12 @@ export class ChatView extends ItemView {
return this.newChatButtonClickHandler;
}
constructor(leaf: WorkspaceLeaf, settings: PluginSettings, vectorStore?: VaultVectorStore) {
constructor(
leaf: WorkspaceLeaf,
settings: PluginSettings,
vectorStore?: VaultVectorStore,
structuredMemoryManager?: StructuredMemoryManager
) {
super(leaf);
this.messages = [];
this.lastMessageEl = null;
@@ -70,6 +76,7 @@ export class ChatView extends ItemView {
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
this.conversationStateManager = new ConversationStateManager();
this.structuredMemoryManager = structuredMemoryManager;
this.workflowEngine = new WorkflowEngine(
this.app.vault,
this.app,
@@ -380,7 +387,6 @@ export class ChatView extends ItemView {
getTools(): OllamaTool[] {
const allTools: OllamaTool[] = [
{
type: 'function',
function: {
@@ -600,17 +606,30 @@ export class ChatView extends ItemView {
buildMessages(userMessageContent: string, tools?: OllamaTool[]): OllamaMessage[] {
const systemContent = getSystemPromptForMode(this.currentAgentMode);
const systemMessage: OllamaMessage = {
const messages: OllamaMessage[] = [];
// Inject structured memory as a preceding system message if available
if (this.structuredMemoryManager) {
const memoryContext = this.structuredMemoryManager.buildMemoryContext();
if (memoryContext) {
messages.push({
role: 'system',
content: memoryContext,
});
}
}
messages.push({
role: 'system',
content: systemContent,
};
});
const userMessage: OllamaMessage = {
role: 'user',
content: userMessageContent,
};
const messages: OllamaMessage[] = [systemMessage, userMessage];
messages.push(userMessage);
if (tools && tools.length > 0) {
messages.push({
@@ -1059,6 +1078,38 @@ export class ChatView extends ItemView {
content: fullResponse,
});
// Update structured memory with preferences, facts, and a conversation summary
if (this.structuredMemoryManager) {
const prefs = this.structuredMemoryManager.extractPreferencesFromMessage(userMessage);
for (const pref of prefs) {
this.structuredMemoryManager.addUserPreference(pref);
}
const facts = this.structuredMemoryManager.extractFactsFromMessage(userMessage);
for (const fact of facts) {
this.structuredMemoryManager.addLearnedFact(fact);
}
// Also extract from assistant response
const assistantFacts = this.structuredMemoryManager.extractFactsFromMessage(fullResponse);
for (const fact of assistantFacts) {
this.structuredMemoryManager.addLearnedFact(fact);
}
const { topic, keyPoints } = this.structuredMemoryManager.summarizeConversation(
this.conversationStateManager.getShortTermContext()
);
if (keyPoints.length > 0) {
this.structuredMemoryManager.addConversationSummary({
id: crypto.randomUUID?.() ?? `summary-${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
topic,
summary: keyPoints.join('; ').slice(0, 300),
keyPoints,
});
}
}
// Limit conversation history to prevent memory issues
if (this.messages.length > this.settings.maxMessageHistory) {
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
@@ -1100,6 +1151,7 @@ export class ChatView extends ItemView {
private noteContextBuilder: NoteContextBuilder;
private workflowEngine: WorkflowEngine;
private conversationStateManager: ConversationStateManager;
private structuredMemoryManager?: StructuredMemoryManager;
private vectorStore?: VaultVectorStore;
private modeSelectorEl: HTMLSelectElement | null = null;
+6
View File
@@ -38,4 +38,10 @@ export const DEFAULT_SETTINGS = {
targetFolder: '',
dryRun: false,
},
structuredMemoryConfig: {
enabled: true,
maxSummaries: 10,
maxPreferences: 20,
maxFacts: 50,
},
};
+152 -25
View File
@@ -5,10 +5,11 @@ import { SemanticCacheService } from './semantic-cache';
import { VaultVectorStore } from './vault-vector-store';
import { VaultIndexer } from './vault-indexer';
import { AutoTagger, AutoLinker } from './auto-organizer';
import { PluginSettings } from './types';
import { PluginSettings, StructuredMemoryData } from './types';
import { Logger } from './utils';
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
import { AgentMode } from './types';
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
@@ -16,6 +17,7 @@ export default class OllamaPlugin extends Plugin {
vaultVectorStore?: VaultVectorStore;
autoTagger?: AutoTagger;
autoLinker?: AutoLinker;
structuredMemoryManager?: StructuredMemoryManager;
private indexingAbortController?: AbortController;
private currentIndexingPromise?: Promise<void>;
@@ -30,7 +32,8 @@ export default class OllamaPlugin extends Plugin {
// Register the chat view
this.registerView(
'ollama-chat-view',
(leaf: WorkspaceLeaf) => new ChatView(leaf, this.settings, this.vaultVectorStore)
(leaf: WorkspaceLeaf) =>
new ChatView(leaf, this.settings, this.vaultVectorStore, this.structuredMemoryManager)
);
// Add a ribbon icon in the left sidebar
@@ -104,7 +107,16 @@ export default class OllamaPlugin extends Plugin {
},
});
// Add a settings tab
// Add a command to clear the structured memory
this.addCommand({
id: 'clear-structured-memory',
name: 'Clear Structured Memory',
callback: async () => {
this.structuredMemoryManager?.clearAll();
await this.saveSettings();
new Notice('Structured memory cleared.');
},
});
this.addSettingTab(new OllamaSettingTab(this.app, this));
// Initialize the semantic cache
@@ -138,17 +150,33 @@ export default class OllamaPlugin extends Plugin {
}
async loadSettings() {
const loadedSettings = ((await this.loadData()) ?? {}) as Partial<PluginSettings>;
const data = ((await this.loadData()) ?? {}) as Record<string, unknown>;
// Backward compatibility: old flat format vs new nested format
const loadedSettings = (data.settings ?? data) as Partial<PluginSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
const memoryData: StructuredMemoryData =
(data.structuredMemory as StructuredMemoryData | undefined) ??
createDefaultStructuredMemoryData();
this.structuredMemoryManager = new StructuredMemoryManager(
this.settings.structuredMemoryConfig,
memoryData
);
}
async saveSettings() {
await this.saveData(this.settings);
await this.saveData({
settings: this.settings,
structuredMemory:
this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(),
});
}
initializeAutoOrganizer(): void {
if (!this.autoTagger) {
this.autoTagger = new AutoTagger(this.app.vault, this.app,
this.autoTagger = new AutoTagger(
this.app.vault,
this.app,
this.settings.ollamaUrl,
this.settings.model,
this.settings.autoTagConfig
@@ -159,7 +187,12 @@ export default class OllamaPlugin extends Plugin {
if (!this.autoLinker) {
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
this.autoLinker = new AutoLinker(this.app.vault, vaultIndexer, this.settings.autoLinkConfig, this.settings.autoLinkConfig.targetFolder);
this.autoLinker = new AutoLinker(
this.app.vault,
vaultIndexer,
this.settings.autoLinkConfig,
this.settings.autoLinkConfig.targetFolder
);
} else {
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
}
@@ -711,24 +744,26 @@ class OllamaSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Normalize Tags')
.setDesc('Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")')
.setDesc(
'Normalize generated tags against existing vault tag vocabulary (e.g., prefer "machine-learning" over "machine learning")'
)
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoTagConfig.normalizeTags).onChange(async (value) => {
this.plugin.settings.autoTagConfig.normalizeTags = value;
await this.plugin.saveSettings();
})
toggle
.setValue(this.plugin.settings.autoTagConfig.normalizeTags)
.onChange(async (value) => {
this.plugin.settings.autoTagConfig.normalizeTags = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Target Folder (Auto-Tag)')
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
.addText((text) =>
text
.setValue(this.plugin.settings.autoTagConfig.targetFolder)
.onChange(async (value) => {
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
text.setValue(this.plugin.settings.autoTagConfig.targetFolder).onChange(async (value) => {
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
@@ -793,14 +828,14 @@ class OllamaSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Target Folder (Auto-Link)')
.setDesc('Only add related links to notes inside this folder path. Leave empty for all notes.')
.setDesc(
'Only add related links to notes inside this folder path. Leave empty for all notes.'
)
.addText((text) =>
text
.setValue(this.plugin.settings.autoLinkConfig.targetFolder)
.onChange(async (value) => {
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
text.setValue(this.plugin.settings.autoLinkConfig.targetFolder).onChange(async (value) => {
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
@@ -845,6 +880,98 @@ class OllamaSettingTab extends PluginSettingTab {
}
})
);
// Structured Memory Settings
containerEl.createEl('h3', { text: 'Structured Memory' });
containerEl.createEl('p', {
text: 'Persist conversation summaries, user preferences, and learned facts across sessions.',
});
new Setting(containerEl)
.setName('Enable Structured Memory')
.setDesc('Inject remembered context from past sessions into the system prompt.')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.structuredMemoryConfig.enabled)
.onChange(async (value) => {
this.plugin.settings.structuredMemoryConfig.enabled = value;
this.plugin.structuredMemoryManager!.updateConfig(
this.plugin.settings.structuredMemoryConfig
);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Max Conversation Summaries')
.setDesc('Maximum number of past conversation summaries to retain (default: 10).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxSummaries))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this.plugin.settings.structuredMemoryConfig.maxSummaries = parsed;
this.plugin.structuredMemoryManager!.updateConfig(
this.plugin.settings.structuredMemoryConfig
);
await this.plugin.saveSettings();
} else {
new Notice('Max summaries must be between 0 and 100.');
}
})
);
new Setting(containerEl)
.setName('Max User Preferences')
.setDesc('Maximum number of user preferences to retain (default: 20).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxPreferences))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 200) {
this.plugin.settings.structuredMemoryConfig.maxPreferences = parsed;
this.plugin.structuredMemoryManager!.updateConfig(
this.plugin.settings.structuredMemoryConfig
);
await this.plugin.saveSettings();
} else {
new Notice('Max preferences must be between 0 and 200.');
}
})
);
new Setting(containerEl)
.setName('Max Learned Facts')
.setDesc('Maximum number of learned facts to retain (default: 50).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.structuredMemoryConfig.maxFacts))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 500) {
this.plugin.settings.structuredMemoryConfig.maxFacts = parsed;
this.plugin.structuredMemoryManager!.updateConfig(
this.plugin.settings.structuredMemoryConfig
);
await this.plugin.saveSettings();
} else {
new Notice('Max facts must be between 0 and 500.');
}
})
);
new Setting(containerEl)
.setName('Clear Structured Memory')
.setDesc('Delete all stored conversation summaries, preferences, and facts.')
.addButton((button) =>
button.setButtonText('Clear Memory').onClick(async () => {
this.plugin.structuredMemoryManager!.clearAll();
await this.plugin.saveSettings();
new Notice('Structured memory cleared.');
})
);
}
hide() {
+305
View File
@@ -0,0 +1,305 @@
// src/structured-memory.ts
import {
StructuredMemoryData,
StructuredMemoryConfig,
ConversationSummary,
UserPreference,
LearnedFact,
OllamaMessage,
} from './types';
export function createDefaultStructuredMemoryData(): StructuredMemoryData {
return {
conversationSummaries: [],
userPreferences: [],
learnedFacts: [],
};
}
/**
* Manages the agent's structured memory: conversation summaries,
* user preferences, and learned facts. Persists in plugin data JSON.
*/
export class StructuredMemoryManager {
private data: StructuredMemoryData;
private config: StructuredMemoryConfig;
constructor(config: StructuredMemoryConfig, initialData?: StructuredMemoryData) {
this.config = config;
this.data = initialData ?? createDefaultStructuredMemoryData();
}
/**
* Replace the in-memory data (e.g., after loading from disk).
*/
loadData(data: StructuredMemoryData): void {
this.data = {
conversationSummaries: data.conversationSummaries ?? [],
userPreferences: data.userPreferences ?? [],
learnedFacts: data.learnedFacts ?? [],
};
}
/**
* Get a serializable copy of the current memory data.
*/
getData(): StructuredMemoryData {
return {
conversationSummaries: [...this.data.conversationSummaries],
userPreferences: [...this.data.userPreferences],
learnedFacts: [...this.data.learnedFacts],
};
}
/**
* Update the config (e.g., when settings change).
*/
updateConfig(config: StructuredMemoryConfig): void {
this.config = config;
this.enforceLimits();
}
/**
* Add a conversation summary, keeping the newest within maxSummaries.
*/
addConversationSummary(summary: ConversationSummary): void {
if (!this.config.enabled) return;
this.data.conversationSummaries.push(summary);
this.enforceLimits();
}
getConversationSummaries(): ConversationSummary[] {
return [...this.data.conversationSummaries];
}
clearConversationSummaries(): void {
this.data.conversationSummaries = [];
}
/**
* Add or update a user preference. If the key already exists, update it.
*/
addUserPreference(preference: UserPreference): void {
if (!this.config.enabled) return;
const existingIndex = this.data.userPreferences.findIndex((p) => p.key === preference.key);
if (existingIndex >= 0) {
this.data.userPreferences[existingIndex] = preference;
} else {
this.data.userPreferences.push(preference);
}
this.enforceLimits();
}
getUserPreference(key: string): UserPreference | undefined {
return this.data.userPreferences.find((p) => p.key === key);
}
getUserPreferences(): UserPreference[] {
return [...this.data.userPreferences];
}
removeUserPreference(key: string): void {
this.data.userPreferences = this.data.userPreferences.filter((p) => p.key !== key);
}
clearUserPreferences(): void {
this.data.userPreferences = [];
}
/**
* Add a learned fact, deduplicating by content (case-insensitive).
*/
addLearnedFact(fact: LearnedFact): void {
if (!this.config.enabled) return;
const normalizedContent = fact.content.trim().toLowerCase();
const existingIndex = this.data.learnedFacts.findIndex(
(f) => f.content.trim().toLowerCase() === normalizedContent
);
if (existingIndex >= 0) {
// Update confidence and timestamp if duplicate
this.data.learnedFacts[existingIndex] = {
...fact,
timestamp: Date.now(),
confidence: Math.max(fact.confidence, this.data.learnedFacts[existingIndex].confidence),
};
} else {
this.data.learnedFacts.push(fact);
}
this.enforceLimits();
}
getLearnedFacts(): LearnedFact[] {
return [...this.data.learnedFacts];
}
getLearnedFactsByCategory(category: LearnedFact['category']): LearnedFact[] {
return this.data.learnedFacts.filter((f) => f.category === category);
}
clearLearnedFacts(): void {
this.data.learnedFacts = [];
}
clearAll(): void {
this.data = createDefaultStructuredMemoryData();
}
/**
* Build a context string from stored memory for injection into the system prompt.
* Returns an empty string if memory is disabled or empty.
*/
buildMemoryContext(): string {
if (!this.config.enabled) return '';
const parts: string[] = [];
const summaries = this.data.conversationSummaries;
if (summaries.length > 0) {
parts.push('## Past Conversations');
for (const s of summaries.slice(-3)) {
parts.push(`- ${s.topic}: ${s.summary}`);
}
}
const preferences = this.data.userPreferences;
if (preferences.length > 0) {
parts.push('## User Preferences');
for (const p of preferences) {
parts.push(`- ${p.key}: ${p.value}`);
}
}
const facts = this.data.learnedFacts;
if (facts.length > 0) {
parts.push('## Learned Facts');
for (const f of facts.filter((fact) => fact.confidence >= 0.5).slice(-10)) {
parts.push(`- ${f.content}`);
}
}
if (parts.length === 0) return '';
return 'The following is remembered context from past sessions:\n' + parts.join('\n');
}
/**
* Extract likely user preferences from a message using lightweight regex heuristics.
*/
extractPreferencesFromMessage(message: string): UserPreference[] {
if (!this.config.enabled) return [];
const preferences: UserPreference[] = [];
const patterns = [
{ regex: /i(?:'d| would)?\s+prefer\s+(?:that\s+)?(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
{ regex: /i\s+(?:like|love|enjoy)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
{ regex: /i\s+(?:dislike|hate|avoid)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
{ regex: /(?:always|never)\s+(.+?)(?:\.|$)/i, keyPrefix: 'preference' },
{
regex: /my\s+(?:favorite|preferred)\s+(\w+)\s+(?:is|are)\s+(.+?)(?:\.|$)/i,
keyPrefix: 'favorite',
},
];
for (const { regex, keyPrefix } of patterns) {
const match = regex.exec(message);
if (match) {
const value = match[match.length - 1].trim();
const key =
value.length > 30
? `${keyPrefix}-${Date.now()}`
: `${keyPrefix}-${value.toLowerCase().replace(/\s+/g, '-')}`;
preferences.push({
key,
value,
timestamp: Date.now(),
source: 'inferred',
});
}
}
return preferences;
}
/**
* Extract likely facts from a message using lightweight regex heuristics.
*/
extractFactsFromMessage(message: string): LearnedFact[] {
if (!this.config.enabled) return [];
const facts: LearnedFact[] = [];
// Vault structure patterns
const folderPattern = /(\/[^\s]+\/(?:[^\s/]+\/)*)/g;
const folderMatches = message.matchAll(folderPattern);
for (const match of folderMatches) {
facts.push({
id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
content: `The vault contains a folder at ${match[1]}.`,
category: 'vault_structure',
confidence: 0.6,
});
}
// Topic patterns ("X is a Y")
const topicPattern = /(\w+(?:\s+\w+){0,5})\s+is\s+(?:a|an|the)\s+(.+?)(?:\.|$)/gi;
const topicMatches = message.matchAll(topicPattern);
for (const match of topicMatches) {
const subject = match[1].trim();
const predicate = match[2].trim();
if (subject.length > 2 && predicate.length > 2) {
facts.push({
id: crypto.randomUUID?.() ?? `fact-${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
content: `${subject} is ${predicate}.`,
category: 'topic',
confidence: 0.5,
});
}
}
return facts;
}
/**
* Generate a simple topic string from a conversation by looking at the first user message.
*/
summarizeConversation(messages: OllamaMessage[]): { topic: string; keyPoints: string[] } {
const firstUser = messages.find((m) => m.role === 'user');
const topic = firstUser
? firstUser.content.slice(0, 60).replace(/\n/g, ' ')
: 'Untitled conversation';
const keyPoints: string[] = [];
for (const msg of messages) {
if (msg.role === 'assistant' && msg.content) {
const sentences = msg.content
.split(/[.!?]+/)
.map((s) => s.trim())
.filter((s) => s.length > 10 && s.length < 120);
keyPoints.push(...sentences.slice(0, 2));
}
if (keyPoints.length >= 3) break;
}
return { topic, keyPoints };
}
private enforceLimits(): void {
if (this.data.conversationSummaries.length > this.config.maxSummaries) {
this.data.conversationSummaries = this.data.conversationSummaries.slice(
-this.config.maxSummaries
);
}
if (this.data.userPreferences.length > this.config.maxPreferences) {
// Keep most recently updated preferences
const sorted = [...this.data.userPreferences].sort((a, b) => b.timestamp - a.timestamp);
this.data.userPreferences = sorted.slice(0, this.config.maxPreferences);
}
if (this.data.learnedFacts.length > this.config.maxFacts) {
// Keep highest-confidence facts
const sorted = [...this.data.learnedFacts].sort((a, b) => b.confidence - a.confidence);
this.data.learnedFacts = sorted.slice(0, this.config.maxFacts);
}
}
}
+41
View File
@@ -203,6 +203,46 @@ export interface DependencyGraph {
export type AgentMode = 'ask' | 'edit' | 'organize' | 'research' | 'workflow';
// ============================================================
// Structured Memory
// ============================================================
export interface ConversationSummary {
id: string;
timestamp: number;
topic: string;
summary: string;
keyPoints: string[];
}
export interface UserPreference {
key: string;
value: string;
timestamp: number;
source: 'explicit' | 'inferred';
}
export interface LearnedFact {
id: string;
timestamp: number;
content: string;
category: 'vault_structure' | 'user_workflow' | 'topic' | 'general';
confidence: number;
}
export interface StructuredMemoryData {
conversationSummaries: ConversationSummary[];
userPreferences: UserPreference[];
learnedFacts: LearnedFact[];
}
export interface StructuredMemoryConfig {
enabled: boolean;
maxSummaries: number;
maxPreferences: number;
maxFacts: number;
}
// ============================================================
// Plugin Configuration
// ============================================================
@@ -250,6 +290,7 @@ export interface PluginSettings {
targetFolder: string;
dryRun: boolean;
};
structuredMemoryConfig: StructuredMemoryConfig;
}
// ============================================================
+35 -9
View File
@@ -59,16 +59,22 @@ const mockSettings: PluginSettings = {
minNoteLength: 50,
maxNoteLength: 8000,
tagPromptTemplate: 'Tags: {{content}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
dryRun: false,
targetFolder: '',
normalizeTags: true,
},
autoLinkConfig: {
enabled: false,
maxLinksPerNote: 3,
similarityThreshold: 0.6,
targetFolder: '',
dryRun: false,
targetFolder: '',
dryRun: false,
},
structuredMemoryConfig: {
enabled: true,
maxSummaries: 10,
maxPreferences: 20,
maxFacts: 50,
},
};
@@ -354,7 +360,17 @@ describe('ChatView', () => {
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest.spyOn(view["noteContextBuilder"], "buildContext").mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [] });
const searchSpy = jest
.spyOn(view['noteContextBuilder'], 'buildContext')
.mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
@@ -363,7 +379,7 @@ describe('ChatView', () => {
await (view as any).handleUserInput('search query');
expect(searchSpy).toHaveBeenCalledWith("search query", 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(chatSpy).toHaveBeenCalled();
});
@@ -799,7 +815,17 @@ describe('ChatView', () => {
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest.spyOn(view["noteContextBuilder"], "buildContext").mockResolvedValue({ explicitMentions: [], openNote: undefined, selectedText: undefined, backlinks: [], outlinks: [], relatedNotes: [], searchResults: [] });
const searchSpy = jest
.spyOn(view['noteContextBuilder'], 'buildContext')
.mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
@@ -808,7 +834,7 @@ describe('ChatView', () => {
await (view as any).handleUserInput('search query');
expect(searchSpy).toHaveBeenCalledWith("search query", 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(searchSpy).toHaveBeenCalledWith('search query', 3, expect.any(Object)); // Should use DEFAULT_VAULT_SEARCH_LIMIT
expect(chatSpy).toHaveBeenCalled();
});
+605
View File
@@ -0,0 +1,605 @@
import {
StructuredMemoryManager,
createDefaultStructuredMemoryData,
} from '../src/structured-memory';
import type {
StructuredMemoryConfig,
ConversationSummary,
UserPreference,
LearnedFact,
OllamaMessage,
} from '../src/types';
describe('createDefaultStructuredMemoryData', () => {
it('should return empty arrays for all memory types', () => {
const data = createDefaultStructuredMemoryData();
expect(data.conversationSummaries).toEqual([]);
expect(data.userPreferences).toEqual([]);
expect(data.learnedFacts).toEqual([]);
});
});
describe('StructuredMemoryManager', () => {
const defaultConfig: StructuredMemoryConfig = {
enabled: true,
maxSummaries: 3,
maxPreferences: 3,
maxFacts: 3,
};
let manager: StructuredMemoryManager;
beforeEach(() => {
manager = new StructuredMemoryManager(defaultConfig);
});
describe('constructor', () => {
it('should initialize with empty data when no initial data provided', () => {
expect(manager.getConversationSummaries()).toEqual([]);
expect(manager.getUserPreferences()).toEqual([]);
expect(manager.getLearnedFacts()).toEqual([]);
});
it('should initialize with provided data', () => {
const initialData = createDefaultStructuredMemoryData();
initialData.userPreferences.push({
key: 'theme',
value: 'dark',
timestamp: Date.now(),
source: 'explicit',
});
const m = new StructuredMemoryManager(defaultConfig, initialData);
expect(m.getUserPreferences()).toHaveLength(1);
});
});
describe('loadData and getData', () => {
it('should load and return data round-trip', () => {
const summary: ConversationSummary = {
id: 's1',
timestamp: 1000,
topic: 'Test',
summary: 'A test summary',
keyPoints: ['point1'],
};
manager.addConversationSummary(summary);
const loaded = manager.getData();
expect(loaded.conversationSummaries).toHaveLength(1);
const newManager = new StructuredMemoryManager(defaultConfig);
newManager.loadData(loaded);
expect(newManager.getConversationSummaries()).toHaveLength(1);
});
});
describe('updateConfig', () => {
it('should enforce new limits after config update', () => {
for (let i = 0; i < 5; i++) {
manager.addConversationSummary({
id: `s${i}`,
timestamp: i,
topic: `Topic ${i}`,
summary: `Summary ${i}`,
keyPoints: [`point ${i}`],
});
}
// Already limited to default max of 3
expect(manager.getConversationSummaries()).toHaveLength(3);
manager.updateConfig({ ...defaultConfig, maxSummaries: 2 });
expect(manager.getConversationSummaries()).toHaveLength(2);
});
it('should disable writes when enabled becomes false', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'Test',
summary: 'Test',
keyPoints: ['test'],
});
expect(manager.getConversationSummaries()).toHaveLength(0);
});
});
describe('addConversationSummary', () => {
it('should add a summary', () => {
const summary: ConversationSummary = {
id: 's1',
timestamp: 1,
topic: 'Topic',
summary: 'Summary',
keyPoints: ['k1'],
};
manager.addConversationSummary(summary);
expect(manager.getConversationSummaries()).toContainEqual(summary);
});
it('should enforce maxSummaries limit keeping newest', () => {
for (let i = 0; i < 5; i++) {
manager.addConversationSummary({
id: `s${i}`,
timestamp: i,
topic: `Topic ${i}`,
summary: `Summary ${i}`,
keyPoints: [`point ${i}`],
});
}
const summaries = manager.getConversationSummaries();
expect(summaries).toHaveLength(3);
expect(summaries[0].id).toBe('s2');
expect(summaries[2].id).toBe('s4');
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'Topic',
summary: 'Summary',
keyPoints: ['k1'],
});
expect(manager.getConversationSummaries()).toHaveLength(0);
});
});
describe('addUserPreference', () => {
it('should add a preference', () => {
const pref: UserPreference = {
key: 'theme',
value: 'dark',
timestamp: 1,
source: 'explicit',
};
manager.addUserPreference(pref);
expect(manager.getUserPreferences()).toContainEqual(pref);
});
it('should update existing preference by key', () => {
manager.addUserPreference({
key: 'theme',
value: 'dark',
timestamp: 1,
source: 'explicit',
});
manager.addUserPreference({
key: 'theme',
value: 'light',
timestamp: 2,
source: 'explicit',
});
const prefs = manager.getUserPreferences();
expect(prefs).toHaveLength(1);
expect(prefs[0].value).toBe('light');
});
it('should enforce maxPreferences keeping most recent', () => {
for (let i = 0; i < 5; i++) {
manager.addUserPreference({
key: `pref-${i}`,
value: `value-${i}`,
timestamp: i,
source: 'explicit',
});
}
const prefs = manager.getUserPreferences();
expect(prefs).toHaveLength(3);
// Most recent 3 (timestamps 2, 3, 4)
expect(prefs.map((p) => p.timestamp)).toEqual([4, 3, 2]);
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.addUserPreference({
key: 'theme',
value: 'dark',
timestamp: 1,
source: 'explicit',
});
expect(manager.getUserPreferences()).toHaveLength(0);
});
});
describe('addLearnedFact', () => {
it('should add a fact', () => {
const fact: LearnedFact = {
id: 'f1',
timestamp: 1,
content: 'The sky is blue.',
category: 'general',
confidence: 0.9,
};
manager.addLearnedFact(fact);
expect(manager.getLearnedFacts()).toContainEqual(fact);
});
it('should deduplicate facts by content (case-insensitive)', () => {
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'The sky is blue.',
category: 'general',
confidence: 0.5,
});
manager.addLearnedFact({
id: 'f2',
timestamp: 2,
content: ' the sky is blue. ',
category: 'topic',
confidence: 0.8,
});
const facts = manager.getLearnedFacts();
expect(facts).toHaveLength(1);
expect(facts[0].confidence).toBe(0.8);
expect(facts[0].category).toBe('topic');
});
it('should enforce maxFacts keeping highest confidence', () => {
for (let i = 0; i < 5; i++) {
manager.addLearnedFact({
id: `f${i}`,
timestamp: i,
content: `Fact ${i}`,
category: 'general',
confidence: 0.1 * i,
});
}
const facts = manager.getLearnedFacts();
expect(facts).toHaveLength(3);
// Highest confidence facts (0.4, 0.3, 0.2)
expect(facts.map((f) => f.confidence)).toEqual([0.4, expect.closeTo(0.3, 10), 0.2]);
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'Fact',
category: 'general',
confidence: 0.9,
});
expect(manager.getLearnedFacts()).toHaveLength(0);
});
});
describe('getUserPreference', () => {
it('should return preference by key', () => {
manager.addUserPreference({
key: 'theme',
value: 'dark',
timestamp: 1,
source: 'explicit',
});
expect(manager.getUserPreference('theme')?.value).toBe('dark');
});
it('should return undefined for missing key', () => {
expect(manager.getUserPreference('missing')).toBeUndefined();
});
});
describe('getLearnedFactsByCategory', () => {
it('should filter facts by category', () => {
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'Vault has a /projects folder.',
category: 'vault_structure',
confidence: 0.8,
});
manager.addLearnedFact({
id: 'f2',
timestamp: 2,
content: 'User likes markdown.',
category: 'general',
confidence: 0.7,
});
expect(manager.getLearnedFactsByCategory('vault_structure')).toHaveLength(1);
expect(manager.getLearnedFactsByCategory('general')).toHaveLength(1);
expect(manager.getLearnedFactsByCategory('topic')).toHaveLength(0);
});
});
describe('clear methods', () => {
it('should clear conversation summaries', () => {
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'T',
summary: 'S',
keyPoints: ['k'],
});
manager.clearConversationSummaries();
expect(manager.getConversationSummaries()).toHaveLength(0);
});
it('should clear user preferences', () => {
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
manager.clearUserPreferences();
expect(manager.getUserPreferences()).toHaveLength(0);
});
it('should clear learned facts', () => {
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'C',
category: 'general',
confidence: 0.5,
});
manager.clearLearnedFacts();
expect(manager.getLearnedFacts()).toHaveLength(0);
});
it('should clear all memory', () => {
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'T',
summary: 'S',
keyPoints: ['k'],
});
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'C',
category: 'general',
confidence: 0.5,
});
manager.clearAll();
expect(manager.getConversationSummaries()).toHaveLength(0);
expect(manager.getUserPreferences()).toHaveLength(0);
expect(manager.getLearnedFacts()).toHaveLength(0);
});
});
describe('buildMemoryContext', () => {
it('should return empty string when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
expect(manager.buildMemoryContext()).toBe('');
});
it('should return empty string when no memory exists', () => {
expect(manager.buildMemoryContext()).toBe('');
});
it('should include conversation summaries', () => {
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'Test Topic',
summary: 'We discussed testing.',
keyPoints: ['testing is good'],
});
const ctx = manager.buildMemoryContext();
expect(ctx).toContain('Past Conversations');
expect(ctx).toContain('Test Topic');
expect(ctx).toContain('We discussed testing.');
});
it('should include user preferences', () => {
manager.addUserPreference({
key: 'theme',
value: 'dark',
timestamp: 1,
source: 'explicit',
});
const ctx = manager.buildMemoryContext();
expect(ctx).toContain('User Preferences');
expect(ctx).toContain('theme: dark');
});
it('should include learned facts above confidence threshold', () => {
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'Vault uses folders.',
category: 'vault_structure',
confidence: 0.6,
});
manager.addLearnedFact({
id: 'f2',
timestamp: 2,
content: 'Low confidence fact.',
category: 'general',
confidence: 0.3,
});
const ctx = manager.buildMemoryContext();
expect(ctx).toContain('Learned Facts');
expect(ctx).toContain('Vault uses folders.');
expect(ctx).not.toContain('Low confidence fact.');
});
it('should combine all sections', () => {
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'T',
summary: 'S',
keyPoints: ['k'],
});
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'Fact',
category: 'general',
confidence: 0.9,
});
const ctx = manager.buildMemoryContext();
expect(ctx).toContain('Past Conversations');
expect(ctx).toContain('User Preferences');
expect(ctx).toContain('Learned Facts');
});
it('should limit summaries to last 3', () => {
for (let i = 0; i < 5; i++) {
manager.addConversationSummary({
id: `s${i}`,
timestamp: i,
topic: `Topic ${i}`,
summary: `Summary ${i}`,
keyPoints: [`k${i}`],
});
}
const ctx = manager.buildMemoryContext();
expect(ctx).toContain('Topic 2');
expect(ctx).toContain('Topic 4');
expect(ctx).not.toContain('Topic 0');
});
it('should limit facts to last 10', () => {
for (let i = 0; i < 15; i++) {
manager.addLearnedFact({
id: `f${i}`,
timestamp: i,
content: `Fact ${i}`,
category: 'general',
confidence: 0.9,
});
}
const ctx = manager.buildMemoryContext();
const factMatches = ctx.match(/Fact \d+/g) ?? [];
expect(factMatches.length).toBeLessThanOrEqual(10);
});
});
describe('extractPreferencesFromMessage', () => {
it('should extract "I prefer" statements', () => {
const prefs = manager.extractPreferencesFromMessage('I prefer dark mode.');
expect(prefs.length).toBeGreaterThanOrEqual(1);
expect(prefs[0].value).toContain('dark mode');
expect(prefs[0].source).toBe('inferred');
});
it('should extract "I like" statements', () => {
const prefs = manager.extractPreferencesFromMessage('I like coffee in the morning.');
expect(prefs.length).toBeGreaterThanOrEqual(1);
expect(prefs[0].value).toContain('coffee');
});
it('should extract "my favorite X is Y" statements', () => {
const prefs = manager.extractPreferencesFromMessage('My favorite color is blue.');
expect(prefs.length).toBeGreaterThanOrEqual(1);
expect(prefs[0].value).toContain('blue');
});
it('should return empty array when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
const prefs = manager.extractPreferencesFromMessage('I like blue.');
expect(prefs).toEqual([]);
});
it('should return empty array for non-preference messages', () => {
const prefs = manager.extractPreferencesFromMessage('What is the weather?');
expect(prefs).toEqual([]);
});
});
describe('extractFactsFromMessage', () => {
it('should extract vault folder paths', () => {
const facts = manager.extractFactsFromMessage('Check the /projects/active/ folder.');
expect(facts.some((f) => f.content.includes('/projects/active/'))).toBe(true);
expect(facts.some((f) => f.category === 'vault_structure')).toBe(true);
});
it('should extract "X is a Y" topic facts', () => {
const facts = manager.extractFactsFromMessage('Obsidian is a note-taking app.');
expect(facts.some((f) => f.content.includes('Obsidian is'))).toBe(true);
expect(facts.some((f) => f.category === 'topic')).toBe(true);
});
it('should not extract short subjects', () => {
const facts = manager.extractFactsFromMessage('It is a thing.');
expect(facts).toEqual([]);
});
it('should return empty array when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
const facts = manager.extractFactsFromMessage('Obsidian is great.');
expect(facts).toEqual([]);
});
});
describe('summarizeConversation', () => {
it('should derive topic from first user message', () => {
const messages: OllamaMessage[] = [
{ role: 'user', content: 'Tell me about quantum physics please' },
{
role: 'assistant',
content: 'Quantum physics is fascinating. It deals with subatomic particles.',
},
];
const { topic, keyPoints } = manager.summarizeConversation(messages);
expect(topic).toContain('Tell me about quantum physics');
expect(keyPoints.length).toBeGreaterThan(0);
});
it('should fallback to Untitled when no user message', () => {
const messages: OllamaMessage[] = [{ role: 'assistant', content: 'Hello there.' }];
const { topic } = manager.summarizeConversation(messages);
expect(topic).toBe('Untitled conversation');
});
it('should limit key points to 3', () => {
const messages: OllamaMessage[] = [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
content: 'Point one. Point two. Point three. Point four. Point five.',
},
];
const { keyPoints } = manager.summarizeConversation(messages);
expect(keyPoints.length).toBeLessThanOrEqual(3);
});
it('should extract sentences between 10 and 120 chars', () => {
const messages: OllamaMessage[] = [
{ role: 'user', content: 'Hi' },
{ role: 'assistant', content: 'A. This is a reasonably sized sentence about topics.' },
];
const { keyPoints } = manager.summarizeConversation(messages);
expect(keyPoints.every((k) => k.length >= 10 && k.length < 120)).toBe(true);
});
});
describe('immutability', () => {
it('getConversationSummaries should return a copy', () => {
manager.addConversationSummary({
id: 's1',
timestamp: 1,
topic: 'T',
summary: 'S',
keyPoints: ['k'],
});
const summaries = manager.getConversationSummaries();
summaries.push({ id: 's2', timestamp: 2, topic: 'T2', summary: 'S2', keyPoints: ['k2'] });
expect(manager.getConversationSummaries()).toHaveLength(1);
});
it('getUserPreferences should return a copy', () => {
manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' });
const prefs = manager.getUserPreferences();
prefs.push({ key: 'k2', value: 'v2', timestamp: 2, source: 'explicit' });
expect(manager.getUserPreferences()).toHaveLength(1);
});
it('getLearnedFacts should return a copy', () => {
manager.addLearnedFact({
id: 'f1',
timestamp: 1,
content: 'C',
category: 'general',
confidence: 0.5,
});
const facts = manager.getLearnedFacts();
facts.push({ id: 'f2', timestamp: 2, content: 'C2', category: 'general', confidence: 0.5 });
expect(manager.getLearnedFacts()).toHaveLength(1);
});
});
});