@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.
- package/hooks/agent-route-guard.mjs +109 -0
- package/hooks/cross-review-tracker.mjs +122 -0
- package/hooks/error-context.mjs +148 -0
- package/hooks/hook-manager.mjs +352 -0
- package/hooks/hook-orchestrator.mjs +312 -0
- package/hooks/hook-registry.json +213 -0
- package/hooks/hooks.json +89 -0
- package/hooks/keyword-rules.json +581 -0
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hooks/pipeline-stop.mjs +76 -0
- package/hooks/safety-guard.mjs +106 -0
- package/hooks/subagent-verifier.mjs +80 -0
- package/hub/assign-callbacks.mjs +133 -0
- package/hub/bridge.mjs +799 -0
- package/hub/cli-adapter-base.mjs +192 -0
- package/hub/codex-adapter.mjs +190 -0
- package/hub/codex-compat.mjs +78 -0
- package/hub/codex-preflight.mjs +147 -0
- package/hub/delegator/contracts.mjs +37 -0
- package/hub/delegator/index.mjs +14 -0
- package/hub/delegator/schema/delegator-tools.schema.json +250 -0
- package/hub/delegator/service.mjs +307 -0
- package/hub/delegator/tool-definitions.mjs +35 -0
- package/hub/fullcycle.mjs +96 -0
- package/hub/gemini-adapter.mjs +179 -0
- package/hub/hitl.mjs +143 -0
- package/hub/intent.mjs +193 -0
- package/hub/lib/process-utils.mjs +361 -0
- package/hub/middleware/request-logger.mjs +81 -0
- package/hub/paths.mjs +30 -0
- package/hub/pipeline/gates/confidence.mjs +56 -0
- package/hub/pipeline/gates/consensus.mjs +94 -0
- package/hub/pipeline/gates/index.mjs +5 -0
- package/hub/pipeline/gates/selfcheck.mjs +82 -0
- package/hub/pipeline/index.mjs +318 -0
- package/hub/pipeline/state.mjs +191 -0
- package/hub/pipeline/transitions.mjs +124 -0
- package/hub/platform.mjs +225 -0
- package/hub/quality/deslop.mjs +253 -0
- package/hub/reflexion.mjs +372 -0
- package/hub/research.mjs +146 -0
- package/hub/router.mjs +791 -0
- package/hub/routing/complexity.mjs +166 -0
- package/hub/routing/index.mjs +117 -0
- package/hub/routing/q-learning.mjs +336 -0
- package/hub/session-fingerprint.mjs +352 -0
- package/hub/state.mjs +245 -0
- package/hub/team-bridge.mjs +25 -0
- package/hub/token-mode.mjs +224 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/hud/colors.mjs +88 -0
- package/hud/constants.mjs +81 -0
- package/hud/hud-qos-status.mjs +206 -0
- package/hud/providers/claude.mjs +309 -0
- package/hud/providers/codex.mjs +151 -0
- package/hud/providers/gemini.mjs +320 -0
- package/hud/renderers.mjs +424 -0
- package/hud/terminal.mjs +140 -0
- package/hud/utils.mjs +287 -0
- package/package.json +31 -0
- package/scripts/lib/claudemd-manager.mjs +325 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +51 -0
- package/scripts/lib/env-probe.mjs +241 -0
- package/scripts/lib/gemini-profiles.mjs +85 -0
- package/scripts/lib/hook-utils.mjs +14 -0
- package/scripts/lib/keyword-rules.mjs +166 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +739 -0
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/lib/mcp-manifest.mjs +79 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/lib/psmux-info.mjs +119 -0
- 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
|
+
}
|
package/hub/research.mjs
ADDED
|
@@ -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
|
+
}
|