Skip to main content
Glama

Memory MCP Server

by inchan
note-manager.ts12.8 kB
/** * 노트 관리 통합 모듈 */ import { MarkdownNote, FrontMatter, Uid, parseMarkdownLinks, logger } from '@memory-mcp/common'; import { readFile, writeFile, getFileStats, createContentHash, listFiles, normalizePath, fileExists } from './file-operations'; import { parseFrontMatter, serializeMarkdownNote, updateFrontMatter, generateFrontMatterFromTitle } from './front-matter'; import { StorageMdError, FileSystemError, NoteMetadata, LinkAnalysis, BacklinkInfo, ExtendedMarkdownNote, FindNoteOptions, ReadFileOptions, WriteFileOptions } from './types'; /** * 파일에서 노트 로드 */ export async function loadNote( filePath: string, options: ReadFileOptions = {} ): Promise<MarkdownNote> { const normalizedPath = normalizePath(filePath); try { logger.debug(`노트 로드 시작: ${normalizedPath}`); // 파일 존재 확인 if (!(await fileExists(normalizedPath))) { throw new FileSystemError( `노트 파일을 찾을 수 없습니다: ${normalizedPath}`, normalizedPath ); } // 파일 읽기 const fileContent = await readFile(normalizedPath, options); // Front Matter 파싱 const parseResult = parseFrontMatter( fileContent, normalizedPath, options.validateFrontMatter !== false ); const note: MarkdownNote = { frontMatter: parseResult.frontMatter, content: parseResult.content, filePath: normalizedPath, }; logger.debug(`노트 로드 완료: ${note.frontMatter.id} (${note.frontMatter.title})`); return note; } catch (error) { if (error instanceof StorageMdError || error instanceof FileSystemError) { throw error; } throw new StorageMdError( `노트 로드 실패: ${normalizedPath}`, 'NOTE_LOAD_ERROR', normalizedPath, error ); } } /** * 노트를 파일로 저장 */ export async function saveNote( note: MarkdownNote, options: WriteFileOptions = {} ): Promise<void> { const normalizedPath = normalizePath(note.filePath); try { logger.debug(`노트 저장 시작: ${note.frontMatter.id} → ${normalizedPath}`); // updated 필드 자동 갱신 const updatedNote = { ...note, frontMatter: updateFrontMatter(note.frontMatter, {}) }; // 마크다운 직렬화 const serializedContent = serializeMarkdownNote(updatedNote); // 파일 저장 await writeFile(normalizedPath, serializedContent, options); logger.debug(`노트 저장 완료: ${updatedNote.frontMatter.id}`); } catch (error) { if (error instanceof StorageMdError || error instanceof FileSystemError) { throw error; } throw new StorageMdError( `노트 저장 실패: ${normalizedPath}`, 'NOTE_SAVE_ERROR', normalizedPath, error ); } } /** * UID로 노트 찾기 */ export async function findNoteByUid( uid: Uid, vaultPath: string, options: FindNoteOptions = {} ): Promise<MarkdownNote | null> { const { searchRoot = vaultPath, exactMatch = true, limit } = options; try { logger.debug(`UID로 노트 검색: ${uid} in ${searchRoot}`); // 마크다운 파일 목록 조회 const markdownFiles = await listFiles( searchRoot, /\.md$/i, true ); let foundFiles: string[] = []; // 각 파일의 Front Matter에서 UID 확인 for (const filePath of markdownFiles) { try { const note = await loadNote(filePath, { validateFrontMatter: false }); if (exactMatch) { if (note.frontMatter.id === uid) { foundFiles.push(filePath); if (limit && foundFiles.length >= limit) break; } } else { if (note.frontMatter.id.includes(uid)) { foundFiles.push(filePath); if (limit && foundFiles.length >= limit) break; } } } catch (error) { // 개별 파일 로드 실패는 무시하고 계속 logger.warn(`파일 로드 실패, 건너뜀: ${filePath}`, error); continue; } } if (foundFiles.length === 0) { logger.debug(`UID에 해당하는 노트를 찾을 수 없음: ${uid}`); return null; } if (foundFiles.length > 1) { logger.warn(`중복된 UID 발견: ${uid}`, { files: foundFiles }); } // 첫 번째 매칭 파일 반환 const firstFile = foundFiles[0]; if (!firstFile) { return null; } const note = await loadNote(firstFile); logger.debug(`UID 검색 완료: ${uid} → ${firstFile}`); return note; } catch (error) { throw new StorageMdError( `UID 검색 실패: ${uid}`, 'UID_SEARCH_ERROR', searchRoot, error ); } } /** * 노트의 메타데이터 생성 */ export async function generateNoteMetadata( note: MarkdownNote ): Promise<NoteMetadata> { try { const stats = await getFileStats(note.filePath); const contentHash = createContentHash(note.content); // 단어 및 문자 수 계산 const wordCount = countWords(note.content); const characterCount = note.content.length; return { fileSize: stats.size, birthtime: stats.birthtime, mtime: stats.mtime, contentHash, wordCount, characterCount, }; } catch (error) { throw new StorageMdError( `메타데이터 생성 실패: ${note.filePath}`, 'METADATA_GENERATION_ERROR', note.filePath, error ); } } /** * 텍스트의 단어 수 계산 */ function countWords(text: string): number { // 한글/영문 혼재 텍스트의 단어 수 계산 const words = text .replace(/[^\w\s가-힣]/g, ' ') // 특수문자를 공백으로 .split(/\s+/) // 공백으로 분할 .filter(word => word.length > 0); // 빈 문자열 제거 return words.length; } /** * 노트의 링크 분석 */ export async function analyzeLinks( note: MarkdownNote, vaultPath: string ): Promise<LinkAnalysis> { try { logger.debug(`링크 분석 시작: ${note.frontMatter.id}`); // 아웃바운드 링크 파싱 const outboundLinks = parseMarkdownLinks(note.content); // 인바운드 링크 검색 (백링크) const inboundLinks = await findBacklinks(note.frontMatter.id, vaultPath); // 깨진 링크 확인 const brokenLinks: string[] = []; for (const link of outboundLinks) { const linkedNote = await findNoteByUid(link as Uid, vaultPath); if (!linkedNote) { brokenLinks.push(link); } } const analysis: LinkAnalysis = { outboundLinks, inboundLinks, brokenLinks, }; logger.debug(`링크 분석 완료: ${note.frontMatter.id}`, { outbound: outboundLinks.length, inbound: inboundLinks.length, broken: brokenLinks.length }); return analysis; } catch (error) { throw new StorageMdError( `링크 분석 실패: ${note.frontMatter.id}`, 'LINK_ANALYSIS_ERROR', note.filePath, error ); } } /** * 특정 UID를 가리키는 백링크 찾기 */ async function findBacklinks( targetUid: Uid, vaultPath: string ): Promise<BacklinkInfo[]> { const backlinks: BacklinkInfo[] = []; try { // 볼트 내 모든 마크다운 파일 검색 const markdownFiles = await listFiles(vaultPath, /\.md$/i, true); for (const filePath of markdownFiles) { try { const note = await loadNote(filePath, { validateFrontMatter: false }); // 해당 노트가 targetUid를 링크하고 있는지 확인 const links = parseMarkdownLinks(note.content); if (links.includes(targetUid)) { // 링크 컨텍스트 추출 const context = extractLinkContext(note.content, targetUid); backlinks.push({ sourceUid: note.frontMatter.id, sourceFilePath: filePath, sourceTitle: note.frontMatter.title, linkText: targetUid, context, }); } } catch (error) { // 개별 파일 처리 실패는 무시 logger.warn(`백링크 검색 중 파일 처리 실패: ${filePath}`, error); continue; } } return backlinks; } catch (error) { logger.error(`백링크 검색 실패: ${targetUid}`, error); return []; } } /** * 링크 주변 컨텍스트 추출 */ function extractLinkContext(content: string, linkUid: string): string { const lines = content.split('\n'); const contextLines: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line && line.includes(linkUid)) { // 앞뒤 1줄씩 포함하여 컨텍스트 생성 const start = Math.max(0, i - 1); const end = Math.min(lines.length - 1, i + 1); for (let j = start; j <= end; j++) { const currentLine = lines[j]; if (currentLine) { if (j === i) { // 링크가 있는 줄은 강조 contextLines.push(`> ${currentLine}`); } else { contextLines.push(currentLine); } } } break; // 첫 번째 매칭만 사용 } } return contextLines.join('\n').trim(); } /** * 확장된 노트 정보 생성 (메타데이터 + 링크 분석 포함) */ export async function createExtendedNote( note: MarkdownNote, vaultPath: string ): Promise<ExtendedMarkdownNote> { try { const [metadata, linkAnalysis] = await Promise.all([ generateNoteMetadata(note), analyzeLinks(note, vaultPath) ]); return { ...note, metadata, linkAnalysis, }; } catch (error) { throw new StorageMdError( `확장 노트 생성 실패: ${note.frontMatter.id}`, 'EXTENDED_NOTE_ERROR', note.filePath, error ); } } /** * 제목으로 새 노트 생성 */ export function createNewNote( title: string, content: string = '', filePath: string, category: FrontMatter['category'] = 'Resources', additionalFrontMatter?: Partial<FrontMatter> ): MarkdownNote { try { const frontMatter = generateFrontMatterFromTitle( title, category, additionalFrontMatter ); return { frontMatter, content, filePath: normalizePath(filePath), }; } catch (error) { throw new StorageMdError( `새 노트 생성 실패: ${title}`, 'NEW_NOTE_ERROR', filePath, error ); } } /** * 노트 복사 (새 UID로) */ export function cloneNote( note: MarkdownNote, newFilePath: string, newTitle?: string ): MarkdownNote { try { const frontMatter = generateFrontMatterFromTitle( newTitle || `${note.frontMatter.title} (복사)`, note.frontMatter.category, { tags: [...note.frontMatter.tags], project: note.frontMatter.project, } ); return { frontMatter, content: note.content, filePath: normalizePath(newFilePath), }; } catch (error) { throw new StorageMdError( `노트 복사 실패: ${note.frontMatter.id}`, 'NOTE_CLONE_ERROR', newFilePath, error ); } } /** * 볼트 내 모든 노트 로드 (배치 처리) */ export async function loadAllNotes( vaultPath: string, options: { skipInvalid?: boolean; concurrency?: number; } = {} ): Promise<MarkdownNote[]> { const { skipInvalid = true, concurrency = 10 } = options; try { logger.debug(`볼트 스캔 시작: ${vaultPath}`); // 마크다운 파일 목록 조회 const markdownFiles = await listFiles(vaultPath, /\.md$/i, true); logger.debug(`${markdownFiles.length}개 파일 발견`); // 동시성 제한하여 배치 처리 const notes: MarkdownNote[] = []; const chunks = []; for (let i = 0; i < markdownFiles.length; i += concurrency) { chunks.push(markdownFiles.slice(i, i + concurrency)); } for (const chunk of chunks) { const chunkPromises = chunk.map(async (filePath) => { try { return await loadNote(filePath, { validateFrontMatter: !skipInvalid }); } catch (error) { if (skipInvalid) { logger.warn(`노트 로드 실패, 건너뜀: ${filePath}`, error); return null; } throw error; } }); const chunkResults = await Promise.all(chunkPromises); const validNotes = chunkResults.filter((note): note is MarkdownNote => note !== null); notes.push(...validNotes); } logger.debug(`볼트 스캔 완료: ${notes.length}개 노트 로드`); return notes; } catch (error) { throw new StorageMdError( `볼트 스캔 실패: ${vaultPath}`, 'VAULT_SCAN_ERROR', vaultPath, error ); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/inchan/memory-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server