claude-session-continuity-mcp 1.13.2 → 1.15.1

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,13 +1,14 @@
1
- # claude-session-continuity-mcp (v1.13.0)
1
+ # claude-session-continuity-mcp
2
2
 
3
- > **Zero Re-explanation Session Continuity for Claude Code**Automatic context capture + semantic search + auto error→solution pipeline
3
+ > **Never re-explain your project to Claude again.** 100% local session memory for Claude Code — auto context injection, semantic search, and error→solution recall. Zero config, zero API cost.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/claude-session-continuity-mcp.svg)](https://www.npmjs.com/package/claude-session-continuity-mcp)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-session-continuity-mcp.svg)](https://www.npmjs.com/package/claude-session-continuity-mcp)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Tests](https://img.shields.io/badge/tests-111%20passed-brightgreen.svg)]()
8
- [![Node](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)]()
9
8
  [![claude-session-continuity-mcp MCP server](https://glama.ai/mcp/servers/leesgit/claude-session-continuity-mcp/badges/card.svg)](https://glama.ai/mcp/servers/leesgit/claude-session-continuity-mcp)
10
9
 
10
+ ![Session continuity demo — Claude auto-restores your project context on session start](assets/demo.gif)
11
+
11
12
  ## The Problem
12
13
 
13
14
  Every new Claude Code session:
@@ -62,18 +63,96 @@ Every new Claude Code session:
62
63
 
63
64
  ---
64
65
 
66
+ ## Why this over other memory tools?
67
+
68
+ Most Claude memory tools rely on **explicit tool calls** ("remember this"), a **cloud API**, or a **background AI worker**. This one is deliberately different:
69
+
70
+ | | claude-session-continuity-mcp | Typical cloud/AI-memory MCP |
71
+ |---|---|---|
72
+ | **Setup** | `npm i -g` → hooks auto-install | Manual server + API key |
73
+ | **Trigger** | 5 automatic hooks (no commands) | You call a `remember` tool |
74
+ | **Storage** | 100% local SQLite | Cloud / external service |
75
+ | **API cost** | **$0** — local embeddings | Per-token / subscription |
76
+ | **Latency** | < 5ms (on-device) | Network round-trip |
77
+ | **Privacy** | Never leaves your machine | Sent to a provider |
78
+ | **Search** | FTS5 + local semantic, KO/EN/JA cross-lingual | Varies |
79
+
80
+ If you want zero-config, offline, no-cost memory that just *happens* while you work — this is it.
81
+
82
+ ---
83
+
65
84
  ## Quick Start
66
85
 
67
- ### One Command Installation
86
+ ### Recommended: Global Installation
68
87
 
69
88
  ```bash
70
- npm install claude-session-continuity-mcp
89
+ npm install -g claude-session-continuity-mcp
71
90
  ```
72
91
 
73
92
  **That's it!** The postinstall script automatically:
74
93
  1. Registers MCP server in `~/.claude.json`
75
94
  2. Installs Claude Hooks in `~/.claude/settings.json`
76
95
 
96
+ ### Why Global (`-g`)?
97
+
98
+ This tool is designed to track **all your Claude Code projects** in a single unified database.
99
+ Global installation is strongly recommended because:
100
+
101
+ | Reason | Detail |
102
+ |---|---|
103
+ | **Single source of truth** | One binary serves every project — no version drift between projects |
104
+ | **Hooks are user-scoped** | `~/.claude/settings.json` lives in your home directory, not per-project |
105
+ | **Cross-project context** | Sessions from `app-a` and `app-b` share the same DB and search index |
106
+ | **One update = everything refreshed** | `npm install -g <latest>` updates all projects at once; no per-project reinstall |
107
+ | **`npm exec` resolves global first** | Hooks call `npm exec -- claude-hook-*` which finds the global package reliably regardless of cwd |
108
+
109
+ **Important**: Even with global install, you can still **disable the hook for specific projects** (see below).
110
+ Global ≠ forced on every project.
111
+
112
+ ### Disabling Hooks for Specific Projects
113
+
114
+ Global install does **not** mean "always on everywhere". You have three layers of control:
115
+
116
+ | Layer | File | Scope |
117
+ |---|---|---|
118
+ | 1. Global ON (default) | `~/.claude/settings.json` | All projects |
119
+ | 2. Project-wide OFF | `<project>/.claude/settings.json` | Whole team (committed) |
120
+ | 3. Personal-only OFF | `<project>/.claude/settings.local.json` | Just you (gitignored) |
121
+
122
+ **To disable hooks in a specific project**, create the override file with empty hook arrays:
123
+
124
+ ```json
125
+ // <project>/.claude/settings.json (or settings.local.json for personal-only)
126
+ {
127
+ "hooks": {
128
+ "SessionStart": [],
129
+ "UserPromptSubmit": [],
130
+ "PostToolUse": [],
131
+ "PreCompact": [],
132
+ "Stop": []
133
+ }
134
+ }
135
+ ```
136
+
137
+ Empty arrays override the global setting → that project's sessions are no longer tracked.
138
+
139
+ ### Updating to a New Version
140
+
141
+ ```bash
142
+ npm install -g claude-session-continuity-mcp@latest
143
+ ```
144
+
145
+ That's the only step — all projects pick up the new binary on next Claude Code restart.
146
+ No need to reinstall in each project.
147
+
148
+ ### Alternative: Local Install (Not Recommended)
149
+
150
+ If you really want per-project install (e.g., locked version for one project):
151
+ ```bash
152
+ cd <project> && npm install claude-session-continuity-mcp
153
+ ```
154
+ 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.
155
+
77
156
  ### What Gets Installed
78
157
 
79
158
  **MCP Server** (in `~/.claude.json`):
@@ -575,3 +654,11 @@ PRs welcome! Please:
575
654
 
576
655
  - [Model Context Protocol](https://modelcontextprotocol.io/) by Anthropic
577
656
  - [Xenova Transformers](https://github.com/xenova/transformers.js) for embeddings
657
+
658
+ ---
659
+
660
+ <div align="center">
661
+
662
+ **If this saves you from re-explaining your project, consider giving it a ⭐ — it genuinely helps others find it.**
663
+
664
+ </div>
@@ -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,247 @@ 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
+ // 흔한 작업 동사·부사 (P1, 2026-07-08: 작은 코퍼스에서 df가 낮아 IDF를 통과해
87
+ // 오탐을 유발했음. "저장/기억/다시" 등이 GCP·영상 메모리를 엉뚱하게 소환)
88
+ '저장', '기억', '삭제', '수정', '검색', '등록', '변경', '추가', '제거', '생성', '실행', '설정',
89
+ '전체', '다시', '이렇게', '그렇게', '저렇게', '계속', '먼저', '바로', '같이', '제대로', '하나하나',
90
+ // 범용 명사 (LIKE 검색 시 아무 메모리나 잡는 오탐원 — 2026-07-08 C3)
91
+ '이름', '정보', '내용', '관련', '부분', '상태', '방법', '문제', '경우', '생각', '이야기', '얘기',
92
+ '어떻게', '무엇', '뭐가', '왜', '어디', '언제', '어느', '어떤', '어떻', '얼마',
93
+ // 한국어 2글자 빈출 (v1.14.3에서 한국어 길이 임계값 2로 낮춤에 따라 추가)
94
+ '이거', '그거', '저거', '한번', '두번', '코드', '작업', '뭐했지', '뭐였지', '봐줘', '한거지', '했지',
95
+ // 영어 빈출 추가 — Next.js 같은 동음이의 발생하는 토큰
96
+ 'next', 'last', 'first', 'prev', 'previous', 'check', 'test', 'run', 'live', 'ready', 'verify', 'verification',
97
+ 'session', 'task', 'item', 'case', 'file', 'line', 'data', 'code', 'word', 'time', 'step', 'part', 'side',
98
+ // 영어 작업 동사 (P1 audit-7, 2026-07-08: 한국어 저장/기억/검색과 대칭. 이들이
99
+ // df 낮아 IDF 통과 시 오탐 유발. inCorpus 게이트가 사후 방어하나 명시 차단이 견고)
100
+ 'save', 'store', 'remember', 'search', 'find', 'delete', 'remove', 'update', 'create', 'add',
101
+ 'change', 'edit', 'fix', 'make', 'show', 'list', 'get', 'set', 'build', 'lint', 'deploy',
102
+ 'whole', 'thing', 'again', 'entire', 'stuff', 'something', 'anything', 'everything',
103
+ // 영어 빈출 stopwords
104
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
105
+ 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must',
106
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their', 'we', 'our',
107
+ 'you', 'your', 'i', 'my', 'me', 'what', 'when', 'where', 'why', 'how', 'which', 'who',
108
+ 'and', 'or', 'but', 'if', 'then', 'else', 'also', 'so', 'as', 'at', 'by', 'for', 'from', 'in', 'of', 'on', 'to', 'with',
109
+ 'just', 'only', 'very', 'really', 'also', 'too', 'still', 'here', 'there', 'now', 'then',
110
+ ]);
111
+ /**
112
+ * Prompt에서 의미 있는 한국어/영어 키워드 추출
113
+ * (P0+ 2026-05-22: 사용자 "트리거 매칭" 발상 구현)
114
+ *
115
+ * @returns 키워드 배열 (3자 이상, stopword 제외, 중복 제거, 최대 5개)
116
+ */
117
+ function extractTriggerKeywords(prompt) {
118
+ if (!prompt || prompt.length < 5)
119
+ return [];
120
+ // 1. 따옴표/괄호/마크다운/슬래시 명령 제거
121
+ const cleaned = prompt
122
+ .replace(/```[\s\S]*?```/g, ' ') // 코드 블록
123
+ .replace(/`[^`]+`/g, ' ') // 인라인 코드
124
+ .replace(/^\/[a-z-]+\s*/i, '') // 슬래시 명령 prefix
125
+ .replace(/[*_~`"'()[\]{}<>]/g, ' '); // 특수문자
126
+ // 2. 토큰화 (한글/영문 단어만)
127
+ // v1.14.3: 한국어 길이 임계값 2, 영어는 3 (한국어는 2글자 단어 많음 — 서버/주소/명령)
128
+ const tokens = cleaned
129
+ .split(/[\s,.!?;:/\\|]+/)
130
+ .map(t => t.trim().toLowerCase())
131
+ .filter(t => /^[a-z0-9가-힣]+$/i.test(t)) // 영숫자+한글만
132
+ // P1 (2026-07-08): 흔한 한국어 동사 어미만 벗겨 stopword 체크가 먹게 함.
133
+ // "저장해줘"→"저장"이 안 되면 STOPWORDS(저장)를 우회해 오탐.
134
+ // ⚠️ 단일 글자 조사(로/를/이/가 등)는 절삭 금지 — "경로→경","추가→추"처럼
135
+ // 진짜 2글자 명사를 훼손함(실측 확인). 명확한 다글자 동사 어미만.
136
+ .map(t => t.replace(/(해줘|해주세요|해주라|했어|했지|하는|하고|해서|합니다|드려|드릴게)$/, ''))
137
+ .filter(t => t.length > 0)
138
+ .filter(t => {
139
+ // 한국어 단일 음절은 거의 무의미, 2글자 이상 허용
140
+ const hasHangul = /[가-힣]/.test(t);
141
+ return hasHangul ? t.length >= 2 : t.length >= 3;
142
+ })
143
+ .filter(t => !STOPWORDS.has(t))
144
+ .filter(t => !/^\d+$/.test(t)); // 숫자만은 제외
145
+ // 3. 중복 제거 (등장 순서 유지)
146
+ const seen = new Set();
147
+ const unique = [];
148
+ for (const t of tokens) {
149
+ if (!seen.has(t)) {
150
+ seen.add(t);
151
+ unique.push(t);
152
+ }
153
+ }
154
+ // 4. 최대 5개
155
+ return unique.slice(0, 5);
156
+ }
157
+ /**
158
+ * IDF 필터: 너무 흔한 토큰 제거
159
+ * (2026-05-23: "commit and push" false positive 발견 — feat/commit/fix 같은
160
+ * 커밋 메시지 토큰이 14/85 메모리에 등장해 IDF가 낮음)
161
+ *
162
+ * IDF = log(N / df), N = 총 메모리 수
163
+ * 임계값 IDF >= 2.0 (= 흔한 토큰 약 13% 이상 등장 시 제외)
164
+ * 실측: commit(idf 1.80), feat(1.96) 차단 + ec2(4.44), 서명키(4.44) 통과
165
+ *
166
+ * v1.14.3 (2026-05-23): df=0 토큰은 살리지 않고 제외 (이전엔 살림).
167
+ * 이유: "check next session for live verification" 케이스에서 session/live/
168
+ * verification은 df=0인데 살아서 카운트만 채우고 실제 매칭은 next 단일
169
+ * 토큰("Next.js")에 의해 false positive 발생. df=0 토큰은 FTS5 매칭에
170
+ * 기여 0이므로 trigger 조건 자체에서 제외하는 게 맞음.
171
+ *
172
+ * @returns 살아남은 키워드 + 각 df (P1 audit-7 2026-07-08: df를 함께 반환해
173
+ * searchTriggeredMemories의 inCorpus 재쿼리 제거)
174
+ */
175
+ function filterByIdf(db, keywords) {
176
+ if (keywords.length === 0)
177
+ return [];
178
+ try {
179
+ const totalRow = db.prepare('SELECT COUNT(*) AS n FROM memories').get();
180
+ const N = Math.max(totalRow.n, 1);
181
+ const survived = [];
182
+ const stmt = db.prepare('SELECT COUNT(*) AS df FROM memories_fts WHERE memories_fts MATCH ?');
183
+ for (const kw of keywords) {
184
+ try {
185
+ const ftsQ = `"${kw.replace(/"/g, '""')}"`;
186
+ const row = stmt.get(ftsQ);
187
+ const df = row.df;
188
+ // v1.14.3 다시 정정: df=0 토큰은 살림 (한국어 부분일치/표기 차이 보완)
189
+ // 예: 메모리에 "비번"으로 적혔지만 사용자가 "비밀번호" 입력 → df=0, 그러나
190
+ // 같이 추출된 "서명키"가 매칭해주면 trigger 정상 작동
191
+ if (df === 0) {
192
+ survived.push({ kw, df: 0 });
193
+ continue;
194
+ }
195
+ const idf = Math.log(N / df);
196
+ if (idf >= 2.0) {
197
+ survived.push({ kw, df });
198
+ }
199
+ }
200
+ catch {
201
+ survived.push({ kw, df: -1 }); // FTS5 구문 오류 시 보수적으로 살림 (df 불명=-1)
202
+ }
203
+ }
204
+ return survived;
205
+ }
206
+ catch {
207
+ return keywords.map(kw => ({ kw, df: -1 })); // DF 측정 실패 시 원본 그대로
208
+ }
209
+ }
210
+ /**
211
+ * Proactive trigger: 추출된 키워드로 memories_fts + bm25 검색
212
+ * 임계값: bm25 score < -2 (강한 매칭만, false positive 최소화)
213
+ * + IDF 필터로 너무 흔한 토큰(commit/feat/fix 등) 제거
214
+ *
215
+ * @returns top 2 매칭 memories (or empty)
216
+ */
217
+ function searchTriggeredMemories(db, keywords, project) {
218
+ // IDF 필터: 흔한 토큰 제거
219
+ const meaningful = filterByIdf(db, keywords);
220
+ if (meaningful.length < 2)
221
+ return []; // IDF 통과 토큰 2개 이상일 때만 trigger
222
+ // P1 (2026-07-08): 실제 코퍼스에 존재하는(df>0) 토큰이 2개 이상일 때만 trigger.
223
+ // filterByIdf가 df=0 토큰(코퍼스에 없음)을 살려주는데, 그게 length>=2 게이트를
224
+ // 우회시켜 실질 단일 토큰 매칭으로 trigger되던 오탐 차단.
225
+ // 예: "gmail draft로 저장" → gmail(df=1) + draft로(df=0) → 실매칭 1개인데 trigger됐음.
226
+ // audit-7 2026-07-08: filterByIdf가 반환한 df를 재사용(중복 쿼리 제거).
227
+ const inCorpus = meaningful.filter(m => m.df > 0);
228
+ if (inCorpus.length < 2)
229
+ return [];
230
+ const meaningfulKws = meaningful.map(m => m.kw);
231
+ try {
232
+ const ftsQuery = meaningfulKws.map(k => `"${k.replace(/"/g, '""')}"`).join(' OR ');
233
+ const projectFilter = project ? `AND (m.project = ? OR m.project IS NULL)` : '';
234
+ const params = [ftsQuery];
235
+ if (project)
236
+ params.push(project);
237
+ const rows = db.prepare(`
238
+ SELECT m.content, m.memory_type, bm25(memories_fts) AS score
239
+ FROM memories_fts fts
240
+ JOIN memories m ON m.id = fts.rowid
241
+ WHERE memories_fts MATCH ?
242
+ ${projectFilter}
243
+ AND m.importance >= 5
244
+ AND (m.tags NOT LIKE '%auto-tracked%' OR m.tags IS NULL)
245
+ ORDER BY score ASC
246
+ LIMIT 5
247
+ `).all(...params);
248
+ // bm25 score < -2 강한 매칭만 (낮을수록 관련도 높음)
249
+ return rows.filter(r => r.score < -2).slice(0, 2);
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ }
73
255
  function searchPastWork(db, keyword) {
74
256
  const result = { sessions: [], memories: [], solutions: [] };
75
- const likeKeyword = `%${keyword}%`;
76
- // 1. sessions 검색 (최근 30일, 상위 3건)
257
+ // C3 (2026-07-08): 다단어 구("GCP 코인 정보")를 통째 LIKE/MATCH하면
258
+ // 조사·어순 때문에 거의 맞았음. 구를 토큰화해 OR 검색으로 커버.
259
+ // 단, 흔한 토큰(이름/정보 등)은 IDF로 걸러야 오탐 방지 — "강아지 이름"의
260
+ // '이름'이 무관한 앱-이름 메모리와 매칭되던 문제.
261
+ // 토큰화 결과가 비면 = 의미 토큰이 없는 질문(예: "내 정보 가지고 있나"의 '정보').
262
+ // 원본 keyword로 폴백하면 흔한 단어로 아무 메모리나 잡으므로 검색 안 함.
263
+ const rawTokens = extractTriggerKeywords(keyword);
264
+ if (rawTokens.length === 0)
265
+ return result;
266
+ // IDF로 흔한 토큰 추가 제거. 남는 게 없으면(전부 흔함) 검색 안 함.
267
+ // audit-7 2026-07-08: filterByIdf가 {kw,df} 반환하도록 바뀜 → kw만 추출.
268
+ const searchTokens = filterByIdf(db, rawTokens).map(m => m.kw);
269
+ if (searchTokens.length === 0)
270
+ return result;
271
+ // 1. sessions 검색 (최근 30일, 상위 3건) — 토큰 LIKE OR
77
272
  try {
273
+ const likeClause = searchTokens.map(() => 'last_work LIKE ?').join(' OR ');
274
+ const likeParams = searchTokens.map(t => `%${t}%`);
78
275
  const sessions = db.prepare(`
79
276
  SELECT last_work, timestamp FROM sessions
80
- WHERE last_work LIKE ?
277
+ WHERE (${likeClause})
81
278
  AND last_work != 'Session ended'
82
279
  AND last_work != 'Session work completed'
83
280
  AND last_work != 'Session started'
84
281
  AND last_work != ''
85
282
  AND timestamp > datetime('now', '-30 days')
86
283
  ORDER BY timestamp DESC LIMIT 3
87
- `).all(likeKeyword);
284
+ `).all(...likeParams);
88
285
  for (const s of sessions) {
89
286
  const work = s.last_work.length > 80 ? s.last_work.slice(0, 80) + '...' : s.last_work;
90
287
  result.sessions.push({ date: s.timestamp?.slice(0, 10) || 'unknown', work });
91
288
  }
92
289
  }
93
290
  catch { /* ignore */ }
94
- // 2. memories FTS5 검색 (상위 2건)
291
+ // 2. memories FTS5 검색 (상위 2건) — 토큰 OR 쿼리
95
292
  try {
293
+ const ftsQuery = searchTokens.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
96
294
  const memories = db.prepare(`
97
295
  SELECT m.content, m.memory_type FROM memories m
98
296
  JOIN memories_fts fts ON m.id = fts.rowid
99
297
  WHERE memories_fts MATCH ?
100
298
  ORDER BY rank LIMIT 2
101
- `).all(keyword);
299
+ `).all(ftsQuery);
102
300
  for (const m of memories) {
103
301
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
104
302
  result.memories.push({ type: m.memory_type, content });
105
303
  }
106
304
  }
107
305
  catch {
108
- // FTS5 매칭 실패 시 LIKE 폴백
306
+ // FTS5 매칭 실패 시 LIKE 폴백 (토큰 OR)
109
307
  try {
308
+ const likeClause = searchTokens.map(() => 'content LIKE ?').join(' OR ');
309
+ const likeParams = searchTokens.map(t => `%${t}%`);
110
310
  const memories = db.prepare(`
111
311
  SELECT content, memory_type FROM memories
112
- WHERE content LIKE ?
312
+ WHERE ${likeClause}
113
313
  ORDER BY importance DESC, created_at DESC LIMIT 2
114
- `).all(likeKeyword);
314
+ `).all(...likeParams);
115
315
  for (const m of memories) {
116
316
  const content = m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content;
117
317
  result.memories.push({ type: m.memory_type, content });
@@ -121,11 +321,16 @@ function searchPastWork(db, keyword) {
121
321
  }
122
322
  // 3. solutions 검색 (상위 2건)
123
323
  try {
324
+ const clause = searchTokens.map(() => '(error_signature LIKE ? OR solution LIKE ?)').join(' OR ');
325
+ const params = [];
326
+ for (const t of searchTokens) {
327
+ params.push(`%${t}%`, `%${t}%`);
328
+ }
124
329
  const solutions = db.prepare(`
125
330
  SELECT error_signature, solution FROM solutions
126
- WHERE error_signature LIKE ? OR solution LIKE ?
331
+ WHERE ${clause}
127
332
  ORDER BY created_at DESC LIMIT 2
128
- `).all(likeKeyword, likeKeyword);
333
+ `).all(...params);
129
334
  for (const s of solutions) {
130
335
  const sol = s.solution.length > 80 ? s.solution.slice(0, 80) + '...' : s.solution;
131
336
  result.solutions.push({ signature: s.error_signature, solution: sol });
@@ -178,6 +383,32 @@ const DIRECTIVE_PATTERNS = [
178
383
  { pattern: /(?:rule|규칙)[:\s]+(.+)/i, priority: 'normal' },
179
384
  ];
180
385
  const MAX_DIRECTIVES = 20;
386
+ /**
387
+ * directive 품질 게이트 (P4, 2026-07-08).
388
+ * DIRECTIVE_PATTERNS의 `.+` 탐욕 캡처가, 사용자가 붙여넣은 어시스턴트 산출물
389
+ * (JSON 평가지/스토리보드/코드)에서 '절대/반드시/must' 부분문자열 뒤를 통째로
390
+ * 잡아 지시문으로 오추출했음(실측 45건 중 42건 오염, 30건이 200자 cap run-on).
391
+ * 진짜 지시문은 짧고 자족적 — 데이터/코드 구문 냄새가 나면 거부.
392
+ * 다국어(영어+한국어) 모두 커버.
393
+ */
394
+ function isValidDirective(d) {
395
+ if (d.length > 120)
396
+ return false; // run-on 캡처
397
+ if (/"\s*:\s*|"\s*\}|\{\s*"|"\},\{"|\}\]|\}\}/.test(d))
398
+ return false; // JSON 구조 구문
399
+ if (/"(?:whatWorks|itemCode|weight|factor|impact|verdict|correction|role|path|purpose|problem|action|files)"/.test(d))
400
+ return false; // 평가지/JSON 키
401
+ if (d.includes('`') || d.includes('**') || /\|.*\|.*\|/.test(d))
402
+ return false; // 코드/마크다운 표
403
+ if ((d.match(/"/g) || []).length >= 4)
404
+ return false; // 따옴표 과다=구조화 데이터
405
+ // 어시스턴트 산문 중간이 잘린 파편은 소문자 라틴 문자로 시작함
406
+ // (진짜 영어 지시문은 대문자 명령형: "Never …", "Always …", "Do NOT …").
407
+ // 한국어 지시문은 이 검사에 안 걸림(라틴 소문자로 시작 안 함).
408
+ if (/^[a-z]/.test(d))
409
+ return false;
410
+ return true;
411
+ }
181
412
  function extractAndSaveDirectives(dbPath, project, prompt) {
182
413
  try {
183
414
  const db = new Database(dbPath);
@@ -188,6 +419,8 @@ function extractAndSaveDirectives(dbPath, project, prompt) {
188
419
  const directive = match[1].trim().slice(0, 200);
189
420
  if (directive.length < 5)
190
421
  continue;
422
+ if (!isValidDirective(directive))
423
+ continue;
191
424
  // UPSERT directive
192
425
  db.prepare(`
193
426
  INSERT INTO user_directives (project, directive, context, source, priority)
@@ -239,7 +472,7 @@ async function main() {
239
472
  if (input.prompt) {
240
473
  extractAndSaveDirectives(dbPath, project, input.prompt);
241
474
  }
242
- // 과거 참조 감지 시에만 출력 (~200 토큰)
475
+ // 1. 명시적 과거 참조 ("저번에", "예전에" 등) 기존 동작 유지
243
476
  if (input.prompt && fs.existsSync(dbPath)) {
244
477
  const keyword = extractPastKeywords(input.prompt);
245
478
  if (keyword) {
@@ -254,10 +487,33 @@ async function main() {
254
487
  }
255
488
  catch { /* ignore */ }
256
489
  }
490
+ else {
491
+ // 2. Proactive trigger: 명시적 참조 없어도 키워드 매칭으로 관련 메모리 inject
492
+ // (P0+ 2026-05-22: 사용자 "트리거 매칭" 발상 구현)
493
+ // 임계값: 추출 키워드 ≥2개 + bm25 score < -2 (강한 매칭만)
494
+ try {
495
+ const triggerKws = extractTriggerKeywords(input.prompt);
496
+ if (triggerKws.length >= 2) {
497
+ const db = new Database(dbPath, { readonly: true });
498
+ const triggered = searchTriggeredMemories(db, triggerKws, project);
499
+ db.close();
500
+ if (triggered.length > 0) {
501
+ const lines = ['## Triggered Memory (auto-matched from your prompt)'];
502
+ for (const m of triggered) {
503
+ const content = m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content;
504
+ lines.push(`- [${m.memory_type}] ${content}`);
505
+ }
506
+ console.log(`\n<triggered-context project="${project ?? 'global'}" keywords="${triggerKws.join(',')}">\n${lines.join('\n')}\n</triggered-context>\n`);
507
+ }
508
+ }
509
+ }
510
+ catch { /* ignore */ }
511
+ }
257
512
  }
258
513
  process.exit(0);
259
514
  }
260
515
  catch (e) {
516
+ logHookError('user-prompt-submit', e);
261
517
  process.exit(0);
262
518
  }
263
519
  }
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.1",
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": {