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 +93 -6
- package/dist/hooks/post-tool-use.js +2 -0
- package/dist/hooks/pre-compact.js +30 -2
- package/dist/hooks/session-end.js +125 -27
- package/dist/hooks/session-start.js +17 -8
- package/dist/hooks/user-prompt-submit.js +268 -12
- 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,13 +1,14 @@
|
|
|
1
|
-
# claude-session-continuity-mcp
|
|
1
|
+
# claude-session-continuity-mcp
|
|
2
2
|
|
|
3
|
-
> **
|
|
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
|
[](https://www.npmjs.com/package/claude-session-continuity-mcp)
|
|
6
|
+
[](https://www.npmjs.com/package/claude-session-continuity-mcp)
|
|
6
7
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[]()
|
|
8
|
-
[]()
|
|
9
8
|
[](https://glama.ai/mcp/servers/leesgit/claude-session-continuity-mcp)
|
|
10
9
|
|
|
10
|
+

|
|
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
|
-
###
|
|
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 = ?
|
|
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
|
|
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
|
-
|
|
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
|
-
// 중복 저장 방지 —
|
|
642
|
-
//
|
|
643
|
-
//
|
|
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', '-
|
|
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', '-
|
|
657
|
-
ORDER BY timestamp DESC LIMIT
|
|
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
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 = ?
|
|
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
|
|
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
|
-
|
|
76
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
312
|
+
WHERE ${likeClause}
|
|
113
313
|
ORDER BY importance DESC, created_at DESC LIMIT 2
|
|
114
|
-
`).all(
|
|
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
|
|
331
|
+
WHERE ${clause}
|
|
127
332
|
ORDER BY created_at DESC LIMIT 2
|
|
128
|
-
`).all(
|
|
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
|
-
// 과거 참조
|
|
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
|
-
//
|
|
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.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.
|
|
63
|
+
"better-sqlite3": "^12.11.1",
|
|
64
64
|
"zod": "^3.23.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|