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:
+57
-5
@@ -25,6 +25,7 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { ConversationStateManager } from './conversation-state';
|
import { ConversationStateManager } from './conversation-state';
|
||||||
import { ErrorHandler } from './error-handler';
|
import { ErrorHandler } from './error-handler';
|
||||||
|
import { StructuredMemoryManager } from './structured-memory';
|
||||||
|
|
||||||
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
||||||
|
|
||||||
@@ -42,7 +43,12 @@ export class ChatView extends ItemView {
|
|||||||
return this.newChatButtonClickHandler;
|
return this.newChatButtonClickHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, settings: PluginSettings, vectorStore?: VaultVectorStore) {
|
constructor(
|
||||||
|
leaf: WorkspaceLeaf,
|
||||||
|
settings: PluginSettings,
|
||||||
|
vectorStore?: VaultVectorStore,
|
||||||
|
structuredMemoryManager?: StructuredMemoryManager
|
||||||
|
) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.lastMessageEl = null;
|
this.lastMessageEl = null;
|
||||||
@@ -70,6 +76,7 @@ export class ChatView extends ItemView {
|
|||||||
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
||||||
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
||||||
this.conversationStateManager = new ConversationStateManager();
|
this.conversationStateManager = new ConversationStateManager();
|
||||||
|
this.structuredMemoryManager = structuredMemoryManager;
|
||||||
this.workflowEngine = new WorkflowEngine(
|
this.workflowEngine = new WorkflowEngine(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.app,
|
this.app,
|
||||||
@@ -380,7 +387,6 @@ export class ChatView extends ItemView {
|
|||||||
|
|
||||||
getTools(): OllamaTool[] {
|
getTools(): OllamaTool[] {
|
||||||
const allTools: OllamaTool[] = [
|
const allTools: OllamaTool[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
@@ -600,17 +606,30 @@ export class ChatView extends ItemView {
|
|||||||
|
|
||||||
buildMessages(userMessageContent: string, tools?: OllamaTool[]): OllamaMessage[] {
|
buildMessages(userMessageContent: string, tools?: OllamaTool[]): OllamaMessage[] {
|
||||||
const systemContent = getSystemPromptForMode(this.currentAgentMode);
|
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',
|
role: 'system',
|
||||||
content: systemContent,
|
content: systemContent,
|
||||||
};
|
});
|
||||||
|
|
||||||
const userMessage: OllamaMessage = {
|
const userMessage: OllamaMessage = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: userMessageContent,
|
content: userMessageContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages: OllamaMessage[] = [systemMessage, userMessage];
|
messages.push(userMessage);
|
||||||
|
|
||||||
if (tools && tools.length > 0) {
|
if (tools && tools.length > 0) {
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -1059,6 +1078,38 @@ export class ChatView extends ItemView {
|
|||||||
content: fullResponse,
|
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
|
// Limit conversation history to prevent memory issues
|
||||||
if (this.messages.length > this.settings.maxMessageHistory) {
|
if (this.messages.length > this.settings.maxMessageHistory) {
|
||||||
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
||||||
@@ -1100,6 +1151,7 @@ export class ChatView extends ItemView {
|
|||||||
private noteContextBuilder: NoteContextBuilder;
|
private noteContextBuilder: NoteContextBuilder;
|
||||||
private workflowEngine: WorkflowEngine;
|
private workflowEngine: WorkflowEngine;
|
||||||
private conversationStateManager: ConversationStateManager;
|
private conversationStateManager: ConversationStateManager;
|
||||||
|
private structuredMemoryManager?: StructuredMemoryManager;
|
||||||
private vectorStore?: VaultVectorStore;
|
private vectorStore?: VaultVectorStore;
|
||||||
|
|
||||||
private modeSelectorEl: HTMLSelectElement | null = null;
|
private modeSelectorEl: HTMLSelectElement | null = null;
|
||||||
|
|||||||
@@ -38,4 +38,10 @@ export const DEFAULT_SETTINGS = {
|
|||||||
targetFolder: '',
|
targetFolder: '',
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
},
|
},
|
||||||
|
structuredMemoryConfig: {
|
||||||
|
enabled: true,
|
||||||
|
maxSummaries: 10,
|
||||||
|
maxPreferences: 20,
|
||||||
|
maxFacts: 50,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+152
-25
@@ -5,10 +5,11 @@ import { SemanticCacheService } from './semantic-cache';
|
|||||||
import { VaultVectorStore } from './vault-vector-store';
|
import { VaultVectorStore } from './vault-vector-store';
|
||||||
import { VaultIndexer } from './vault-indexer';
|
import { VaultIndexer } from './vault-indexer';
|
||||||
import { AutoTagger, AutoLinker } from './auto-organizer';
|
import { AutoTagger, AutoLinker } from './auto-organizer';
|
||||||
import { PluginSettings } from './types';
|
import { PluginSettings, StructuredMemoryData } from './types';
|
||||||
import { Logger } from './utils';
|
import { Logger } from './utils';
|
||||||
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
|
||||||
import { AgentMode } from './types';
|
import { AgentMode } from './types';
|
||||||
|
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
|
||||||
|
|
||||||
export default class OllamaPlugin extends Plugin {
|
export default class OllamaPlugin extends Plugin {
|
||||||
settings: PluginSettings = DEFAULT_SETTINGS;
|
settings: PluginSettings = DEFAULT_SETTINGS;
|
||||||
@@ -16,6 +17,7 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
vaultVectorStore?: VaultVectorStore;
|
vaultVectorStore?: VaultVectorStore;
|
||||||
autoTagger?: AutoTagger;
|
autoTagger?: AutoTagger;
|
||||||
autoLinker?: AutoLinker;
|
autoLinker?: AutoLinker;
|
||||||
|
structuredMemoryManager?: StructuredMemoryManager;
|
||||||
private indexingAbortController?: AbortController;
|
private indexingAbortController?: AbortController;
|
||||||
private currentIndexingPromise?: Promise<void>;
|
private currentIndexingPromise?: Promise<void>;
|
||||||
|
|
||||||
@@ -30,7 +32,8 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
// Register the chat view
|
// Register the chat view
|
||||||
this.registerView(
|
this.registerView(
|
||||||
'ollama-chat-view',
|
'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
|
// 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));
|
this.addSettingTab(new OllamaSettingTab(this.app, this));
|
||||||
|
|
||||||
// Initialize the semantic cache
|
// Initialize the semantic cache
|
||||||
@@ -138,17 +150,33 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
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);
|
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() {
|
async saveSettings() {
|
||||||
await this.saveData(this.settings);
|
await this.saveData({
|
||||||
|
settings: this.settings,
|
||||||
|
structuredMemory:
|
||||||
|
this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeAutoOrganizer(): void {
|
initializeAutoOrganizer(): void {
|
||||||
if (!this.autoTagger) {
|
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.ollamaUrl,
|
||||||
this.settings.model,
|
this.settings.model,
|
||||||
this.settings.autoTagConfig
|
this.settings.autoTagConfig
|
||||||
@@ -159,7 +187,12 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
|
|
||||||
if (!this.autoLinker) {
|
if (!this.autoLinker) {
|
||||||
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
|
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 {
|
} else {
|
||||||
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
this.autoLinker.updateConfig(this.settings.autoLinkConfig);
|
||||||
}
|
}
|
||||||
@@ -711,24 +744,26 @@ class OllamaSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Normalize Tags')
|
.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) =>
|
.addToggle((toggle) =>
|
||||||
toggle.setValue(this.plugin.settings.autoTagConfig.normalizeTags).onChange(async (value) => {
|
toggle
|
||||||
this.plugin.settings.autoTagConfig.normalizeTags = value;
|
.setValue(this.plugin.settings.autoTagConfig.normalizeTags)
|
||||||
await this.plugin.saveSettings();
|
.onChange(async (value) => {
|
||||||
})
|
this.plugin.settings.autoTagConfig.normalizeTags = value;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Target Folder (Auto-Tag)')
|
.setName('Target Folder (Auto-Tag)')
|
||||||
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
|
.setDesc('Only auto-tag notes inside this folder path. Leave empty for all notes.')
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text
|
text.setValue(this.plugin.settings.autoTagConfig.targetFolder).onChange(async (value) => {
|
||||||
.setValue(this.plugin.settings.autoTagConfig.targetFolder)
|
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
|
||||||
.onChange(async (value) => {
|
await this.plugin.saveSettings();
|
||||||
this.plugin.settings.autoTagConfig.targetFolder = value.trim();
|
})
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
@@ -793,14 +828,14 @@ class OllamaSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Target Folder (Auto-Link)')
|
.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) =>
|
.addText((text) =>
|
||||||
text
|
text.setValue(this.plugin.settings.autoLinkConfig.targetFolder).onChange(async (value) => {
|
||||||
.setValue(this.plugin.settings.autoLinkConfig.targetFolder)
|
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
|
||||||
.onChange(async (value) => {
|
await this.plugin.saveSettings();
|
||||||
this.plugin.settings.autoLinkConfig.targetFolder = value.trim();
|
})
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
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() {
|
hide() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,46 @@ export interface DependencyGraph {
|
|||||||
|
|
||||||
export type AgentMode = 'ask' | 'edit' | 'organize' | 'research' | 'workflow';
|
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
|
// Plugin Configuration
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -250,6 +290,7 @@ export interface PluginSettings {
|
|||||||
targetFolder: string;
|
targetFolder: string;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
};
|
};
|
||||||
|
structuredMemoryConfig: StructuredMemoryConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+35
-9
@@ -59,16 +59,22 @@ const mockSettings: PluginSettings = {
|
|||||||
minNoteLength: 50,
|
minNoteLength: 50,
|
||||||
maxNoteLength: 8000,
|
maxNoteLength: 8000,
|
||||||
tagPromptTemplate: 'Tags: {{content}}',
|
tagPromptTemplate: 'Tags: {{content}}',
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
targetFolder: '',
|
targetFolder: '',
|
||||||
normalizeTags: true,
|
normalizeTags: true,
|
||||||
},
|
},
|
||||||
autoLinkConfig: {
|
autoLinkConfig: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
maxLinksPerNote: 3,
|
maxLinksPerNote: 3,
|
||||||
similarityThreshold: 0.6,
|
similarityThreshold: 0.6,
|
||||||
targetFolder: '',
|
targetFolder: '',
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
|
},
|
||||||
|
structuredMemoryConfig: {
|
||||||
|
enabled: true,
|
||||||
|
maxSummaries: 10,
|
||||||
|
maxPreferences: 20,
|
||||||
|
maxFacts: 50,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,7 +360,17 @@ describe('ChatView', () => {
|
|||||||
view['inputEl'] = document.createElement('textarea');
|
view['inputEl'] = document.createElement('textarea');
|
||||||
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
(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(
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield { role: 'assistant', content: 'response' };
|
yield { role: 'assistant', content: 'response' };
|
||||||
@@ -363,7 +379,7 @@ describe('ChatView', () => {
|
|||||||
|
|
||||||
await (view as any).handleUserInput('search query');
|
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();
|
expect(chatSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,7 +815,17 @@ describe('ChatView', () => {
|
|||||||
view['inputEl'] = document.createElement('textarea');
|
view['inputEl'] = document.createElement('textarea');
|
||||||
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
|
(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(
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield { role: 'assistant', content: 'response' };
|
yield { role: 'assistant', content: 'response' };
|
||||||
@@ -808,7 +834,7 @@ describe('ChatView', () => {
|
|||||||
|
|
||||||
await (view as any).handleUserInput('search query');
|
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();
|
expect(chatSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user