@memory-bank/mcp
Version:
Memory-enabled Co-Pilot (MCP) server for managing project documentation and context
334 lines • 15.8 kB
JavaScript
import path from 'node:path';
import { MemoryDocument } from '../../../domain/entities/MemoryDocument.js';
import { DocumentPath } from '../../../domain/entities/DocumentPath.js';
import { Tag } from '../../../domain/entities/Tag.js';
import { InfrastructureError, InfrastructureErrorCodes, } from '../../../shared/errors/InfrastructureError.js';
import { DomainError } from '../../../shared/errors/DomainError.js';
import { extractTags } from '../../../shared/utils/index.js';
import { logger } from '../../../shared/utils/logger.js';
/**
* File system based implementation of memory document repository
*/
export class FileSystemMemoryDocumentRepository {
basePath;
fileSystemService;
/**
* Constructor
* @param basePath Base path for storing documents
* @param fileSystemService File system service
*/
constructor(basePath, fileSystemService) {
this.basePath = basePath;
this.fileSystemService = fileSystemService;
}
/**
* Find document by path
* @param path Document path
* @returns Promise resolving to document if found, null otherwise
*/
async findByPath(path) {
try {
const filePath = this.resolvePath(path.value);
logger.debug(`[DEBUG] Finding document at path: ${filePath}`);
const exists = await this.fileSystemService.fileExists(filePath);
if (!exists) {
logger.debug(`File not found at ${filePath}`);
return null;
}
const content = await this.fileSystemService.readFile(filePath);
logger.debug(`[DEBUG] File content read from ${filePath}`);
if (path.isJSON) {
try {
const jsonObj = JSON.parse(content);
logger.debug('JSON parsed for file:', { filePath, schema: jsonObj.schema });
if (jsonObj.schema === 'memory_document_v1' && jsonObj.metadata && jsonObj.content) {
const doc = MemoryDocument.fromJSON(jsonObj, path);
logger.debug('Created document from JSON with tags:', { tags: doc.tags.map(t => t.value) });
return doc;
}
else {
const stats = await this.fileSystemService.getFileStats(filePath);
let tags = [];
if (jsonObj.metadata && Array.isArray(jsonObj.metadata.tags)) {
try {
tags = jsonObj.metadata.tags.map((tag) => Tag.create(tag));
}
catch (tagError) {
logger.warn(`Ignoring invalid tags in ${path.value}:`, tagError);
}
}
return MemoryDocument.create({
path,
content,
tags,
lastModified: stats.lastModified,
});
}
}
catch (error) {
if (error instanceof DomainError && error.code === 'DOMAIN_ERROR.INVALID_TAG_FORMAT') {
logger.error(`Invalid tag format in document ${path.value}:`, error);
try {
const jsonDoc = JSON.parse(content);
if (jsonDoc.metadata && jsonDoc.metadata.tags) {
jsonDoc.metadata.tags = jsonDoc.metadata.tags.map((tag) => {
return tag.toLowerCase().replace(/[^a-z0-9-]/g, '-');
});
logger.warn(`Sanitized tags in ${path.value}: ${JSON.stringify(jsonDoc.metadata.tags)}`);
if (jsonDoc.schema === 'memory_document_v1' && jsonDoc.metadata && jsonDoc.content) {
return MemoryDocument.fromJSON(jsonDoc, path);
}
}
}
catch (recoveryError) {
logger.error(`Failed to recover document ${path.value}:`, recoveryError);
}
logger.warn(`Skipping document with invalid tags: ${path.value}`);
return null;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_READ_ERROR, `Failed to parse JSON document: ${path.value}`, { originalError: error });
}
}
const tags = extractTags(content).map((tag) => Tag.create(tag));
const stats = await this.fileSystemService.getFileStats(filePath);
return MemoryDocument.create({
path,
content,
tags,
lastModified: stats.lastModified,
});
}
catch (error) {
if (error instanceof DomainError) {
throw error;
}
if (error instanceof InfrastructureError) {
if (error.code === `INFRA_ERROR.${InfrastructureErrorCodes.FILE_NOT_FOUND}`) {
return null;
}
throw error;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_READ_ERROR, `Failed to find document by path: ${path.value}`, { originalError: error });
}
}
/**
* Find documents by tags
* @param tags Tags to search for
* @returns Promise resolving to array of matching documents
*/
async findByTags(tags) {
try {
const files = await this.fileSystemService.listFiles(this.basePath);
logger.debug(`[DEBUG] Found ${files.length} files in ${this.basePath}`);
const documents = [];
for (const file of files) {
try {
const relativePath = path.relative(this.basePath, file);
if (!relativePath.endsWith('.md') && !relativePath.endsWith('.json')) {
continue;
}
logger.debug(`[DEBUG] Processing file: ${relativePath}`);
const documentPath = DocumentPath.create(relativePath);
const document = await this.findByPath(documentPath);
if (document) {
const docTags = document.tags.map(t => t.value);
const searchTags = tags.map(t => t.value);
logger.debug('Document tags:', { relativePath, docTags });
logger.debug('Searching for tags:', { searchTags });
const hasAnyTags = tags.some((tag) => {
const hasTag = document.hasTag(tag);
logger.debug(`[DEBUG] Checking tag "${tag.value}" against document ${relativePath}: ${hasTag}`);
return hasTag;
});
if (hasAnyTags) {
logger.debug(`[DEBUG] Adding document ${relativePath} to results`);
documents.push(document);
}
else {
logger.debug(`[DEBUG] Document ${relativePath} does not match any search tags`);
}
}
else {
logger.debug(`[DEBUG] No document found for ${relativePath}`);
}
}
catch (error) {
logger.error(`Error reading file ${file}:`, error);
}
}
logger.debug(`Found ${documents.length} documents matching tags:`, tags.map(t => t.value));
return documents;
}
catch (error) {
if (error instanceof DomainError) {
throw error;
}
if (error instanceof InfrastructureError) {
throw error;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_SYSTEM_ERROR, `Failed to find documents by tags in global memory bank`, { originalError: error });
}
}
/**
* Save document
* @param document Document to save
* @returns Promise resolving when done
*/
async save(document) {
try {
const filePath = this.resolvePath(document.path.value);
let contentToSave = document.content;
let isJson = false;
try {
// JSONかどうかを試す
JSON.parse(document.content);
isJson = true;
logger.debug(`Content is valid JSON for ${document.path.value}`);
// JSONの場合はそのままの contentToSave を使う
}
catch (error) {
// JSONでない場合はプレーンテキストとして扱う
isJson = false;
logger.debug(`Content is not JSON for ${document.path.value}, treating as plain text.`);
// プレーンテキストの場合、タグ埋め込みはしない (シンプルにするため)
// もしタグ埋め込みが必要なら、ここで行う
// if (document.tags.length > 0) {
// const tagLine = `tags: ${document.tags.map((tag) => tag.toHashtag()).join(' ')}\n\n`;
// if (contentToSave.includes('tags:')) {
// contentToSave = contentToSave.replace(/tags:.*\n\n/, tagLine);
// } else {
// const lines = contentToSave.split('\n');
// const firstLine = lines[0];
// const rest = lines.slice(1).join('\n');
// contentToSave = `${firstLine}\n\n${tagLine}${rest}`;
// }
// }
}
// ファイル書き込み
await this.fileSystemService.writeFile(filePath, contentToSave);
logger.debug(`Successfully wrote file: ${filePath} (isJson: ${isJson})`);
}
catch (error) {
if (error instanceof DomainError) {
throw error;
}
if (error instanceof InfrastructureError) {
throw error;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_WRITE_ERROR, `Failed to save document: ${document.path.value}`, { originalError: error });
}
}
/**
* Delete document
* @param path Document path
* @returns Promise resolving to boolean indicating success
*/
async delete(path) {
try {
const filePath = this.resolvePath(path.value);
return await this.fileSystemService.deleteFile(filePath);
}
catch (error) {
if (error instanceof DomainError) {
throw error;
}
if (error instanceof InfrastructureError) {
throw error;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_SYSTEM_ERROR, `Failed to delete document: ${path.value}`, { originalError: error });
}
}
/**
* List all document paths
* @returns Promise resolving to array of document paths
*/
async list() {
try {
const files = await this.fileSystemService.listFiles(this.basePath);
logger.debug(`FileSystemMemoryDocumentRepository.list() found ${files.length} files`);
// Group files by basename (excluding extension)
const fileGroups = new Map();
for (const file of files) {
try {
const relativePath = path.relative(this.basePath, file);
if (!relativePath.endsWith('.md') && !relativePath.endsWith('.json')) {
continue;
}
const parts = relativePath.split(path.sep);
if (parts.includes('..') || parts.some((part) => part.startsWith('..'))) {
logger.error('Invalid document path:', {
path: relativePath,
reason: 'Path traversal attempt detected',
});
continue;
}
// Get basename (without extension) and file extension
const extension = path.extname(relativePath);
// Get basename including directory path
const dirPath = path.dirname(relativePath);
const baseName = path.basename(relativePath, extension);
const baseNameWithDir = path.join(dirPath, baseName);
logger.debug(`Processing file: ${relativePath}, baseNameWithDir: ${baseNameWithDir}, extension: ${extension}`);
// Add to group
const group = fileGroups.get(baseNameWithDir) || { md: undefined, json: undefined };
if (extension === '.md') {
group.md = relativePath;
}
else if (extension === '.json') {
group.json = relativePath;
}
fileGroups.set(baseNameWithDir, group);
}
catch (error) {
logger.error('Error processing file path:', {
path: file,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Prefer JSON file, otherwise use MD file
const paths = [];
logger.debug(`Found ${fileGroups.size} unique base files`);
for (const [baseNameWithDir, group] of fileGroups) {
try {
// Prefer JSON file
const pathToUse = group.json || group.md;
if (pathToUse) {
logger.debug(`Using ${pathToUse} for base ${baseNameWithDir} (JSON: ${!!group.json}, MD: ${!!group.md})`);
const documentPath = DocumentPath.create(pathToUse);
paths.push(documentPath);
}
}
catch (error) {
logger.error('Error creating document path:', {
baseNameWithDir,
error: error instanceof Error ? error.message : String(error),
});
}
}
return paths;
}
catch (error) {
if (error instanceof DomainError) {
throw error;
}
if (error instanceof InfrastructureError) {
throw error;
}
throw new InfrastructureError(InfrastructureErrorCodes.FILE_SYSTEM_ERROR, 'Failed to list documents', { originalError: error });
}
}
/**
* Resolve path to full file path
* @param documentPath Document path relative to base path
* @returns Full file path
*/
resolvePath(documentPath) {
const normalizedPath = path.normalize(documentPath);
if (normalizedPath.startsWith('..')) {
throw new InfrastructureError(InfrastructureErrorCodes.FILE_SYSTEM_ERROR, `Invalid document path: ${documentPath}. Path cannot traverse outside the base directory.`);
}
return path.join(this.basePath, normalizedPath);
}
}
//# sourceMappingURL=FileSystemMemoryDocumentRepository.js.map