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.
- package/dist/hooks/session-end.d.ts +5 -0
- package/dist/hooks/session-end.js +146 -115
- package/package.json +1 -1
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
161
|
-
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
// 빈 세션
|
|
201
|
-
if (
|
|
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초 이내
|
|
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,
|
|
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,
|
|
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,
|
|
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: ${
|
|
230
|
-
console.log(` Modified files: ${
|
|
231
|
-
console.log(` Next tasks: ${
|
|
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) {
|