docuking-mcp 2.11.1 → 2.13.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,14 +1221,54 @@ export async function handlePullInternal(args) {
1142
1221
  // Pull 후 mark-as-pulled할 파일 목록 (source: web 파일들)
1143
1222
  const filesToMarkAsPulled = [];
1144
1223
 
1145
- for (const file of sortedFiles) {
1224
+ // ========================================
1225
+ // 로컬 인덱스 로드 (Pull 후 업데이트용)
1226
+ // ========================================
1227
+ const localIndex = loadIndex(localPath);
1228
+ const syncedFiles = []; // 다운로드/업데이트된 파일 목록
1229
+
1230
+ // ========================================
1231
+ // 폴더 먼저 처리
1232
+ // ========================================
1233
+ const folders = sortedFiles.filter(f => f.type === 'folder');
1234
+ const files_only = sortedFiles.filter(f => f.type !== 'folder');
1235
+
1236
+ for (const folder of folders) {
1146
1237
  current++;
1147
- const progress = `${current}/${total}`;
1238
+ const serverMeta = serverPathToMeta[folder.path];
1239
+ let fullPath;
1240
+ if (folder.path.startsWith('xx_') ||
1241
+ folder.path.startsWith('yy_All_Docu/') ||
1242
+ folder.path.startsWith('yy_All_Docu') ||
1243
+ folder.path.startsWith('yy_Coworker_') ||
1244
+ folder.path.startsWith('zz_ai_')) {
1245
+ fullPath = path.join(localPath, folder.path);
1246
+ } else {
1247
+ fullPath = path.join(mainFolderPath, folder.path);
1248
+ }
1148
1249
 
1149
- // 서버 메타 정보 조회
1150
- const serverMeta = serverPathToMeta[file.path];
1250
+ if (fs.existsSync(fullPath)) {
1251
+ results.push({ type: 'skip', path: folder.path, itemType: 'folder' });
1252
+ skipped++;
1253
+ } else {
1254
+ fs.mkdirSync(fullPath, { recursive: true });
1255
+ results.push({ type: 'download', path: folder.path, itemType: 'folder' });
1256
+ foldersCreated++;
1257
+ downloaded++;
1258
+ if (serverMeta && serverMeta.source === 'web') {
1259
+ filesToMarkAsPulled.push(folder.path);
1260
+ }
1261
+ }
1262
+ }
1151
1263
 
1152
- // 서버 경로에 따라 로컬 저장 위치 결정
1264
+ // ========================================
1265
+ // 파일 처리 - Batch API 사용 (200개씩)
1266
+ // ========================================
1267
+ const BATCH_SIZE = 200;
1268
+
1269
+ // 먼저 로컬 해시 기반으로 스킵할 파일 필터링
1270
+ const filesToDownload = [];
1271
+ for (const file of files_only) {
1153
1272
  let fullPath;
1154
1273
  if (file.path.startsWith('xx_') ||
1155
1274
  file.path.startsWith('yy_All_Docu/') ||
@@ -1161,116 +1280,159 @@ export async function handlePullInternal(args) {
1161
1280
  fullPath = path.join(mainFolderPath, file.path);
1162
1281
  }
1163
1282
 
1164
- try {
1165
- // ========================================
1166
- // 폴더 처리 (type === 'folder')
1167
- // ========================================
1168
- if (file.type === 'folder') {
1169
- if (fs.existsSync(fullPath)) {
1170
- // 이미 있음 → 스킵
1171
- results.push({ type: 'skip', path: file.path, itemType: 'folder' });
1172
- skipped++;
1173
- } else {
1174
- // 폴더 생성
1175
- fs.mkdirSync(fullPath, { recursive: true });
1176
- results.push({ type: 'download', path: file.path, itemType: 'folder' });
1177
- foldersCreated++;
1178
- downloaded++;
1283
+ const serverHash = serverPathToHash[file.path];
1179
1284
 
1180
- // source: 'web' 폴더면 mark-as-pulled 대상
1181
- if (serverMeta && serverMeta.source === 'web') {
1182
- filesToMarkAsPulled.push(file.path);
1183
- }
1285
+ // 로컬 파일 해시 비교
1286
+ if (fs.existsSync(fullPath)) {
1287
+ try {
1288
+ const localStat = fs.statSync(fullPath);
1289
+ if (localStat.isDirectory()) {
1290
+ results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
1291
+ skipped++;
1292
+ current++;
1293
+ continue;
1184
1294
  }
1185
- continue;
1295
+ const localBuffer = fs.readFileSync(fullPath);
1296
+ const localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
1297
+ if (localHash === serverHash) {
1298
+ results.push({ type: 'skip', path: file.path });
1299
+ skipped++;
1300
+ current++;
1301
+ continue;
1302
+ }
1303
+ } catch (e) {
1304
+ // 해시 계산 실패 시 다운로드 대상
1186
1305
  }
1306
+ }
1187
1307
 
1188
- // ========================================
1189
- // 파일 처리 (type === 'file')
1190
- // ========================================
1191
- const response = await fetch(
1192
- `${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
1193
- {
1194
- headers: {
1195
- 'Authorization': `Bearer ${apiKey}`,
1196
- },
1197
- }
1198
- );
1308
+ filesToDownload.push({ ...file, fullPath });
1309
+ }
1199
1310
 
1200
- if (!response.ok) {
1201
- results.push({ type: 'fail', path: file.path, error: await response.text() });
1202
- failed++;
1203
- continue;
1204
- }
1311
+ console.error(`[DocuKing] Batch Pull: ${filesToDownload.length}개 파일 다운로드 필요 (${Math.ceil(filesToDownload.length / BATCH_SIZE)}개 배치)`);
1205
1312
 
1206
- const data = await response.json();
1313
+ // Batch API로 파일 다운로드
1314
+ for (let i = 0; i < filesToDownload.length; i += BATCH_SIZE) {
1315
+ const batch = filesToDownload.slice(i, i + BATCH_SIZE);
1316
+ const batchPaths = batch.map(f => f.path);
1317
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
1318
+ const totalBatches = Math.ceil(filesToDownload.length / BATCH_SIZE);
1319
+
1320
+ console.error(`[DocuKing] Batch ${batchNum}/${totalBatches}: ${batch.length}개 파일 다운로드 중...`);
1207
1321
 
1208
- // 인코딩에 따라 저장
1209
- const content = data.file?.content || data.content || '';
1210
- const encoding = data.file?.encoding || data.encoding || 'utf-8';
1322
+ try {
1323
+ const batchResponse = await fetch(`${API_ENDPOINT}/files/batch-pull`, {
1324
+ method: 'POST',
1325
+ headers: {
1326
+ 'Content-Type': 'application/json',
1327
+ 'Authorization': `Bearer ${apiKey}`,
1328
+ },
1329
+ body: JSON.stringify({
1330
+ projectId,
1331
+ paths: batchPaths,
1332
+ }),
1333
+ });
1211
1334
 
1212
- // 서버 파일 해시 계산
1213
- let serverBuffer;
1214
- if (encoding === 'base64') {
1215
- serverBuffer = Buffer.from(content, 'base64');
1216
- } else {
1217
- serverBuffer = Buffer.from(content, 'utf-8');
1335
+ if (!batchResponse.ok) {
1336
+ const errorText = await batchResponse.text();
1337
+ console.error(`[DocuKing] Batch Pull 실패: ${errorText}`);
1338
+ // 배치 실패 시 모든 파일 실패 처리
1339
+ for (const file of batch) {
1340
+ results.push({ type: 'fail', path: file.path, error: 'Batch request failed' });
1341
+ failed++;
1342
+ current++;
1343
+ }
1344
+ continue;
1218
1345
  }
1219
- const serverHash = crypto.createHash('sha256').update(serverBuffer).digest('hex');
1220
1346
 
1221
- // 로컬 파일 존재 여부 및 해시 비교
1222
- let localExists = fs.existsSync(fullPath);
1223
- let localHash = null;
1347
+ const batchData = await batchResponse.json();
1348
+ const filesData = batchData.files || [];
1349
+
1350
+ // 파일별 처리
1351
+ for (const file of batch) {
1352
+ current++;
1353
+ const fileData = filesData.find(f => f.path === file.path);
1354
+ const serverMeta = serverPathToMeta[file.path];
1355
+
1356
+ if (!fileData || fileData.error) {
1357
+ results.push({ type: 'fail', path: file.path, error: fileData?.error || 'Not found' });
1358
+ failed++;
1359
+ continue;
1360
+ }
1224
1361
 
1225
- if (localExists) {
1226
1362
  try {
1227
- const localStat = fs.statSync(fullPath);
1228
- if (localStat.isDirectory()) {
1229
- // 로컬에 같은 이름의 폴더가 있으면 건너뜀
1230
- results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
1231
- skipped++;
1232
- continue;
1363
+ const content = fileData.content || '';
1364
+ const encoding = fileData.encoding || 'utf-8';
1365
+
1366
+ // 서버 파일 버퍼
1367
+ let serverBuffer;
1368
+ if (encoding === 'base64') {
1369
+ serverBuffer = Buffer.from(content, 'base64');
1370
+ } else {
1371
+ serverBuffer = Buffer.from(content, 'utf-8');
1233
1372
  }
1234
- const localBuffer = fs.readFileSync(fullPath);
1235
- localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
1236
- } catch (e) {
1237
- localExists = false;
1238
- }
1239
- }
1373
+ const serverHash = fileData.hash || crypto.createHash('sha256').update(serverBuffer).digest('hex');
1240
1374
 
1241
- // 변경 감지
1242
- if (localExists && localHash === serverHash) {
1243
- // 변경 없음 - 스킵
1244
- results.push({ type: 'skip', path: file.path });
1245
- skipped++;
1246
- continue;
1247
- }
1375
+ // 로컬 존재 여부
1376
+ const localExists = fs.existsSync(file.fullPath);
1248
1377
 
1249
- // 디렉토리 생성
1250
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1378
+ // 디렉토리 생성
1379
+ fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
1251
1380
 
1252
- // 파일 저장
1253
- if (encoding === 'base64') {
1254
- fs.writeFileSync(fullPath, serverBuffer);
1255
- } else {
1256
- fs.writeFileSync(fullPath, content, 'utf-8');
1257
- }
1381
+ // 파일 저장
1382
+ if (encoding === 'base64') {
1383
+ fs.writeFileSync(file.fullPath, serverBuffer);
1384
+ } else {
1385
+ fs.writeFileSync(file.fullPath, content, 'utf-8');
1386
+ }
1258
1387
 
1259
- if (localExists) {
1260
- results.push({ type: 'update', path: file.path });
1261
- updated++;
1262
- } else {
1263
- results.push({ type: 'download', path: file.path });
1264
- downloaded++;
1265
- }
1388
+ if (localExists) {
1389
+ results.push({ type: 'update', path: file.path });
1390
+ updated++;
1391
+ } else {
1392
+ results.push({ type: 'download', path: file.path });
1393
+ downloaded++;
1394
+ }
1266
1395
 
1267
- // source: 'web' 파일이면 mark-as-pulled 대상
1268
- if (serverMeta && serverMeta.source === 'web') {
1269
- filesToMarkAsPulled.push(file.path);
1396
+ // 인덱스에 기록
1397
+ syncedFiles.push({ serverPath: file.path, fullPath: file.fullPath, hash: serverHash });
1398
+
1399
+ // source: 'web' 파일이면 mark-as-pulled 대상
1400
+ if (serverMeta && serverMeta.source === 'web') {
1401
+ filesToMarkAsPulled.push(file.path);
1402
+ }
1403
+ } catch (e) {
1404
+ results.push({ type: 'fail', path: file.path, error: e.message });
1405
+ failed++;
1406
+ }
1270
1407
  }
1271
1408
  } catch (e) {
1272
- results.push({ type: 'fail', path: file.path, error: e.message });
1273
- failed++;
1409
+ console.error(`[DocuKing] Batch ${batchNum} 네트워크 오류: ${e.message}`);
1410
+ // 배치 실패 시 개별 요청으로 폴백
1411
+ for (const file of batch) {
1412
+ current++;
1413
+ try {
1414
+ const response = await fetch(
1415
+ `${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
1416
+ { headers: { 'Authorization': `Bearer ${apiKey}` } }
1417
+ );
1418
+ if (!response.ok) {
1419
+ results.push({ type: 'fail', path: file.path, error: await response.text() });
1420
+ failed++;
1421
+ continue;
1422
+ }
1423
+ const data = await response.json();
1424
+ const content = data.file?.content || data.content || '';
1425
+ const encoding = data.file?.encoding || data.encoding || 'utf-8';
1426
+ let serverBuffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8');
1427
+ fs.mkdirSync(path.dirname(file.fullPath), { recursive: true });
1428
+ fs.writeFileSync(file.fullPath, encoding === 'base64' ? serverBuffer : content, encoding === 'base64' ? undefined : 'utf-8');
1429
+ results.push({ type: 'download', path: file.path });
1430
+ downloaded++;
1431
+ } catch (fallbackErr) {
1432
+ results.push({ type: 'fail', path: file.path, error: fallbackErr.message });
1433
+ failed++;
1434
+ }
1435
+ }
1274
1436
  }
1275
1437
  }
1276
1438
 
@@ -1300,6 +1462,16 @@ export async function handlePullInternal(args) {
1300
1462
  }
1301
1463
  }
1302
1464
 
1465
+ // ========================================
1466
+ // 로컬 인덱스 업데이트 및 저장
1467
+ // ========================================
1468
+ if (syncedFiles.length > 0) {
1469
+ updateIndexAfterSync(localIndex, syncedFiles, []);
1470
+ if (saveIndex(localPath, localIndex)) {
1471
+ console.error(`[DocuKing] 로컬 인덱스 업데이트: ${syncedFiles.length}개 파일`);
1472
+ }
1473
+ }
1474
+
1303
1475
  // 상세 결과 구성 (Push 스타일)
1304
1476
  let resultText = `✓ Pull 완료!
1305
1477
 
@@ -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.13.0",
4
4
  "description": "DocuKing MCP Server - AI 시대의 문서 협업 플랫폼",
5
5
  "type": "module",
6
6
  "main": "index.js",