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';
|
||||
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;
|
||||
|
||||
@@ -38,4 +38,10 @@ export const DEFAULT_SETTINGS = {
|
||||
targetFolder: '',
|
||||
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 { 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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ============================================================
|
||||
// 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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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