claude-session-continuity-mcp 1.9.3 → 1.9.4

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.
@@ -3,5 +3,10 @@
3
3
  * SessionEnd Hook (Stop 이벤트) - 세션 종료 시 자동 저장
4
4
  *
5
5
  * Claude Code 세션 종료 시 자동으로 컨텍스트를 저장합니다.
6
+ *
7
+ * Stop 이벤트 입력 필드:
8
+ * - session_id, cwd, permission_mode, hook_event_name, stop_hook_active
9
+ * - transcript_path: JSONL 파일 경로 (전체 대화 기록)
10
+ * - last_assistant_message: 마지막 assistant 메시지 텍스트
6
11
  */
7
12
  export {};
@@ -3,9 +3,15 @@
3
3
  * SessionEnd Hook (Stop 이벤트) - 세션 종료 시 자동 저장
4
4
  *
5
5
  * Claude Code 세션 종료 시 자동으로 컨텍스트를 저장합니다.
6
+ *
7
+ * Stop 이벤트 입력 필드:
8
+ * - session_id, cwd, permission_mode, hook_event_name, stop_hook_active
9
+ * - transcript_path: JSONL 파일 경로 (전체 대화 기록)
10
+ * - last_assistant_message: 마지막 assistant 메시지 텍스트
6
11
  */
7
12
  import * as fs from 'fs';
8
13
  import * as path from 'path';
14
+ import * as readline from 'readline';
9
15
  import Database from 'better-sqlite3';
10
16
  function detectWorkspaceRoot(cwd) {
11
17
  let current = cwd;
@@ -30,12 +36,10 @@ function getDbPath(cwd) {
30
36
  function detectProject(cwd) {
31
37
  const workspaceRoot = detectWorkspaceRoot(cwd);
32
38
  const appsDir = path.join(workspaceRoot, 'apps');
33
- // apps/ 하위인지 확인
34
39
  if (cwd.startsWith(appsDir + path.sep)) {
35
40
  const relative = path.relative(appsDir, cwd);
36
41
  return relative.split(path.sep)[0];
37
42
  }
38
- // apps/ 외부 하위 프로젝트 (hackathons/ 등)
39
43
  if (cwd !== workspaceRoot) {
40
44
  let current = cwd;
41
45
  while (current !== workspaceRoot && current !== path.parse(current).root) {
@@ -52,97 +56,107 @@ function detectProject(cwd) {
52
56
  current = path.dirname(current);
53
57
  }
54
58
  }
55
- // 워크스페이스 루트 (모노레포 포함) → 폴더명 반환
56
59
  return path.basename(workspaceRoot);
57
60
  }
58
- function extractLastWork(transcript) {
59
- // 마지막 assistant 메시지들에서 의미있는 요약 추출 (역순)
60
- const assistantMessages = transcript.filter(m => m.role === 'assistant');
61
- for (let i = assistantMessages.length - 1; i >= Math.max(0, assistantMessages.length - 5); i--) {
62
- const content = assistantMessages[i].content;
63
- if (!content || content.length < 10)
64
- continue;
65
- // 전략 1: "✅ 작업 완료" 또는 "## Summary" 등 요약 섹션 추출
66
- const summaryPatterns = [
67
- /✅\s*(.+?)(?:\n\n|\n(?=[#*-]))/s,
68
- /(?:##?\s*(?:Summary|변경\s*사항|작업\s*완료|결과))\s*\n([\s\S]+?)(?:\n\n|\n(?=##))/i,
69
- ];
70
- for (const pattern of summaryPatterns) {
71
- const match = content.match(pattern);
72
- if (match?.[1]) {
73
- const summary = match[1].replace(/\n/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
74
- if (summary.length > 10)
75
- return summary;
76
- }
77
- }
78
- // 전략 2: 볼드 텍스트(**...**) 추출 — 핵심 내용이 담겨 있는 경우 많음
79
- const boldMatches = content.match(/\*\*(.+?)\*\*/g);
80
- if (boldMatches && boldMatches.length > 0) {
81
- const cleaned = boldMatches
82
- .map(b => b.replace(/\*\*/g, ''))
83
- .filter(b => b.length > 5 && b.length < 100)
84
- .slice(0, 3);
85
- if (cleaned.length > 0)
86
- return cleaned.join('; ').slice(0, 200);
87
- }
88
- // 전략 3: 첫 줄이 의미있는 텍스트인 경우
89
- const firstLine = content.split('\n').find(line => {
90
- const trimmed = line.trim();
91
- return trimmed.length > 10 && !trimmed.startsWith('#') && !trimmed.startsWith('```');
92
- });
93
- if (firstLine) {
94
- const trimmed = firstLine.trim().slice(0, 200);
95
- if (trimmed.length > 10)
96
- return trimmed;
61
+ /**
62
+ * 단일 텍스트(last_assistant_message 등)에서 의미있는 요약 추출
63
+ */
64
+ function extractSummaryFromText(content) {
65
+ if (!content || content.length < 10)
66
+ return '';
67
+ // 전략 1: ✅ 또는 ## Summary 등 요약 섹션
68
+ const summaryPatterns = [
69
+ /✅\s*(.+?)(?:\n\n|\n(?=[#*-]))/s,
70
+ /(?:##?\s*(?:Summary|변경\s*사항|작업\s*완료|결과))\s*\n([\s\S]+?)(?:\n\n|\n(?=##))/i,
71
+ ];
72
+ for (const pattern of summaryPatterns) {
73
+ const match = content.match(pattern);
74
+ if (match?.[1]) {
75
+ const summary = match[1].replace(/\n/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
76
+ if (summary.length > 10)
77
+ return summary;
97
78
  }
98
79
  }
99
- return 'Session work completed';
80
+ // 전략 2: 볼드 텍스트(**...**) 추출
81
+ const boldMatches = content.match(/\*\*(.+?)\*\*/g);
82
+ if (boldMatches && boldMatches.length > 0) {
83
+ const cleaned = boldMatches
84
+ .map(b => b.replace(/\*\*/g, ''))
85
+ .filter(b => b.length > 5 && b.length < 100)
86
+ .slice(0, 3);
87
+ if (cleaned.length > 0)
88
+ return cleaned.join('; ').slice(0, 200);
89
+ }
90
+ // 전략 3: 첫 의미있는 줄
91
+ const firstLine = content.split('\n').find(line => {
92
+ const trimmed = line.trim();
93
+ return trimmed.length > 10 && !trimmed.startsWith('#') && !trimmed.startsWith('```');
94
+ });
95
+ if (firstLine) {
96
+ const trimmed = firstLine.trim().slice(0, 200);
97
+ if (trimmed.length > 10)
98
+ return trimmed;
99
+ }
100
+ return '';
100
101
  }
101
- function extractSessionSummary(transcript) {
102
- const nextTasks = [];
103
- const modifiedFiles = new Set();
104
- // last_work: 개선된 다중 전략 추출
105
- const lastWork = extractLastWork(transcript);
106
- // 최근 메시지 분석
107
- const recentMessages = transcript.slice(-30);
108
- for (const msg of recentMessages) {
109
- const content = msg.content;
110
- // 파일 수정 추출 (Edit, Write 도구 결과에서) — 폴백용
111
- const filePatterns = [
112
- /(?:edited|modified|updated|created|wrote|수정|생성|변경)\s+[`"]?([^\s`"]+\.[a-z]+)/gi,
113
- /file[:\s]+[`"]?([^\s`"]+\.[a-z]+)/gi,
114
- ];
115
- for (const pattern of filePatterns) {
116
- let match;
117
- while ((match = pattern.exec(content)) !== null) {
118
- if (match[1] && !match[1].includes('...')) {
119
- modifiedFiles.add(match[1]);
102
+ /**
103
+ * transcript_path (JSONL)에서 마지막 N개의 assistant 메시지 읽기
104
+ * 전체 파일을 메모리에 올리지 않고 스트림으로 처리
105
+ */
106
+ async function readRecentAssistantMessages(transcriptPath, maxMessages = 5) {
107
+ if (!fs.existsSync(transcriptPath))
108
+ return [];
109
+ const messages = [];
110
+ try {
111
+ const fileStream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
112
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
113
+ for await (const line of rl) {
114
+ if (!line.trim())
115
+ continue;
116
+ try {
117
+ const entry = JSON.parse(line);
118
+ if (entry.type === 'assistant' || entry.role === 'assistant') {
119
+ // JSONL 형식에 따라 content 추출
120
+ const content = typeof entry.message?.content === 'string'
121
+ ? entry.message.content
122
+ : Array.isArray(entry.message?.content)
123
+ ? entry.message.content
124
+ .filter((b) => b.type === 'text')
125
+ .map((b) => b.text)
126
+ .join('\n')
127
+ : '';
128
+ if (content.length > 10) {
129
+ messages.push(content);
130
+ }
120
131
  }
121
132
  }
133
+ catch { /* skip malformed lines */ }
122
134
  }
123
- if (msg.role === 'assistant') {
124
- // 다음 추출
125
- const nextPatterns = [
126
- /(?:next|todo|remaining|다음|남은|해야|예정)[:\s]*([^.!?\n]+)/gi,
127
- /(?:should|need to|필요|권장|추천)[:\s]*([^.!?\n]+)/gi,
128
- ];
129
- for (const pattern of nextPatterns) {
130
- let match;
131
- while ((match = pattern.exec(content)) !== null) {
132
- if (match[1]) {
133
- const task = match[1].trim().slice(0, 100);
134
- if (task.length > 5)
135
- nextTasks.push(task);
136
- }
137
- }
135
+ }
136
+ catch { /* file read error */ }
137
+ // 마지막 N개만 반환
138
+ return messages.slice(-maxMessages);
139
+ }
140
+ /**
141
+ * 다음 일 추출 (텍스트에서)
142
+ */
143
+ function extractNextTasks(content) {
144
+ const nextTasks = [];
145
+ const nextPatterns = [
146
+ /(?:next|todo|remaining|다음|남은|해야|예정)[:\s]*([^.!?\n]+)/gi,
147
+ /(?:should|need to|필요|권장|추천)[:\s]*([^.!?\n]+)/gi,
148
+ ];
149
+ for (const pattern of nextPatterns) {
150
+ let match;
151
+ while ((match = pattern.exec(content)) !== null) {
152
+ if (match[1]) {
153
+ const task = match[1].trim().slice(0, 100);
154
+ if (task.length > 5)
155
+ nextTasks.push(task);
138
156
  }
139
157
  }
140
158
  }
141
- return {
142
- lastWork,
143
- nextTasks: [...new Set(nextTasks)].slice(0, 5),
144
- modifiedFiles: [...modifiedFiles].slice(0, 10)
145
- };
159
+ return nextTasks;
146
160
  }
147
161
  async function main() {
148
162
  try {
@@ -154,61 +168,78 @@ async function main() {
154
168
  const cwd = input.cwd || process.cwd();
155
169
  const project = detectProject(cwd);
156
170
  const dbPath = getDbPath(cwd);
157
- // 디버그 로그: 실제 전달되는 데이터 구조 확인
171
+ // 디버그 로그
158
172
  const debugLogPath = path.join(path.dirname(dbPath), 'session-end-debug.log');
159
173
  const inputKeys = Object.keys(input);
160
- const transcriptInfo = input.transcript
161
- ? `${input.transcript.length} messages (assistant: ${input.transcript.filter(m => m.role === 'assistant').length})`
162
- : 'NO TRANSCRIPT';
163
- const debugLine = `[${new Date().toISOString()}] project=${project} keys=[${inputKeys.join(',')}] transcript=${transcriptInfo}\n`;
174
+ const lastMsgLen = input.last_assistant_message?.length || 0;
175
+ const debugLine = `[${new Date().toISOString()}] project=${project} keys=[${inputKeys.join(',')}] transcript_path=${input.transcript_path || 'none'} last_msg_len=${lastMsgLen}\n`;
164
176
  fs.appendFileSync(debugLogPath, debugLine);
165
177
  if (!fs.existsSync(dbPath)) {
166
178
  console.log('[SessionEnd] No DB found, skipping');
167
179
  process.exit(0);
168
180
  }
169
181
  const db = new Database(dbPath);
170
- // transcript 유효성 검사: assistant 메시지가 2개 미만이면 의미 없는 세션
171
- if (input.transcript) {
172
- const assistantCount = input.transcript.filter(m => m.role === 'assistant').length;
173
- if (assistantCount < 2) {
174
- console.log(`[SessionEnd] Skipping empty session for ${project} (${assistantCount} assistant messages)`);
182
+ // === last_work 추출 ===
183
+ let lastWork = '';
184
+ let nextTasks = [];
185
+ // 소스 1: last_assistant_message (Stop 이벤트에서 직접 제공)
186
+ if (input.last_assistant_message) {
187
+ lastWork = extractSummaryFromText(input.last_assistant_message);
188
+ nextTasks = extractNextTasks(input.last_assistant_message);
189
+ }
190
+ // 소스 2: transcript_path에서 마지막 assistant 메시지들 읽기 (소스 1 실패 시)
191
+ if (!lastWork && input.transcript_path) {
192
+ const recentMessages = await readRecentAssistantMessages(input.transcript_path);
193
+ for (let i = recentMessages.length - 1; i >= 0; i--) {
194
+ lastWork = extractSummaryFromText(recentMessages[i]);
195
+ if (lastWork)
196
+ break;
197
+ }
198
+ // next tasks도 마지막 메시지에서 추출
199
+ if (recentMessages.length > 0) {
200
+ nextTasks = extractNextTasks(recentMessages[recentMessages.length - 1]);
201
+ }
202
+ }
203
+ // 소스 3: 레거시 transcript 배열 (이전 버전 호환)
204
+ if (!lastWork && input.transcript) {
205
+ const assistantMsgs = input.transcript.filter(m => m.role === 'assistant');
206
+ if (assistantMsgs.length < 2) {
207
+ console.log(`[SessionEnd] Skipping empty session for ${project}`);
175
208
  db.close();
176
209
  process.exit(0);
177
210
  }
211
+ for (let i = assistantMsgs.length - 1; i >= Math.max(0, assistantMsgs.length - 5); i--) {
212
+ lastWork = extractSummaryFromText(assistantMsgs[i].content);
213
+ if (lastWork)
214
+ break;
215
+ }
178
216
  }
179
- // transcript에서 세션 요약 추출
180
- const summary = input.transcript
181
- ? extractSessionSummary(input.transcript)
182
- : { lastWork: 'Session ended', nextTasks: [], modifiedFiles: [] };
183
- // active_context에서 PostToolUse가 실시간 저장한 파일 목록 병합
184
- let realtimeFiles = [];
217
+ // === modified_files: active_context에서 PostToolUse가 실시간 저장한 파일 목록 ===
218
+ let modifiedFiles = [];
185
219
  try {
186
220
  const activeCtx = db.prepare('SELECT recent_files FROM active_context WHERE project = ?').get(project);
187
221
  if (activeCtx?.recent_files) {
188
- realtimeFiles = JSON.parse(activeCtx.recent_files);
189
- const mergedFiles = [...new Set([...realtimeFiles, ...summary.modifiedFiles])].slice(0, 15);
190
- summary.modifiedFiles = mergedFiles;
222
+ modifiedFiles = JSON.parse(activeCtx.recent_files);
191
223
  }
192
224
  }
193
225
  catch { /* active_context may not exist */ }
194
- // transcript가 없거나 last_work 추출 못한 경우, modified_files로 요약 생성
195
- const emptyWorkPatterns = ['Session ended', 'Session work completed', 'Session started', ''];
196
- if (emptyWorkPatterns.includes(summary.lastWork) && realtimeFiles.length > 0) {
197
- const fileNames = realtimeFiles.slice(0, 5).map(f => path.basename(f)).join(', ');
198
- summary.lastWork = `Modified files: ${fileNames}`;
226
+ // last_work 폴백: 파일 목록 기반
227
+ if (!lastWork && modifiedFiles.length > 0) {
228
+ const fileNames = modifiedFiles.slice(0, 5).map(f => path.basename(f)).join(', ');
229
+ lastWork = `Modified files: ${fileNames}`;
199
230
  }
200
- // 빈 세션 저장 방지: last_work도 없고 파일도 없으면 skip
201
- if (emptyWorkPatterns.includes(summary.lastWork)) {
231
+ // 빈 세션 skip
232
+ if (!lastWork) {
202
233
  console.log(`[SessionEnd] Skipping empty session for ${project} (no meaningful last_work)`);
203
234
  db.close();
204
235
  process.exit(0);
205
236
  }
206
- // 중복 저장 방지: 최근 60초 이내 같은 프로젝트의 동일한 last_work가 있으면 skip
237
+ // 중복 저장 방지: 최근 60초 이내 동일 last_work
207
238
  const recentDup = db.prepare(`
208
239
  SELECT id FROM sessions
209
240
  WHERE project = ? AND last_work = ? AND timestamp > datetime('now', '-60 seconds')
210
241
  LIMIT 1
211
- `).get(project, summary.lastWork);
242
+ `).get(project, lastWork);
212
243
  if (recentDup) {
213
244
  console.log(`[SessionEnd] Skipping duplicate session for ${project}`);
214
245
  db.close();
@@ -218,17 +249,17 @@ async function main() {
218
249
  db.prepare(`
219
250
  INSERT INTO sessions (project, last_work, next_tasks, modified_files)
220
251
  VALUES (?, ?, ?, ?)
221
- `).run(project, summary.lastWork, JSON.stringify(summary.nextTasks), JSON.stringify(summary.modifiedFiles));
252
+ `).run(project, lastWork, JSON.stringify([...new Set(nextTasks)].slice(0, 5)), JSON.stringify(modifiedFiles.slice(0, 15)));
222
253
  // 활성 컨텍스트 업데이트
223
254
  db.prepare(`
224
255
  INSERT OR REPLACE INTO active_context (project, current_state, recent_files, updated_at)
225
256
  VALUES (?, ?, ?, datetime('now'))
226
- `).run(project, summary.lastWork, JSON.stringify(summary.modifiedFiles));
257
+ `).run(project, lastWork, JSON.stringify(modifiedFiles.slice(0, 15)));
227
258
  db.close();
228
259
  console.log(`[SessionEnd] Saved session for ${project}`);
229
- console.log(` Last work: ${summary.lastWork.slice(0, 50)}...`);
230
- console.log(` Modified files: ${summary.modifiedFiles.length}`);
231
- console.log(` Next tasks: ${summary.nextTasks.length}`);
260
+ console.log(` Last work: ${lastWork.slice(0, 80)}`);
261
+ console.log(` Modified files: ${modifiedFiles.length}`);
262
+ console.log(` Next tasks: ${nextTasks.length}`);
232
263
  process.exit(0);
233
264
  }
234
265
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-session-continuity-mcp",
3
- "version": "1.9.3",
3
+ "version": "1.9.4",
4
4
  "description": "Session Continuity for Claude Code - Never re-explain your project again",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",