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 +63 -3
- package/dist/hooks/post-tool-use.js +3 -0
- package/dist/hooks/pre-compact.js +33 -3
- package/dist/hooks/session-end.js +289 -21
- package/dist/hooks/session-start.js +18 -8
- package/dist/hooks/user-prompt-submit.js +250 -14
- package/dist/index.js +66 -25
- package/dist/utils/logger.d.ts +11 -0
- package/dist/utils/logger.js +29 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# claude-session-continuity-mcp (v1.13.
|
|
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
|
-
###
|
|
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 = ?
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
// 중복 저장 방지
|
|
529
|
-
|
|
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', '-
|
|
687
|
+
WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-24 hour')
|
|
532
688
|
LIMIT 1
|
|
533
689
|
`).get(project, lastWork);
|
|
534
|
-
if (
|
|
535
|
-
console.log(`[SessionEnd] Skipping duplicate
|
|
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
|
-
|
|
550
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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 = ?
|
|
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
|
|
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
|
-
|
|
71
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
286
|
+
WHERE ${likeClause}
|
|
108
287
|
ORDER BY importance DESC, created_at DESC LIMIT 2
|
|
109
|
-
`).all(
|
|
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
|
|
305
|
+
WHERE ${clause}
|
|
122
306
|
ORDER BY created_at DESC LIMIT 2
|
|
123
|
-
`).all(
|
|
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
|
-
// 과거 참조
|
|
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
|
-
//
|
|
1504
|
-
//
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1537
|
+
catch {
|
|
1538
|
+
// FTS5 구문 오류(MATCH 토큰 이슈) 시 폴백
|
|
1539
|
+
ftsResults = [];
|
|
1524
1540
|
}
|
|
1525
|
-
if (
|
|
1526
|
-
|
|
1527
|
-
|
|
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);
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/utils/logger.js
CHANGED
|
@@ -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.
|
|
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.
|
|
63
|
+
"better-sqlite3": "^12.11.1",
|
|
64
64
|
"zod": "^3.23.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|