diff --git a/package.json b/package.json index b4557f9dd..68f48d9a1 100644 --- a/package.json +++ b/package.json @@ -288,6 +288,64 @@ ] } }, + { + "displayName": "%copilot.tools.memory.name%", + "name": "copilot_memory", + "tags": [], + "canBeReferencedInPrompt": true, + "toolReferenceName": "memory", + "modelDescription": "Store, search, and manage persistent memory across chat sessions. This tool allows you to save important information, retrieve relevant context, and maintain continuity across conversations. Use this tool to:\n\n1. Store important user preferences, facts, or context\n2. Search for previously stored information\n3. List existing memories\n4. Store conversation summaries for later reference\n5. Delete outdated or incorrect memories\n\nMemories persist across chat sessions and can include tags for better organization.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "store", + "search", + "list", + "delete", + "storeConversation" + ], + "description": "The action to perform with memory" + }, + "content": { + "type": "string", + "description": "Content to store (required for store action)" + }, + "query": { + "type": "string", + "description": "Search query (required for search action)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to associate with memory or filter by" + }, + "memoryId": { + "type": "string", + "description": "Memory ID (required for delete action)" + }, + "maxResults": { + "type": "number", + "description": "Maximum number of results to return (default: 10)" + }, + "conversationSummary": { + "type": "string", + "description": "Summary of conversation to store" + }, + "conversationContext": { + "type": "string", + "description": "Additional context about the conversation" + } + }, + "required": [ + "action" + ] + } + }, { "name": "copilot_findFiles", "toolReferenceName": "fileSearch", @@ -2283,6 +2341,11 @@ "terminal" ] }, + "copilot.memoryStoragePath": { + "type": "string", + "default": "", + "description": "Optional path or file:// URI to store Copilot memories (including embeddings). If empty, uses workspace storage or extension global storage. Can be an absolute path or file URI. Relative paths are resolved against the first workspace folder if present." + }, "github.copilot.chat.scopeSelection": { "type": "boolean", "default": false, diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index 04fab1d78..c38c4c7f4 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -103,6 +103,8 @@ import { IWorkspaceListenerService } from '../../workspaceRecorder/common/worksp import { WorkspacListenerService } from '../../workspaceRecorder/vscode-node/workspaceListenerService'; import { registerServices as registerCommonServices } from '../vscode/services'; import { NativeEnvServiceImpl } from '../../../platform/env/vscode-node/nativeEnvServiceImpl'; +import { IMemoryService } from '../../memory/common/memoryService'; +import { MemoryServiceImpl } from '../../memory/node/memoryServiceImpl'; // ########################################################################################### // ### ### @@ -195,6 +197,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IWorkspaceListenerService, new SyncDescriptor(WorkspacListenerService)); builder.define(ICodeSearchAuthenticationService, new SyncDescriptor(VsCodeCodeSearchAuthenticationService)); builder.define(ITodoListContextProvider, new SyncDescriptor(TodoListContextProvider)); + builder.define(IMemoryService, new SyncDescriptor(MemoryServiceImpl)); } function setupMSFTExperimentationService(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext) { diff --git a/src/extension/memory/common/memoryService.ts b/src/extension/memory/common/memoryService.ts new file mode 100644 index 000000000..5105d2d82 --- /dev/null +++ b/src/extension/memory/common/memoryService.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { Embedding } from '../../../platform/embeddings/common/embeddingsComputer'; + +export interface IMemoryItem { + id: string; + content: string; + tags: string[]; + timestamp: Date; + source: string; + embedding?: Embedding; + metadata?: Record; +} + +export interface IMemorySearchOptions { + maxResults?: number; + tags?: string[]; + semanticSearch?: boolean; + textSearch?: boolean; + threshold?: number; + dateRange?: { + start?: Date; + end?: Date; + }; +} + +export interface IMemorySearchResult { + memory: IMemoryItem; + similarity: number; + matchType: 'semantic' | 'text' | 'tag'; +} + +export interface IMemoryListOptions { + tags?: string[]; + limit?: number; + offset?: number; + sortBy?: 'timestamp' | 'content' | 'relevance'; + sortOrder?: 'asc' | 'desc'; +} + +export interface IMemoryStoreOptions { + generateEmbedding?: boolean; + metadata?: Record; +} + +export const IMemoryService = createDecorator('memoryService'); + +export interface IMemoryService { + /** + * Store a new memory item + */ + storeMemory(memory: Omit, options?: IMemoryStoreOptions): Promise; + + /** + * Search memories using semantic and/or text search + */ + searchMemories(query: string, options?: IMemorySearchOptions): Promise; + + /** + * List memories with filtering and sorting + */ + listMemories(options?: IMemoryListOptions): Promise; + + /** + * Get a specific memory by ID + */ + getMemory(id: string): Promise; + + /** + * Update an existing memory + */ + updateMemory(id: string, updates: Partial>): Promise; + + /** + * Delete a memory + */ + deleteMemory(id: string): Promise; + + /** + * Get all unique tags + */ + getTags(): Promise; + + /** + * Clear all memories (with optional tag filter) + */ + clearMemories(tags?: string[]): Promise; + + /** + * Export memories to a format (JSON, markdown, etc.) + */ + exportMemories(format: 'json' | 'markdown', options?: IMemoryListOptions): Promise; + + /** + * Import memories from a format + */ + importMemories(data: string, format: 'json' | 'markdown'): Promise; +} \ No newline at end of file diff --git a/src/extension/memory/node/memoryServiceImpl.ts b/src/extension/memory/node/memoryServiceImpl.ts new file mode 100644 index 000000000..ff19dc947 --- /dev/null +++ b/src/extension/memory/node/memoryServiceImpl.ts @@ -0,0 +1,550 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { Embedding, EmbeddingType, IEmbeddingsComputer, rankEmbeddings } from '../../../platform/embeddings/common/embeddingsComputer'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService, fileSystemServiceReadAsJSON } from '../../../platform/filesystem/common/fileSystemService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { VSBuffer } from '../../../util/vs/base/common/buffer'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../util/vs/base/common/uri'; +import { generateUuid } from '../../../util/vs/base/common/uuid'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { + IMemoryItem, + IMemoryListOptions, + IMemorySearchOptions, + IMemorySearchResult, + IMemoryService, + IMemoryStoreOptions +} from '../common/memoryService'; + +interface IMemoryStorage { + memories: IMemoryItem[]; + version: string; +} + +export class MemoryServiceImpl extends Disposable implements IMemoryService { + private readonly _storageUri: URI; + // Directory where individual memory files are stored + private readonly _storageDir: URI; + private readonly _memories = new Map(); + private _isLoaded = false; + private _embeddingsComputer: IEmbeddingsComputer | undefined; + + constructor( + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly fileSystemService: IFileSystemService, + @ILogService private readonly logService: ILogService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + ) { + super(); + + // Determine storage location in priority order: + // 1) user-configured setting `copilot.memoryStoragePath` (accepts file path or URI) + // 2) first workspace folder (workspace root) + // 3) extensionContext.storageUri or extensionContext.globalStorageUri (original behavior) + let baseStorageUri: URI | undefined; + + // 1) Try configured path + try { + const configured = this.configurationService.getNonExtensionConfig('copilot.memoryStoragePath'); + if (configured && configured.trim().length > 0) { + try { + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(configured)) { + baseStorageUri = URI.parse(configured); + } else { + // If relative path provided and a workspace folder exists, resolve against it + const absPathRegex = /^[A-Za-z]:\\|^\//; + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + if (workspaceFolders && workspaceFolders.length > 0 && !absPathRegex.test(configured)) { + const ws = workspaceFolders[0].fsPath; + const path = require('path'); + baseStorageUri = URI.file(path.resolve(ws, configured)); + } else { + baseStorageUri = URI.file(configured); + } + } + this.logService.info(`Using configured copilot.memoryStoragePath: ${configured}`); + } catch (err) { + this.logService.warn(`Invalid copilot.memoryStoragePath: ${configured} - ${err}`); + } + } + } catch (err) { + this.logService.debug(`Failed to read configuration copilot.memoryStoragePath: ${err}`); + } + + // 2) Use workspace root if no configured path + if (!baseStorageUri) { + try { + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + if (workspaceFolders && workspaceFolders.length > 0) { + const wsUriStr = workspaceFolders[0].toString(); + baseStorageUri = URI.parse(wsUriStr); + this.logService.info(`Using workspace root for memories: ${wsUriStr}`); + } + } catch (err) { + this.logService.debug(`Failed to use workspace folder as storage location: ${err}`); + } + } + + // 3) Fallback to previous behavior + if (!baseStorageUri) { + const storageUri = this.extensionContext.storageUri || this.extensionContext.globalStorageUri; + if (!storageUri) { + throw new Error('No storage URI available for memory service'); + } + baseStorageUri = storageUri; + this.logService.info(`Using extension storage location for memories: ${baseStorageUri.toString()}`); + } + + this._storageDir = URI.joinPath(baseStorageUri, 'copilot-memories'); + this._storageUri = URI.joinPath(baseStorageUri, 'copilot-memories.json'); + } + + private async ensureLoaded(): Promise { + if (this._isLoaded) { + return; + } + + try { + // Ensure storage directory exists + try { + await this.fileSystemService.stat(this._storageDir); + } catch { + await this.fileSystemService.createDirectory(this._storageDir); + } + + // Attempt migration from single file (copilot-memories.json) if present + try { + const data = await fileSystemServiceReadAsJSON.readJSON(this.fileSystemService, this._storageUri); + if (data?.memories && data.memories.length > 0) { + for (const memory of data.memories) { + memory.timestamp = new Date(memory.timestamp); + // Write each memory into its own file + const id = memory.id || generateUuid(); + memory.id = id; + const fileUri = URI.joinPath(this._storageDir, `${id}.json`); + await this.fileSystemService.writeFile(fileUri, VSBuffer.fromString(JSON.stringify(memory, null, 2)).buffer); + this._memories.set(id, memory); + } + // Remove old single-file storage after migration + try { + await this.fileSystemService.delete(this._storageUri); + } catch { + // Non-fatal + } + this.logService.info(`Migrated ${data.memories.length} memories from single-file storage`); + } + } catch (err) { + // If readJSON failed because file doesn't exist, ignore + } + + // Load individual memory files + const entries = await this.fileSystemService.readDirectory(this._storageDir); + for (const [name, type] of entries) { + if (type === 1 /* file */ && name.endsWith('.json')) { + try { + const fileUri = URI.joinPath(this._storageDir, name); + const mem = await fileSystemServiceReadAsJSON.readJSON(this.fileSystemService, fileUri); + mem.timestamp = new Date(mem.timestamp); + this._memories.set(mem.id, mem); + } catch (err) { + this.logService.warn(`Failed to read memory file ${name}: ${err}`); + } + } + } + this.logService.info(`Loaded ${this._memories.size} memories from storage`); + } catch (error) { + if (error.code !== 'ENOENT') { + this.logService.warn(`Failed to load memories from storage: ${error}`); + } + } + + this._isLoaded = true; + } + + // Save an individual memory file + private async saveMemoryToFile(memory: IMemoryItem): Promise { + const fileUri = URI.joinPath(this._storageDir, `${memory.id}.json`); + await this.fileSystemService.writeFile(fileUri, VSBuffer.fromString(JSON.stringify(memory, null, 2)).buffer); + } + + private async getEmbeddingsComputer(): Promise { + if (!this._embeddingsComputer) { + try { + // Use the same embedding computer as the workspace search + this._embeddingsComputer = this.instantiationService.createInstance( + (await import('../../../platform/embeddings/common/remoteEmbeddingsComputer')).RemoteEmbeddingsComputer + ); + } catch (error) { + this.logService.warn(`Failed to create embeddings computer: ${error}`); + } + } + return this._embeddingsComputer; + } + + async storeMemory(memory: Omit, options?: IMemoryStoreOptions): Promise { + await this.ensureLoaded(); + + const id = generateUuid(); + let embedding: Embedding | undefined; + + // Generate embedding if requested and embeddings computer is available + if (options?.generateEmbedding !== false) { + const embeddingsComputer = await this.getEmbeddingsComputer(); + if (embeddingsComputer) { + try { + const embeddings = await embeddingsComputer.computeEmbeddings( + EmbeddingType.text3small_512, + [memory.content] + ); + if (embeddings.values.length > 0) { + embedding = embeddings.values[0]; + } + } catch (error) { + this.logService.warn(`Failed to generate embedding for memory: ${error}`); + } + } + } + + const newMemory: IMemoryItem = { + ...memory, + id, + embedding, + metadata: { ...memory.metadata, ...options?.metadata } + }; + + this._memories.set(id, newMemory); + try { + await this.saveMemoryToFile(newMemory); + } catch (err) { + this.logService.error(`Failed to save memory file for ID ${id}: ${err}`); + throw err; + } + + this.logService.debug(`Stored memory with ID: ${id}`); + return newMemory; + } + + async searchMemories(query: string, options?: IMemorySearchOptions): Promise { + await this.ensureLoaded(); + + const results: IMemorySearchResult[] = []; + const memories = Array.from(this._memories.values()); + + // Filter by tags if specified + const filteredMemories = options?.tags?.length + ? memories.filter(m => options.tags!.some(tag => m.tags.includes(tag))) + : memories; + + // Filter by date range if specified + const dateFilteredMemories = options?.dateRange + ? filteredMemories.filter(m => { + const timestamp = m.timestamp.getTime(); + const start = options.dateRange!.start?.getTime() || 0; + const end = options.dateRange!.end?.getTime() || Date.now(); + return timestamp >= start && timestamp <= end; + }) + : filteredMemories; + + // Semantic search if enabled and embeddings are available + if (options?.semanticSearch !== false) { + const embeddingsComputer = await this.getEmbeddingsComputer(); + if (embeddingsComputer) { + try { + const queryEmbeddings = await embeddingsComputer.computeEmbeddings( + EmbeddingType.text3small_512, + [query] + ); + + if (queryEmbeddings.values.length > 0) { + const queryEmbedding = queryEmbeddings.values[0]; + const memoriesWithEmbeddings = dateFilteredMemories.filter(m => m.embedding); + + if (memoriesWithEmbeddings.length > 0) { + const ranked = rankEmbeddings( + queryEmbedding, + memoriesWithEmbeddings.map(m => [m, m.embedding!] as const), + options?.maxResults || 50 + ); + + for (const { value: memory, distance } of ranked) { + if (distance.value >= (options?.threshold || 0.1)) { + results.push({ + memory, + similarity: distance.value, + matchType: 'semantic' + }); + } + } + } + } + } catch (error) { + this.logService.warn(`Failed to perform semantic search: ${error}`); + } + } + } + + // Text search if enabled or semantic search failed + if (options?.textSearch !== false || results.length === 0) { + const queryLower = query.toLowerCase(); + for (const memory of dateFilteredMemories) { + const contentLower = memory.content.toLowerCase(); + if (contentLower.includes(queryLower)) { + // Simple text similarity based on substring match position and length + const similarity = Math.max(0.1, query.length / memory.content.length); + + // Avoid duplicates from semantic search + if (!results.some(r => r.memory.id === memory.id)) { + results.push({ + memory, + similarity, + matchType: 'text' + }); + } + } + } + } + + // Tag-based search + if (options?.tags?.length) { + for (const memory of dateFilteredMemories) { + const matchingTags = memory.tags.filter(tag => + options.tags!.some(searchTag => tag.toLowerCase().includes(searchTag.toLowerCase())) + ); + + if (matchingTags.length > 0 && !results.some(r => r.memory.id === memory.id)) { + results.push({ + memory, + similarity: matchingTags.length / memory.tags.length, + matchType: 'tag' + }); + } + } + } + + // Sort by similarity (descending) and limit results + results.sort((a, b) => b.similarity - a.similarity); + + const maxResults = options?.maxResults || 10; + return results.slice(0, maxResults); + } + + async listMemories(options?: IMemoryListOptions): Promise { + await this.ensureLoaded(); + + let memories = Array.from(this._memories.values()); + + // Filter by tags + if (options?.tags?.length) { + memories = memories.filter(m => + options.tags!.some(tag => m.tags.includes(tag)) + ); + } + + // Sort + const sortBy = options?.sortBy || 'timestamp'; + const sortOrder = options?.sortOrder || 'desc'; + + memories.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case 'timestamp': + comparison = a.timestamp.getTime() - b.timestamp.getTime(); + break; + case 'content': + comparison = a.content.localeCompare(b.content); + break; + default: + comparison = 0; + } + + return sortOrder === 'asc' ? comparison : -comparison; + }); + + // Pagination + const offset = options?.offset || 0; + const limit = options?.limit || memories.length; + + return memories.slice(offset, offset + limit); + } + + async getMemory(id: string): Promise { + await this.ensureLoaded(); + return this._memories.get(id); + } + + async updateMemory(id: string, updates: Partial>): Promise { + await this.ensureLoaded(); + + const existing = this._memories.get(id); + if (!existing) { + throw new Error(`Memory with ID ${id} not found`); + } + + // Regenerate embedding if content changed + let embedding = existing.embedding; + if (updates.content && updates.content !== existing.content) { + const embeddingsComputer = await this.getEmbeddingsComputer(); + if (embeddingsComputer) { + try { + const embeddings = await embeddingsComputer.computeEmbeddings( + EmbeddingType.text3small_512, + [updates.content] + ); + if (embeddings.values.length > 0) { + embedding = embeddings.values[0]; + } + } catch (error) { + this.logService.warn(`Failed to generate embedding for updated memory: ${error}`); + } + } + } + + const updated: IMemoryItem = { + ...existing, + ...updates, + id, // Ensure ID doesn't change + embedding: embedding || existing.embedding, + }; + + this._memories.set(id, updated); + try { + await this.saveMemoryToFile(updated); + } catch (err) { + this.logService.error(`Failed to save updated memory file for ID ${id}: ${err}`); + throw err; + } + + return updated; + } + + async deleteMemory(id: string): Promise { + await this.ensureLoaded(); + + if (!this._memories.has(id)) { + throw new Error(`Memory with ID ${id} not found`); + } + + this._memories.delete(id); + const fileUri = URI.joinPath(this._storageDir, `${id}.json`); + try { + await this.fileSystemService.delete(fileUri); + } catch (err) { + // Non-fatal: log and continue + this.logService.warn(`Failed to delete memory file for ID ${id}: ${err}`); + } + + this.logService.debug(`Deleted memory with ID: ${id}`); + } + + async getTags(): Promise { + await this.ensureLoaded(); + + const tagSet = new Set(); + for (const memory of this._memories.values()) { + memory.tags.forEach(tag => tagSet.add(tag)); + } + + return Array.from(tagSet).sort(); + } + + async clearMemories(tags?: string[]): Promise { + await this.ensureLoaded(); + + if (tags?.length) { + // Delete only memories with specified tags + const toDelete: string[] = []; + for (const [id, memory] of this._memories) { + if (memory.tags.some(tag => tags.includes(tag))) { + toDelete.push(id); + } + } + toDelete.forEach(id => this._memories.delete(id)); + // Remove files for deleted memories + for (const id of toDelete) { + const fileUri = URI.joinPath(this._storageDir, `${id}.json`); + try { + await this.fileSystemService.delete(fileUri); + } catch { + // ignore + } + } + } else { + // Delete all memories + this._memories.clear(); + // Remove all files in storage dir + try { + const entries = await this.fileSystemService.readDirectory(this._storageDir); + for (const [name, type] of entries) { + if (type === 1 && name.endsWith('.json')) { + const fileUri = URI.joinPath(this._storageDir, name); + try { await this.fileSystemService.delete(fileUri); } catch { /* ignore */ } + } + } + } catch { + // ignore + } + } + + this.logService.info(`Cleared ${tags ? 'tagged' : 'all'} memories`); + } + + async exportMemories(format: 'json' | 'markdown', options?: IMemoryListOptions): Promise { + const memories = await this.listMemories(options); + + switch (format) { + case 'json': + return JSON.stringify(memories, null, 2); + + case 'markdown': { + const markdown = memories.map(memory => { + const date = memory.timestamp.toLocaleDateString(); + const tags = memory.tags.length > 0 ? `\n**Tags:** ${memory.tags.join(', ')}` : ''; + return `## Memory (${date})\n\n${memory.content}${tags}\n\n---\n`; + }).join('\n'); + + return `# Copilot Memories Export\n\n${markdown}`; + } + + default: + throw new Error(`Unsupported export format: ${format}`); + } + } + + async importMemories(data: string, format: 'json' | 'markdown'): Promise { + const imported: IMemoryItem[] = []; + + switch (format) { + case 'json': { + const parsed = JSON.parse(data) as IMemoryItem[]; + for (const memory of parsed) { + // Generate new IDs to avoid conflicts + const newMemory = await this.storeMemory({ + content: memory.content, + tags: memory.tags || [], + timestamp: new Date(memory.timestamp), + source: memory.source || 'import', + metadata: memory.metadata + }); + imported.push(newMemory); + } + break; + } + + default: + throw new Error(`Unsupported import format: ${format}`); + } + + this.logService.info(`Imported ${imported.length} memories`); + return imported; + } +} \ No newline at end of file diff --git a/src/extension/tools/common/toolNames.ts b/src/extension/tools/common/toolNames.ts index 6f8671fcd..605bed3f6 100644 --- a/src/extension/tools/common/toolNames.ts +++ b/src/extension/tools/common/toolNames.ts @@ -33,6 +33,7 @@ export enum ToolName { ReadCellOutput = 'read_notebook_cell_output', InstallExtension = 'install_extension', Think = 'think', + Memory = 'memory', FetchWebPage = 'fetch_webpage', FindTestFiles = 'test_search', GetProjectSetupInfo = 'get_project_setup_info', @@ -87,6 +88,7 @@ export enum ContributedToolName { ReadCellOutput = 'copilot_readNotebookCellOutput', InstallExtension = 'copilot_installExtension', Think = 'copilot_think', + Memory = 'copilot_memory', FetchWebPage = 'copilot_fetchWebPage', FindTestFiles = 'copilot_findTestFiles', GetProjectSetupInfo = 'copilot_getProjectSetupInfo', diff --git a/src/extension/tools/node/allTools.ts b/src/extension/tools/node/allTools.ts index 05c736c7a..5d76a868d 100644 --- a/src/extension/tools/node/allTools.ts +++ b/src/extension/tools/node/allTools.ts @@ -20,6 +20,7 @@ import './githubRepoTool'; import './insertEditTool'; import './installExtensionTool'; import './listDirTool'; +import './memoryTool'; import './multiReplaceStringTool'; import './newNotebookTool'; import './newWorkspace/newWorkspaceTool'; @@ -34,8 +35,9 @@ import './searchWorkspaceSymbolsTool'; import './simpleBrowserTool'; import './testFailureTool'; import './thinkTool'; +import './toolReplayTool'; import './usagesTool'; import './userPreferencesTool'; import './vscodeAPITool'; import './vscodeCmdTool'; -import './toolReplayTool'; + diff --git a/src/extension/tools/node/memoryTool.tsx b/src/extension/tools/node/memoryTool.tsx new file mode 100644 index 000000000..8020caca0 --- /dev/null +++ b/src/extension/tools/node/memoryTool.tsx @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes'; +import { IMemoryService } from '../../memory/common/memoryService'; +import { ToolName } from '../common/toolNames'; +import { ToolRegistry } from '../common/toolsRegistry'; +import { checkCancellation } from './toolUtils'; + +interface IMemoryToolParams { + action: 'store' | 'search' | 'list' | 'delete' | 'storeConversation'; + content?: string; + query?: string; + tags?: string[]; + memoryId?: string; + maxResults?: number; + conversationSummary?: string; + conversationContext?: string; +} + +class MemoryTool implements vscode.LanguageModelTool { + public static readonly toolName = ToolName.Memory; + + constructor( + @IMemoryService private readonly memoryService: IMemoryService + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + const { action, content, query, tags, memoryId, maxResults = 10, conversationSummary, conversationContext } = options.input; + + checkCancellation(token); + + switch (action) { + case 'store': { + if (!content) { + throw new Error('Content is required for storing memory'); + } + + const memory = await this.memoryService.storeMemory({ + content, + tags: tags || [], + timestamp: new Date(), + source: 'user-input' + }); + + return new LanguageModelToolResult([ + new LanguageModelTextPart(`Memory stored successfully with ID: ${memory.id}`) + ]); + } + + case 'search': { + if (!query) { + throw new Error('Query is required for searching memory'); + } + + const results = await this.memoryService.searchMemories(query, { + maxResults, + tags, + semanticSearch: true + }); + + const resultText = results.length > 0 + ? results.map((r, i) => + `${i + 1}. [${r.similarity.toFixed(3)}] ${r.memory.content.substring(0, 200)}${r.memory.content.length > 200 ? '...' : ''} (Tags: ${r.memory.tags.join(', ') || 'none'})` + ).join('\n\n') + : 'No matching memories found.'; + + return new LanguageModelToolResult([ + new LanguageModelTextPart(`Found ${results.length} memory results:\n\n${resultText}`) + ]); + } + + case 'list': { + const memories = await this.memoryService.listMemories({ + tags, + limit: maxResults + }); + + const listText = memories.length > 0 + ? memories.map((m, i) => + `${i + 1}. ${m.content.substring(0, 150)}${m.content.length > 150 ? '...' : ''} (${m.timestamp.toLocaleDateString()})` + ).join('\n') + : 'No memories found.'; + + return new LanguageModelToolResult([ + new LanguageModelTextPart(`Memory list (${memories.length} items):\n\n${listText}`) + ]); + } + + case 'delete': { + if (!memoryId) { + throw new Error('Memory ID is required for deletion'); + } + + await this.memoryService.deleteMemory(memoryId); + + return new LanguageModelToolResult([ + new LanguageModelTextPart(`Memory with ID ${memoryId} deleted successfully`) + ]); + } + + case 'storeConversation': { + if (!conversationSummary && !conversationContext) { + throw new Error('Conversation summary or context is required for storing conversation'); + } + + const memory = await this.memoryService.storeMemory({ + content: conversationSummary || conversationContext || '', + tags: [...(tags || []), 'conversation', 'context'], + timestamp: new Date(), + source: 'conversation-summary' + }); + + return new LanguageModelToolResult([ + new LanguageModelTextPart(`Conversation context stored successfully with ID: ${memory.id}`) + ]); + } + + default: + throw new Error(`Unknown action: ${action}`); + } + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, token: vscode.CancellationToken): Promise { + const action = options.input.action; + const actionMessages = { + store: 'Storing memory...', + search: 'Searching memories...', + list: 'Listing memories...', + delete: 'Deleting memory...', + storeConversation: 'Storing conversation context...' + }; + + return { + invocationMessage: actionMessages[action] || 'Processing memory request...' + }; + } +} + +ToolRegistry.registerTool(MemoryTool); \ No newline at end of file diff --git a/src/extension/tools/node/test/memoryTool.spec.tsx b/src/extension/tools/node/test/memoryTool.spec.tsx new file mode 100644 index 000000000..cba1e25cd --- /dev/null +++ b/src/extension/tools/node/test/memoryTool.spec.tsx @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterAll, beforeAll, expect, suite, test } from 'vitest'; +import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; +import { IMemoryItem, IMemoryListOptions, IMemorySearchOptions, IMemorySearchResult, IMemoryService, IMemoryStoreOptions } from '../../../memory/common/memoryService'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { ToolName } from '../../common/toolNames'; +import { IToolsService } from '../../common/toolsService'; +import { toolResultToString } from './toolTestUtils'; + +// Mock memory service for testing +class MockMemoryService implements IMemoryService { + _serviceBrand: undefined; + + private memories: IMemoryItem[] = []; + private nextId = 1; + + async storeMemory(memory: Omit, options?: IMemoryStoreOptions): Promise { + const storedMemory: IMemoryItem = { + id: `memory-${this.nextId++}`, + ...memory + }; + this.memories.push(storedMemory); + return storedMemory; + } + + async searchMemories(query: string, options?: IMemorySearchOptions): Promise { + const filtered = this.memories.filter(m => + m.content.toLowerCase().includes(query.toLowerCase()) && + (!options?.tags || options.tags.every(tag => m.tags.includes(tag))) + ); + + return filtered.slice(0, options?.maxResults || 10).map(memory => ({ + memory, + similarity: 0.9, + matchType: 'text' as const + })); + } + + async listMemories(options?: IMemoryListOptions): Promise { + let filtered = this.memories; + if (options?.tags) { + filtered = filtered.filter(m => options.tags!.every(tag => m.tags.includes(tag))); + } + return filtered.slice(0, options?.limit || 100); + } + + async getMemory(id: string): Promise { + return this.memories.find(m => m.id === id); + } + + async updateMemory(id: string, updates: Partial>): Promise { + const memory = this.memories.find(m => m.id === id); + if (!memory) { + throw new Error(`Memory with ID ${id} not found`); + } + Object.assign(memory, updates); + return memory; + } + + async deleteMemory(id: string): Promise { + const index = this.memories.findIndex(m => m.id === id); + if (index === -1) { + throw new Error(`Memory with ID ${id} not found`); + } + this.memories.splice(index, 1); + } + + async getTags(): Promise { + const allTags = this.memories.flatMap(m => m.tags); + return [...new Set(allTags)]; + } + + async clearMemories(tags?: string[]): Promise { + if (tags) { + this.memories = this.memories.filter(m => !tags.some(tag => m.tags.includes(tag))); + } else { + this.memories = []; + } + } + + async exportMemories(format: 'json' | 'markdown', options?: IMemoryListOptions): Promise { + const memories = await this.listMemories(options); + if (format === 'json') { + return JSON.stringify(memories, null, 2); + } else { + return memories.map(m => `# ${m.content}\nTags: ${m.tags.join(', ')}\n`).join('\n'); + } + } + + async importMemories(data: string, format: 'json' | 'markdown'): Promise { + if (format === 'json') { + const memories = JSON.parse(data) as IMemoryItem[]; + this.memories.push(...memories); + return memories; + } + throw new Error('Markdown import not implemented in mock'); + } +} + +suite('MemoryTool', () => { + let accessor: ITestingServicesAccessor; + + beforeAll(() => { + const services = createExtensionUnitTestingServices(); + services.define(IMemoryService, new SyncDescriptor(MockMemoryService)); + accessor = services.createTestingAccessor(); + }); + + afterAll(() => { + accessor.dispose(); + }); + + test('stores memory successfully', async () => { + const toolsService = accessor.get(IToolsService); + + const input = { + action: 'store' as const, + content: 'This is a test memory', + tags: ['test', 'example'] + }; + + const result = await toolsService.invokeTool( + ToolName.Memory, + { input, toolInvocationToken: null as never }, + CancellationToken.None + ); + + const resultString = await toolResultToString(accessor, result); + expect(resultString).toMatch(/Memory stored successfully with ID: memory-\d+/); + }); + + test('searches memories successfully', async () => { + // First store a memory through the tool + const toolsService = accessor.get(IToolsService); + + await toolsService.invokeTool( + ToolName.Memory, + { + input: { + action: 'store' as const, + content: 'This is about TypeScript programming', + tags: ['programming', 'typescript'] + }, + toolInvocationToken: null as never + }, + CancellationToken.None + ); + + const input = { + action: 'search' as const, + query: 'TypeScript', + maxResults: 5 + }; + + const result = await toolsService.invokeTool( + ToolName.Memory, + { input, toolInvocationToken: null as never }, + CancellationToken.None + ); + + const resultString = await toolResultToString(accessor, result); + expect(resultString).toContain('Found 1 memory results'); + expect(resultString).toContain('TypeScript programming'); + }); + + test('lists memories successfully', async () => { + const toolsService = accessor.get(IToolsService); + + const input = { + action: 'list' as const, + maxResults: 10 + }; + + const result = await toolsService.invokeTool( + ToolName.Memory, + { input, toolInvocationToken: null as never }, + CancellationToken.None + ); + + const resultString = await toolResultToString(accessor, result); + expect(resultString).toMatch(/Memory list \(\d+ items\):/); + }); + + test('throws error for invalid store action', async () => { + const toolsService = accessor.get(IToolsService); + + const input = { + action: 'store' as const, + // Missing required content + tags: ['test'] + }; + + await expect( + toolsService.invokeTool( + ToolName.Memory, + { input, toolInvocationToken: null as never }, + CancellationToken.None + ) + ).rejects.toThrow('Content is required for storing memory'); + }); + + test('throws error for invalid search action', async () => { + const toolsService = accessor.get(IToolsService); + + const input = { + action: 'search' as const, + // Missing required query + maxResults: 5 + }; + + await expect( + toolsService.invokeTool( + ToolName.Memory, + { input, toolInvocationToken: null as never }, + CancellationToken.None + ) + ).rejects.toThrow('Query is required for searching memory'); + }); +}); \ No newline at end of file