claude-session-continuity-mcp 1.13.1 → 1.15.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # claude-session-continuity-mcp (v1.13.0)
1
+ # claude-session-continuity-mcp (v1.13.2)
2
2
 
3
3
  > **Zero Re-explanation Session Continuity for Claude Code** — Automatic context capture + semantic search + auto error→solution pipeline
4
4
 
@@ -64,16 +64,76 @@ Every new Claude Code session:
64
64
 
65
65
  ## Quick Start
66
66
 
67
- ### One Command Installation
67
+ ### Recommended: Global Installation
68
68
 
69
69
  ```bash
70
- npm install claude-session-continuity-mcp
70
+ npm install -g claude-session-continuity-mcp
71
71
  ```
72
72
 
73
73
  **That's it!** The postinstall script automatically:
74
74
  1. Registers MCP server in `~/.claude.json`
75
75
  2. Installs Claude Hooks in `~/.claude/settings.json`
76
76
 
77
+ ### Why Global (`-g`)?
78
+
79
+ This tool is designed to track **all your Claude Code projects** in a single unified database.
80
+ Global installation is strongly recommended because:
81
+
82
+ | Reason | Detail |
83
+ |---|---|
84
+ | **Single source of truth** | One binary serves every project — no version drift between projects |
85
+ | **Hooks are user-scoped** | `~/.claude/settings.json` lives in your home directory, not per-project |
86
+ | **Cross-project context** | Sessions from `app-a` and `app-b` share the same DB and search index |
87
+ | **One update = everything refreshed** | `npm install -g <latest>` updates all projects at once; no per-project reinstall |
88
+ | **`npm exec` resolves global first** | Hooks call `npm exec -- claude-hook-*` which finds the global package reliably regardless of cwd |
89
+
90
+ **Important**: Even with global install, you can still **disable the hook for specific projects** (see below).
91
+ Global ≠ forced on every project.
92
+
93
+ ### Disabling Hooks for Specific Projects
94
+
95
+ Global install does **not** mean "always on everywhere". You have three layers of control:
96
+
97
+ | Layer | File | Scope |
98
+ |---|---|---|
99
+ | 1. Global ON (default) | `~/.claude/settings.json` | All projects |
100
+ | 2. Project-wide OFF | `<project>/.claude/settings.json` | Whole team (committed) |
101
+ | 3. Personal-only OFF | `<project>/.claude/settings.local.json` | Just you (gitignored) |
102
+
103
+ **To disable hooks in a specific project**, create the override file with empty hook arrays:
104
+
105
+ ```json
106
+ // <project>/.claude/settings.json (or settings.local.json for personal-only)
107
+ {
108
+ "hooks": {
109
+ "SessionStart": [],
110
+ "UserPromptSubmit": [],
111
+ "PostToolUse": [],
112
+ "PreCompact": [],
113
+ "Stop": []
114
+ }
115
+ }
116
+ ```
117
+
118
+ Empty arrays override the global setting → that project's sessions are no longer tracked.
119
+
120
+ ### Updating to a New Version
121
+
122
+ ```bash
123
+ npm install -g claude-session-continuity-mcp@latest
124
+ ```
125
+
126
+ That's the only step — all projects pick up the new binary on next Claude Code restart.
127
+ No need to reinstall in each project.
128
+
129
+ ### Alternative: Local Install (Not Recommended)
130
+
131
+ If you really want per-project install (e.g., locked version for one project):
132
+ ```bash
133
+ cd <project> && npm install claude-session-continuity-mcp
134
+ ```
135
+ Drawback: you must install separately in every project, and `npm exec` may not find the local copy reliably from hook context (cwd-dependent). Stick with `-g` unless you have a specific reason.
136
+
77
137
  ### What Gets Installed
78
138
 
79
139
  **MCP Server** (in `~/.claude.json`):
@@ -7,6 +7,7 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import Database from 'better-sqlite3';
10
+ import { logHookError } from '../utils/logger.js';
10
11
  // ===== Playwright 캐시 정리 (20MB 컨텍스트 초과 방지) =====
11
12
  function cleanPlaywrightCache(cwd) {
12
13
  const MAX_TOTAL = 3 * 1024 * 1024; // 3MB 초과 시 정리
@@ -265,6 +266,7 @@ async function main() {
265
266
  process.exit(0);
266
267
  }
267
268
  const db = new Database(dbPath);
269
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
268
270
  // hot_paths 추적 (모든 추적 도구)
269
271
  try {
270
272
  const pathType = filePath.includes('.') ? 'file' : 'directory';
@@ -319,6 +321,7 @@ async function main() {
319
321
  process.exit(0);
320
322
  }
321
323
  catch (e) {
324
+ logHookError('post-tool-use', e);
322
325
  process.exit(0);
323
326
  }
324
327
  }
@@ -193,6 +193,7 @@ function cleanPlaywrightCache(cwd) {
193
193
  catch { /* ignore */ }
194
194
  }
195
195
  async function main() {
196
+ let cwdForErrorLog = process.cwd();
196
197
  try {
197
198
  let inputData = '';
198
199
  for await (const chunk of process.stdin) {
@@ -200,15 +201,25 @@ async function main() {
200
201
  }
201
202
  const input = inputData ? JSON.parse(inputData) : {};
202
203
  const cwd = input.cwd || process.cwd();
204
+ cwdForErrorLog = cwd;
203
205
  // Playwright 캐시 정리 (20MB 컨텍스트 초과 방지)
204
206
  cleanPlaywrightCache(cwd);
205
207
  const project = detectProject(cwd);
206
208
  const dbPath = getDbPath(cwd);
209
+ // 디버그 로그 (DB 없어도 발화 자체는 기록)
210
+ const debugLogPath = path.join(path.dirname(dbPath), 'pre-compact-debug.log');
211
+ const transcriptLen = input.transcript?.length || 0;
212
+ const sid = input.sessionId?.slice(0, 8) || 'none';
207
213
  if (!fs.existsSync(dbPath)) {
214
+ try {
215
+ fs.appendFileSync(debugLogPath, `[${new Date().toISOString()}] project=${project} sid=${sid} transcript_msgs=${transcriptLen} status=no_db\n`);
216
+ }
217
+ catch { /* ignore */ }
208
218
  process.stdout.write(JSON.stringify({ continue: true }));
209
219
  process.exit(0);
210
220
  }
211
221
  const db = new Database(dbPath);
222
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
212
223
  // 핸드오버 컨텍스트 빌드
213
224
  const handover = input.transcript ? buildHandoverContext(input.transcript) : null;
214
225
  // active_context 업데이트 (memories에는 저장하지 않음)
@@ -229,7 +240,10 @@ async function main() {
229
240
  try {
230
241
  const directives = db.prepare(`
231
242
  SELECT directive, priority FROM user_directives
232
- WHERE project = ? ORDER BY priority DESC, created_at DESC LIMIT 10
243
+ WHERE project = ?
244
+ ORDER BY CASE priority WHEN 'high' THEN 3 WHEN 'normal' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,
245
+ created_at DESC
246
+ LIMIT 10
233
247
  `).all(project);
234
248
  if (directives.length > 0) {
235
249
  recoveryLines.push('## DIRECTIVES (MUST FOLLOW)');
@@ -276,15 +290,31 @@ async function main() {
276
290
  }
277
291
  }
278
292
  db.close();
293
+ const systemMessage = recoveryLines.join('\n');
294
+ // 디버그 로그 (성공 케이스)
295
+ try {
296
+ const factsCount = handover?.keyFacts.length || 0;
297
+ const errsCount = handover?.recentErrors.length || 0;
298
+ fs.appendFileSync(debugLogPath, `[${new Date().toISOString()}] project=${project} sid=${sid} transcript_msgs=${transcriptLen} sysmsg_len=${systemMessage.length} active_file=${handover?.activeFile || 'none'} pending=${handover?.pendingAction ? 'yes' : 'no'} facts=${factsCount} errs=${errsCount} status=ok\n`);
299
+ }
300
+ catch { /* ignore */ }
279
301
  const output = {
280
302
  continue: true,
281
- systemMessage: recoveryLines.join('\n')
303
+ systemMessage
282
304
  };
283
305
  process.stdout.write(JSON.stringify(output));
284
306
  process.exit(0);
285
307
  }
286
308
  catch (e) {
287
- // 에러 조용히 종료
309
+ // fail-soft: 컴팩션이 멈추지 않도록 continue:true 반드시 반환
310
+ try {
311
+ const dbPath = getDbPath(cwdForErrorLog);
312
+ const debugLogPath = path.join(path.dirname(dbPath), 'pre-compact-debug.log');
313
+ const errMsg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
314
+ fs.appendFileSync(debugLogPath, `[${new Date().toISOString()}] status=error err=${errMsg.slice(0, 200)}\n`);
315
+ }
316
+ catch { /* ignore */ }
317
+ process.stdout.write(JSON.stringify({ continue: true }));
288
318
  process.exit(0);
289
319
  }
290
320
  }
@@ -12,7 +12,9 @@
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';
17
+ import { logHookError } from '../utils/logger.js';
16
18
  function detectWorkspaceRoot(cwd) {
17
19
  let current = cwd;
18
20
  const root = path.parse(current).root;
@@ -183,6 +185,10 @@ function extractNextTasks(content) {
183
185
  * 사용자 메시지를 유효한 요청인지 필터링
184
186
  */
185
187
  function parseUserText(entry) {
188
+ // 슬래시 커맨드 확장으로 주입된 메타 턴은 사용자 요청이 아님
189
+ // (예: "# /work - 프로젝트 작업 메인 명령어 (v2)…" 커맨드 본문 전체가 여기 들어옴)
190
+ if (entry.isMeta === true)
191
+ return '';
186
192
  const content = entry.message?.content;
187
193
  let text = '';
188
194
  if (typeof content === 'string') {
@@ -194,6 +200,11 @@ function parseUserText(entry) {
194
200
  .map(b => b.text || '')
195
201
  .join('\n');
196
202
  }
203
+ // 슬래시 커맨드 실행 시 실제 사용자 요청은 <command-args> 안에 있음 → 삭제 말고 추출
204
+ const argsMatch = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
205
+ if (argsMatch && argsMatch[1].trim().length >= 3) {
206
+ return argsMatch[1].trim();
207
+ }
197
208
  // system-reminder, local-command 태그 제거
198
209
  text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
199
210
  text = text.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '').trim();
@@ -238,6 +249,8 @@ async function parseTranscriptSinglePass(transcriptPath) {
238
249
  userRequests: { firstRequest: '', allRequests: [] },
239
250
  recentAssistantMessages: [],
240
251
  errorFixPairs: [],
252
+ firstTimestamp: null,
253
+ lastTimestamp: null,
241
254
  };
242
255
  if (!transcriptPath || !fs.existsSync(transcriptPath))
243
256
  return result;
@@ -267,6 +280,12 @@ async function parseTranscriptSinglePass(transcriptPath) {
267
280
  const entry = JSON.parse(line);
268
281
  const role = entry.type || entry.role || '';
269
282
  const content = entry.message?.content;
283
+ // === 0. Timestamp 추출 (세션 duration 계산용) ===
284
+ if (entry.timestamp) {
285
+ if (!result.firstTimestamp)
286
+ result.firstTimestamp = entry.timestamp;
287
+ result.lastTimestamp = entry.timestamp;
288
+ }
270
289
  // === 1. Commit 추출 (tool_use 블록에서) ===
271
290
  if (line.includes('git commit') && Array.isArray(content)) {
272
291
  for (const block of content) {
@@ -343,9 +362,11 @@ async function parseTranscriptSinglePass(transcriptPath) {
343
362
  }
344
363
  }
345
364
  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;
365
+ // Error-Fix pairs (한국어 패턴 보강)
366
+ // P3 (2026-07-08): '충돌' 제거 — 영상 파이프라인 "충돌(collision) 컷" 잡담이
367
+ // 에러로 오분류되던 원인. 진짜 conflict 에러는 라틴 토큰으로 매칭됨.
368
+ const errorRe = /(?:error|Error|ERROR|오류|에러|버그|예외|실패|FAILED|Exception|TypeError|ReferenceError|SyntaxError|crash|crashed|문제)[:\s](.{5,80})/;
369
+ const fixRe = /(?:fixed|resolved|patched|수정|해결|고침|처리|완료|변경|적용|반영|커밋|Added|수정 완료|문제 해결|해결됨|되돌림)/i;
349
370
  const pairSet = new Set();
350
371
  for (let i = 0; i < recentEntries.length - 1; i++) {
351
372
  const errorMatch = recentEntries[i].text.match(errorRe);
@@ -370,6 +391,69 @@ async function parseTranscriptSinglePass(transcriptPath) {
370
391
  result.recentAssistantMessages = result.recentAssistantMessages.slice(-5);
371
392
  return result;
372
393
  }
394
+ /**
395
+ * 슬래시 커맨드 prefix 제거 — "/mcp-dev 측정해줘" → "측정해줘"
396
+ * 첫 토큰이 `/`로 시작하면 다음 의미 토큰까지 스킵
397
+ * 슬래시뿐이면 빈 문자열 반환 (호출자가 폴백 처리)
398
+ */
399
+ function stripSlashPrefix(text) {
400
+ const trimmed = text.trim();
401
+ if (!trimmed.startsWith('/'))
402
+ return trimmed;
403
+ // 첫 줄에서 슬래시 토큰을 제거
404
+ const firstLine = trimmed.split('\n')[0];
405
+ const tokens = firstLine.split(/\s+/);
406
+ let i = 0;
407
+ while (i < tokens.length && tokens[i].startsWith('/'))
408
+ i++;
409
+ const rest = tokens.slice(i).join(' ').trim();
410
+ if (rest.length >= 3)
411
+ return rest;
412
+ // 다음 줄에 의미 있는 본문이 있으면 사용
413
+ const lines = trimmed.split('\n').slice(1).map(l => l.trim()).filter(l => l.length >= 3 && !l.startsWith('/'));
414
+ return lines[0] || '';
415
+ }
416
+ /**
417
+ * Jaccard 유사도 (토큰 단위) — 0~1 사이
418
+ * 동일하면 1, 완전 다르면 0
419
+ */
420
+ function jaccardSimilarity(a, b) {
421
+ const tokenize = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(t => t.length >= 2));
422
+ const setA = tokenize(a);
423
+ const setB = tokenize(b);
424
+ if (setA.size === 0 || setB.size === 0)
425
+ return 0;
426
+ let intersect = 0;
427
+ for (const t of setA)
428
+ if (setB.has(t))
429
+ intersect++;
430
+ const union = setA.size + setB.size - intersect;
431
+ return union === 0 ? 0 : intersect / union;
432
+ }
433
+ /**
434
+ * URL 정규화: 같은 도메인 + path는 같은 노이즈로 취급
435
+ * (P1-3, 2026-05-22) Google Forms ID, AdMob app ID 같은 동적 segment 제거
436
+ *
437
+ * 예: https://docs.google.com/forms/d/e/1FAIpQLScm.../viewform
438
+ * → https://docs.google.com/forms/...
439
+ */
440
+ function normalizeUrls(text) {
441
+ if (!text)
442
+ return text;
443
+ return text.replace(/(https?:\/\/[^\s]+)/gi, (match) => {
444
+ try {
445
+ const url = new URL(match);
446
+ // path의 첫 1~2 segment만 유지, 나머지(ID/토큰)는 '...'로 축약
447
+ const segments = url.pathname.split('/').filter(s => s);
448
+ const keepCount = segments.length <= 2 ? segments.length : 2;
449
+ const truncated = segments.slice(0, keepCount).join('/');
450
+ return `${url.protocol}//${url.host}/${truncated}${segments.length > keepCount ? '/...' : ''}`;
451
+ }
452
+ catch {
453
+ return match;
454
+ }
455
+ });
456
+ }
373
457
  /**
374
458
  * 사용자 메시지들을 세션 요약으로 압축
375
459
  * 예: ["MCP 테스트해줘", "개선해줘", "npm 배포하고 커밋해줘"] → "MCP 테스트 + 개선 + npm 배포/커밋"
@@ -377,12 +461,24 @@ async function parseTranscriptSinglePass(transcriptPath) {
377
461
  function summarizeUserRequests(requests) {
378
462
  if (requests.length === 0)
379
463
  return '';
380
- if (requests.length === 1)
381
- return requests[0];
464
+ // 슬래시 커맨드 도움말 본문(/work, /clone-pro 등이 첫 줄에 박히는 케이스) 제외
465
+ // → 36건 동일 last_work 누적 문제 해결
466
+ const meaningful = requests
467
+ .map(r => stripSlashPrefix(r))
468
+ .filter(r => {
469
+ if (!r)
470
+ return false;
471
+ if (/^[A-Z][a-z]+\s+(skill|command):/i.test(r))
472
+ return false;
473
+ return r.length > 0;
474
+ });
475
+ const source = meaningful.length > 0 ? meaningful : requests;
476
+ if (source.length === 1)
477
+ return source[0];
382
478
  // 중복/유사 요청 제거 (앞 20글자 기준)
383
479
  const unique = [];
384
480
  const seen = new Set();
385
- for (const req of requests) {
481
+ for (const req of source) {
386
482
  const key = req.slice(0, 20).toLowerCase();
387
483
  if (!seen.has(key)) {
388
484
  seen.add(key);
@@ -407,20 +503,59 @@ async function main() {
407
503
  inputData += chunk;
408
504
  }
409
505
  const input = inputData ? JSON.parse(inputData) : {};
506
+ // 중복 호출 가드 1: stop_hook_active 플래그 (Claude Code 공식 플래그)
507
+ if (input.stop_hook_active === true) {
508
+ process.exit(0);
509
+ }
410
510
  const cwd = input.cwd || process.cwd();
411
511
  const project = detectProject(cwd);
412
512
  const dbPath = getDbPath(cwd);
513
+ // 중복 호출 가드 2: transcript_path 해시 기반 5초 윈도우 파일락
514
+ // Phase 3: session_id 5초 락 도입 (sid 있는 호출만 차단됨)
515
+ // Phase 5: 실측에서 모든 stop이 [sid 있는 호출 + sid 없는 호출] 페어로 들어옴
516
+ // → transcript_path 해시를 우선 키로 사용해야 같은 페어가 같은 락을 공유
517
+ // → transcript_path 없을 때만 session_id 폴백
518
+ const lockKey = input.transcript_path
519
+ ? crypto.createHash('md5').update(input.transcript_path).digest('hex').slice(0, 16)
520
+ : (input.session_id || null);
521
+ if (lockKey) {
522
+ const lockPath = path.join(path.dirname(dbPath), `.session-end-${lockKey}.lock`);
523
+ const now = Date.now();
524
+ try {
525
+ // Phase 5: atomic `wx` (존재 시 EEXIST throw) → 두 hook 인스턴스가 거의 동시에 진입하는 race 차단
526
+ // 베이스라인: id=1606/1607이 ~500ms 차이로 동시 INSERT 됨 (debug.log 13:26:51.205 + 13:26:51.702)
527
+ fs.writeFileSync(lockPath, String(now), { flag: 'wx' });
528
+ }
529
+ catch (e) {
530
+ // 파일이 이미 존재 (다른 hook 인스턴스가 처리 중) → mtime 확인 후 5초 내면 차단
531
+ if (e.code === 'EEXIST') {
532
+ try {
533
+ const lockMtime = fs.statSync(lockPath).mtimeMs;
534
+ if (now - lockMtime < 5000) {
535
+ process.exit(0); // 5초 내 재발화 차단
536
+ }
537
+ // 5초 지난 stale 락 → 덮어쓰기 (이 호출이 새 작업)
538
+ fs.writeFileSync(lockPath, String(now));
539
+ }
540
+ catch {
541
+ // stat/write 실패는 fail-soft
542
+ }
543
+ }
544
+ // 그 외 락 파일 에러는 무시 (fail-soft)
545
+ }
546
+ }
413
547
  // 디버그 로그
414
548
  const debugLogPath = path.join(path.dirname(dbPath), 'session-end-debug.log');
415
549
  const inputKeys = Object.keys(input);
416
550
  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`;
551
+ 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
552
  fs.appendFileSync(debugLogPath, debugLine);
419
553
  if (!fs.existsSync(dbPath)) {
420
554
  console.log('[SessionEnd] No DB found, skipping');
421
555
  process.exit(0);
422
556
  }
423
557
  const db = new Database(dbPath);
558
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
424
559
  // === 추출 시작 ===
425
560
  let lastWork = '';
426
561
  let nextTasks = [];
@@ -433,6 +568,8 @@ async function main() {
433
568
  userRequests: { firstRequest: '', allRequests: [] },
434
569
  recentAssistantMessages: [],
435
570
  errorFixPairs: [],
571
+ firstTimestamp: null,
572
+ lastTimestamp: null,
436
573
  };
437
574
  if (input.transcript_path) {
438
575
  transcript = await parseTranscriptSinglePass(input.transcript_path);
@@ -441,7 +578,9 @@ async function main() {
441
578
  decisions = transcript.decisions;
442
579
  }
443
580
  // lastWork 결정 (우선순위 폴백)
444
- const { firstRequest, allRequests } = transcript.userRequests;
581
+ const { firstRequest: rawFirstRequest, allRequests } = transcript.userRequests;
582
+ // firstRequest 슬래시 prefix 제거 (예: "/mcp-dev 측정" → "측정")
583
+ const firstRequest = rawFirstRequest ? (stripSlashPrefix(rawFirstRequest) || rawFirstRequest) : '';
445
584
  // 2a: 사용자 요청 + 커밋 메시지 조합 (가장 이상적)
446
585
  if (firstRequest && commitMessages.length > 0) {
447
586
  lastWork = `${firstRequest} → ${commitMessages.slice(0, 2).join('; ')}`;
@@ -519,23 +658,79 @@ async function main() {
519
658
  const fileNames = modifiedFiles.slice(0, 5).map(f => path.basename(f)).join(', ');
520
659
  lastWork = `Modified files: ${fileNames}`;
521
660
  }
661
+ // Phase 5: 모든 last_work 결정 경로에 stripSlashPrefix 강제 적용
662
+ // 베이스라인: 2a 경로(firstRequest)만 stripSlashPrefix 적용되고 2c~2e 폴백은 미적용
663
+ // → 같은 transcript에서 두 hook 인스턴스가 서로 다른 경로로 들어가
664
+ // 한쪽은 "측정", 한쪽은 "/mcp-dev 측정"으로 분기 → Jaccard 0.85 미달로 둘 다 통과
665
+ // stripSlashPrefix가 빈 문자열을 반환하면 원본 유지 (의미 토큰이 없는 경우)
666
+ if (lastWork) {
667
+ const stripped = stripSlashPrefix(lastWork);
668
+ if (stripped)
669
+ lastWork = stripped;
670
+ }
522
671
  // 빈 세션 skip
523
672
  if (!lastWork) {
524
673
  console.log(`[SessionEnd] Skipping empty session for ${project} (no meaningful last_work)`);
525
674
  db.close();
526
675
  process.exit(0);
527
676
  }
528
- // 중복 저장 방지 (같은 내용이 1시간 이내에 저장됐으면 skip)
529
- const recentDup = db.prepare(`
677
+ // 중복 저장 방지 3단계 dedup
678
+ // P1-3 (2026-05-22): Q8에서 발견된 3 클러스터(25 세션) 분석 결과
679
+ // • Google Forms URL × 12 (1h~2h 간격) — URL 정규화 + 24h 윈도우로 해결
680
+ // • IAP "이어서 진행해줘" × 8 (5h 분포, 동일 텍스트) — 24h exact로 해결
681
+ // • AdMob URL × 5 — URL 정규화로 해결
682
+ // 1단계: 24시간 내 exact 일치 차단 (이전 1h → 24h, 동일 last_work 반복 막음)
683
+ // 2단계: URL 정규화 후 exact 일치 (Forms/AdMob ID 무시)
684
+ // 3단계: stripSlashPrefix 정규화 후 Jaccard >= 0.85 (1h 윈도우)
685
+ const recentExact = db.prepare(`
530
686
  SELECT id FROM sessions
531
- WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-1 hour')
687
+ WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-24 hour')
532
688
  LIMIT 1
533
689
  `).get(project, lastWork);
534
- if (recentDup) {
535
- console.log(`[SessionEnd] Skipping duplicate session for ${project}`);
690
+ if (recentExact) {
691
+ console.log(`[SessionEnd] Skipping duplicate (exact, 24h) for ${project}`);
536
692
  db.close();
537
693
  process.exit(0);
538
694
  }
695
+ // 2단계: URL 정규화 후 exact (24h 윈도우)
696
+ const normalizedLastWorkUrl = normalizeUrls(lastWork);
697
+ if (normalizedLastWorkUrl !== lastWork) {
698
+ const recent24h = db.prepare(`
699
+ SELECT last_work FROM sessions
700
+ WHERE project = ? AND timestamp > datetime('now', '-24 hour')
701
+ ORDER BY timestamp DESC LIMIT 20
702
+ `).all(project);
703
+ for (const row of recent24h) {
704
+ if (!row.last_work)
705
+ continue;
706
+ if (normalizeUrls(row.last_work) === normalizedLastWorkUrl) {
707
+ console.log(`[SessionEnd] Skipping URL-normalized duplicate for ${project}`);
708
+ db.close();
709
+ process.exit(0);
710
+ }
711
+ }
712
+ }
713
+ // 3단계: Jaccard 유사도 (24h 윈도우)
714
+ // P2 (2026-07-08): 1h 윈도우가 너무 좁아 시간 넘는 near-dup이 통과했음.
715
+ // 실측: /mcp-dev 클러스터 60건이 수 시간~수일 간격으로 반복 저장됨.
716
+ // 1·2단계(exact/URL)가 이미 24h이므로 Jaccard만 1h인 건 불일치 → 24h로 통일.
717
+ // 임계값 0.85는 매우 높아 "진짜 다른 작업"은 24h로 넓혀도 통과(Phase 4 검증).
718
+ const recentRows = db.prepare(`
719
+ SELECT last_work FROM sessions
720
+ WHERE project = ? AND timestamp > datetime('now', '-24 hour')
721
+ ORDER BY timestamp DESC LIMIT 30
722
+ `).all(project);
723
+ for (const row of recentRows) {
724
+ if (!row.last_work)
725
+ continue;
726
+ const normalizedCurrent = stripSlashPrefix(lastWork) || lastWork;
727
+ const normalizedRow = stripSlashPrefix(row.last_work) || row.last_work;
728
+ if (jaccardSimilarity(normalizedCurrent, normalizedRow) >= 0.85) {
729
+ console.log(`[SessionEnd] Skipping near-duplicate (jaccard >= 0.85) for ${project}`);
730
+ db.close();
731
+ process.exit(0);
732
+ }
733
+ }
539
734
  // 구조화 메타데이터 (issues 컬럼 활용)
540
735
  const metadata = {
541
736
  commits: commitMessages,
@@ -543,26 +738,69 @@ async function main() {
543
738
  errorsSolved
544
739
  };
545
740
  const hasMetadata = commitMessages.length > 0 || decisions.length > 0 || errorsSolved.length > 0;
546
- // 세션 기록 저장
741
+ // P3 (2026-07-08): duration_minutes 계산 폐기.
742
+ // transcript first↔last 차이는 resume/continue 시 벽시계 경과(최대 30일)를 담아
743
+ // 실측 33%가 24h 초과로 신뢰 불가였고, 이 필드를 읽는 소비처가 코드 전체에 없음.
744
+ // 컬럼은 스키마에 유지(NULL로 남김), INSERT만 중단.
745
+ // 세션 기록 저장 — 원자적 조건부 INSERT로 페어 race 차단.
746
+ // P2b (2026-07-08): 두 hook 인스턴스가 같은 초에 동시 발화하면(transcript 락을
747
+ // 우회한 sid-less 페어) 앞 단계 dedup은 서로의 미커밋 행을 못 봐 둘 다 통과 →
748
+ // id 897/898처럼 동일 last_work+timestamp 2행 저장(실측 재현).
749
+ // INSERT ... WHERE NOT EXISTS로 "최근 10초 내 동일 project+last_work"를 원자적
750
+ // 단일 문장에서 재확인 → race 윈도우 제거. 10초 초과 정당한 재작업은 통과.
547
751
  db.prepare(`
548
752
  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);
753
+ SELECT ?, ?, ?, ?, ?
754
+ WHERE NOT EXISTS (
755
+ SELECT 1 FROM sessions
756
+ WHERE project = ? AND last_work = ?
757
+ AND timestamp > datetime('now', '-10 seconds')
758
+ )
759
+ `).run(project, lastWork, JSON.stringify([...new Set(nextTasks)].slice(0, 5)), JSON.stringify(modifiedFiles.slice(0, 15)), hasMetadata ? JSON.stringify(metadata) : null, project, lastWork);
551
760
  // 활성 컨텍스트 업데이트
552
761
  db.prepare(`
553
762
  INSERT OR REPLACE INTO active_context (project, current_state, recent_files, updated_at)
554
763
  VALUES (?, ?, ?, datetime('now'))
555
764
  `).run(project, lastWork, JSON.stringify(modifiedFiles.slice(0, 15)));
556
765
  // 에러→솔루션 자동 기록 (solutions 테이블)
766
+ // P1-4 (2026-05-22): 품질 필터 + 동일 solution 텍스트 dedup 추가
557
767
  let solutionsRecorded = 0;
558
768
  if (transcript.errorFixPairs.length > 0) {
559
769
  try {
560
770
  for (const pair of transcript.errorFixPairs) {
561
- const existing = db.prepare('SELECT id FROM solutions WHERE project = ? AND error_signature = ? LIMIT 1').get(project, pair.error);
562
- if (!existing) {
563
- db.prepare('INSERT INTO solutions (project, error_signature, solution) VALUES (?, ?, ?)').run(project, pair.error, pair.fix);
564
- solutionsRecorded++;
565
- }
771
+ const errSig = pair.error?.trim() || '';
772
+ const sol = pair.fix?.trim() || '';
773
+ // 품질 필터: stub/짧음/footer 거부
774
+ if (sol.length < 30)
775
+ continue;
776
+ if (/^(\[이미\s*완료\]|✅\s*완료|✅\s*푸시\s*완료|done|완료)\s*$/i.test(sol))
777
+ continue;
778
+ if (sol.includes('Co-Authored-By:'))
779
+ continue;
780
+ if (errSig.length < 5)
781
+ continue;
782
+ // P3 (2026-07-08): error_signature 품질 게이트 — 대화 잡음 거부.
783
+ // errorRe가 '문제/충돌' 같은 일상어를 에러로 오분류해 내레이션 파편이
784
+ // 시그니처로 저장됐음(실측 28% 노이즈).
785
+ // 다국어(영어+한국어) 지원:
786
+ // - 라틴 문자([A-Za-z])가 있으면 영어 에러(TypeError, Build failed,
787
+ // ECONNREFUSED, undefined …)로 간주해 전부 통과.
788
+ // - 라틴이 없는 순수 한국어는 구체적 에러 표현이 있을 때만 통과.
789
+ // - 둘 다 없는 순수 파편(문제/충돌 단독)만 거부.
790
+ const hasErrorSignal = /[A-Za-z]/.test(errSig) ||
791
+ /(실패|오류|누락|초과|깨짐|깨진|중단|크래시|안 ?됨|불가|타임아웃|한도)/.test(errSig);
792
+ if (!hasErrorSignal)
793
+ continue;
794
+ // 1차 dedup: 동일 error_signature
795
+ const existingByError = db.prepare('SELECT id FROM solutions WHERE project = ? AND error_signature = ? LIMIT 1').get(project, errSig);
796
+ if (existingByError)
797
+ continue;
798
+ // 2차 dedup: 동일 solution 텍스트 (다른 error라도 같은 해법은 중복)
799
+ const existingBySol = db.prepare('SELECT id FROM solutions WHERE project = ? AND solution = ? LIMIT 1').get(project, sol);
800
+ if (existingBySol)
801
+ continue;
802
+ db.prepare('INSERT INTO solutions (project, error_signature, solution) VALUES (?, ?, ?)').run(project, errSig, sol);
803
+ solutionsRecorded++;
566
804
  }
567
805
  }
568
806
  catch { /* solutions table may not exist */ }
@@ -588,6 +826,35 @@ async function main() {
588
826
  }
589
827
  catch { /* project_context table may not exist */ }
590
828
  }
829
+ // 고품질 자동 메모리 추출 (v1.10 노이즈 제거 정책 유지하면서 가치 있는 것만)
830
+ // - decisions: 의미있는 의사결정 (importance=7)
831
+ // - commits: feat/fix만 (importance=6)
832
+ // - 동일 content 중복 방지 (검색해서 없을 때만 INSERT)
833
+ try {
834
+ const memoryDupCheck = db.prepare('SELECT id FROM memories WHERE project = ? AND content = ? LIMIT 1');
835
+ const memoryInsert = db.prepare(`
836
+ INSERT INTO memories (content, memory_type, tags, project, importance)
837
+ VALUES (?, ?, ?, ?, ?)
838
+ `);
839
+ for (const decision of decisions) {
840
+ if (decision.length < 15)
841
+ continue;
842
+ if (!memoryDupCheck.get(project, decision)) {
843
+ memoryInsert.run(decision, 'decision', JSON.stringify(['auto-extracted']), project, 7);
844
+ }
845
+ }
846
+ // feat/fix 커밋만 (chore, docs, style은 학습 가치 낮음)
847
+ for (const commit of commitMessages) {
848
+ if (!/^(feat|fix)(\(.+\))?:/i.test(commit))
849
+ continue;
850
+ if (commit.length < 20)
851
+ continue;
852
+ if (!memoryDupCheck.get(project, commit)) {
853
+ memoryInsert.run(commit, 'learning', JSON.stringify(['auto-extracted', 'commit']), project, 6);
854
+ }
855
+ }
856
+ }
857
+ catch { /* memories table issue, skip */ }
591
858
  // 세션 임베딩 사전 생성 (search_sessions 성능 최적화)
592
859
  try {
593
860
  const lastSession = db.prepare('SELECT id FROM sessions WHERE project = ? ORDER BY timestamp DESC LIMIT 1').get(project);
@@ -608,6 +875,7 @@ async function main() {
608
875
  process.exit(0);
609
876
  }
610
877
  catch (e) {
878
+ logHookError('session-end', e);
611
879
  process.exit(0);
612
880
  }
613
881
  }
@@ -5,6 +5,7 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import Database from 'better-sqlite3';
8
+ import { logHookError } from '../utils/logger.js';
8
9
  function detectWorkspaceRoot(cwd) {
9
10
  let current = cwd;
10
11
  const root = path.parse(current).root;
@@ -72,6 +73,7 @@ function loadContext(dbPath, project) {
72
73
  return null;
73
74
  try {
74
75
  const db = new Database(dbPath);
76
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
75
77
  // 노이즈 메모리 자동 정리
76
78
  cleanupNoiseMemories(db);
77
79
  const lines = [`# ${project} - Session Resumed\n`];
@@ -123,7 +125,10 @@ function loadContext(dbPath, project) {
123
125
  try {
124
126
  const directives = db.prepare(`
125
127
  SELECT directive, priority FROM user_directives
126
- WHERE project = ? ORDER BY priority DESC, created_at DESC LIMIT 5
128
+ WHERE project = ?
129
+ ORDER BY CASE priority WHEN 'high' THEN 3 WHEN 'normal' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,
130
+ created_at DESC
131
+ LIMIT 5
127
132
  `).all(project);
128
133
  if (directives.length > 0) {
129
134
  const directiveLines = ['## Directives'];
@@ -172,21 +177,25 @@ function loadContext(dbPath, project) {
172
177
  catch { /* table may not exist */ }
173
178
  }
174
179
  // [Priority 5] 중요 메모리 (temporal decay 적용, 예산 내에서)
180
+ // P0 (2026-05-22): reference/observation 타입 + global(project=NULL) 메모리 포함
181
+ // 사용자 pain: "서버 주소 기억할 때도 있고 못할 때도 있다"
182
+ // 원인: SessionStart가 reference 타입 미포함 + project filter가 NULL 거름
175
183
  if (tokenBudget > 80)
176
184
  try {
177
185
  const memories = db.prepare(`
178
186
  SELECT content, memory_type, importance, created_at, access_count FROM memories
179
- WHERE project = ?
180
- AND memory_type IN ('decision', 'learning', 'error', 'preference')
187
+ WHERE (project = ? OR project IS NULL)
188
+ AND memory_type IN ('decision', 'learning', 'error', 'preference', 'reference', 'observation')
181
189
  AND importance >= 3
182
190
  AND (tags NOT LIKE '%auto-tracked%' OR tags IS NULL)
183
191
  AND (tags NOT LIKE '%auto-compact%' OR tags IS NULL)
184
- ORDER BY importance DESC, accessed_at DESC LIMIT 20
192
+ ORDER BY importance DESC, accessed_at DESC LIMIT 30
185
193
  `).all(project);
186
194
  if (memories.length > 0) {
187
- // Decay 적용 후 top 5 선택
195
+ // Decay 적용 후 top 5 선택 (reference는 decay 거의 0 — 인프라 정보는 영구)
188
196
  const DECAY_RATES = {
189
- decision: 0.001, learning: 0.003, error: 0.01, preference: 0.002
197
+ decision: 0.001, learning: 0.003, error: 0.01, preference: 0.002,
198
+ reference: 0.0001, observation: 0.005
190
199
  };
191
200
  const scored = memories.map(m => {
192
201
  const ageDays = (Date.now() - new Date(m.created_at).getTime()) / (1000 * 60 * 60 * 24);
@@ -195,7 +204,8 @@ function loadContext(dbPath, project) {
195
204
  return { ...m, score };
196
205
  }).sort((a, b) => b.score - a.score).slice(0, 5);
197
206
  const typeIcons = {
198
- decision: '🎯', learning: '📚', error: '⚠️', preference: '💡'
207
+ decision: '🎯', learning: '📚', error: '⚠️', preference: '💡',
208
+ reference: '🔧', observation: '👁'
199
209
  };
200
210
  const memoryLines = ['## Key Memories'];
201
211
  for (const m of scored) {
@@ -257,7 +267,7 @@ async function main() {
257
267
  process.exit(0);
258
268
  }
259
269
  catch (e) {
260
- // 에러 시 조용히 종료
270
+ logHookError('session-start', e);
261
271
  process.exit(0);
262
272
  }
263
273
  }
@@ -5,6 +5,7 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import Database from 'better-sqlite3';
8
+ import { logHookError } from '../utils/logger.js';
8
9
  function detectWorkspaceRoot(cwd) {
9
10
  let current = cwd;
10
11
  const root = path.parse(current).root;
@@ -46,14 +47,19 @@ function getProject(cwd, workspaceRoot) {
46
47
  }
47
48
  // ===== 과거 참조 자동 감지 =====
48
49
  const PAST_REFERENCE_PATTERNS = [
49
- // 한국어
50
+ // 한국어 - 시간 참조
50
51
  /(?:저번에|전에|이전에|그때|지난번에|예전에|아까)\s+(.+?)(?:\s*(?:어떻게|뭐|무엇|왜|어디|언제))/,
51
52
  /(?:했던|했었던|만들었던|수정했던|구현했던|해결했던)\s*(.+)/,
52
53
  /(?:지난|이전|전)\s*(?:세션|작업|시간|번).*?(?:에서|때)\s*(.+)/,
54
+ // 한국어 - 보유/기억 질문 ("내꺼 GCP 코인 정보 가지고 있나?")
55
+ /(?:내|내꺼|우리)\s+(.+?)\s+(?:가지고\s*있|있어|있나|있지|있냐|남아|남았|저장)/,
56
+ /(.+?)\s+(?:기억해|기억하고|기억나|알고\s*있|아는)/,
57
+ /(?:저장한|기록한|적어둔|메모한|남긴)\s+(.+)/,
53
58
  // 영어
54
59
  /(?:last time|before|previously|earlier)\s+(?:.*?)\s*((?:how|what|why|where|when).*)/i,
55
60
  /(?:did we|did I|have we|have I)\s+(.+)\s+(?:before|last time|earlier)/i,
56
- /(?:remember when|recall when)\s+(.+)/i,
61
+ /(?:remember when|recall when|do you remember|do you recall)\s+(.+)/i,
62
+ /(?:do you have|do you know|got)\s+(.+?)\s+(?:info|information|saved|stored|record)/i,
57
63
  ];
58
64
  function extractPastKeywords(prompt) {
59
65
  for (const pattern of PAST_REFERENCE_PATTERNS) {
@@ -65,48 +71,221 @@ function extractPastKeywords(prompt) {
65
71
  }
66
72
  return null;
67
73
  }
74
+ /**
75
+ * Korean + English 일반 stopword
76
+ * (Proactive trigger matching용 — 사용자 발상 "전부 기록하되 트리거 매칭" 구현)
77
+ */
78
+ const STOPWORDS = new Set([
79
+ // 한국어 빈출 (조사/대명사/일반 동사/부사)
80
+ '있다', '없다', '하다', '되다', '이다', '아니다', '같다', '보다', '주다', '받다', '쓰다', '놓다',
81
+ '하는', '있는', '없는', '되는', '이런', '저런', '그런', '이게', '저게', '그게', '이것', '저것', '그것',
82
+ '내가', '네가', '우리', '저희', '당신', '자기', '지금', '이제', '오늘', '어제', '내일', '다음', '이전',
83
+ '하나', '둘', '셋', '정말', '진짜', '아주', '너무', '매우', '조금', '약간', '대충', '그냥', '일단',
84
+ '그리고', '하지만', '그래서', '그래도', '근데', '그런데', '또는', '또한', '이런데', '그런데',
85
+ '진행', '확인', '시작', '완료', '종료', '해줘', '해주세요', '부탁', '알려', '알려줘',
86
+ // 범용 명사 (LIKE 검색 시 아무 메모리나 잡는 오탐원 — 2026-07-08 C3)
87
+ '이름', '정보', '내용', '관련', '부분', '상태', '방법', '문제', '경우', '생각', '이야기', '얘기',
88
+ '어떻게', '무엇', '뭐가', '왜', '어디', '언제', '어느', '어떤', '어떻', '얼마',
89
+ // 한국어 2글자 빈출 (v1.14.3에서 한국어 길이 임계값 2로 낮춤에 따라 추가)
90
+ '이거', '그거', '저거', '한번', '두번', '코드', '작업', '뭐했지', '뭐였지', '봐줘', '한거지', '했지',
91
+ // 영어 빈출 추가 — Next.js 같은 동음이의 발생하는 토큰
92
+ 'next', 'last', 'first', 'prev', 'previous', 'check', 'test', 'run', 'live', 'ready', 'verify', 'verification',
93
+ 'session', 'task', 'item', 'case', 'file', 'line', 'data', 'code', 'word', 'time', 'step', 'part', 'side',
94
+ // 영어 빈출 stopwords
95
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
96
+ 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must',
97
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their', 'we', 'our',
98
+ 'you', 'your', 'i', 'my', 'me', 'what', 'when', 'where', 'why', 'how', 'which', 'who',
99
+ 'and', 'or', 'but', 'if', 'then', 'else', 'also', 'so', 'as', 'at', 'by', 'for', 'from', 'in', 'of', 'on', 'to', 'with',
100
+ 'just', 'only', 'very', 'really', 'also', 'too', 'still', 'here', 'there', 'now', 'then',
101
+ ]);
102
+ /**
103
+ * Prompt에서 의미 있는 한국어/영어 키워드 추출
104
+ * (P0+ 2026-05-22: 사용자 "트리거 매칭" 발상 구현)
105
+ *
106
+ * @returns 키워드 배열 (3자 이상, stopword 제외, 중복 제거, 최대 5개)
107
+ */
108
+ function extractTriggerKeywords(prompt) {
109
+ if (!prompt || prompt.length < 5)
110
+ return [];
111
+ // 1. 따옴표/괄호/마크다운/슬래시 명령 제거
112
+ const cleaned = prompt
113
+ .replace(/```[\s\S]*?```/g, ' ') // 코드 블록
114
+ .replace(/`[^`]+`/g, ' ') // 인라인 코드
115
+ .replace(/^\/[a-z-]+\s*/i, '') // 슬래시 명령 prefix
116
+ .replace(/[*_~`"'()[\]{}<>]/g, ' '); // 특수문자
117
+ // 2. 토큰화 (한글/영문 단어만)
118
+ // v1.14.3: 한국어 길이 임계값 2, 영어는 3 (한국어는 2글자 단어 많음 — 서버/주소/명령)
119
+ const tokens = cleaned
120
+ .split(/[\s,.!?;:/\\|]+/)
121
+ .map(t => t.trim().toLowerCase())
122
+ .filter(t => /^[a-z0-9가-힣]+$/i.test(t)) // 영숫자+한글만
123
+ .filter(t => {
124
+ // 한국어 단일 음절은 거의 무의미, 2글자 이상 허용
125
+ const hasHangul = /[가-힣]/.test(t);
126
+ return hasHangul ? t.length >= 2 : t.length >= 3;
127
+ })
128
+ .filter(t => !STOPWORDS.has(t))
129
+ .filter(t => !/^\d+$/.test(t)); // 숫자만은 제외
130
+ // 3. 중복 제거 (등장 순서 유지)
131
+ const seen = new Set();
132
+ const unique = [];
133
+ for (const t of tokens) {
134
+ if (!seen.has(t)) {
135
+ seen.add(t);
136
+ unique.push(t);
137
+ }
138
+ }
139
+ // 4. 최대 5개
140
+ return unique.slice(0, 5);
141
+ }
142
+ /**
143
+ * IDF 필터: 너무 흔한 토큰 제거
144
+ * (2026-05-23: "commit and push" false positive 발견 — feat/commit/fix 같은
145
+ * 커밋 메시지 토큰이 14/85 메모리에 등장해 IDF가 낮음)
146
+ *
147
+ * IDF = log(N / df), N = 총 메모리 수
148
+ * 임계값 IDF >= 2.0 (= 흔한 토큰 약 13% 이상 등장 시 제외)
149
+ * 실측: commit(idf 1.80), feat(1.96) 차단 + ec2(4.44), 서명키(4.44) 통과
150
+ *
151
+ * v1.14.3 (2026-05-23): df=0 토큰은 살리지 않고 제외 (이전엔 살림).
152
+ * 이유: "check next session for live verification" 케이스에서 session/live/
153
+ * verification은 df=0인데 살아서 카운트만 채우고 실제 매칭은 next 단일
154
+ * 토큰("Next.js")에 의해 false positive 발생. df=0 토큰은 FTS5 매칭에
155
+ * 기여 0이므로 trigger 조건 자체에서 제외하는 게 맞음.
156
+ *
157
+ * @returns 살아남은 키워드 (희소한 의미 토큰만)
158
+ */
159
+ function filterByIdf(db, keywords) {
160
+ if (keywords.length === 0)
161
+ return [];
162
+ try {
163
+ const totalRow = db.prepare('SELECT COUNT(*) AS n FROM memories').get();
164
+ const N = Math.max(totalRow.n, 1);
165
+ const survived = [];
166
+ const stmt = db.prepare('SELECT COUNT(*) AS df FROM memories_fts WHERE memories_fts MATCH ?');
167
+ for (const kw of keywords) {
168
+ try {
169
+ const ftsQ = `"${kw.replace(/"/g, '""')}"`;
170
+ const row = stmt.get(ftsQ);
171
+ const df = row.df;
172
+ // v1.14.3 다시 정정: df=0 토큰은 살림 (한국어 부분일치/표기 차이 보완)
173
+ // 예: 메모리에 "비번"으로 적혔지만 사용자가 "비밀번호" 입력 → df=0, 그러나
174
+ // 같이 추출된 "서명키"가 매칭해주면 trigger 정상 작동
175
+ if (df === 0) {
176
+ survived.push(kw);
177
+ continue;
178
+ }
179
+ const idf = Math.log(N / df);
180
+ if (idf >= 2.0) {
181
+ survived.push(kw);
182
+ }
183
+ }
184
+ catch {
185
+ survived.push(kw); // FTS5 구문 오류 시 보수적으로 살림
186
+ }
187
+ }
188
+ return survived;
189
+ }
190
+ catch {
191
+ return keywords; // DF 측정 실패 시 원본 그대로
192
+ }
193
+ }
194
+ /**
195
+ * Proactive trigger: 추출된 키워드로 memories_fts + bm25 검색
196
+ * 임계값: bm25 score < -2 (강한 매칭만, false positive 최소화)
197
+ * + IDF 필터로 너무 흔한 토큰(commit/feat/fix 등) 제거
198
+ *
199
+ * @returns top 2 매칭 memories (or empty)
200
+ */
201
+ function searchTriggeredMemories(db, keywords, project) {
202
+ // IDF 필터: 흔한 토큰 제거
203
+ const meaningful = filterByIdf(db, keywords);
204
+ if (meaningful.length < 2)
205
+ return []; // IDF 통과 토큰 2개 이상일 때만 trigger
206
+ try {
207
+ const ftsQuery = meaningful.map(k => `"${k.replace(/"/g, '""')}"`).join(' OR ');
208
+ const projectFilter = project ? `AND (m.project = ? OR m.project IS NULL)` : '';
209
+ const params = [ftsQuery];
210
+ if (project)
211
+ params.push(project);
212
+ const rows = db.prepare(`
213
+ SELECT m.content, m.memory_type, bm25(memories_fts) AS score
214
+ FROM memories_fts fts
215
+ JOIN memories m ON m.id = fts.rowid
216
+ WHERE memories_fts MATCH ?
217
+ ${projectFilter}
218
+ AND m.importance >= 5
219
+ AND (m.tags NOT LIKE '%auto-tracked%' OR m.tags IS NULL)
220
+ ORDER BY score ASC
221
+ LIMIT 5
222
+ `).all(...params);
223
+ // bm25 score < -2 강한 매칭만 (낮을수록 관련도 높음)
224
+ return rows.filter(r => r.score < -2).slice(0, 2);
225
+ }
226
+ catch {
227
+ return [];
228
+ }
229
+ }
68
230
  function searchPastWork(db, keyword) {
69
231
  const result = { sessions: [], memories: [], solutions: [] };
70
- const likeKeyword = `%${keyword}%`;
71
- // 1. sessions 검색 (최근 30일, 상위 3건)
232
+ // C3 (2026-07-08): 다단어 구("GCP 코인 정보")를 통째 LIKE/MATCH하면
233
+ // 조사·어순 때문에 거의 맞았음. 구를 토큰화해 OR 검색으로 커버.
234
+ // 단, 흔한 토큰(이름/정보 등)은 IDF로 걸러야 오탐 방지 — "강아지 이름"의
235
+ // '이름'이 무관한 앱-이름 메모리와 매칭되던 문제.
236
+ // 토큰화 결과가 비면 = 의미 토큰이 없는 질문(예: "내 정보 가지고 있나"의 '정보').
237
+ // 원본 keyword로 폴백하면 흔한 단어로 아무 메모리나 잡으므로 검색 안 함.
238
+ const rawTokens = extractTriggerKeywords(keyword);
239
+ if (rawTokens.length === 0)
240
+ return result;
241
+ // IDF로 흔한 토큰 추가 제거. 남는 게 없으면(전부 흔함) 검색 안 함.
242
+ const searchTokens = filterByIdf(db, rawTokens);
243
+ if (searchTokens.length === 0)
244
+ return result;
245
+ // 1. sessions 검색 (최근 30일, 상위 3건) — 토큰 LIKE OR
72
246
  try {
247
+ const likeClause = searchTokens.map(() => 'last_work LIKE ?').join(' OR ');
248
+ const likeParams = searchTokens.map(t => `%${t}%`);
73
249
  const sessions = db.prepare(`
74
250
  SELECT last_work, timestamp FROM sessions
75
- WHERE last_work LIKE ?
251
+ WHERE (${likeClause})
76
252
  AND last_work != 'Session ended'
77
253
  AND last_work != 'Session work completed'
78
254
  AND last_work != 'Session started'
79
255
  AND last_work != ''
80
256
  AND timestamp > datetime('now', '-30 days')
81
257
  ORDER BY timestamp DESC LIMIT 3
82
- `).all(likeKeyword);
258
+ `).all(...likeParams);
83
259
  for (const s of sessions) {
84
260
  const work = s.last_work.length > 80 ? s.last_work.slice(0, 80) + '...' : s.last_work;
85
261
  result.sessions.push({ date: s.timestamp?.slice(0, 10) || 'unknown', work });
86
262
  }
87
263
  }
88
264
  catch { /* ignore */ }
89
- // 2. memories FTS5 검색 (상위 2건)
265
+ // 2. memories FTS5 검색 (상위 2건) — 토큰 OR 쿼리
90
266
  try {
267
+ const ftsQuery = searchTokens.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
91
268
  const memories = db.prepare(`
92
269
  SELECT m.content, m.memory_type FROM memories m
93
270
  JOIN memories_fts fts ON m.id = fts.rowid
94
271
  WHERE memories_fts MATCH ?
95
272
  ORDER BY rank LIMIT 2
96
- `).all(keyword);
273
+ `).all(ftsQuery);
97
274
  for (const m of memories) {
98
275
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
99
276
  result.memories.push({ type: m.memory_type, content });
100
277
  }
101
278
  }
102
279
  catch {
103
- // FTS5 매칭 실패 시 LIKE 폴백
280
+ // FTS5 매칭 실패 시 LIKE 폴백 (토큰 OR)
104
281
  try {
282
+ const likeClause = searchTokens.map(() => 'content LIKE ?').join(' OR ');
283
+ const likeParams = searchTokens.map(t => `%${t}%`);
105
284
  const memories = db.prepare(`
106
285
  SELECT content, memory_type FROM memories
107
- WHERE content LIKE ?
286
+ WHERE ${likeClause}
108
287
  ORDER BY importance DESC, created_at DESC LIMIT 2
109
- `).all(likeKeyword);
288
+ `).all(...likeParams);
110
289
  for (const m of memories) {
111
290
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
112
291
  result.memories.push({ type: m.memory_type, content });
@@ -116,11 +295,16 @@ function searchPastWork(db, keyword) {
116
295
  }
117
296
  // 3. solutions 검색 (상위 2건)
118
297
  try {
298
+ const clause = searchTokens.map(() => '(error_signature LIKE ? OR solution LIKE ?)').join(' OR ');
299
+ const params = [];
300
+ for (const t of searchTokens) {
301
+ params.push(`%${t}%`, `%${t}%`);
302
+ }
119
303
  const solutions = db.prepare(`
120
304
  SELECT error_signature, solution FROM solutions
121
- WHERE error_signature LIKE ? OR solution LIKE ?
305
+ WHERE ${clause}
122
306
  ORDER BY created_at DESC LIMIT 2
123
- `).all(likeKeyword, likeKeyword);
307
+ `).all(...params);
124
308
  for (const s of solutions) {
125
309
  const sol = s.solution.length > 80 ? s.solution.slice(0, 80) + '...' : s.solution;
126
310
  result.solutions.push({ signature: s.error_signature, solution: sol });
@@ -173,15 +357,44 @@ const DIRECTIVE_PATTERNS = [
173
357
  { pattern: /(?:rule|규칙)[:\s]+(.+)/i, priority: 'normal' },
174
358
  ];
175
359
  const MAX_DIRECTIVES = 20;
360
+ /**
361
+ * directive 품질 게이트 (P4, 2026-07-08).
362
+ * DIRECTIVE_PATTERNS의 `.+` 탐욕 캡처가, 사용자가 붙여넣은 어시스턴트 산출물
363
+ * (JSON 평가지/스토리보드/코드)에서 '절대/반드시/must' 부분문자열 뒤를 통째로
364
+ * 잡아 지시문으로 오추출했음(실측 45건 중 42건 오염, 30건이 200자 cap run-on).
365
+ * 진짜 지시문은 짧고 자족적 — 데이터/코드 구문 냄새가 나면 거부.
366
+ * 다국어(영어+한국어) 모두 커버.
367
+ */
368
+ function isValidDirective(d) {
369
+ if (d.length > 120)
370
+ return false; // run-on 캡처
371
+ if (/"\s*:\s*|"\s*\}|\{\s*"|"\},\{"|\}\]|\}\}/.test(d))
372
+ return false; // JSON 구조 구문
373
+ if (/"(?:whatWorks|itemCode|weight|factor|impact|verdict|correction|role|path|purpose|problem|action|files)"/.test(d))
374
+ return false; // 평가지/JSON 키
375
+ if (d.includes('`') || d.includes('**') || /\|.*\|.*\|/.test(d))
376
+ return false; // 코드/마크다운 표
377
+ if ((d.match(/"/g) || []).length >= 4)
378
+ return false; // 따옴표 과다=구조화 데이터
379
+ // 어시스턴트 산문 중간이 잘린 파편은 소문자 라틴 문자로 시작함
380
+ // (진짜 영어 지시문은 대문자 명령형: "Never …", "Always …", "Do NOT …").
381
+ // 한국어 지시문은 이 검사에 안 걸림(라틴 소문자로 시작 안 함).
382
+ if (/^[a-z]/.test(d))
383
+ return false;
384
+ return true;
385
+ }
176
386
  function extractAndSaveDirectives(dbPath, project, prompt) {
177
387
  try {
178
388
  const db = new Database(dbPath);
389
+ db.pragma('journal_mode = WAL'); // 다중 hook 프로세스 동시성 보장
179
390
  for (const { pattern, priority } of DIRECTIVE_PATTERNS) {
180
391
  const match = prompt.match(pattern);
181
392
  if (match && match[1]) {
182
393
  const directive = match[1].trim().slice(0, 200);
183
394
  if (directive.length < 5)
184
395
  continue;
396
+ if (!isValidDirective(directive))
397
+ continue;
185
398
  // UPSERT directive
186
399
  db.prepare(`
187
400
  INSERT INTO user_directives (project, directive, context, source, priority)
@@ -233,7 +446,7 @@ async function main() {
233
446
  if (input.prompt) {
234
447
  extractAndSaveDirectives(dbPath, project, input.prompt);
235
448
  }
236
- // 과거 참조 감지 시에만 출력 (~200 토큰)
449
+ // 1. 명시적 과거 참조 ("저번에", "예전에" 등) 기존 동작 유지
237
450
  if (input.prompt && fs.existsSync(dbPath)) {
238
451
  const keyword = extractPastKeywords(input.prompt);
239
452
  if (keyword) {
@@ -248,10 +461,33 @@ async function main() {
248
461
  }
249
462
  catch { /* ignore */ }
250
463
  }
464
+ else {
465
+ // 2. Proactive trigger: 명시적 참조 없어도 키워드 매칭으로 관련 메모리 inject
466
+ // (P0+ 2026-05-22: 사용자 "트리거 매칭" 발상 구현)
467
+ // 임계값: 추출 키워드 ≥2개 + bm25 score < -2 (강한 매칭만)
468
+ try {
469
+ const triggerKws = extractTriggerKeywords(input.prompt);
470
+ if (triggerKws.length >= 2) {
471
+ const db = new Database(dbPath, { readonly: true });
472
+ const triggered = searchTriggeredMemories(db, triggerKws, project);
473
+ db.close();
474
+ if (triggered.length > 0) {
475
+ const lines = ['## Triggered Memory (auto-matched from your prompt)'];
476
+ for (const m of triggered) {
477
+ const content = m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content;
478
+ lines.push(`- [${m.memory_type}] ${content}`);
479
+ }
480
+ console.log(`\n<triggered-context project="${project ?? 'global'}" keywords="${triggerKws.join(',')}">\n${lines.join('\n')}\n</triggered-context>\n`);
481
+ }
482
+ }
483
+ }
484
+ catch { /* ignore */ }
485
+ }
251
486
  }
252
487
  process.exit(0);
253
488
  }
254
489
  catch (e) {
490
+ logHookError('user-prompt-submit', e);
255
491
  process.exit(0);
256
492
  }
257
493
  }
package/dist/index.js CHANGED
@@ -1500,35 +1500,76 @@ async function handleTool(name, args) {
1500
1500
  }
1501
1501
  }
1502
1502
  else {
1503
- // LIKE 기반 키워드 검색 (FTS5보다 안정적)
1504
- // 검색어를 단어로 분리하여 OR 조건으로 검색
1503
+ // FTS5 + bm25() 랭킹 우선, 결과 없으면 LIKE 폴백
1504
+ // (P1-2, 2026-05-22: 7-agent 검증 적용)
1505
1505
  const words = query.split(/\s+/).filter(w => w.length > 0);
1506
- const likeConditions = words.map(() => '(content LIKE ? OR tags LIKE ?)').join(' OR ');
1507
- const likeParams = [];
1508
- words.forEach(w => {
1509
- likeParams.push(`%${w}%`, `%${w}%`);
1510
- });
1511
- let sql = `
1512
- SELECT * FROM memories
1513
- WHERE (${likeConditions || 'content LIKE ?'})
1514
- AND importance >= ?
1515
- `;
1516
- const params = [...(likeConditions ? likeParams : [`%${query}%`]), minImportance];
1517
- if (memoryType && memoryType !== 'all') {
1518
- sql += ` AND memory_type = ?`;
1519
- params.push(memoryType);
1506
+ // 1차: FTS5 MATCH + bm25() 점수
1507
+ let ftsResults = [];
1508
+ try {
1509
+ const ftsQuery = words.length > 0
1510
+ ? words.map(w => `"${w.replace(/"/g, '""')}"`).join(' OR ')
1511
+ : query;
1512
+ let ftsSql = `
1513
+ SELECT m.*, bm25(memories_fts) AS bm25_score
1514
+ FROM memories_fts fts
1515
+ JOIN memories m ON m.id = fts.rowid
1516
+ WHERE memories_fts MATCH ?
1517
+ AND m.importance >= ?
1518
+ `;
1519
+ const ftsParams = [ftsQuery, minImportance];
1520
+ if (memoryType && memoryType !== 'all') {
1521
+ ftsSql += ` AND m.memory_type = ?`;
1522
+ ftsParams.push(memoryType);
1523
+ }
1524
+ if (project) {
1525
+ ftsSql += ` AND m.project = ?`;
1526
+ ftsParams.push(project);
1527
+ }
1528
+ if (tags && tags.length > 0) {
1529
+ ftsSql += ` AND (${tags.map(() => 'm.tags LIKE ?').join(' OR ')})`;
1530
+ ftsParams.push(...tags.map(t => `%"${t}"%`));
1531
+ }
1532
+ // bm25는 낮을수록 관련도 높음. importance 가중치 더해 종합 랭킹.
1533
+ ftsSql += ` ORDER BY bm25_score ASC, m.importance DESC LIMIT ?`;
1534
+ ftsParams.push(limit);
1535
+ ftsResults = db.prepare(ftsSql).all(...ftsParams);
1520
1536
  }
1521
- if (project) {
1522
- sql += ` AND project = ?`;
1523
- params.push(project);
1537
+ catch {
1538
+ // FTS5 구문 오류(MATCH 토큰 이슈) 시 폴백
1539
+ ftsResults = [];
1524
1540
  }
1525
- if (tags && tags.length > 0) {
1526
- sql += ` AND (${tags.map(() => 'tags LIKE ?').join(' OR ')})`;
1527
- params.push(...tags.map(t => `%"${t}"%`));
1541
+ if (ftsResults.length > 0) {
1542
+ results = ftsResults;
1543
+ }
1544
+ else {
1545
+ // 2차 폴백: LIKE (FTS5 0건 시 — 한국어 부분일치 등)
1546
+ const likeConditions = words.map(() => '(content LIKE ? OR tags LIKE ?)').join(' OR ');
1547
+ const likeParams = [];
1548
+ words.forEach(w => {
1549
+ likeParams.push(`%${w}%`, `%${w}%`);
1550
+ });
1551
+ let sql = `
1552
+ SELECT * FROM memories
1553
+ WHERE (${likeConditions || 'content LIKE ?'})
1554
+ AND importance >= ?
1555
+ `;
1556
+ const params = [...(likeConditions ? likeParams : [`%${query}%`]), minImportance];
1557
+ if (memoryType && memoryType !== 'all') {
1558
+ sql += ` AND memory_type = ?`;
1559
+ params.push(memoryType);
1560
+ }
1561
+ if (project) {
1562
+ sql += ` AND project = ?`;
1563
+ params.push(project);
1564
+ }
1565
+ if (tags && tags.length > 0) {
1566
+ sql += ` AND (${tags.map(() => 'tags LIKE ?').join(' OR ')})`;
1567
+ params.push(...tags.map(t => `%"${t}"%`));
1568
+ }
1569
+ sql += ` ORDER BY importance DESC, accessed_at DESC LIMIT ?`;
1570
+ params.push(limit);
1571
+ results = db.prepare(sql).all(...params);
1528
1572
  }
1529
- sql += ` ORDER BY importance DESC, accessed_at DESC LIMIT ?`;
1530
- params.push(limit);
1531
- results = db.prepare(sql).all(...params);
1532
1573
  }
1533
1574
  // 접근 기록 업데이트
1534
1575
  const ids = results.map(r => r.id);
@@ -14,4 +14,15 @@ declare class Logger {
14
14
  withTool<T>(toolName: string, fn: () => Promise<T>, args?: Record<string, unknown>): Promise<T>;
15
15
  }
16
16
  export declare const logger: Logger;
17
+ /**
18
+ * Hook 치명적 오류 로거 — outer catch 전용.
19
+ *
20
+ * 배경: 2026-07-08, Node를 v26(ABI 147)로 업그레이드하면서 better-sqlite3
21
+ * 네이티브 모듈(ABI 141)이 로드 실패 → 5개 hook 전부 outer catch에서
22
+ * 조용히 exit(0). 18일간 세션이 한 건도 저장되지 않았는데 아무 흔적도 없었음.
23
+ * fail-soft는 유지하되, 반드시 흔적을 남긴다.
24
+ *
25
+ * 로그 위치: $HOOK_ERROR_LOG > cwd/.claude/hook-errors.log > ~/.claude/hook-errors.log
26
+ */
27
+ export declare function logHookError(hook: string, err: unknown): void;
17
28
  export {};
@@ -1,5 +1,6 @@
1
1
  // 구조화된 로깅 시스템
2
2
  import * as fs from 'fs';
3
+ import * as path from 'path';
3
4
  // 민감 정보 패턴
4
5
  const SENSITIVE_PATTERNS = [
5
6
  /password["\s:=]+["']?[\w\-!@#$%^&*]+["']?/gi,
@@ -109,3 +110,31 @@ class Logger {
109
110
  }
110
111
  // 싱글톤 인스턴스
111
112
  export const logger = new Logger();
113
+ /**
114
+ * Hook 치명적 오류 로거 — outer catch 전용.
115
+ *
116
+ * 배경: 2026-07-08, Node를 v26(ABI 147)로 업그레이드하면서 better-sqlite3
117
+ * 네이티브 모듈(ABI 141)이 로드 실패 → 5개 hook 전부 outer catch에서
118
+ * 조용히 exit(0). 18일간 세션이 한 건도 저장되지 않았는데 아무 흔적도 없었음.
119
+ * fail-soft는 유지하되, 반드시 흔적을 남긴다.
120
+ *
121
+ * 로그 위치: $HOOK_ERROR_LOG > cwd/.claude/hook-errors.log > ~/.claude/hook-errors.log
122
+ */
123
+ export function logHookError(hook, err) {
124
+ try {
125
+ const candidates = [
126
+ process.env.HOOK_ERROR_LOG,
127
+ path.join(process.cwd(), '.claude', 'hook-errors.log'),
128
+ path.join(process.env.HOME || '/tmp', '.claude', 'hook-errors.log'),
129
+ ].filter(Boolean);
130
+ const line = `[${new Date().toISOString()}] hook=${hook} ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`;
131
+ for (const p of candidates) {
132
+ try {
133
+ fs.appendFileSync(p, line);
134
+ return;
135
+ }
136
+ catch { /* try next candidate */ }
137
+ }
138
+ }
139
+ catch { /* never throw from the error logger */ }
140
+ }
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.15.0",
4
4
  "description": "Session Continuity for Claude Code - Never re-explain your project again",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -60,7 +60,7 @@
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.0.0",
62
62
  "@xenova/transformers": "^2.17.0",
63
- "better-sqlite3": "^12.6.2",
63
+ "better-sqlite3": "^12.11.1",
64
64
  "zod": "^3.23.0"
65
65
  },
66
66
  "devDependencies": {