claude-session-continuity-mcp 1.15.1 → 1.16.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 +18 -0
- package/dist/hooks/install.js +62 -0
- package/dist/hooks/session-end.js +68 -2
- package/dist/hooks/session-start.js +6 -3
- package/dist/hooks/user-prompt-submit.js +8 -80
- package/dist/index.js +14 -5
- package/dist/tools-v2/memory.js +6 -1
- package/dist/utils/logger.d.ts +17 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/tokenize.d.ts +26 -0
- package/dist/utils/tokenize.js +128 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,24 @@ If you want zero-config, offline, no-cost memory that just *happens* while you w
|
|
|
81
81
|
|
|
82
82
|
---
|
|
83
83
|
|
|
84
|
+
## Works with Codex CLI too (v1.16.0+)
|
|
85
|
+
|
|
86
|
+
Beyond Claude Code, this also supports **OpenAI Codex CLI**. If `~/.codex` exists,
|
|
87
|
+
the installer registers the same hooks in `~/.codex/hooks.json` (SessionStart,
|
|
88
|
+
UserPromptSubmit, PreCompact, Stop), and the hooks auto-detect the host and emit
|
|
89
|
+
the right output format (Codex's `hookSpecificOutput.additionalContext`).
|
|
90
|
+
|
|
91
|
+
The same local `sessions.db` is shared, so context carries across both agents:
|
|
92
|
+
what you did in Codex is available in Claude Code and vice versa.
|
|
93
|
+
|
|
94
|
+
**Scope:** session save + context injection work on both. Codex file-change
|
|
95
|
+
tracking (PostToolUse) isn't wired yet — session save already covers most of it
|
|
96
|
+
via transcript parsing. Codex's `transcript_path` is treated as an unstable
|
|
97
|
+
interface (it can be null at startup), so host detection uses an installer-injected
|
|
98
|
+
`--codex` marker rather than relying on the path.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
84
102
|
## Quick Start
|
|
85
103
|
|
|
86
104
|
### Recommended: Global Installation
|
package/dist/hooks/install.js
CHANGED
|
@@ -16,6 +16,9 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
|
16
16
|
const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
|
|
17
17
|
const LEGACY_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.local.json');
|
|
18
18
|
const MCP_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
|
|
19
|
+
// Codex CLI (2026-07-09): hooks register in ~/.codex/hooks.json (same JSON shape as Claude)
|
|
20
|
+
const CODEX_DIR = path.join(os.homedir(), '.codex');
|
|
21
|
+
const CODEX_HOOKS_FILE = path.join(CODEX_DIR, 'hooks.json');
|
|
19
22
|
// 설치된 패키지 경로 찾기
|
|
20
23
|
function getPackagePath() {
|
|
21
24
|
// 1. 글로벌 설치 확인
|
|
@@ -123,6 +126,44 @@ function installMcpServer() {
|
|
|
123
126
|
return false;
|
|
124
127
|
}
|
|
125
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Register the same hooks in ~/.codex/hooks.json for OpenAI Codex CLI (2026-07-09).
|
|
131
|
+
* Only runs if ~/.codex exists (Codex installed). Preserves user's existing hooks;
|
|
132
|
+
* replaces only ours (matched by the claude-hook- command prefix).
|
|
133
|
+
*/
|
|
134
|
+
function installCodexHooks() {
|
|
135
|
+
if (!fs.existsSync(CODEX_DIR))
|
|
136
|
+
return; // Codex not installed -> skip silently
|
|
137
|
+
const OUR_PREFIX = 'claude-hook-';
|
|
138
|
+
let hooksConfig = { hooks: {} };
|
|
139
|
+
if (fs.existsSync(CODEX_HOOKS_FILE)) {
|
|
140
|
+
try {
|
|
141
|
+
hooksConfig = JSON.parse(fs.readFileSync(CODEX_HOOKS_FILE, 'utf-8'));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
hooksConfig = { hooks: {} };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const hooks = hooksConfig.hooks || {};
|
|
148
|
+
const merge = (event, ourEntries) => {
|
|
149
|
+
const existing = (hooks[event] || []);
|
|
150
|
+
const userEntries = existing.filter(e => !(e.hooks || []).some(h => h.command && h.command.includes(OUR_PREFIX)));
|
|
151
|
+
hooks[event] = [...userEntries, ...ourEntries];
|
|
152
|
+
};
|
|
153
|
+
// Codex uses the same event names as Claude (SessionStart/UserPromptSubmit/Stop...).
|
|
154
|
+
// Append "--codex" so hooks detect the host reliably: Codex passes transcript_path
|
|
155
|
+
// as null at SessionStart, so the argv marker is the only dependable signal.
|
|
156
|
+
merge('SessionStart', [{ hooks: [{ type: 'command', command: 'npm exec -- claude-hook-session-start --codex' }] }]);
|
|
157
|
+
merge('UserPromptSubmit', [{ hooks: [{ type: 'command', command: 'npm exec -- claude-hook-user-prompt --codex' }] }]);
|
|
158
|
+
merge('PreCompact', [{ hooks: [{ type: 'command', command: 'npm exec -- claude-hook-pre-compact --codex' }] }]);
|
|
159
|
+
merge('Stop', [{ hooks: [{ type: 'command', command: 'npm exec -- claude-hook-session-end --codex' }] }]);
|
|
160
|
+
hooksConfig.hooks = hooks;
|
|
161
|
+
try {
|
|
162
|
+
fs.writeFileSync(CODEX_HOOKS_FILE, JSON.stringify(hooksConfig, null, 2));
|
|
163
|
+
console.log('✅ Codex CLI hooks installed (~/.codex/hooks.json)');
|
|
164
|
+
}
|
|
165
|
+
catch { /* non-fatal: Codex hooks are optional */ }
|
|
166
|
+
}
|
|
126
167
|
function install() {
|
|
127
168
|
console.log('');
|
|
128
169
|
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
@@ -170,6 +211,9 @@ function install() {
|
|
|
170
211
|
]);
|
|
171
212
|
settings.hooks = hooks;
|
|
172
213
|
saveSettings(settings);
|
|
214
|
+
// Codex CLI hooks (2026-07-09): register the same hooks in ~/.codex/hooks.json
|
|
215
|
+
// if Codex is present. Hooks auto-detect the host and emit the right output format.
|
|
216
|
+
installCodexHooks();
|
|
173
217
|
console.log('✅ Hooks installed (npm exec mode - works with local or global install!)');
|
|
174
218
|
console.log(' SessionStart: context auto-load');
|
|
175
219
|
console.log(' UserPromptSubmit: relevant memory injection');
|
|
@@ -224,6 +268,24 @@ function uninstall() {
|
|
|
224
268
|
settings.hooks = hooks;
|
|
225
269
|
}
|
|
226
270
|
saveSettings(settings);
|
|
271
|
+
// Also remove our hooks from Codex (2026-07-09), preserving user's hooks.
|
|
272
|
+
if (fs.existsSync(CODEX_HOOKS_FILE)) {
|
|
273
|
+
try {
|
|
274
|
+
const cfg = JSON.parse(fs.readFileSync(CODEX_HOOKS_FILE, 'utf-8'));
|
|
275
|
+
const ch = cfg.hooks || {};
|
|
276
|
+
for (const event of ['SessionStart', 'UserPromptSubmit', 'PreCompact', 'Stop']) {
|
|
277
|
+
const existing = (ch[event] || []);
|
|
278
|
+
const remaining = existing.filter(e => !(e.hooks || []).some(h => h.command && h.command.includes(OUR_PREFIX)));
|
|
279
|
+
if (remaining.length === 0)
|
|
280
|
+
delete ch[event];
|
|
281
|
+
else
|
|
282
|
+
ch[event] = remaining;
|
|
283
|
+
}
|
|
284
|
+
cfg.hooks = ch;
|
|
285
|
+
fs.writeFileSync(CODEX_HOOKS_FILE, JSON.stringify(cfg, null, 2));
|
|
286
|
+
}
|
|
287
|
+
catch { /* non-fatal */ }
|
|
288
|
+
}
|
|
227
289
|
console.log('✅ Hooks removed successfully!');
|
|
228
290
|
}
|
|
229
291
|
function status() {
|
|
@@ -14,7 +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
|
+
import { logHookError, isCodexHost } from '../utils/logger.js';
|
|
18
18
|
function detectWorkspaceRoot(cwd) {
|
|
19
19
|
let current = cwd;
|
|
20
20
|
const root = path.parse(current).root;
|
|
@@ -207,6 +207,8 @@ function parseUserText(entry) {
|
|
|
207
207
|
}
|
|
208
208
|
// system-reminder, local-command 태그 제거
|
|
209
209
|
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
210
|
+
// Codex support (2026-07-09): Codex injects <environment_context> into the first user message -> strip it
|
|
211
|
+
text = text.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, '').trim();
|
|
210
212
|
text = text.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '').trim();
|
|
211
213
|
text = text.replace(/<command-name>[\s\S]*?<\/command-name>/g, '').trim();
|
|
212
214
|
text = text.replace(/<command-message>[\s\S]*?<\/command-message>/g, '').trim();
|
|
@@ -237,6 +239,64 @@ function extractTextFromContent(content) {
|
|
|
237
239
|
}
|
|
238
240
|
return '';
|
|
239
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Codex support (2026-07-09): normalize a Codex CLI rollout JSONL line into the
|
|
244
|
+
* Claude transcript entry shape, so the rest of parseTranscriptSinglePass
|
|
245
|
+
* (role/content/timestamp/commit/user-request extraction) can be reused as-is.
|
|
246
|
+
*
|
|
247
|
+
* Codex format: {timestamp, type, payload}
|
|
248
|
+
* - payload.type=message, role=user/assistant, content[].text (input_text/output_text)
|
|
249
|
+
* - payload.type=function_call, arguments={command:["zsh","-lc","git commit..."]}
|
|
250
|
+
* - payload.type=session_meta (first line, ignored)
|
|
251
|
+
* - reasoning/token_count etc. are ignored
|
|
252
|
+
* Returns a Claude-shaped entry ({type, timestamp, message:{content:[...]}}) or null (skip).
|
|
253
|
+
*/
|
|
254
|
+
function normalizeCodexLine(codexEntry) {
|
|
255
|
+
const p = codexEntry.payload;
|
|
256
|
+
if (!p)
|
|
257
|
+
return null;
|
|
258
|
+
const ts = codexEntry.timestamp;
|
|
259
|
+
// 1. user/assistant message -> text block
|
|
260
|
+
if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
|
|
261
|
+
const text = (p.content || [])
|
|
262
|
+
.filter(c => c.type === 'input_text' || c.type === 'output_text' || c.type === 'text')
|
|
263
|
+
.map(c => c.text || '')
|
|
264
|
+
.join('\n');
|
|
265
|
+
if (!text)
|
|
266
|
+
return null;
|
|
267
|
+
return {
|
|
268
|
+
type: p.role === 'user' ? 'user' : 'assistant',
|
|
269
|
+
timestamp: ts,
|
|
270
|
+
message: { content: [{ type: 'text', text }] },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// 2. function_call(shell) -> tool_use block (for git commit extraction).
|
|
274
|
+
// Codex passes command as an argv array (["zsh","-lc","git commit -m ..."]).
|
|
275
|
+
if (p.type === 'function_call' && p.arguments) {
|
|
276
|
+
let cmd = '';
|
|
277
|
+
try {
|
|
278
|
+
const args = JSON.parse(p.arguments);
|
|
279
|
+
if (Array.isArray(args.command)) {
|
|
280
|
+
// Codex uses ["zsh","-lc","<actual cmd>"]. The commit regex requires
|
|
281
|
+
// "^git" or "&& git", so strip the shell wrapper (zsh/bash -lc/-c)
|
|
282
|
+
// and keep only the actual command.
|
|
283
|
+
const a = args.command;
|
|
284
|
+
cmd = (a.length >= 3 && /^(zsh|bash|sh)$/.test(a[0]) && /^-[lc]+$/.test(a[1]))
|
|
285
|
+
? a.slice(2).join(' ')
|
|
286
|
+
: a.join(' ');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch { /* arguments not JSON -> skip */ }
|
|
290
|
+
if (!cmd)
|
|
291
|
+
return null;
|
|
292
|
+
return {
|
|
293
|
+
type: 'assistant',
|
|
294
|
+
timestamp: ts,
|
|
295
|
+
message: { content: [{ type: 'tool_use', input: { command: cmd } }] },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return null; // skip reasoning/token_count/session_meta etc.
|
|
299
|
+
}
|
|
240
300
|
/**
|
|
241
301
|
* Single-Pass Transcript Parser
|
|
242
302
|
* JSONL을 1회 스트림으로 읽으며 모든 데이터를 동시 추출
|
|
@@ -254,6 +314,8 @@ async function parseTranscriptSinglePass(transcriptPath) {
|
|
|
254
314
|
};
|
|
255
315
|
if (!transcriptPath || !fs.existsSync(transcriptPath))
|
|
256
316
|
return result;
|
|
317
|
+
// Host detection: Codex uses ~/.codex/sessions/...rollout-*.jsonl, Claude uses ~/.claude/projects/...
|
|
318
|
+
const isCodex = isCodexHost(transcriptPath); // shared detection (argv marker or path)
|
|
257
319
|
// commit 추출용 패턴
|
|
258
320
|
const commitPatterns = [
|
|
259
321
|
/git commit.*?-m\s*"\$\(cat <<'?EOF'?\n(.+?)(?:\n\n|\nCo-Authored|\nEOF)/s,
|
|
@@ -277,7 +339,11 @@ async function parseTranscriptSinglePass(transcriptPath) {
|
|
|
277
339
|
if (!line.trim())
|
|
278
340
|
continue;
|
|
279
341
|
try {
|
|
280
|
-
const
|
|
342
|
+
const rawEntry = JSON.parse(line);
|
|
343
|
+
// For Codex, normalize the rollout line into a Claude entry shape. Skipped lines (reasoning etc.) return null.
|
|
344
|
+
const entry = isCodex ? normalizeCodexLine(rawEntry) : rawEntry;
|
|
345
|
+
if (!entry)
|
|
346
|
+
continue;
|
|
281
347
|
const role = entry.type || entry.role || '';
|
|
282
348
|
const content = entry.message?.content;
|
|
283
349
|
// === 0. Timestamp 추출 (세션 duration 계산용) ===
|
|
@@ -5,7 +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
|
+
import { logHookError, emitContext, isCodexHost } from '../utils/logger.js';
|
|
9
9
|
function detectWorkspaceRoot(cwd) {
|
|
10
10
|
let current = cwd;
|
|
11
11
|
const root = path.parse(current).root;
|
|
@@ -259,10 +259,13 @@ async function main() {
|
|
|
259
259
|
const dbPath = path.join(workspaceRoot, '.claude', 'sessions.db');
|
|
260
260
|
const context = loadContext(dbPath, project);
|
|
261
261
|
if (context) {
|
|
262
|
-
|
|
262
|
+
emitContext(`\n<session-context project="${project}">\n${context}\n</session-context>\n`, 'SessionStart', input.transcript_path);
|
|
263
263
|
}
|
|
264
264
|
else {
|
|
265
|
-
|
|
265
|
+
// Only Claude gets the "no context" placeholder; Codex would just get empty context.
|
|
266
|
+
if (!isCodexHost(input.transcript_path)) {
|
|
267
|
+
console.log(`\n[Session] Project: ${project} (no context yet)\n`);
|
|
268
|
+
}
|
|
266
269
|
}
|
|
267
270
|
process.exit(0);
|
|
268
271
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
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
|
+
import { logHookError, emitContext } from '../utils/logger.js';
|
|
9
|
+
import { tokenizeQuery } from '../utils/tokenize.js';
|
|
9
10
|
function detectWorkspaceRoot(cwd) {
|
|
10
11
|
let current = cwd;
|
|
11
12
|
const root = path.parse(current).root;
|
|
@@ -72,87 +73,14 @@ function extractPastKeywords(prompt) {
|
|
|
72
73
|
return null;
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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개)
|
|
76
|
+
* Prompt에서 의미 있는 한국어/영어 키워드 추출 (트리거용, 최대 5개)
|
|
77
|
+
* 토큰화 로직은 utils/tokenize.ts로 공용화됨 (memory_search와 공유, 2026-07-09).
|
|
78
|
+
* 트리거는 프롬프트 길이 5 미만이면 스킵.
|
|
116
79
|
*/
|
|
117
80
|
function extractTriggerKeywords(prompt) {
|
|
118
81
|
if (!prompt || prompt.length < 5)
|
|
119
82
|
return [];
|
|
120
|
-
|
|
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);
|
|
83
|
+
return tokenizeQuery(prompt, 5);
|
|
156
84
|
}
|
|
157
85
|
/**
|
|
158
86
|
* IDF 필터: 너무 흔한 토큰 제거
|
|
@@ -482,7 +410,7 @@ async function main() {
|
|
|
482
410
|
const pastSection = formatPastWork(pastWork);
|
|
483
411
|
db.close();
|
|
484
412
|
if (pastSection) {
|
|
485
|
-
|
|
413
|
+
emitContext(`\n<past-context project="${project}">\n${pastSection}\n</past-context>\n`, 'UserPromptSubmit', input.transcript_path);
|
|
486
414
|
}
|
|
487
415
|
}
|
|
488
416
|
catch { /* ignore */ }
|
|
@@ -503,7 +431,7 @@ async function main() {
|
|
|
503
431
|
const content = m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content;
|
|
504
432
|
lines.push(`- [${m.memory_type}] ${content}`);
|
|
505
433
|
}
|
|
506
|
-
|
|
434
|
+
emitContext(`\n<triggered-context project="${project ?? 'global'}" keywords="${triggerKws.join(',')}">\n${lines.join('\n')}\n</triggered-context>\n`, 'UserPromptSubmit', input.transcript_path);
|
|
507
435
|
}
|
|
508
436
|
}
|
|
509
437
|
}
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { mkdirSync, existsSync } from 'fs';
|
|
|
22
22
|
import * as path from 'path';
|
|
23
23
|
import { execSync } from 'child_process';
|
|
24
24
|
import Database from 'better-sqlite3';
|
|
25
|
+
import { tokenizeQuery, buildFtsQuery } from './utils/tokenize.js';
|
|
25
26
|
// @xenova/transformers - 동적 import (sharp 의존성 문제 방지)
|
|
26
27
|
let transformersModule = null;
|
|
27
28
|
async function loadTransformers() {
|
|
@@ -1265,7 +1266,12 @@ async function handleTool(name, args) {
|
|
|
1265
1266
|
}
|
|
1266
1267
|
else {
|
|
1267
1268
|
// 키워드 검색 (LIKE 기반, 단어별 OR)
|
|
1268
|
-
|
|
1269
|
+
// 2026-07-09 audit-7 방안A: 공용 토큰화(stopword 제거)로 흔한 단어가 아무
|
|
1270
|
+
// solution이나 잡는 오탐 감소. 의미 토큰이 없으면 raw split 폴백.
|
|
1271
|
+
const solTokens = tokenizeQuery(query);
|
|
1272
|
+
const words = solTokens.length > 0
|
|
1273
|
+
? solTokens
|
|
1274
|
+
: query.split(/\s+/).filter(w => w.length > 0);
|
|
1269
1275
|
const wordConditions = words.map(() => '(error_signature LIKE ? OR error_message LIKE ? OR solution LIKE ? OR keywords LIKE ?)').join(' OR ');
|
|
1270
1276
|
const wordParams = [];
|
|
1271
1277
|
words.forEach(w => {
|
|
@@ -1502,13 +1508,16 @@ async function handleTool(name, args) {
|
|
|
1502
1508
|
else {
|
|
1503
1509
|
// FTS5 + bm25() 랭킹 우선, 결과 없으면 LIKE 폴백
|
|
1504
1510
|
// (P1-2, 2026-05-22: 7-agent 검증 후 적용)
|
|
1505
|
-
|
|
1511
|
+
// 2026-07-09 audit-7 방안A: 공용 토큰화로 자동주입과 같은 한국어/다구 품질.
|
|
1512
|
+
// 의미 토큰이 없으면 raw split 폴백(recall 보존).
|
|
1513
|
+
const semanticTokens = tokenizeQuery(query);
|
|
1514
|
+
const words = semanticTokens.length > 0
|
|
1515
|
+
? semanticTokens
|
|
1516
|
+
: query.split(/\s+/).filter(w => w.length > 0);
|
|
1506
1517
|
// 1차: FTS5 MATCH + bm25() 점수
|
|
1507
1518
|
let ftsResults = [];
|
|
1508
1519
|
try {
|
|
1509
|
-
const ftsQuery = words
|
|
1510
|
-
? words.map(w => `"${w.replace(/"/g, '""')}"`).join(' OR ')
|
|
1511
|
-
: query;
|
|
1520
|
+
const ftsQuery = buildFtsQuery(words, true) ?? query; // expandEnglish: 영어 복수/시제 recall
|
|
1512
1521
|
let ftsSql = `
|
|
1513
1522
|
SELECT m.*, bm25(memories_fts) AS bm25_score
|
|
1514
1523
|
FROM memories_fts fts
|
package/dist/tools-v2/memory.js
CHANGED
|
@@ -4,6 +4,7 @@ import { db } from '../db/database.js';
|
|
|
4
4
|
import { generateEmbedding, embeddingToBuffer, bufferToEmbedding, cosineSimilarity } from '../utils/embedding.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
6
|
import { MemoryStoreSchema, MemorySearchSchema, MemoryGetSchema, MemoryDeleteSchema } from '../schemas.js';
|
|
7
|
+
import { tokenizeQuery, buildFtsQuery } from '../utils/tokenize.js';
|
|
7
8
|
// ===== Temporal Decay =====
|
|
8
9
|
const DECAY_RATES = {
|
|
9
10
|
decision: 0.001, // 반감기 ~693일
|
|
@@ -228,7 +229,11 @@ export async function handleMemorySearch(args) {
|
|
|
228
229
|
}, args);
|
|
229
230
|
}
|
|
230
231
|
async function performFTSSearch(query, type, project, limit = 10, minImportance = 1, detail = false) {
|
|
231
|
-
|
|
232
|
+
// 공용 토큰화(어미 절삭+STOPWORDS+길이 필터)로 자동주입과 같은 검색 품질.
|
|
233
|
+
// 의미 토큰이 하나도 안 남으면(전부 stopword) raw split으로 폴백 — recall 보존.
|
|
234
|
+
const tokens = tokenizeQuery(query);
|
|
235
|
+
const ftsQuery = buildFtsQuery(tokens, true) // expandEnglish: 복수/시제 변형 OR (영어권 recall)
|
|
236
|
+
?? query.split(/\s+/).filter(w => w.length > 1).join(' OR ');
|
|
232
237
|
let sql = `
|
|
233
238
|
SELECT m.id, m.content, m.memory_type, m.tags, m.project, m.importance, m.created_at, m.access_count
|
|
234
239
|
FROM memories_fts fts
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -25,4 +25,21 @@ export declare const logger: Logger;
|
|
|
25
25
|
* 로그 위치: $HOOK_ERROR_LOG > cwd/.claude/hook-errors.log > ~/.claude/hook-errors.log
|
|
26
26
|
*/
|
|
27
27
|
export declare function logHookError(hook: string, err: unknown): void;
|
|
28
|
+
/**
|
|
29
|
+
* Detect whether the hook is running under OpenAI Codex CLI (vs Claude Code).
|
|
30
|
+
*
|
|
31
|
+
* Two signals (either is sufficient):
|
|
32
|
+
* 1. A "--codex" argv marker injected by our installer into ~/.codex/hooks.json.
|
|
33
|
+
* This is the reliable signal — Codex passes transcript_path as null at
|
|
34
|
+
* SessionStart (fresh session, rollout not yet written), so path alone fails.
|
|
35
|
+
* 2. Transcript path under ~/.codex/sessions/...rollout-*.jsonl (fallback for
|
|
36
|
+
* Stop/UserPromptSubmit where the rollout already exists).
|
|
37
|
+
*/
|
|
38
|
+
export declare function isCodexHost(transcriptPath?: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Emit context to stdout in the host's expected format (Codex support, 2026-07-09).
|
|
41
|
+
* - Claude Code injects raw stdout text directly.
|
|
42
|
+
* - Codex CLI expects {hookSpecificOutput:{hookEventName, additionalContext}}.
|
|
43
|
+
*/
|
|
44
|
+
export declare function emitContext(context: string, hookEventName: string, transcriptPath?: string): void;
|
|
28
45
|
export {};
|
package/dist/utils/logger.js
CHANGED
|
@@ -138,3 +138,35 @@ export function logHookError(hook, err) {
|
|
|
138
138
|
}
|
|
139
139
|
catch { /* never throw from the error logger */ }
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Detect whether the hook is running under OpenAI Codex CLI (vs Claude Code).
|
|
143
|
+
*
|
|
144
|
+
* Two signals (either is sufficient):
|
|
145
|
+
* 1. A "--codex" argv marker injected by our installer into ~/.codex/hooks.json.
|
|
146
|
+
* This is the reliable signal — Codex passes transcript_path as null at
|
|
147
|
+
* SessionStart (fresh session, rollout not yet written), so path alone fails.
|
|
148
|
+
* 2. Transcript path under ~/.codex/sessions/...rollout-*.jsonl (fallback for
|
|
149
|
+
* Stop/UserPromptSubmit where the rollout already exists).
|
|
150
|
+
*/
|
|
151
|
+
export function isCodexHost(transcriptPath) {
|
|
152
|
+
if (process.argv.includes('--codex'))
|
|
153
|
+
return true;
|
|
154
|
+
if (!transcriptPath)
|
|
155
|
+
return false;
|
|
156
|
+
return transcriptPath.includes('/.codex/sessions/') || /rollout-.*\.jsonl$/.test(transcriptPath);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Emit context to stdout in the host's expected format (Codex support, 2026-07-09).
|
|
160
|
+
* - Claude Code injects raw stdout text directly.
|
|
161
|
+
* - Codex CLI expects {hookSpecificOutput:{hookEventName, additionalContext}}.
|
|
162
|
+
*/
|
|
163
|
+
export function emitContext(context, hookEventName, transcriptPath) {
|
|
164
|
+
if (isCodexHost(transcriptPath)) {
|
|
165
|
+
process.stdout.write(JSON.stringify({
|
|
166
|
+
hookSpecificOutput: { hookEventName, additionalContext: context },
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
console.log(context);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Korean + English 일반 stopword
|
|
3
|
+
* (자동주입 트리거 매칭 + 명시 검색 공용)
|
|
4
|
+
*/
|
|
5
|
+
export declare const STOPWORDS: Set<string>;
|
|
6
|
+
/**
|
|
7
|
+
* 쿼리/프롬프트에서 의미 있는 한국어/영어 키워드 추출.
|
|
8
|
+
*
|
|
9
|
+
* - 한국어 동사 어미 절삭("저장해줘"→"저장"), 단일 조사(로/를/이/가)는 명사 훼손 방지로 보존
|
|
10
|
+
* - 한국어 2글자 이상, 영어 3글자 이상
|
|
11
|
+
* - STOPWORDS 및 숫자-only 제외
|
|
12
|
+
* - 중복 제거(등장 순서 유지)
|
|
13
|
+
*
|
|
14
|
+
* @param text 원본 문자열
|
|
15
|
+
* @param maxTokens 상한 (자동주입 트리거는 5, 명시 검색은 무제한 = Infinity)
|
|
16
|
+
* @returns 키워드 배열
|
|
17
|
+
*/
|
|
18
|
+
export declare function tokenizeQuery(text: string, maxTokens?: number): string[];
|
|
19
|
+
/**
|
|
20
|
+
* 토큰들을 FTS5 MATCH 쿼리로 조립("token1" OR "token2" ...). 토큰이 없으면 null.
|
|
21
|
+
* memory_search 계열이 raw split 대신 이걸 쓰면 자동주입과 같은 품질을 얻는다.
|
|
22
|
+
*
|
|
23
|
+
* @param expandEnglish 영어 복수/시제 변형도 OR에 포함(명시 검색 recall↑). 자동주입
|
|
24
|
+
* 트리거는 오탐 억제가 중요하니 기본 false, memory_search는 true 권장.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildFtsQuery(tokens: string[], expandEnglish?: boolean): string | null;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 공용 쿼리 토큰화 (2026-07-09, audit-7 방안 A)
|
|
2
|
+
// 배경: 자동주입 hook(user-prompt-submit)은 어미 절삭 + STOPWORDS + 길이 필터로
|
|
3
|
+
// 한국어/다구 검색 품질을 확보하는데, 명시 검색(memory_search)은 raw split(/\s+/)만
|
|
4
|
+
// 써서 품질이 낮았다(audit-7 Stage2 실측 CONFIRMED). 이 토큰화를 공용화해 양쪽이 공유한다.
|
|
5
|
+
/**
|
|
6
|
+
* Korean + English 일반 stopword
|
|
7
|
+
* (자동주입 트리거 매칭 + 명시 검색 공용)
|
|
8
|
+
*/
|
|
9
|
+
export const STOPWORDS = new Set([
|
|
10
|
+
// 한국어 빈출 (조사/대명사/일반 동사/부사)
|
|
11
|
+
'있다', '없다', '하다', '되다', '이다', '아니다', '같다', '보다', '주다', '받다', '쓰다', '놓다',
|
|
12
|
+
'하는', '있는', '없는', '되는', '이런', '저런', '그런', '이게', '저게', '그게', '이것', '저것', '그것',
|
|
13
|
+
'내가', '네가', '우리', '저희', '당신', '자기', '지금', '이제', '오늘', '어제', '내일', '다음', '이전',
|
|
14
|
+
'하나', '둘', '셋', '정말', '진짜', '아주', '너무', '매우', '조금', '약간', '대충', '그냥', '일단',
|
|
15
|
+
'그리고', '하지만', '그래서', '그래도', '근데', '그런데', '또는', '또한', '이런데', '그런데',
|
|
16
|
+
'진행', '확인', '시작', '완료', '종료', '해줘', '해주세요', '부탁', '알려', '알려줘',
|
|
17
|
+
// 흔한 작업 동사·부사
|
|
18
|
+
'저장', '기억', '삭제', '수정', '검색', '등록', '변경', '추가', '제거', '생성', '실행', '설정',
|
|
19
|
+
'전체', '다시', '이렇게', '그렇게', '저렇게', '계속', '먼저', '바로', '같이', '제대로', '하나하나',
|
|
20
|
+
// 범용 명사
|
|
21
|
+
'이름', '정보', '내용', '관련', '부분', '상태', '방법', '문제', '경우', '생각', '이야기', '얘기',
|
|
22
|
+
'어떻게', '무엇', '뭐가', '왜', '어디', '언제', '어느', '어떤', '어떻', '얼마',
|
|
23
|
+
// 한국어 2글자 빈출
|
|
24
|
+
'이거', '그거', '저거', '한번', '두번', '코드', '작업', '뭐했지', '뭐였지', '봐줘', '한거지', '했지',
|
|
25
|
+
// 영어 빈출 추가 — Next.js 같은 동음이의 발생하는 토큰
|
|
26
|
+
'next', 'last', 'first', 'prev', 'previous', 'check', 'test', 'run', 'live', 'ready', 'verify', 'verification',
|
|
27
|
+
'session', 'task', 'item', 'case', 'file', 'line', 'data', 'code', 'word', 'time', 'step', 'part', 'side',
|
|
28
|
+
// 영어 작업 동사
|
|
29
|
+
'save', 'store', 'remember', 'search', 'find', 'delete', 'remove', 'update', 'create', 'add',
|
|
30
|
+
'change', 'edit', 'fix', 'make', 'show', 'list', 'get', 'set', 'build', 'lint', 'deploy',
|
|
31
|
+
'whole', 'thing', 'again', 'entire', 'stuff', 'something', 'anything', 'everything',
|
|
32
|
+
// 영어 빈출 stopwords
|
|
33
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
34
|
+
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must',
|
|
35
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their', 'we', 'our',
|
|
36
|
+
'you', 'your', 'i', 'my', 'me', 'what', 'when', 'where', 'why', 'how', 'which', 'who',
|
|
37
|
+
'and', 'or', 'but', 'if', 'then', 'else', 'also', 'so', 'as', 'at', 'by', 'for', 'from', 'in', 'of', 'on', 'to', 'with',
|
|
38
|
+
'just', 'only', 'very', 'really', 'also', 'too', 'still', 'here', 'there', 'now', 'then',
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* 쿼리/프롬프트에서 의미 있는 한국어/영어 키워드 추출.
|
|
42
|
+
*
|
|
43
|
+
* - 한국어 동사 어미 절삭("저장해줘"→"저장"), 단일 조사(로/를/이/가)는 명사 훼손 방지로 보존
|
|
44
|
+
* - 한국어 2글자 이상, 영어 3글자 이상
|
|
45
|
+
* - STOPWORDS 및 숫자-only 제외
|
|
46
|
+
* - 중복 제거(등장 순서 유지)
|
|
47
|
+
*
|
|
48
|
+
* @param text 원본 문자열
|
|
49
|
+
* @param maxTokens 상한 (자동주입 트리거는 5, 명시 검색은 무제한 = Infinity)
|
|
50
|
+
* @returns 키워드 배열
|
|
51
|
+
*/
|
|
52
|
+
export function tokenizeQuery(text, maxTokens = Infinity) {
|
|
53
|
+
if (!text || text.length < 2)
|
|
54
|
+
return [];
|
|
55
|
+
const cleaned = text
|
|
56
|
+
.replace(/```[\s\S]*?```/g, ' ') // 코드 블록
|
|
57
|
+
.replace(/`[^`]+`/g, ' ') // 인라인 코드
|
|
58
|
+
.replace(/^\/[a-z-]+\s*/i, '') // 슬래시 명령 prefix
|
|
59
|
+
.replace(/[*_~`"'()[\]{}<>]/g, ' '); // 특수문자
|
|
60
|
+
const tokens = cleaned
|
|
61
|
+
.split(/[\s,.!?;:/\\|]+/)
|
|
62
|
+
.map(t => t.trim().toLowerCase())
|
|
63
|
+
.filter(t => /^[a-z0-9가-힣]+$/i.test(t))
|
|
64
|
+
.map(t => t.replace(/(해줘|해주세요|해주라|했어|했지|하는|하고|해서|합니다|드려|드릴게)$/, ''))
|
|
65
|
+
.filter(t => t.length > 0)
|
|
66
|
+
.filter(t => {
|
|
67
|
+
const hasHangul = /[가-힣]/.test(t);
|
|
68
|
+
return hasHangul ? t.length >= 2 : t.length >= 3;
|
|
69
|
+
})
|
|
70
|
+
.filter(t => !STOPWORDS.has(t))
|
|
71
|
+
.filter(t => !/^\d+$/.test(t));
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const unique = [];
|
|
74
|
+
for (const t of tokens) {
|
|
75
|
+
if (!seen.has(t)) {
|
|
76
|
+
seen.add(t);
|
|
77
|
+
unique.push(t);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return maxTokens === Infinity ? unique : unique.slice(0, maxTokens);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 영어 토큰의 경량 어간 변형 생성 (2026-07-09, 영어권 유저 고려).
|
|
84
|
+
* FTS5는 정확 일치라 "servers"로 검색하면 "server"로 저장된 메모리를 놓친다.
|
|
85
|
+
* Porter 같은 무거운 stemmer 대신, 흔한 어미(복수 s/es, 과거 ed, 진행 ing)만
|
|
86
|
+
* 벗겨 원형 후보를 만든다. 원형과 변형을 FTS5 OR에 함께 넣어 recall을 높인다.
|
|
87
|
+
*
|
|
88
|
+
* 보수적 규칙 (과절삭 방지):
|
|
89
|
+
* - 5글자 미만은 건드리지 않음(bus/class 훼손 방지)
|
|
90
|
+
* - 한글 포함 토큰은 그대로(영어 규칙 미적용)
|
|
91
|
+
*
|
|
92
|
+
* @returns 원형 후보 (없으면 빈 배열). 호출부에서 원 토큰과 합쳐 dedupe.
|
|
93
|
+
*/
|
|
94
|
+
function englishVariants(token) {
|
|
95
|
+
if (/[가-힣]/.test(token) || token.length < 5)
|
|
96
|
+
return [];
|
|
97
|
+
const out = [];
|
|
98
|
+
if (token.endsWith('ies') && token.length > 4)
|
|
99
|
+
out.push(token.slice(0, -3) + 'y'); // libraries→library
|
|
100
|
+
else if (token.endsWith('es') && token.length > 4)
|
|
101
|
+
out.push(token.slice(0, -2)); // boxes→box
|
|
102
|
+
if (token.endsWith('s') && !token.endsWith('ss') && !token.endsWith('us'))
|
|
103
|
+
out.push(token.slice(0, -1)); // servers→server
|
|
104
|
+
if (token.endsWith('ing') && token.length > 5)
|
|
105
|
+
out.push(token.slice(0, -3)); // deploying→deploy
|
|
106
|
+
if (token.endsWith('ed') && token.length > 4)
|
|
107
|
+
out.push(token.slice(0, -2)); // saved→sav (불완전하나 FTS OR라 무해)
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 토큰들을 FTS5 MATCH 쿼리로 조립("token1" OR "token2" ...). 토큰이 없으면 null.
|
|
112
|
+
* memory_search 계열이 raw split 대신 이걸 쓰면 자동주입과 같은 품질을 얻는다.
|
|
113
|
+
*
|
|
114
|
+
* @param expandEnglish 영어 복수/시제 변형도 OR에 포함(명시 검색 recall↑). 자동주입
|
|
115
|
+
* 트리거는 오탐 억제가 중요하니 기본 false, memory_search는 true 권장.
|
|
116
|
+
*/
|
|
117
|
+
export function buildFtsQuery(tokens, expandEnglish = false) {
|
|
118
|
+
if (tokens.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
const terms = new Set();
|
|
121
|
+
for (const t of tokens) {
|
|
122
|
+
terms.add(t);
|
|
123
|
+
if (expandEnglish)
|
|
124
|
+
for (const v of englishVariants(t))
|
|
125
|
+
terms.add(v);
|
|
126
|
+
}
|
|
127
|
+
return [...terms].map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
128
|
+
}
|