claude-session-continuity-mcp 1.13.1 → 1.13.2

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.
@@ -265,6 +265,7 @@ async function main() {
265
265
  process.exit(0);
266
266
  }
267
267
  const db = new Database(dbPath);
268
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
268
269
  // hot_paths 추적 (모든 추적 도구)
269
270
  try {
270
271
  const pathType = filePath.includes('.') ? 'file' : 'directory';
@@ -209,6 +209,7 @@ async function main() {
209
209
  process.exit(0);
210
210
  }
211
211
  const db = new Database(dbPath);
212
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
212
213
  // 핸드오버 컨텍스트 빌드
213
214
  const handover = input.transcript ? buildHandoverContext(input.transcript) : null;
214
215
  // active_context 업데이트 (memories에는 저장하지 않음)
@@ -284,7 +285,8 @@ async function main() {
284
285
  process.exit(0);
285
286
  }
286
287
  catch (e) {
287
- // 에러 조용히 종료
288
+ // fail-soft: 컴팩션이 멈추지 않도록 continue:true 반드시 반환
289
+ process.stdout.write(JSON.stringify({ continue: true }));
288
290
  process.exit(0);
289
291
  }
290
292
  }
@@ -12,6 +12,7 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as readline from 'readline';
15
+ import * as crypto from 'crypto';
15
16
  import Database from 'better-sqlite3';
16
17
  function detectWorkspaceRoot(cwd) {
17
18
  let current = cwd;
@@ -238,6 +239,8 @@ async function parseTranscriptSinglePass(transcriptPath) {
238
239
  userRequests: { firstRequest: '', allRequests: [] },
239
240
  recentAssistantMessages: [],
240
241
  errorFixPairs: [],
242
+ firstTimestamp: null,
243
+ lastTimestamp: null,
241
244
  };
242
245
  if (!transcriptPath || !fs.existsSync(transcriptPath))
243
246
  return result;
@@ -267,6 +270,12 @@ async function parseTranscriptSinglePass(transcriptPath) {
267
270
  const entry = JSON.parse(line);
268
271
  const role = entry.type || entry.role || '';
269
272
  const content = entry.message?.content;
273
+ // === 0. Timestamp 추출 (세션 duration 계산용) ===
274
+ if (entry.timestamp) {
275
+ if (!result.firstTimestamp)
276
+ result.firstTimestamp = entry.timestamp;
277
+ result.lastTimestamp = entry.timestamp;
278
+ }
270
279
  // === 1. Commit 추출 (tool_use 블록에서) ===
271
280
  if (line.includes('git commit') && Array.isArray(content)) {
272
281
  for (const block of content) {
@@ -343,9 +352,9 @@ async function parseTranscriptSinglePass(transcriptPath) {
343
352
  }
344
353
  }
345
354
  result.decisions = [...decisionSet].slice(0, 3);
346
- // Error-Fix pairs
347
- const errorRe = /(?:error|Error|ERROR|오류|실패|FAILED|Exception)[:\s](.{5,80})/;
348
- const fixRe = /(?:fixed|resolved|수정|해결|Added|수정 완료)/i;
355
+ // Error-Fix pairs (한국어 패턴 보강)
356
+ const errorRe = /(?:error|Error|ERROR|오류|에러|버그|예외|실패|FAILED|Exception|TypeError|ReferenceError|SyntaxError|crash|crashed|충돌|문제)[:\s](.{5,80})/;
357
+ const fixRe = /(?:fixed|resolved|patched|수정|해결|고침|처리|완료|변경|적용|반영|커밋|Added|수정 완료|문제 해결|해결됨|되돌림)/i;
349
358
  const pairSet = new Set();
350
359
  for (let i = 0; i < recentEntries.length - 1; i++) {
351
360
  const errorMatch = recentEntries[i].text.match(errorRe);
@@ -370,6 +379,45 @@ async function parseTranscriptSinglePass(transcriptPath) {
370
379
  result.recentAssistantMessages = result.recentAssistantMessages.slice(-5);
371
380
  return result;
372
381
  }
382
+ /**
383
+ * 슬래시 커맨드 prefix 제거 — "/mcp-dev 측정해줘" → "측정해줘"
384
+ * 첫 토큰이 `/`로 시작하면 다음 의미 토큰까지 스킵
385
+ * 슬래시뿐이면 빈 문자열 반환 (호출자가 폴백 처리)
386
+ */
387
+ function stripSlashPrefix(text) {
388
+ const trimmed = text.trim();
389
+ if (!trimmed.startsWith('/'))
390
+ return trimmed;
391
+ // 첫 줄에서 슬래시 토큰을 제거
392
+ const firstLine = trimmed.split('\n')[0];
393
+ const tokens = firstLine.split(/\s+/);
394
+ let i = 0;
395
+ while (i < tokens.length && tokens[i].startsWith('/'))
396
+ i++;
397
+ const rest = tokens.slice(i).join(' ').trim();
398
+ if (rest.length >= 3)
399
+ return rest;
400
+ // 다음 줄에 의미 있는 본문이 있으면 사용
401
+ const lines = trimmed.split('\n').slice(1).map(l => l.trim()).filter(l => l.length >= 3 && !l.startsWith('/'));
402
+ return lines[0] || '';
403
+ }
404
+ /**
405
+ * Jaccard 유사도 (토큰 단위) — 0~1 사이
406
+ * 동일하면 1, 완전 다르면 0
407
+ */
408
+ function jaccardSimilarity(a, b) {
409
+ const tokenize = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(t => t.length >= 2));
410
+ const setA = tokenize(a);
411
+ const setB = tokenize(b);
412
+ if (setA.size === 0 || setB.size === 0)
413
+ return 0;
414
+ let intersect = 0;
415
+ for (const t of setA)
416
+ if (setB.has(t))
417
+ intersect++;
418
+ const union = setA.size + setB.size - intersect;
419
+ return union === 0 ? 0 : intersect / union;
420
+ }
373
421
  /**
374
422
  * 사용자 메시지들을 세션 요약으로 압축
375
423
  * 예: ["MCP 테스트해줘", "개선해줘", "npm 배포하고 커밋해줘"] → "MCP 테스트 + 개선 + npm 배포/커밋"
@@ -377,12 +425,24 @@ async function parseTranscriptSinglePass(transcriptPath) {
377
425
  function summarizeUserRequests(requests) {
378
426
  if (requests.length === 0)
379
427
  return '';
380
- if (requests.length === 1)
381
- return requests[0];
428
+ // 슬래시 커맨드 도움말 본문(/work, /clone-pro 등이 첫 줄에 박히는 케이스) 제외
429
+ // → 36건 동일 last_work 누적 문제 해결
430
+ const meaningful = requests
431
+ .map(r => stripSlashPrefix(r))
432
+ .filter(r => {
433
+ if (!r)
434
+ return false;
435
+ if (/^[A-Z][a-z]+\s+(skill|command):/i.test(r))
436
+ return false;
437
+ return r.length > 0;
438
+ });
439
+ const source = meaningful.length > 0 ? meaningful : requests;
440
+ if (source.length === 1)
441
+ return source[0];
382
442
  // 중복/유사 요청 제거 (앞 20글자 기준)
383
443
  const unique = [];
384
444
  const seen = new Set();
385
- for (const req of requests) {
445
+ for (const req of source) {
386
446
  const key = req.slice(0, 20).toLowerCase();
387
447
  if (!seen.has(key)) {
388
448
  seen.add(key);
@@ -407,20 +467,59 @@ async function main() {
407
467
  inputData += chunk;
408
468
  }
409
469
  const input = inputData ? JSON.parse(inputData) : {};
470
+ // 중복 호출 가드 1: stop_hook_active 플래그 (Claude Code 공식 플래그)
471
+ if (input.stop_hook_active === true) {
472
+ process.exit(0);
473
+ }
410
474
  const cwd = input.cwd || process.cwd();
411
475
  const project = detectProject(cwd);
412
476
  const dbPath = getDbPath(cwd);
477
+ // 중복 호출 가드 2: transcript_path 해시 기반 5초 윈도우 파일락
478
+ // Phase 3: session_id 5초 락 도입 (sid 있는 호출만 차단됨)
479
+ // Phase 5: 실측에서 모든 stop이 [sid 있는 호출 + sid 없는 호출] 페어로 들어옴
480
+ // → transcript_path 해시를 우선 키로 사용해야 같은 페어가 같은 락을 공유
481
+ // → transcript_path 없을 때만 session_id 폴백
482
+ const lockKey = input.transcript_path
483
+ ? crypto.createHash('md5').update(input.transcript_path).digest('hex').slice(0, 16)
484
+ : (input.session_id || null);
485
+ if (lockKey) {
486
+ const lockPath = path.join(path.dirname(dbPath), `.session-end-${lockKey}.lock`);
487
+ const now = Date.now();
488
+ try {
489
+ // Phase 5: atomic `wx` (존재 시 EEXIST throw) → 두 hook 인스턴스가 거의 동시에 진입하는 race 차단
490
+ // 베이스라인: id=1606/1607이 ~500ms 차이로 동시 INSERT 됨 (debug.log 13:26:51.205 + 13:26:51.702)
491
+ fs.writeFileSync(lockPath, String(now), { flag: 'wx' });
492
+ }
493
+ catch (e) {
494
+ // 파일이 이미 존재 (다른 hook 인스턴스가 처리 중) → mtime 확인 후 5초 내면 차단
495
+ if (e.code === 'EEXIST') {
496
+ try {
497
+ const lockMtime = fs.statSync(lockPath).mtimeMs;
498
+ if (now - lockMtime < 5000) {
499
+ process.exit(0); // 5초 내 재발화 차단
500
+ }
501
+ // 5초 지난 stale 락 → 덮어쓰기 (이 호출이 새 작업)
502
+ fs.writeFileSync(lockPath, String(now));
503
+ }
504
+ catch {
505
+ // stat/write 실패는 fail-soft
506
+ }
507
+ }
508
+ // 그 외 락 파일 에러는 무시 (fail-soft)
509
+ }
510
+ }
413
511
  // 디버그 로그
414
512
  const debugLogPath = path.join(path.dirname(dbPath), 'session-end-debug.log');
415
513
  const inputKeys = Object.keys(input);
416
514
  const lastMsgLen = input.last_assistant_message?.length || 0;
417
- const debugLine = `[${new Date().toISOString()}] project=${project} keys=[${inputKeys.join(',')}] transcript_path=${input.transcript_path || 'none'} last_msg_len=${lastMsgLen}\n`;
515
+ const debugLine = `[${new Date().toISOString()}] project=${project} sid=${input.session_id?.slice(0, 8) || 'none'} keys=[${inputKeys.join(',')}] transcript_path=${input.transcript_path || 'none'} last_msg_len=${lastMsgLen}\n`;
418
516
  fs.appendFileSync(debugLogPath, debugLine);
419
517
  if (!fs.existsSync(dbPath)) {
420
518
  console.log('[SessionEnd] No DB found, skipping');
421
519
  process.exit(0);
422
520
  }
423
521
  const db = new Database(dbPath);
522
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
424
523
  // === 추출 시작 ===
425
524
  let lastWork = '';
426
525
  let nextTasks = [];
@@ -433,6 +532,8 @@ async function main() {
433
532
  userRequests: { firstRequest: '', allRequests: [] },
434
533
  recentAssistantMessages: [],
435
534
  errorFixPairs: [],
535
+ firstTimestamp: null,
536
+ lastTimestamp: null,
436
537
  };
437
538
  if (input.transcript_path) {
438
539
  transcript = await parseTranscriptSinglePass(input.transcript_path);
@@ -441,7 +542,9 @@ async function main() {
441
542
  decisions = transcript.decisions;
442
543
  }
443
544
  // lastWork 결정 (우선순위 폴백)
444
- const { firstRequest, allRequests } = transcript.userRequests;
545
+ const { firstRequest: rawFirstRequest, allRequests } = transcript.userRequests;
546
+ // firstRequest 슬래시 prefix 제거 (예: "/mcp-dev 측정" → "측정")
547
+ const firstRequest = rawFirstRequest ? (stripSlashPrefix(rawFirstRequest) || rawFirstRequest) : '';
445
548
  // 2a: 사용자 요청 + 커밋 메시지 조합 (가장 이상적)
446
549
  if (firstRequest && commitMessages.length > 0) {
447
550
  lastWork = `${firstRequest} → ${commitMessages.slice(0, 2).join('; ')}`;
@@ -519,23 +622,52 @@ async function main() {
519
622
  const fileNames = modifiedFiles.slice(0, 5).map(f => path.basename(f)).join(', ');
520
623
  lastWork = `Modified files: ${fileNames}`;
521
624
  }
625
+ // Phase 5: 모든 last_work 결정 경로에 stripSlashPrefix 강제 적용
626
+ // 베이스라인: 2a 경로(firstRequest)만 stripSlashPrefix 적용되고 2c~2e 폴백은 미적용
627
+ // → 같은 transcript에서 두 hook 인스턴스가 서로 다른 경로로 들어가
628
+ // 한쪽은 "측정", 한쪽은 "/mcp-dev 측정"으로 분기 → Jaccard 0.85 미달로 둘 다 통과
629
+ // stripSlashPrefix가 빈 문자열을 반환하면 원본 유지 (의미 토큰이 없는 경우)
630
+ if (lastWork) {
631
+ const stripped = stripSlashPrefix(lastWork);
632
+ if (stripped)
633
+ lastWork = stripped;
634
+ }
522
635
  // 빈 세션 skip
523
636
  if (!lastWork) {
524
637
  console.log(`[SessionEnd] Skipping empty session for ${project} (no meaningful last_work)`);
525
638
  db.close();
526
639
  process.exit(0);
527
640
  }
528
- // 중복 저장 방지 (같은 내용이 1시간 이내에 저장됐으면 skip)
529
- const recentDup = db.prepare(`
641
+ // 중복 저장 방지 Jaccard 유사도 기반 (1시간 이내, 동일 프로젝트)
642
+ // 베이스라인: 동일 last_work가 4~5분 간격으로 반복 INSERT되는 케이스 5건/24h 발견
643
+ // 정확 일치 + Jaccard >= 0.85 둘 다 차단
644
+ const recentExact = db.prepare(`
530
645
  SELECT id FROM sessions
531
646
  WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-1 hour')
532
647
  LIMIT 1
533
648
  `).get(project, lastWork);
534
- if (recentDup) {
535
- console.log(`[SessionEnd] Skipping duplicate session for ${project}`);
649
+ if (recentExact) {
650
+ console.log(`[SessionEnd] Skipping duplicate (exact) for ${project}`);
536
651
  db.close();
537
652
  process.exit(0);
538
653
  }
654
+ const recentRows = db.prepare(`
655
+ SELECT last_work FROM sessions
656
+ WHERE project = ? AND timestamp > datetime('now', '-1 hour')
657
+ ORDER BY timestamp DESC LIMIT 10
658
+ `).all(project);
659
+ for (const row of recentRows) {
660
+ if (!row.last_work)
661
+ continue;
662
+ // Phase 5: 비교 전 양쪽 모두 stripSlashPrefix 정규화 → 표현 차이 흡수
663
+ const normalizedCurrent = stripSlashPrefix(lastWork) || lastWork;
664
+ const normalizedRow = stripSlashPrefix(row.last_work) || row.last_work;
665
+ if (jaccardSimilarity(normalizedCurrent, normalizedRow) >= 0.85) {
666
+ console.log(`[SessionEnd] Skipping near-duplicate (jaccard >= 0.85) for ${project}`);
667
+ db.close();
668
+ process.exit(0);
669
+ }
670
+ }
539
671
  // 구조화 메타데이터 (issues 컬럼 활용)
540
672
  const metadata = {
541
673
  commits: commitMessages,
@@ -543,11 +675,20 @@ async function main() {
543
675
  errorsSolved
544
676
  };
545
677
  const hasMetadata = commitMessages.length > 0 || decisions.length > 0 || errorsSolved.length > 0;
678
+ // 세션 duration 계산 (transcript first ↔ last timestamp)
679
+ let durationMinutes = null;
680
+ if (transcript.firstTimestamp && transcript.lastTimestamp) {
681
+ const start = new Date(transcript.firstTimestamp).getTime();
682
+ const end = new Date(transcript.lastTimestamp).getTime();
683
+ if (!isNaN(start) && !isNaN(end) && end >= start) {
684
+ durationMinutes = Math.round((end - start) / 60000);
685
+ }
686
+ }
546
687
  // 세션 기록 저장
547
688
  db.prepare(`
548
- INSERT INTO sessions (project, last_work, next_tasks, modified_files, issues)
549
- VALUES (?, ?, ?, ?, ?)
550
- `).run(project, lastWork, JSON.stringify([...new Set(nextTasks)].slice(0, 5)), JSON.stringify(modifiedFiles.slice(0, 15)), hasMetadata ? JSON.stringify(metadata) : null);
689
+ INSERT INTO sessions (project, last_work, next_tasks, modified_files, issues, duration_minutes)
690
+ VALUES (?, ?, ?, ?, ?, ?)
691
+ `).run(project, lastWork, JSON.stringify([...new Set(nextTasks)].slice(0, 5)), JSON.stringify(modifiedFiles.slice(0, 15)), hasMetadata ? JSON.stringify(metadata) : null, durationMinutes);
551
692
  // 활성 컨텍스트 업데이트
552
693
  db.prepare(`
553
694
  INSERT OR REPLACE INTO active_context (project, current_state, recent_files, updated_at)
@@ -588,6 +729,35 @@ async function main() {
588
729
  }
589
730
  catch { /* project_context table may not exist */ }
590
731
  }
732
+ // 고품질 자동 메모리 추출 (v1.10 노이즈 제거 정책 유지하면서 가치 있는 것만)
733
+ // - decisions: 의미있는 의사결정 (importance=7)
734
+ // - commits: feat/fix만 (importance=6)
735
+ // - 동일 content 중복 방지 (검색해서 없을 때만 INSERT)
736
+ try {
737
+ const memoryDupCheck = db.prepare('SELECT id FROM memories WHERE project = ? AND content = ? LIMIT 1');
738
+ const memoryInsert = db.prepare(`
739
+ INSERT INTO memories (content, memory_type, tags, project, importance)
740
+ VALUES (?, ?, ?, ?, ?)
741
+ `);
742
+ for (const decision of decisions) {
743
+ if (decision.length < 15)
744
+ continue;
745
+ if (!memoryDupCheck.get(project, decision)) {
746
+ memoryInsert.run(decision, 'decision', JSON.stringify(['auto-extracted']), project, 7);
747
+ }
748
+ }
749
+ // feat/fix 커밋만 (chore, docs, style은 학습 가치 낮음)
750
+ for (const commit of commitMessages) {
751
+ if (!/^(feat|fix)(\(.+\))?:/i.test(commit))
752
+ continue;
753
+ if (commit.length < 20)
754
+ continue;
755
+ if (!memoryDupCheck.get(project, commit)) {
756
+ memoryInsert.run(commit, 'learning', JSON.stringify(['auto-extracted', 'commit']), project, 6);
757
+ }
758
+ }
759
+ }
760
+ catch { /* memories table issue, skip */ }
591
761
  // 세션 임베딩 사전 생성 (search_sessions 성능 최적화)
592
762
  try {
593
763
  const lastSession = db.prepare('SELECT id FROM sessions WHERE project = ? ORDER BY timestamp DESC LIMIT 1').get(project);
@@ -72,6 +72,7 @@ function loadContext(dbPath, project) {
72
72
  return null;
73
73
  try {
74
74
  const db = new Database(dbPath);
75
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
75
76
  // 노이즈 메모리 자동 정리
76
77
  cleanupNoiseMemories(db);
77
78
  const lines = [`# ${project} - Session Resumed\n`];
@@ -46,14 +46,19 @@ function getProject(cwd, workspaceRoot) {
46
46
  }
47
47
  // ===== 과거 참조 자동 감지 =====
48
48
  const PAST_REFERENCE_PATTERNS = [
49
- // 한국어
49
+ // 한국어 - 시간 참조
50
50
  /(?:저번에|전에|이전에|그때|지난번에|예전에|아까)\s+(.+?)(?:\s*(?:어떻게|뭐|무엇|왜|어디|언제))/,
51
51
  /(?:했던|했었던|만들었던|수정했던|구현했던|해결했던)\s*(.+)/,
52
52
  /(?:지난|이전|전)\s*(?:세션|작업|시간|번).*?(?:에서|때)\s*(.+)/,
53
+ // 한국어 - 보유/기억 질문 ("내꺼 GCP 코인 정보 가지고 있나?")
54
+ /(?:내|내꺼|우리)\s+(.+?)\s+(?:가지고\s*있|있어|있나|있지|있냐|남아|남았|저장)/,
55
+ /(.+?)\s+(?:기억해|기억하고|기억나|알고\s*있|아는)/,
56
+ /(?:저장한|기록한|적어둔|메모한|남긴)\s+(.+)/,
53
57
  // 영어
54
58
  /(?:last time|before|previously|earlier)\s+(?:.*?)\s*((?:how|what|why|where|when).*)/i,
55
59
  /(?:did we|did I|have we|have I)\s+(.+)\s+(?:before|last time|earlier)/i,
56
- /(?:remember when|recall when)\s+(.+)/i,
60
+ /(?:remember when|recall when|do you remember|do you recall)\s+(.+)/i,
61
+ /(?:do you have|do you know|got)\s+(.+?)\s+(?:info|information|saved|stored|record)/i,
57
62
  ];
58
63
  function extractPastKeywords(prompt) {
59
64
  for (const pattern of PAST_REFERENCE_PATTERNS) {
@@ -176,6 +181,7 @@ const MAX_DIRECTIVES = 20;
176
181
  function extractAndSaveDirectives(dbPath, project, prompt) {
177
182
  try {
178
183
  const db = new Database(dbPath);
184
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
179
185
  for (const { pattern, priority } of DIRECTIVE_PATTERNS) {
180
186
  const match = prompt.match(pattern);
181
187
  if (match && match[1]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-session-continuity-mcp",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "Session Continuity for Claude Code - Never re-explain your project again",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",