claude-session-continuity-mcp 1.13.2 → 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 초과 시 정리
@@ -320,6 +321,7 @@ async function main() {
320
321
  process.exit(0);
321
322
  }
322
323
  catch (e) {
324
+ logHookError('post-tool-use', e);
323
325
  process.exit(0);
324
326
  }
325
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,11 +201,20 @@ 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
  }
@@ -230,7 +240,10 @@ async function main() {
230
240
  try {
231
241
  const directives = db.prepare(`
232
242
  SELECT directive, priority FROM user_directives
233
- 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
234
247
  `).all(project);
235
248
  if (directives.length > 0) {
236
249
  recoveryLines.push('## DIRECTIVES (MUST FOLLOW)');
@@ -277,15 +290,30 @@ async function main() {
277
290
  }
278
291
  }
279
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 */ }
280
301
  const output = {
281
302
  continue: true,
282
- systemMessage: recoveryLines.join('\n')
303
+ systemMessage
283
304
  };
284
305
  process.stdout.write(JSON.stringify(output));
285
306
  process.exit(0);
286
307
  }
287
308
  catch (e) {
288
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 */ }
289
317
  process.stdout.write(JSON.stringify({ continue: true }));
290
318
  process.exit(0);
291
319
  }
@@ -14,6 +14,7 @@ import * as path from 'path';
14
14
  import * as readline from 'readline';
15
15
  import * as crypto from 'crypto';
16
16
  import Database from 'better-sqlite3';
17
+ import { logHookError } from '../utils/logger.js';
17
18
  function detectWorkspaceRoot(cwd) {
18
19
  let current = cwd;
19
20
  const root = path.parse(current).root;
@@ -184,6 +185,10 @@ function extractNextTasks(content) {
184
185
  * 사용자 메시지를 유효한 요청인지 필터링
185
186
  */
186
187
  function parseUserText(entry) {
188
+ // 슬래시 커맨드 확장으로 주입된 메타 턴은 사용자 요청이 아님
189
+ // (예: "# /work - 프로젝트 작업 메인 명령어 (v2)…" 커맨드 본문 전체가 여기 들어옴)
190
+ if (entry.isMeta === true)
191
+ return '';
187
192
  const content = entry.message?.content;
188
193
  let text = '';
189
194
  if (typeof content === 'string') {
@@ -195,6 +200,11 @@ function parseUserText(entry) {
195
200
  .map(b => b.text || '')
196
201
  .join('\n');
197
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
+ }
198
208
  // system-reminder, local-command 태그 제거
199
209
  text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
200
210
  text = text.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '').trim();
@@ -353,7 +363,9 @@ async function parseTranscriptSinglePass(transcriptPath) {
353
363
  }
354
364
  result.decisions = [...decisionSet].slice(0, 3);
355
365
  // Error-Fix pairs (한국어 패턴 보강)
356
- const errorRe = /(?:error|Error|ERROR|오류|에러|버그|예외|실패|FAILED|Exception|TypeError|ReferenceError|SyntaxError|crash|crashed|충돌|문제)[:\s](.{5,80})/;
366
+ // P3 (2026-07-08): '충돌' 제거 — 영상 파이프라인 "충돌(collision) 컷" 잡담이
367
+ // 에러로 오분류되던 주 원인. 진짜 conflict 에러는 라틴 토큰으로 매칭됨.
368
+ const errorRe = /(?:error|Error|ERROR|오류|에러|버그|예외|실패|FAILED|Exception|TypeError|ReferenceError|SyntaxError|crash|crashed|문제)[:\s](.{5,80})/;
357
369
  const fixRe = /(?:fixed|resolved|patched|수정|해결|고침|처리|완료|변경|적용|반영|커밋|Added|수정 완료|문제 해결|해결됨|되돌림)/i;
358
370
  const pairSet = new Set();
359
371
  for (let i = 0; i < recentEntries.length - 1; i++) {
@@ -418,6 +430,30 @@ function jaccardSimilarity(a, b) {
418
430
  const union = setA.size + setB.size - intersect;
419
431
  return union === 0 ? 0 : intersect / union;
420
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
+ }
421
457
  /**
422
458
  * 사용자 메시지들을 세션 요약으로 압축
423
459
  * 예: ["MCP 테스트해줘", "개선해줘", "npm 배포하고 커밋해줘"] → "MCP 테스트 + 개선 + npm 배포/커밋"
@@ -638,28 +674,55 @@ async function main() {
638
674
  db.close();
639
675
  process.exit(0);
640
676
  }
641
- // 중복 저장 방지 — Jaccard 유사도 기반 (1시간 이내, 동일 프로젝트)
642
- // 베이스라인: 동일 last_work가 4~5분 간격으로 반복 INSERT되는 케이스 5건/24h 발견
643
- // 정확 일치 + Jaccard >= 0.85 차단
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 윈도우)
644
685
  const recentExact = db.prepare(`
645
686
  SELECT id FROM sessions
646
- WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-1 hour')
687
+ WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-24 hour')
647
688
  LIMIT 1
648
689
  `).get(project, lastWork);
649
690
  if (recentExact) {
650
- console.log(`[SessionEnd] Skipping duplicate (exact) for ${project}`);
691
+ console.log(`[SessionEnd] Skipping duplicate (exact, 24h) for ${project}`);
651
692
  db.close();
652
693
  process.exit(0);
653
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 검증).
654
718
  const recentRows = db.prepare(`
655
719
  SELECT last_work FROM sessions
656
- WHERE project = ? AND timestamp > datetime('now', '-1 hour')
657
- ORDER BY timestamp DESC LIMIT 10
720
+ WHERE project = ? AND timestamp > datetime('now', '-24 hour')
721
+ ORDER BY timestamp DESC LIMIT 30
658
722
  `).all(project);
659
723
  for (const row of recentRows) {
660
724
  if (!row.last_work)
661
725
  continue;
662
- // Phase 5: 비교 전 양쪽 모두 stripSlashPrefix 정규화 → 표현 차이 흡수
663
726
  const normalizedCurrent = stripSlashPrefix(lastWork) || lastWork;
664
727
  const normalizedRow = stripSlashPrefix(row.last_work) || row.last_work;
665
728
  if (jaccardSimilarity(normalizedCurrent, normalizedRow) >= 0.85) {
@@ -675,35 +738,69 @@ async function main() {
675
738
  errorsSolved
676
739
  };
677
740
  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
- }
687
- // 세션 기록 저장
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초 초과 정당한 재작업은 통과.
688
751
  db.prepare(`
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);
752
+ INSERT INTO sessions (project, last_work, next_tasks, modified_files, issues)
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);
692
760
  // 활성 컨텍스트 업데이트
693
761
  db.prepare(`
694
762
  INSERT OR REPLACE INTO active_context (project, current_state, recent_files, updated_at)
695
763
  VALUES (?, ?, ?, datetime('now'))
696
764
  `).run(project, lastWork, JSON.stringify(modifiedFiles.slice(0, 15)));
697
765
  // 에러→솔루션 자동 기록 (solutions 테이블)
766
+ // P1-4 (2026-05-22): 품질 필터 + 동일 solution 텍스트 dedup 추가
698
767
  let solutionsRecorded = 0;
699
768
  if (transcript.errorFixPairs.length > 0) {
700
769
  try {
701
770
  for (const pair of transcript.errorFixPairs) {
702
- const existing = db.prepare('SELECT id FROM solutions WHERE project = ? AND error_signature = ? LIMIT 1').get(project, pair.error);
703
- if (!existing) {
704
- db.prepare('INSERT INTO solutions (project, error_signature, solution) VALUES (?, ?, ?)').run(project, pair.error, pair.fix);
705
- solutionsRecorded++;
706
- }
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++;
707
804
  }
708
805
  }
709
806
  catch { /* solutions table may not exist */ }
@@ -778,6 +875,7 @@ async function main() {
778
875
  process.exit(0);
779
876
  }
780
877
  catch (e) {
878
+ logHookError('session-end', e);
781
879
  process.exit(0);
782
880
  }
783
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;
@@ -124,7 +125,10 @@ function loadContext(dbPath, project) {
124
125
  try {
125
126
  const directives = db.prepare(`
126
127
  SELECT directive, priority FROM user_directives
127
- 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
128
132
  `).all(project);
129
133
  if (directives.length > 0) {
130
134
  const directiveLines = ['## Directives'];
@@ -173,21 +177,25 @@ function loadContext(dbPath, project) {
173
177
  catch { /* table may not exist */ }
174
178
  }
175
179
  // [Priority 5] 중요 메모리 (temporal decay 적용, 예산 내에서)
180
+ // P0 (2026-05-22): reference/observation 타입 + global(project=NULL) 메모리 포함
181
+ // 사용자 pain: "서버 주소 기억할 때도 있고 못할 때도 있다"
182
+ // 원인: SessionStart가 reference 타입 미포함 + project filter가 NULL 거름
176
183
  if (tokenBudget > 80)
177
184
  try {
178
185
  const memories = db.prepare(`
179
186
  SELECT content, memory_type, importance, created_at, access_count FROM memories
180
- WHERE project = ?
181
- 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')
182
189
  AND importance >= 3
183
190
  AND (tags NOT LIKE '%auto-tracked%' OR tags IS NULL)
184
191
  AND (tags NOT LIKE '%auto-compact%' OR tags IS NULL)
185
- ORDER BY importance DESC, accessed_at DESC LIMIT 20
192
+ ORDER BY importance DESC, accessed_at DESC LIMIT 30
186
193
  `).all(project);
187
194
  if (memories.length > 0) {
188
- // Decay 적용 후 top 5 선택
195
+ // Decay 적용 후 top 5 선택 (reference는 decay 거의 0 — 인프라 정보는 영구)
189
196
  const DECAY_RATES = {
190
- 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
191
199
  };
192
200
  const scored = memories.map(m => {
193
201
  const ageDays = (Date.now() - new Date(m.created_at).getTime()) / (1000 * 60 * 60 * 24);
@@ -196,7 +204,8 @@ function loadContext(dbPath, project) {
196
204
  return { ...m, score };
197
205
  }).sort((a, b) => b.score - a.score).slice(0, 5);
198
206
  const typeIcons = {
199
- decision: '🎯', learning: '📚', error: '⚠️', preference: '💡'
207
+ decision: '🎯', learning: '📚', error: '⚠️', preference: '💡',
208
+ reference: '🔧', observation: '👁'
200
209
  };
201
210
  const memoryLines = ['## Key Memories'];
202
211
  for (const m of scored) {
@@ -258,7 +267,7 @@ async function main() {
258
267
  process.exit(0);
259
268
  }
260
269
  catch (e) {
261
- // 에러 시 조용히 종료
270
+ logHookError('session-start', e);
262
271
  process.exit(0);
263
272
  }
264
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;
@@ -70,48 +71,221 @@ function extractPastKeywords(prompt) {
70
71
  }
71
72
  return null;
72
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
+ }
73
230
  function searchPastWork(db, keyword) {
74
231
  const result = { sessions: [], memories: [], solutions: [] };
75
- const likeKeyword = `%${keyword}%`;
76
- // 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
77
246
  try {
247
+ const likeClause = searchTokens.map(() => 'last_work LIKE ?').join(' OR ');
248
+ const likeParams = searchTokens.map(t => `%${t}%`);
78
249
  const sessions = db.prepare(`
79
250
  SELECT last_work, timestamp FROM sessions
80
- WHERE last_work LIKE ?
251
+ WHERE (${likeClause})
81
252
  AND last_work != 'Session ended'
82
253
  AND last_work != 'Session work completed'
83
254
  AND last_work != 'Session started'
84
255
  AND last_work != ''
85
256
  AND timestamp > datetime('now', '-30 days')
86
257
  ORDER BY timestamp DESC LIMIT 3
87
- `).all(likeKeyword);
258
+ `).all(...likeParams);
88
259
  for (const s of sessions) {
89
260
  const work = s.last_work.length > 80 ? s.last_work.slice(0, 80) + '...' : s.last_work;
90
261
  result.sessions.push({ date: s.timestamp?.slice(0, 10) || 'unknown', work });
91
262
  }
92
263
  }
93
264
  catch { /* ignore */ }
94
- // 2. memories FTS5 검색 (상위 2건)
265
+ // 2. memories FTS5 검색 (상위 2건) — 토큰 OR 쿼리
95
266
  try {
267
+ const ftsQuery = searchTokens.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
96
268
  const memories = db.prepare(`
97
269
  SELECT m.content, m.memory_type FROM memories m
98
270
  JOIN memories_fts fts ON m.id = fts.rowid
99
271
  WHERE memories_fts MATCH ?
100
272
  ORDER BY rank LIMIT 2
101
- `).all(keyword);
273
+ `).all(ftsQuery);
102
274
  for (const m of memories) {
103
275
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
104
276
  result.memories.push({ type: m.memory_type, content });
105
277
  }
106
278
  }
107
279
  catch {
108
- // FTS5 매칭 실패 시 LIKE 폴백
280
+ // FTS5 매칭 실패 시 LIKE 폴백 (토큰 OR)
109
281
  try {
282
+ const likeClause = searchTokens.map(() => 'content LIKE ?').join(' OR ');
283
+ const likeParams = searchTokens.map(t => `%${t}%`);
110
284
  const memories = db.prepare(`
111
285
  SELECT content, memory_type FROM memories
112
- WHERE content LIKE ?
286
+ WHERE ${likeClause}
113
287
  ORDER BY importance DESC, created_at DESC LIMIT 2
114
- `).all(likeKeyword);
288
+ `).all(...likeParams);
115
289
  for (const m of memories) {
116
290
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
117
291
  result.memories.push({ type: m.memory_type, content });
@@ -121,11 +295,16 @@ function searchPastWork(db, keyword) {
121
295
  }
122
296
  // 3. solutions 검색 (상위 2건)
123
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
+ }
124
303
  const solutions = db.prepare(`
125
304
  SELECT error_signature, solution FROM solutions
126
- WHERE error_signature LIKE ? OR solution LIKE ?
305
+ WHERE ${clause}
127
306
  ORDER BY created_at DESC LIMIT 2
128
- `).all(likeKeyword, likeKeyword);
307
+ `).all(...params);
129
308
  for (const s of solutions) {
130
309
  const sol = s.solution.length > 80 ? s.solution.slice(0, 80) + '...' : s.solution;
131
310
  result.solutions.push({ signature: s.error_signature, solution: sol });
@@ -178,6 +357,32 @@ const DIRECTIVE_PATTERNS = [
178
357
  { pattern: /(?:rule|규칙)[:\s]+(.+)/i, priority: 'normal' },
179
358
  ];
180
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
+ }
181
386
  function extractAndSaveDirectives(dbPath, project, prompt) {
182
387
  try {
183
388
  const db = new Database(dbPath);
@@ -188,6 +393,8 @@ function extractAndSaveDirectives(dbPath, project, prompt) {
188
393
  const directive = match[1].trim().slice(0, 200);
189
394
  if (directive.length < 5)
190
395
  continue;
396
+ if (!isValidDirective(directive))
397
+ continue;
191
398
  // UPSERT directive
192
399
  db.prepare(`
193
400
  INSERT INTO user_directives (project, directive, context, source, priority)
@@ -239,7 +446,7 @@ async function main() {
239
446
  if (input.prompt) {
240
447
  extractAndSaveDirectives(dbPath, project, input.prompt);
241
448
  }
242
- // 과거 참조 감지 시에만 출력 (~200 토큰)
449
+ // 1. 명시적 과거 참조 ("저번에", "예전에" 등) 기존 동작 유지
243
450
  if (input.prompt && fs.existsSync(dbPath)) {
244
451
  const keyword = extractPastKeywords(input.prompt);
245
452
  if (keyword) {
@@ -254,10 +461,33 @@ async function main() {
254
461
  }
255
462
  catch { /* ignore */ }
256
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
+ }
257
486
  }
258
487
  process.exit(0);
259
488
  }
260
489
  catch (e) {
490
+ logHookError('user-prompt-submit', e);
261
491
  process.exit(0);
262
492
  }
263
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.2",
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": {