@triflux/core 10.0.0-alpha.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.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,372 @@
1
+ // hub/reflexion.mjs — Cross-Session Error Learning Engine
2
+ // 에러를 구조화 저장 → 다음 세션에서 유사 에러 패턴 매칭 → 자동 솔루션 적용
3
+
4
+ const DEFAULT_REFLEXION_TYPE = "reflexion";
5
+ export const ADAPTIVE_RULE_TYPE = "adaptive";
6
+ const DEFAULT_CONFIDENCE = 0.5;
7
+ const ACTIVE_RULE_CONFIDENCE = 0.5;
8
+ const ADAPTIVE_PROMOTION_STEP = 0.1;
9
+ const ADAPTIVE_DECAY_STEP = 0.1;
10
+ const ADAPTIVE_DECAY_WINDOW = 5;
11
+ const ADAPTIVE_DELETE_THRESHOLD = 0.3;
12
+
13
+ function clampConfidence(value) {
14
+ const next = Number(value);
15
+ if (!Number.isFinite(next)) return DEFAULT_CONFIDENCE;
16
+ return Math.max(0, Math.min(1, next));
17
+ }
18
+
19
+ function safeJson(value) {
20
+ try {
21
+ return JSON.stringify(value);
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ function pickString(...values) {
28
+ return (
29
+ values.find((value) => typeof value === "string" && value.trim())?.trim() ||
30
+ ""
31
+ );
32
+ }
33
+
34
+ function pickSessionCount(...values) {
35
+ const raw = values.find((value) => Number.isFinite(Number(value)));
36
+ return raw == null ? 0 : Math.max(0, Math.trunc(Number(raw)));
37
+ }
38
+
39
+ function pickSessionId(errorContext = {}) {
40
+ return pickString(
41
+ errorContext.session_id,
42
+ errorContext.sessionId,
43
+ errorContext.context?.session_id,
44
+ errorContext.context?.sessionId,
45
+ );
46
+ }
47
+
48
+ function pickProjectSlug(errorContext = {}) {
49
+ return pickString(
50
+ errorContext.projectSlug,
51
+ errorContext.project_slug,
52
+ errorContext.context?.projectSlug,
53
+ errorContext.context?.project_slug,
54
+ );
55
+ }
56
+
57
+ function compactObject(value) {
58
+ return Object.fromEntries(
59
+ Object.entries(value).filter(([, entry]) => entry != null && entry !== ""),
60
+ );
61
+ }
62
+
63
+ function buildErrorText(errorContext = {}) {
64
+ const parts = [
65
+ pickString(errorContext.tool_output),
66
+ pickString(errorContext.error),
67
+ pickString(errorContext.tool_input?.command),
68
+ errorContext.tool_result == null ? "" : safeJson(errorContext.tool_result),
69
+ ].filter(Boolean);
70
+ return parts.join("\n").trim();
71
+ }
72
+
73
+ function buildAdaptiveContext(errorContext = {}) {
74
+ return compactObject({
75
+ source: "PostToolUseFailure",
76
+ tool_name: pickString(errorContext.tool_name),
77
+ agent: pickString(errorContext.agent, errorContext.context?.agent),
78
+ cli: pickString(errorContext.cli, errorContext.context?.cli),
79
+ command: pickString(errorContext.tool_input?.command),
80
+ file: pickString(
81
+ errorContext.tool_input?.file_path,
82
+ errorContext.context?.file,
83
+ ),
84
+ project_slug: pickProjectSlug(errorContext),
85
+ });
86
+ }
87
+
88
+ function buildAdaptiveSolution(errorContext = {}, errorText = "") {
89
+ const explicit = pickString(
90
+ errorContext.systemMessage,
91
+ errorContext.additionalContext,
92
+ errorContext.hint,
93
+ );
94
+ if (explicit) return explicit;
95
+ const toolName = pickString(errorContext.tool_name) || "tool";
96
+ const command = pickString(errorContext.tool_input?.command);
97
+ const summary = errorText.split("\n")[0]?.trim() || "반복 실패 패턴";
98
+ if (command)
99
+ return `${toolName} 재시도 전 입력을 검증하세요: ${command}\n원인: ${summary}`;
100
+ return `${toolName} 재시도 전 실패 원인을 검증하세요: ${summary}`;
101
+ }
102
+
103
+ function normalizeSessionIds(sessionIds) {
104
+ if (!Array.isArray(sessionIds)) return [];
105
+ return [
106
+ ...new Set(
107
+ sessionIds.filter((value) => typeof value === "string" && value.trim()),
108
+ ),
109
+ ];
110
+ }
111
+
112
+ function getAdaptiveState(rule = {}) {
113
+ const state = rule.adaptive_state || {};
114
+ const session_ids = normalizeSessionIds(state.session_ids);
115
+ return {
116
+ ...state,
117
+ project_slug: pickString(state.project_slug, rule.context?.project_slug),
118
+ session_ids,
119
+ session_occurrences: Math.max(
120
+ state.session_occurrences || 0,
121
+ session_ids.length,
122
+ ),
123
+ last_seen_session: pickSessionCount(state.last_seen_session),
124
+ last_decay_session: pickSessionCount(state.last_decay_session),
125
+ };
126
+ }
127
+
128
+ function mergeAdaptiveState(rule, errorContext = {}) {
129
+ const current = getAdaptiveState(rule);
130
+ const sessionId = pickSessionId(errorContext);
131
+ const sessionCount = pickSessionCount(
132
+ errorContext.sessionCount,
133
+ errorContext.session_count,
134
+ errorContext.context?.sessionCount,
135
+ errorContext.context?.session_count,
136
+ );
137
+ const session_ids = normalizeSessionIds(
138
+ sessionId ? [...current.session_ids, sessionId] : current.session_ids,
139
+ );
140
+ return {
141
+ ...current,
142
+ project_slug: pickString(
143
+ pickProjectSlug(errorContext),
144
+ current.project_slug,
145
+ ),
146
+ session_ids,
147
+ session_occurrences: Math.max(
148
+ current.session_occurrences,
149
+ session_ids.length,
150
+ ),
151
+ last_seen_session: Math.max(current.last_seen_session, sessionCount),
152
+ last_decay_session: current.last_decay_session || sessionCount,
153
+ };
154
+ }
155
+
156
+ function filterEntriesByType(entries, type) {
157
+ return entries.filter(
158
+ (entry) => (entry.type || DEFAULT_REFLEXION_TYPE) === type,
159
+ );
160
+ }
161
+
162
+ /**
163
+ * 에러 메시지를 정규화된 패턴 시그니처로 변환
164
+ * 파일 경로, 줄 번호, 타임스탬프, UUID, 숫자 리터럴을 플레이스홀더로 치환
165
+ * @param {string} errorMessage
166
+ * @returns {string}
167
+ */
168
+ export function normalizeError(errorMessage) {
169
+ if (!errorMessage || typeof errorMessage !== "string") return "";
170
+ let p = errorMessage;
171
+ p = p.replace(/[A-Za-z]:\\[\w\\.\-/]+/g, "<FILE>");
172
+ p = p.replace(/(?:\/[\w.-]+){2,}/g, "<FILE>");
173
+ p = p.replace(/:(\d+)(:\d+)?(?=[\s,)\]]|$)/g, ":<LINE>");
174
+ p = p.replace(/\b[Ll]ine\s+\d+/g, "line <LINE>");
175
+ p = p.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[\w.+-]*/g, "<TIME>");
176
+ p = p.replace(
177
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
178
+ "<ID>",
179
+ );
180
+ p = p.replace(/\b[0-9a-f]{32,}\b/gi, "<ID>");
181
+ p = p.replace(/\b\d{10,13}\b/g, "<TIME>");
182
+ p = p.replace(/\b\d{4,}\b/g, "<NUM>");
183
+ return p.toLowerCase().replace(/\s+/g, " ").trim();
184
+ }
185
+
186
+ /**
187
+ * 에러에 대한 기존 솔루션 검색
188
+ * @param {object} store - createStore() 반환 객체
189
+ * @param {string} errorMessage - 원본 에러 메시지
190
+ * @param {object} [context={}] - { file, function, cli, agent }
191
+ * @returns {{ found: boolean, entries: Array, bestMatch: object|null }}
192
+ */
193
+ export function lookupSolution(store, errorMessage, context = {}) {
194
+ const pattern = normalizeError(errorMessage);
195
+ if (!pattern) return { found: false, entries: [], bestMatch: null };
196
+ const entries = store.findReflexion(pattern, context);
197
+ if (!entries.length) return { found: false, entries: [], bestMatch: null };
198
+ return { found: true, entries, bestMatch: entries[0] };
199
+ }
200
+
201
+ /**
202
+ * 에러 해결 후 학습 저장
203
+ * 동일 패턴이 존재하면 hit 업데이트, 없으면 새로 생성
204
+ * @param {object} store
205
+ * @param {{ error: string, solution: string, context?: object, success?: boolean }} opts
206
+ * @returns {object|null}
207
+ */
208
+ export function learnFromError(
209
+ store,
210
+ { error, solution, context = {}, success = false },
211
+ ) {
212
+ const pattern = normalizeError(error);
213
+ if (!pattern || !solution) return null;
214
+ const existing = filterEntriesByType(
215
+ store.findReflexion(pattern, context),
216
+ DEFAULT_REFLEXION_TYPE,
217
+ );
218
+ if (existing.length && existing[0].error_pattern === pattern) {
219
+ return store.updateReflexionHit(existing[0].id, success);
220
+ }
221
+ const newEntry = store.addReflexion({
222
+ type: DEFAULT_REFLEXION_TYPE,
223
+ error_pattern: pattern,
224
+ error_message: error,
225
+ context,
226
+ solution,
227
+ solution_code: null,
228
+ });
229
+ return success && newEntry
230
+ ? store.updateReflexionHit(newEntry.id, true)
231
+ : newEntry;
232
+ }
233
+
234
+ /**
235
+ * 솔루션 적용 결과 피드백
236
+ * @param {object} store
237
+ * @param {string} entryId
238
+ * @param {boolean} success
239
+ * @returns {object|null}
240
+ */
241
+ export function reportOutcome(store, entryId, success) {
242
+ return store.updateReflexionHit(entryId, success);
243
+ }
244
+
245
+ /**
246
+ * PostToolUseFailure 컨텍스트에서 adaptive rule payload 생성
247
+ * @param {object} errorContext
248
+ * @returns {object|null}
249
+ */
250
+ export function adaptiveRuleFromError(errorContext = {}) {
251
+ const errorText = buildErrorText(errorContext);
252
+ const pattern = normalizeError(errorText);
253
+ if (!pattern) return null;
254
+ return {
255
+ type: ADAPTIVE_RULE_TYPE,
256
+ error_pattern: pattern,
257
+ error_message: errorText,
258
+ context: buildAdaptiveContext(errorContext),
259
+ solution: buildAdaptiveSolution(errorContext, errorText),
260
+ solution_code: null,
261
+ adaptive_state: (() => {
262
+ const sessionId = pickSessionId(errorContext);
263
+ const sessionCount = pickSessionCount(
264
+ errorContext.sessionCount,
265
+ errorContext.session_count,
266
+ );
267
+ return {
268
+ project_slug: pickProjectSlug(errorContext),
269
+ session_ids: sessionId ? [sessionId] : [],
270
+ session_occurrences: sessionId ? 1 : 0,
271
+ last_seen_session: sessionCount,
272
+ last_decay_session: sessionCount,
273
+ };
274
+ })(),
275
+ confidence: DEFAULT_CONFIDENCE,
276
+ hit_count: 1,
277
+ success_count: 0,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * 동일 패턴이 여러 세션에서 재발하면 adaptive rule confidence를 승격
283
+ * @param {object} store
284
+ * @param {string} ruleId
285
+ * @param {object} [errorContext={}]
286
+ * @returns {object|null}
287
+ */
288
+ export function promoteRule(store, ruleId, errorContext = {}) {
289
+ const rule = store.getReflexion(ruleId);
290
+ if (!rule || rule.type !== ADAPTIVE_RULE_TYPE || !store.patchReflexion)
291
+ return null;
292
+ const current = getAdaptiveState(rule);
293
+ const next = mergeAdaptiveState(rule, errorContext);
294
+ const newSession = next.session_occurrences > current.session_occurrences;
295
+ const promoted =
296
+ newSession && next.session_occurrences >= 2
297
+ ? clampConfidence(rule.confidence + ADAPTIVE_PROMOTION_STEP)
298
+ : rule.confidence;
299
+ return store.patchReflexion(ruleId, {
300
+ adaptive_state: next,
301
+ confidence: promoted,
302
+ hit_count: (rule.hit_count || 0) + 1,
303
+ last_hit_ms: Date.now(),
304
+ });
305
+ }
306
+
307
+ /**
308
+ * 지정 세션 수만큼 관측되지 않은 adaptive rules confidence 감소
309
+ * @param {object} store
310
+ * @param {number} sessionCount
311
+ * @returns {{ updated: Array, deleted: string[] }}
312
+ */
313
+ export function decayRules(store, sessionCount) {
314
+ const currentSession = pickSessionCount(sessionCount);
315
+ if (!store.listReflexion || !store.patchReflexion || !store.deleteReflexion) {
316
+ return { updated: [], deleted: [] };
317
+ }
318
+ const result = { updated: [], deleted: [] };
319
+ for (const rule of store.listReflexion({ type: ADAPTIVE_RULE_TYPE })) {
320
+ const state = getAdaptiveState(rule);
321
+ const baseline = Math.max(
322
+ state.last_seen_session,
323
+ state.last_decay_session,
324
+ );
325
+ const decaySteps = Math.floor(
326
+ (currentSession - baseline) / ADAPTIVE_DECAY_WINDOW,
327
+ );
328
+ if (decaySteps <= 0) continue;
329
+ const confidence = clampConfidence(
330
+ rule.confidence - decaySteps * ADAPTIVE_DECAY_STEP,
331
+ );
332
+ if (confidence <= ADAPTIVE_DELETE_THRESHOLD) {
333
+ if (store.deleteReflexion(rule.id)) result.deleted.push(rule.id);
334
+ continue;
335
+ }
336
+ const updated = store.patchReflexion(rule.id, {
337
+ confidence,
338
+ adaptive_state: {
339
+ ...state,
340
+ last_decay_session: baseline + decaySteps * ADAPTIVE_DECAY_WINDOW,
341
+ },
342
+ });
343
+ if (updated) result.updated.push(updated);
344
+ }
345
+ return result;
346
+ }
347
+
348
+ /**
349
+ * 현재 활성화된 adaptive rules 조회
350
+ * @param {object} store
351
+ * @param {string} projectSlug
352
+ * @returns {Array}
353
+ */
354
+ export function getActiveAdaptiveRules(store, projectSlug) {
355
+ if (!store.listReflexion) return [];
356
+ return store
357
+ .listReflexion({ type: ADAPTIVE_RULE_TYPE, projectSlug })
358
+ .filter((rule) => rule.confidence > ACTIVE_RULE_CONFIDENCE);
359
+ }
360
+
361
+ /**
362
+ * 신뢰도 자동 조정 (success_count / hit_count, 샘플 크기 기반 감쇠)
363
+ * hit_count가 작으면 0.5(기본값)쪽으로 보수적으로 수렴
364
+ * @param {object} entry - { hit_count, success_count }
365
+ * @returns {number} 0~1 사이 신뢰도
366
+ */
367
+ export function recalcConfidence(entry) {
368
+ if (!entry?.hit_count || entry.hit_count <= 0) return DEFAULT_CONFIDENCE;
369
+ const ratio = entry.success_count / entry.hit_count;
370
+ const decay = Math.min(1, entry.hit_count / 10);
371
+ return ratio * decay + DEFAULT_CONFIDENCE * (1 - decay);
372
+ }
@@ -0,0 +1,146 @@
1
+ // hub/research.mjs — 자율 웹 리서치 엔진 코어
2
+ // 검색 쿼리 생성 → 결과 정규화 → 보고서 빌드 → 저장
3
+
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { TFX_REPORTS_DIR } from './paths.mjs';
7
+
8
+ /**
9
+ * 주제에서 검색 쿼리 3-5개를 자동 생성한다.
10
+ * 한국어 주제 → 한국어 + 영어 혼합, 영어 주제 → 영어 쿼리.
11
+ * @param {string} topic - 리서치 주제
12
+ * @param {'ko'|'en'|'auto'} [lang='auto'] - 언어 힌트
13
+ * @returns {string[]} 검색 쿼리 배열
14
+ */
15
+ export function generateQueries(topic, lang = 'auto') {
16
+ if (!topic || typeof topic !== 'string' || !topic.trim()) return [];
17
+
18
+ const t = topic.trim();
19
+ const detectedLang = lang === 'auto' ? detectLang(t) : lang;
20
+
21
+ if (detectedLang === 'ko') {
22
+ return [
23
+ `${t} 정리`,
24
+ `${t} 비교 분석`,
25
+ `${t} 최신 동향 ${new Date().getFullYear()}`,
26
+ `${toEnglishQuery(t)} overview`,
27
+ `${toEnglishQuery(t)} comparison ${new Date().getFullYear()}`,
28
+ ];
29
+ }
30
+
31
+ return [
32
+ `${t} overview`,
33
+ `${t} comparison`,
34
+ `${t} best practices ${new Date().getFullYear()}`,
35
+ `${t} pros and cons`,
36
+ ];
37
+ }
38
+
39
+ /**
40
+ * 검색 원시 결과를 정규화한다. 중복 URL 제거 + 빈/null 필터링.
41
+ * @param {Array<object|null|undefined>} rawResults - 검색 엔진 원시 결과
42
+ * @returns {Array<{title: string, url: string, snippet: string}>}
43
+ */
44
+ export function normalizeResults(rawResults) {
45
+ if (!Array.isArray(rawResults)) return [];
46
+
47
+ const seen = new Set();
48
+ const out = [];
49
+
50
+ for (const r of rawResults) {
51
+ if (!r || typeof r !== 'object') continue;
52
+ const url = (r.url || r.link || '').trim();
53
+ const title = (r.title || r.name || '').trim();
54
+ const snippet = (r.snippet || r.description || r.content || '').trim();
55
+
56
+ if (!url || seen.has(url)) continue;
57
+ seen.add(url);
58
+ out.push({ title, url, snippet });
59
+ }
60
+
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 리서치 보고서를 마크다운으로 빌드한다.
66
+ * @param {string} topic - 리서치 주제
67
+ * @param {string[]} findings - 핵심 발견 목록
68
+ * @param {Array<{title: string, url: string, snippet: string}>} sources - 출처 목록
69
+ * @returns {string} 마크다운 문자열
70
+ */
71
+ export function buildReport(topic, findings, sources) {
72
+ const date = new Date().toISOString().split('T')[0];
73
+ const findingsSection = (findings || [])
74
+ .map((f, i) => `${i + 1}. ${f}`)
75
+ .join('\n');
76
+ const sourcesSection = (sources || [])
77
+ .map((s) => `- [${s.title || s.url}](${s.url})${s.snippet ? ` — ${s.snippet}` : ''}`)
78
+ .join('\n');
79
+
80
+ return `# Research: ${topic}
81
+ Date: ${date}
82
+
83
+ ## Executive Summary
84
+ ${topic}에 대한 자동 리서치 결과입니다.
85
+
86
+ ## Key Findings
87
+ ${findingsSection || '_발견 없음_'}
88
+
89
+ ## Actionable Recommendations
90
+ 리서치 결과를 바탕으로 다음 단계를 검토하세요.
91
+
92
+ ## Sources
93
+ ${sourcesSection || '_출처 없음_'}
94
+ `;
95
+ }
96
+
97
+ /**
98
+ * 보고서를 .tfx/reports/research-{timestamp}.md에 저장한다.
99
+ * @param {string} topic - 리서치 주제 (파일명 생성용)
100
+ * @param {string} content - 마크다운 보고서 내용
101
+ * @param {string} [baseDir=process.cwd()] - 프로젝트 루트 경로
102
+ * @returns {string} 저장된 파일 경로
103
+ */
104
+ export function saveReport(topic, content, baseDir = process.cwd()) {
105
+ const dir = join(baseDir, TFX_REPORTS_DIR);
106
+ const resolvedDir = resolve(dir);
107
+ const expectedBase = resolve(baseDir || TFX_REPORTS_DIR);
108
+ if (!resolvedDir.startsWith(expectedBase)) {
109
+ throw new Error('Invalid report directory: path traversal detected');
110
+ }
111
+ mkdirSync(dir, { recursive: true });
112
+
113
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
114
+ const slug = (topic || 'untitled')
115
+ .replace(/[^a-zA-Z0-9가-힣\s-]/g, '')
116
+ .replace(/\s+/g, '-')
117
+ .slice(0, 40)
118
+ .toLowerCase();
119
+ const filename = `research-${ts}-${slug}.md`;
120
+ const filepath = join(dir, filename);
121
+
122
+ writeFileSync(filepath, content, 'utf-8');
123
+ return filepath;
124
+ }
125
+
126
+ // ── internal helpers ──
127
+
128
+ /**
129
+ * 텍스트에 한글이 포함되어 있으면 'ko', 아니면 'en'
130
+ * @param {string} text
131
+ * @returns {'ko'|'en'}
132
+ */
133
+ function detectLang(text) {
134
+ return /[가-힣]/.test(text) ? 'ko' : 'en';
135
+ }
136
+
137
+ /**
138
+ * 한국어 토픽에서 영어 검색 쿼리용 문자열 추출.
139
+ * 영문/숫자만 남기고, 없으면 원문 그대로 반환.
140
+ * @param {string} text
141
+ * @returns {string}
142
+ */
143
+ function toEnglishQuery(text) {
144
+ const eng = text.replace(/[가-힣\s]+/g, ' ').trim();
145
+ return eng || text;
146
+ }