docuking-mcp 2.11.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/handlers/sync.js CHANGED
@@ -37,6 +37,17 @@ import {
37
37
 
38
38
  import { executeKingcast } from './kingcast.js';
39
39
 
40
+ import {
41
+ loadIndex,
42
+ saveIndex,
43
+ detectChanges,
44
+ updateIndexEntry,
45
+ removeIndexEntry,
46
+ updateIndexAfterSync,
47
+ computeFileHash,
48
+ getIndexStats,
49
+ } from '../lib/index-cache.js';
50
+
40
51
  // 레포지토리 매핑 (메모리에 캐시)
41
52
  const repoMapping = {};
42
53
 
@@ -522,6 +533,16 @@ docuking_init을 먼저 실행하세요.`,
522
533
  let current = 0;
523
534
  let skipped = 0;
524
535
 
536
+ // ========================================
537
+ // 로컬 인덱스 로드 (mtime 기반 변경 감지용)
538
+ // ========================================
539
+ const localIndex = loadIndex(localPath);
540
+ const indexStats = getIndexStats(localIndex);
541
+ console.error(`[DocuKing] 로컬 인덱스: ${indexStats.fileCount}개 파일 (${indexStats.totalSizeMB}MB)`);
542
+
543
+ // 동기화 후 인덱스 업데이트용 배열
544
+ const syncedFiles = [];
545
+
525
546
  // 시작 안내 메시지 출력 (AI가 사용자에게 전달할 수 있도록)
526
547
  console.error(`[DocuKing] Push 시작: ${total}개 파일 (총 ${totalSizeMB.toFixed(1)}MB)`);
527
548
  console.error(`[DocuKing] 💡 실시간 진행상황은 DocuKing 웹(https://docuking.ai)에서 확인하세요`);
@@ -582,32 +603,75 @@ docuking_init을 먼저 실행하세요.`,
582
603
  }
583
604
 
584
605
  try {
585
- // 파일 해시 계산 (변경 감지)
606
+ // ========================================
607
+ // 로컬 인덱스 기반 mtime 체크 (해시 계산 최소화)
608
+ // ========================================
609
+ const cached = localIndex.files[file.serverPath];
586
610
  let fileHash;
587
611
  let content;
588
612
  let encoding = 'utf-8';
613
+ let usedCache = false;
589
614
 
590
- if (file.fileType === 'binary') {
591
- // 바이너리 파일은 Base64로 인코딩
592
- const buffer = fs.readFileSync(file.fullPath);
593
- fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
594
- content = buffer.toString('base64');
595
- encoding = 'base64';
596
- } else {
597
- // 텍스트 파일은 UTF-8
598
- content = fs.readFileSync(file.fullPath, 'utf-8');
599
- fileHash = crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
615
+ // mtime 비교: 변경되지 않았으면 캐시된 해시 사용
616
+ if (cached) {
617
+ try {
618
+ const stat = fs.statSync(file.fullPath);
619
+ const currentMtime = stat.mtimeMs;
620
+
621
+ // mtime 동일 (1초 오차 허용) + 서버 해시와도 동일 → 완전 스킵
622
+ if (Math.abs(currentMtime - cached.mtime) < 1000) {
623
+ if (serverPathToHash[file.serverPath] === cached.hash) {
624
+ const resultText = `${progress} ⊘ ${file.serverPath} (변경 없음)`;
625
+ results.push(resultText);
626
+ skipped++;
627
+ continue;
628
+ }
629
+ // mtime 동일하지만 서버 해시 다름 → 캐시 해시 사용, 업로드 필요
630
+ fileHash = cached.hash;
631
+ usedCache = true;
632
+ }
633
+ } catch (e) {
634
+ // stat 실패 시 해시 계산으로 폴백
635
+ }
636
+ }
637
+
638
+ // 캐시 사용 못하면 파일 읽어서 해시 계산
639
+ if (!usedCache) {
640
+ if (file.fileType === 'binary') {
641
+ // 바이너리 파일은 Base64로 인코딩
642
+ const buffer = fs.readFileSync(file.fullPath);
643
+ fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
644
+ content = buffer.toString('base64');
645
+ encoding = 'base64';
646
+ } else {
647
+ // 텍스트 파일은 UTF-8
648
+ content = fs.readFileSync(file.fullPath, 'utf-8');
649
+ fileHash = crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
650
+ }
600
651
  }
601
652
 
602
653
  // Git 스타일 동기화:
603
654
  // 1. 같은 경로 + 같은 해시 → 스킵 (변경 없음)
604
655
  if (serverPathToHash[file.serverPath] === fileHash) {
656
+ // 인덱스 업데이트 (다음 Push 때 스킵 가능하도록)
657
+ syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
605
658
  const resultText = `${progress} ⊘ ${file.serverPath} (변경 없음)`;
606
659
  results.push(resultText);
607
660
  skipped++;
608
661
  continue;
609
662
  }
610
663
 
664
+ // 업로드 필요 → content가 없으면 파일 읽기
665
+ if (!content) {
666
+ if (file.fileType === 'binary') {
667
+ const buffer = fs.readFileSync(file.fullPath);
668
+ content = buffer.toString('base64');
669
+ encoding = 'base64';
670
+ } else {
671
+ content = fs.readFileSync(file.fullPath, 'utf-8');
672
+ }
673
+ }
674
+
611
675
  // 2. 같은 해시가 다른 경로에 있음 → Move (경로 변경)
612
676
  const existingPath = serverHashToPath[fileHash];
613
677
  if (existingPath && existingPath !== file.serverPath) {
@@ -632,6 +696,9 @@ docuking_init을 먼저 실행하세요.`,
632
696
  // 이동된 파일의 해시 맵 업데이트
633
697
  delete serverHashToPath[fileHash];
634
698
  serverHashToPath[fileHash] = file.serverPath;
699
+ // 인덱스에 이동된 파일 기록 (기존 경로 삭제, 새 경로 추가)
700
+ removeIndexEntry(localIndex, existingPath);
701
+ syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
635
702
  continue;
636
703
  }
637
704
  } catch (e) {
@@ -684,6 +751,8 @@ docuking_init을 먼저 실행하세요.`,
684
751
  : `${progress} ✓ ${file.path}`;
685
752
  results.push(resultText);
686
753
  success = true;
754
+ // 인덱스에 업로드된 파일 기록
755
+ syncedFiles.push({ serverPath: file.serverPath, fullPath: file.fullPath, hash: fileHash });
687
756
  break; // 성공하면 재시도 중단
688
757
  } else {
689
758
  const error = await response.text();
@@ -843,6 +912,16 @@ docuking_init을 먼저 실행하세요.`,
843
912
  }
844
913
  }
845
914
 
915
+ // ========================================
916
+ // 로컬 인덱스 업데이트 및 저장
917
+ // ========================================
918
+ if (syncedFiles.length > 0) {
919
+ updateIndexAfterSync(localIndex, syncedFiles, deletedFilePaths);
920
+ if (saveIndex(localPath, localIndex)) {
921
+ console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
922
+ }
923
+ }
924
+
846
925
  // Sync 완료 알림
847
926
  try {
848
927
  await fetch(`${API_ENDPOINT}/projects/${projectId}/sync/complete`, {
@@ -1142,6 +1221,12 @@ export async function handlePullInternal(args) {
1142
1221
  // Pull 후 mark-as-pulled할 파일 목록 (source: web 파일들)
1143
1222
  const filesToMarkAsPulled = [];
1144
1223
 
1224
+ // ========================================
1225
+ // 로컬 인덱스 로드 (Pull 후 업데이트용)
1226
+ // ========================================
1227
+ const localIndex = loadIndex(localPath);
1228
+ const syncedFiles = []; // 다운로드/업데이트된 파일 목록
1229
+
1145
1230
  for (const file of sortedFiles) {
1146
1231
  current++;
1147
1232
  const progress = `${current}/${total}`;
@@ -1264,6 +1349,9 @@ export async function handlePullInternal(args) {
1264
1349
  downloaded++;
1265
1350
  }
1266
1351
 
1352
+ // 인덱스에 다운로드된 파일 기록
1353
+ syncedFiles.push({ serverPath: file.path, fullPath, hash: serverHash });
1354
+
1267
1355
  // source: 'web' 파일이면 mark-as-pulled 대상
1268
1356
  if (serverMeta && serverMeta.source === 'web') {
1269
1357
  filesToMarkAsPulled.push(file.path);
@@ -1300,6 +1388,16 @@ export async function handlePullInternal(args) {
1300
1388
  }
1301
1389
  }
1302
1390
 
1391
+ // ========================================
1392
+ // 로컬 인덱스 업데이트 및 저장
1393
+ // ========================================
1394
+ if (syncedFiles.length > 0) {
1395
+ updateIndexAfterSync(localIndex, syncedFiles, []);
1396
+ if (saveIndex(localPath, localIndex)) {
1397
+ console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
1398
+ }
1399
+ }
1400
+
1303
1401
  // 상세 결과 구성 (Push 스타일)
1304
1402
  let resultText = `✓ Pull 완료!
1305
1403
 
@@ -0,0 +1,242 @@
1
+ /**
2
+ * DocuKing MCP - 로컬 인덱스 캐시 모듈
3
+ *
4
+ * Git의 .git/index처럼 마지막 동기화 상태를 저장하여
5
+ * mtime 기반으로 변경된 파일만 빠르게 탐지합니다.
6
+ *
7
+ * 효과:
8
+ * - 변경되지 않은 파일은 해시 계산 스킵
9
+ * - Pull/Push 시 변경된 파일만 처리
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import crypto from 'crypto';
15
+
16
+ const INDEX_VERSION = 1;
17
+ const INDEX_FILENAME = 'index.json';
18
+
19
+ /**
20
+ * 인덱스 파일 경로 반환
21
+ */
22
+ function getIndexPath(localPath) {
23
+ return path.join(localPath, '.docuking', INDEX_FILENAME);
24
+ }
25
+
26
+ /**
27
+ * 로컬 인덱스 로드
28
+ * @returns {{ version: number, lastSync: string, files: Object }} 인덱스 객체
29
+ */
30
+ export function loadIndex(localPath) {
31
+ const indexPath = getIndexPath(localPath);
32
+
33
+ try {
34
+ if (fs.existsSync(indexPath)) {
35
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
36
+
37
+ // 버전 체크 (호환되지 않으면 초기화)
38
+ if (data.version !== INDEX_VERSION) {
39
+ console.error('[DocuKing] 인덱스 버전 불일치, 재생성합니다.');
40
+ return createEmptyIndex();
41
+ }
42
+
43
+ return data;
44
+ }
45
+ } catch (e) {
46
+ console.error('[DocuKing] 인덱스 로드 실패:', e.message);
47
+ }
48
+
49
+ return createEmptyIndex();
50
+ }
51
+
52
+ /**
53
+ * 빈 인덱스 생성
54
+ */
55
+ function createEmptyIndex() {
56
+ return {
57
+ version: INDEX_VERSION,
58
+ lastSync: null,
59
+ files: {},
60
+ };
61
+ }
62
+
63
+ /**
64
+ * 인덱스 저장
65
+ */
66
+ export function saveIndex(localPath, index) {
67
+ const indexPath = getIndexPath(localPath);
68
+ const indexDir = path.dirname(indexPath);
69
+
70
+ try {
71
+ if (!fs.existsSync(indexDir)) {
72
+ fs.mkdirSync(indexDir, { recursive: true });
73
+ }
74
+
75
+ index.lastSync = new Date().toISOString();
76
+ fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8');
77
+
78
+ return true;
79
+ } catch (e) {
80
+ console.error('[DocuKing] 인덱스 저장 실패:', e.message);
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 파일 해시 계산
87
+ */
88
+ export function computeFileHash(filePath, isBinary = false) {
89
+ try {
90
+ const buffer = fs.readFileSync(filePath);
91
+ return crypto.createHash('sha256').update(buffer).digest('hex');
92
+ } catch (e) {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * mtime 기반으로 변경된 파일 탐지
99
+ *
100
+ * @param {string} localPath - 프로젝트 루트 경로
101
+ * @param {Object} index - 로컬 인덱스
102
+ * @param {string[]} filePaths - 검사할 파일 경로 목록 (serverPath 형식)
103
+ * @param {Function} getFullPath - serverPath → fullPath 변환 함수
104
+ * @returns {{ changed: Array, unchanged: Array, newFiles: Array, deleted: Array }}
105
+ */
106
+ export function detectChanges(localPath, index, filePaths, getFullPath) {
107
+ const result = {
108
+ changed: [], // mtime 변경됨 (해시 계산 필요)
109
+ unchanged: [], // mtime 동일 (해시 계산 스킵)
110
+ newFiles: [], // 인덱스에 없음 (새 파일)
111
+ deleted: [], // 로컬에 없음 (삭제됨)
112
+ };
113
+
114
+ for (const serverPath of filePaths) {
115
+ const fullPath = getFullPath(serverPath);
116
+
117
+ // 로컬 파일 존재 여부 확인
118
+ if (!fs.existsSync(fullPath)) {
119
+ result.deleted.push(serverPath);
120
+ continue;
121
+ }
122
+
123
+ // 인덱스에 있는지 확인
124
+ const cached = index.files[serverPath];
125
+ if (!cached) {
126
+ result.newFiles.push(serverPath);
127
+ continue;
128
+ }
129
+
130
+ // mtime 비교
131
+ try {
132
+ const stat = fs.statSync(fullPath);
133
+ const currentMtime = stat.mtimeMs;
134
+
135
+ if (Math.abs(currentMtime - cached.mtime) < 1000) {
136
+ // mtime 동일 (1초 오차 허용) → 변경 없음
137
+ result.unchanged.push(serverPath);
138
+ } else {
139
+ // mtime 다름 → 변경됨
140
+ result.changed.push(serverPath);
141
+ }
142
+ } catch (e) {
143
+ // stat 실패 → 변경됨으로 처리
144
+ result.changed.push(serverPath);
145
+ }
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ /**
152
+ * 인덱스에 파일 정보 업데이트
153
+ */
154
+ export function updateIndexEntry(index, serverPath, fullPath, hash) {
155
+ try {
156
+ const stat = fs.statSync(fullPath);
157
+
158
+ index.files[serverPath] = {
159
+ hash,
160
+ mtime: stat.mtimeMs,
161
+ size: stat.size,
162
+ };
163
+
164
+ return true;
165
+ } catch (e) {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 인덱스에서 파일 정보 삭제
172
+ */
173
+ export function removeIndexEntry(index, serverPath) {
174
+ delete index.files[serverPath];
175
+ }
176
+
177
+ /**
178
+ * 인덱스의 해시와 비교하여 실제 변경 여부 확인
179
+ * (mtime 변경됐지만 내용은 같을 수 있음)
180
+ *
181
+ * @returns {{ actuallyChanged: Array, sameContent: Array }}
182
+ */
183
+ export function verifyChanges(index, changedPaths, getFullPath) {
184
+ const result = {
185
+ actuallyChanged: [], // 해시도 다름 (실제 변경)
186
+ sameContent: [], // 해시 동일 (mtime만 변경)
187
+ };
188
+
189
+ for (const serverPath of changedPaths) {
190
+ const fullPath = getFullPath(serverPath);
191
+ const cached = index.files[serverPath];
192
+
193
+ if (!cached) {
194
+ result.actuallyChanged.push(serverPath);
195
+ continue;
196
+ }
197
+
198
+ const currentHash = computeFileHash(fullPath);
199
+
200
+ if (currentHash === cached.hash) {
201
+ result.sameContent.push(serverPath);
202
+ } else {
203
+ result.actuallyChanged.push(serverPath);
204
+ }
205
+ }
206
+
207
+ return result;
208
+ }
209
+
210
+ /**
211
+ * 동기화 후 인덱스 일괄 업데이트
212
+ *
213
+ * @param {Object} index - 인덱스 객체
214
+ * @param {Array} syncedFiles - 동기화된 파일 목록 [{ serverPath, fullPath, hash }]
215
+ * @param {Array} deletedPaths - 삭제된 파일 경로 목록
216
+ */
217
+ export function updateIndexAfterSync(index, syncedFiles, deletedPaths = []) {
218
+ // 동기화된 파일 업데이트
219
+ for (const file of syncedFiles) {
220
+ updateIndexEntry(index, file.serverPath, file.fullPath, file.hash);
221
+ }
222
+
223
+ // 삭제된 파일 제거
224
+ for (const serverPath of deletedPaths) {
225
+ removeIndexEntry(index, serverPath);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 인덱스 통계 반환
231
+ */
232
+ export function getIndexStats(index) {
233
+ const files = Object.keys(index.files);
234
+ const totalSize = Object.values(index.files).reduce((sum, f) => sum + (f.size || 0), 0);
235
+
236
+ return {
237
+ fileCount: files.length,
238
+ totalSize,
239
+ totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
240
+ lastSync: index.lastSync,
241
+ };
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docuking-mcp",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "DocuKing MCP Server - AI 시대의 문서 협업 플랫폼",
5
5
  "type": "module",
6
6
  "main": "index.js",