@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,179 @@
|
|
|
1
|
+
// hub/gemini-adapter.mjs — Gemini CLI 방어 계층
|
|
2
|
+
// codex-adapter.mjs와 동일 패턴, cli-adapter-base 공통 인터페이스 사용
|
|
3
|
+
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
import { withRetry } from './workers/worker-utils.mjs';
|
|
9
|
+
import { whichCommandAsync } from './platform.mjs';
|
|
10
|
+
import {
|
|
11
|
+
createCircuitBreaker,
|
|
12
|
+
createResult,
|
|
13
|
+
appendWarnings,
|
|
14
|
+
normalizePathForShell,
|
|
15
|
+
shellQuote,
|
|
16
|
+
runProcess,
|
|
17
|
+
} from './cli-adapter-base.mjs';
|
|
18
|
+
|
|
19
|
+
const breaker = createCircuitBreaker();
|
|
20
|
+
|
|
21
|
+
// ── Gemini-specific stall inference ─────────────────────────────
|
|
22
|
+
|
|
23
|
+
function inferStallMode(stdout, stderr) {
|
|
24
|
+
const text = `${stdout}\n${stderr}`.toLowerCase();
|
|
25
|
+
if (/(unauthorized|forbidden|auth|login|token|credential|api.?key)/u.test(text)) return 'auth_stall';
|
|
26
|
+
if (/\bmcp\b|playwright|tavily|brave|sequential|server/u.test(text)) return 'mcp_stall';
|
|
27
|
+
return 'timeout';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Preflight ───────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function runPreflight(opts = {}) {
|
|
33
|
+
const geminiPath = await whichCommandAsync('gemini');
|
|
34
|
+
if (!geminiPath) {
|
|
35
|
+
return {
|
|
36
|
+
geminiPath: null,
|
|
37
|
+
warnings: ['Gemini CLI not found. Install Gemini and ensure `gemini` is available on PATH.'],
|
|
38
|
+
excludeMcpServers: [],
|
|
39
|
+
ok: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const excludeMcpServers = [];
|
|
45
|
+
|
|
46
|
+
for (const name of Array.isArray(opts.mcpServers) ? opts.mcpServers : []) {
|
|
47
|
+
const server = String(name ?? '').trim();
|
|
48
|
+
if (!server) continue;
|
|
49
|
+
// Gemini MCP health는 best-effort: 실행 시점에 --allowed-mcp-server-names로 필터링
|
|
50
|
+
// 사전 probe는 수행하지 않음 (gemini가 자체적으로 graceful degrade)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { geminiPath, warnings, excludeMcpServers, ok: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Command building ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function buildGeminiCommand(prompt, resultFile, opts = {}) {
|
|
59
|
+
const parts = ['gemini'];
|
|
60
|
+
|
|
61
|
+
if (opts.model) parts.push('--model', shellQuote(opts.model));
|
|
62
|
+
parts.push('--yolo');
|
|
63
|
+
|
|
64
|
+
const allowed = Array.isArray(opts.allowedMcpServers) ? opts.allowedMcpServers : [];
|
|
65
|
+
const excluded = Array.isArray(opts.excludeMcpServers) ? opts.excludeMcpServers : [];
|
|
66
|
+
const filtered = allowed.filter((name) => !excluded.includes(name));
|
|
67
|
+
if (filtered.length) {
|
|
68
|
+
parts.push('--allowed-mcp-server-names', ...filtered.map((n) => shellQuote(n)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
parts.push('--prompt', shellQuote(prompt));
|
|
72
|
+
parts.push('--output-format', 'text');
|
|
73
|
+
|
|
74
|
+
if (resultFile) {
|
|
75
|
+
return `${parts.join(' ')} > ${shellQuote(normalizePathForShell(resultFile))} 2>${shellQuote(normalizePathForShell(resultFile + '.err'))}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return parts.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildAttempts(opts, preflight) {
|
|
82
|
+
const timeout = Number.isFinite(opts.timeout) ? opts.timeout : 900_000;
|
|
83
|
+
const base = {
|
|
84
|
+
timeout,
|
|
85
|
+
model: opts.model,
|
|
86
|
+
allowedMcpServers: Array.isArray(opts.mcpServers) ? [...opts.mcpServers] : [],
|
|
87
|
+
excludeMcpServers: [...(preflight.excludeMcpServers || [])],
|
|
88
|
+
};
|
|
89
|
+
if (opts.retryOnFail === false) return [base];
|
|
90
|
+
return [
|
|
91
|
+
base,
|
|
92
|
+
{ ...base, timeout: timeout * 2, allowedMcpServers: [] },
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Public: buildExecArgs ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function buildExecArgs(opts = {}) {
|
|
99
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
|
|
100
|
+
return buildGeminiCommand(prompt, opts.resultFile || null, {
|
|
101
|
+
model: opts.model,
|
|
102
|
+
allowedMcpServers: opts.mcpServers,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Execution ───────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async function runGemini(prompt, workdir, preflight, attempt) {
|
|
109
|
+
const dir = join(tmpdir(), 'triflux-gemini-exec');
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const resultFile = join(dir, `gemini-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
|
|
112
|
+
const command = buildGeminiCommand(prompt, resultFile, {
|
|
113
|
+
model: attempt.model,
|
|
114
|
+
allowedMcpServers: attempt.allowedMcpServers,
|
|
115
|
+
excludeMcpServers: attempt.excludeMcpServers,
|
|
116
|
+
});
|
|
117
|
+
return runProcess(command, workdir, attempt.timeout, { resultFile, inferStallMode });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export function getCircuitState(now) {
|
|
123
|
+
return breaker.getState(now);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function execute(opts = {}) {
|
|
127
|
+
const entry = breaker.canExecute();
|
|
128
|
+
if (!entry.allowed) {
|
|
129
|
+
return createResult(false, { fellBack: true, failureMode: 'circuit_open' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const preflight = await runPreflight({ mcpServers: opts.mcpServers });
|
|
133
|
+
if (!preflight.ok) {
|
|
134
|
+
breaker.clearTrial();
|
|
135
|
+
breaker.recordFailure(entry.halfOpen);
|
|
136
|
+
return createResult(false, {
|
|
137
|
+
stderr: appendWarnings('', preflight.warnings),
|
|
138
|
+
fellBack: opts.fallbackToClaude !== false,
|
|
139
|
+
failureMode: 'crash',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const attempts = buildAttempts(opts, preflight);
|
|
144
|
+
let attemptIndex = 0;
|
|
145
|
+
let lastResult = createResult(false);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
lastResult = await withRetry(async () => {
|
|
149
|
+
const result = await runGemini(opts.prompt || '', opts.workdir || process.cwd(), preflight, attempts[attemptIndex]);
|
|
150
|
+
const current = { ...result, stderr: appendWarnings(result.stderr, preflight.warnings), retried: attemptIndex > 0 };
|
|
151
|
+
const canRetry = !current.ok && attemptIndex < attempts.length - 1;
|
|
152
|
+
attemptIndex += 1;
|
|
153
|
+
if (!canRetry) return current;
|
|
154
|
+
const error = new Error('retry');
|
|
155
|
+
error.retryable = true;
|
|
156
|
+
error.result = current;
|
|
157
|
+
throw error;
|
|
158
|
+
}, {
|
|
159
|
+
maxAttempts: attempts.length,
|
|
160
|
+
baseDelayMs: 250,
|
|
161
|
+
maxDelayMs: 750,
|
|
162
|
+
shouldRetry: (error) => error?.retryable === true,
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
lastResult = error?.result || createResult(false, { stderr: String(error?.message || error) });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (lastResult.ok) {
|
|
169
|
+
breaker.reset();
|
|
170
|
+
return lastResult;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
breaker.recordFailure(entry.halfOpen);
|
|
174
|
+
return {
|
|
175
|
+
...lastResult,
|
|
176
|
+
retried: attempts.length > 1,
|
|
177
|
+
fellBack: opts.fallbackToClaude !== false,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/hub/hitl.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// hub/hitl.mjs — Human-in-the-Loop 매니저
|
|
2
|
+
// 사용자 입력 요청/응답, 타임아웃 자동 처리
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HITL 매니저 생성
|
|
6
|
+
* @param {object} store — createStore() 반환 객체
|
|
7
|
+
* @param {object} router — createRouter() 반환 객체
|
|
8
|
+
*/
|
|
9
|
+
export function createHitlManager(store, router = null) {
|
|
10
|
+
function forwardHumanResponse({ requesterAgent, requestId, action, content, submittedBy, correlationId, traceId, priority }) {
|
|
11
|
+
if (!router?.handlePublish) {
|
|
12
|
+
throw new Error('router.handlePublish is required for HITL forwarding');
|
|
13
|
+
}
|
|
14
|
+
return router.handlePublish({
|
|
15
|
+
from: 'hub:hitl',
|
|
16
|
+
to: requesterAgent,
|
|
17
|
+
topic: 'human.response',
|
|
18
|
+
priority,
|
|
19
|
+
ttl_ms: 300000,
|
|
20
|
+
payload: { request_id: requestId, action, content, submitted_by: submittedBy },
|
|
21
|
+
correlation_id: correlationId,
|
|
22
|
+
trace_id: traceId,
|
|
23
|
+
message_type: 'human_response',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
/**
|
|
29
|
+
* 사용자에게 입력 요청 생성
|
|
30
|
+
* 터미널에 알림 출력 후 pending 상태로 저장
|
|
31
|
+
*/
|
|
32
|
+
requestHumanInput({
|
|
33
|
+
requester_agent, kind, prompt, requested_schema = {},
|
|
34
|
+
deadline_ms, default_action, channel_preference = 'terminal',
|
|
35
|
+
correlation_id, trace_id,
|
|
36
|
+
}) {
|
|
37
|
+
const result = store.insertHumanRequest({
|
|
38
|
+
requester_agent, kind, prompt, requested_schema,
|
|
39
|
+
deadline_ms, default_action,
|
|
40
|
+
correlation_id, trace_id,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 터미널 알림 (stderr — stdout은 MCP 용)
|
|
44
|
+
const kindLabel = { captcha: 'CAPTCHA', approval: '승인', credential: '자격증명', choice: '선택', text: '텍스트' };
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
`\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
|
|
47
|
+
` 요청자: ${requester_agent}\n` +
|
|
48
|
+
` 내용: ${prompt}\n` +
|
|
49
|
+
` ID: ${result.request_id}\n` +
|
|
50
|
+
` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { ok: true, data: result };
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 사용자 입력 응답 제출
|
|
58
|
+
* 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
|
|
59
|
+
*/
|
|
60
|
+
submitHumanInput({ request_id, action, content = null, submitted_by = 'human' }) {
|
|
61
|
+
// 요청 조회
|
|
62
|
+
const hr = store.getHumanRequest(request_id);
|
|
63
|
+
if (!hr) {
|
|
64
|
+
return { ok: false, error: { code: 'NOT_FOUND', message: `요청 없음: ${request_id}` } };
|
|
65
|
+
}
|
|
66
|
+
if (hr.state !== 'pending') {
|
|
67
|
+
return { ok: false, error: { code: 'ALREADY_HANDLED', message: `이미 처리됨: ${hr.state}` } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 상태 매핑
|
|
71
|
+
const stateMap = { accept: 'accepted', decline: 'declined', cancel: 'cancelled' };
|
|
72
|
+
const newState = stateMap[action];
|
|
73
|
+
if (!newState) {
|
|
74
|
+
return { ok: false, error: { code: 'INVALID_ACTION', message: `잘못된 action: ${action}` } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// DB 업데이트
|
|
78
|
+
store.updateHumanRequest(request_id, newState, content);
|
|
79
|
+
|
|
80
|
+
// 요청자에게 응답 메시지 전달
|
|
81
|
+
let forwardedMessageId = null;
|
|
82
|
+
if (action === 'accept' || action === 'decline') {
|
|
83
|
+
const published = forwardHumanResponse({
|
|
84
|
+
requesterAgent: hr.requester_agent,
|
|
85
|
+
requestId: request_id,
|
|
86
|
+
action,
|
|
87
|
+
content,
|
|
88
|
+
submittedBy: submitted_by,
|
|
89
|
+
correlationId: hr.correlation_id,
|
|
90
|
+
traceId: hr.trace_id,
|
|
91
|
+
priority: 7,
|
|
92
|
+
});
|
|
93
|
+
forwardedMessageId = published.data?.message_id || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
data: { request_id, new_state: newState, forwarded_message_id: forwardedMessageId },
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 만료된 요청 자동 처리
|
|
104
|
+
* deadline 초과 시 default_action 적용
|
|
105
|
+
*/
|
|
106
|
+
checkTimeouts() {
|
|
107
|
+
const pending = store.getPendingHumanRequests();
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const expired = pending.filter(hr => hr.deadline_ms <= now);
|
|
110
|
+
if (!expired.length) return 0;
|
|
111
|
+
|
|
112
|
+
const expireRequests = () => {
|
|
113
|
+
for (const hr of expired) {
|
|
114
|
+
store.updateHumanRequest(hr.request_id, 'timed_out', null);
|
|
115
|
+
if (hr.default_action === 'timeout_continue') {
|
|
116
|
+
forwardHumanResponse({
|
|
117
|
+
requesterAgent: hr.requester_agent,
|
|
118
|
+
requestId: hr.request_id,
|
|
119
|
+
action: 'timeout_continue',
|
|
120
|
+
content: null,
|
|
121
|
+
submittedBy: 'system',
|
|
122
|
+
correlationId: hr.correlation_id,
|
|
123
|
+
traceId: hr.trace_id,
|
|
124
|
+
priority: 5,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return expired.length;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const processExpired = store.db?.transaction
|
|
132
|
+
? store.db.transaction(expireRequests)
|
|
133
|
+
: expireRequests;
|
|
134
|
+
|
|
135
|
+
return processExpired();
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/** 대기 중인 요청 목록 */
|
|
139
|
+
getPendingRequests() {
|
|
140
|
+
return store.getPendingHumanRequests();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
package/hub/intent.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// hub/intent.mjs — Intent Classification Engine
|
|
2
|
+
// 사용자 요청의 "진짜 의도"를 분석 → 카테고리 분류 → 최적 에이전트/모델 자동 선택
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { whichCommand } from './platform.mjs';
|
|
7
|
+
|
|
8
|
+
/** 캐시 엔트리: { category, confidence, ts } */
|
|
9
|
+
const _intentCache = new Map();
|
|
10
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
11
|
+
|
|
12
|
+
/** codex 설치 여부 (프로세스당 1회 확인) */
|
|
13
|
+
let _codexAvailable = null;
|
|
14
|
+
|
|
15
|
+
function _isCodexAvailable() {
|
|
16
|
+
if (_codexAvailable !== null) return _codexAvailable;
|
|
17
|
+
_codexAvailable = Boolean(whichCommand('codex'));
|
|
18
|
+
return _codexAvailable;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _promptHash(prompt) {
|
|
22
|
+
return crypto.createHash('md5').update(prompt).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _getCached(hash) {
|
|
26
|
+
const entry = _intentCache.get(hash);
|
|
27
|
+
if (!entry) return null;
|
|
28
|
+
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
|
29
|
+
_intentCache.delete(hash);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return entry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _tryCodexClassify(prompt) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = execFileSync(
|
|
38
|
+
'codex',
|
|
39
|
+
['exec', `Classify intent: ${prompt}. Reply JSON: {intent, confidence}`],
|
|
40
|
+
{ timeout: 8000, encoding: 'utf8' }
|
|
41
|
+
);
|
|
42
|
+
// JSON 블록 추출 (응답에 다른 텍스트가 섞일 수 있음)
|
|
43
|
+
const match = raw.match(/\{[\s\S]*?\}/);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const parsed = JSON.parse(match[0]);
|
|
46
|
+
const intent = typeof parsed.intent === 'string' ? parsed.intent : null;
|
|
47
|
+
const confidence = typeof parsed.confidence === 'number' ? parsed.confidence : null;
|
|
48
|
+
if (!intent || confidence === null) return null;
|
|
49
|
+
// intent가 알려진 카테고리여야 함
|
|
50
|
+
if (!INTENT_CATEGORIES[intent]) return null;
|
|
51
|
+
return { category: intent, confidence };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** triflux 특화 의도 카테고리 (10개) */
|
|
58
|
+
export const INTENT_CATEGORIES = {
|
|
59
|
+
implement: { agent: 'executor', mcp: 'implement', effort: 'codex53_high' },
|
|
60
|
+
debug: { agent: 'debugger', mcp: 'implement', effort: 'codex53_high' },
|
|
61
|
+
analyze: { agent: 'analyst', mcp: 'analyze', effort: 'gpt54_xhigh' },
|
|
62
|
+
design: { agent: 'architect', mcp: 'analyze', effort: 'gpt54_xhigh' },
|
|
63
|
+
review: { agent: 'code-reviewer', mcp: 'review', effort: 'codex53_high' },
|
|
64
|
+
document: { agent: 'writer', mcp: 'docs', effort: 'pro' },
|
|
65
|
+
research: { agent: 'scientist', mcp: 'analyze', effort: 'codex53_high' },
|
|
66
|
+
'quick-fix':{ agent: 'build-fixer', mcp: 'implement', effort: 'codex53_low' },
|
|
67
|
+
explain: { agent: 'writer', mcp: 'docs', effort: 'flash' },
|
|
68
|
+
test: { agent: 'test-engineer', mcp: null, effort: null },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** @internal 키워드 → 카테고리 매핑 패턴 */
|
|
72
|
+
const KEYWORD_PATTERNS = [
|
|
73
|
+
{ category: 'implement', keywords: ['구현', '만들', '추가', '생성', '작성', '빌드', 'implement', 'create', 'add', 'build', 'make', 'develop'], weight: 1.0 },
|
|
74
|
+
{ category: 'debug', keywords: ['버그', '에러', '오류', '고쳐', '수정', '디버그', 'fix', 'bug', 'error', 'debug', 'troubleshoot', 'crash', 'broken'], weight: 1.0 },
|
|
75
|
+
{ category: 'analyze', keywords: ['분석', '조사', '파악', 'analyze', 'investigate', 'examine', 'inspect'], weight: 0.9 },
|
|
76
|
+
{ category: 'design', keywords: ['설계', '아키텍처', '디자인', '구조', 'design', 'architect', 'structure'], weight: 0.9 },
|
|
77
|
+
{ category: 'review', keywords: ['리뷰', '검토', '코드리뷰', 'review', 'code review', 'audit'], weight: 1.0 },
|
|
78
|
+
{ category: 'document', keywords: ['문서', '도큐먼트', '문서화', 'document', 'docs', 'documentation', 'readme'], weight: 0.9 },
|
|
79
|
+
{ category: 'research', keywords: ['리서치', '연구', '탐색', 'research', 'explore', 'study'], weight: 0.8 },
|
|
80
|
+
{ category: 'quick-fix', keywords: ['빠르게', '간단히', '급한', 'quick fix', 'hotfix', 'quick'], weight: 0.85 },
|
|
81
|
+
{ category: 'explain', keywords: ['설명', '뭐야', '알려', '이해', 'explain', 'what is', 'how does', 'tell me', 'describe'], weight: 1.0 },
|
|
82
|
+
{ category: 'test', keywords: ['테스트', '테스팅', '시험', '검증', 'test', 'testing', 'spec', 'unit test'], weight: 1.0 },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 키워드 기반 빠른 분류 (0-cost, Codex 호출 없이)
|
|
87
|
+
* 고신뢰(>0.8) 시 Codex triage 건너뜀
|
|
88
|
+
* @param {string} prompt - 사용자 프롬프트
|
|
89
|
+
* @returns {{ category: string, confidence: number }}
|
|
90
|
+
*/
|
|
91
|
+
export function quickClassify(prompt) {
|
|
92
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
93
|
+
return { category: 'implement', confidence: 0.1 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lower = prompt.toLowerCase().trim();
|
|
97
|
+
let bestCategory = null;
|
|
98
|
+
let bestScore = 0;
|
|
99
|
+
let bestMatchCount = 0;
|
|
100
|
+
|
|
101
|
+
for (const { category, keywords, weight } of KEYWORD_PATTERNS) {
|
|
102
|
+
let matchCount = 0;
|
|
103
|
+
for (const kw of keywords) {
|
|
104
|
+
if (lower.includes(kw)) matchCount++;
|
|
105
|
+
}
|
|
106
|
+
if (matchCount > 0) {
|
|
107
|
+
const score = (matchCount / keywords.length) * weight;
|
|
108
|
+
if (score > bestScore) {
|
|
109
|
+
bestScore = score;
|
|
110
|
+
bestCategory = category;
|
|
111
|
+
bestMatchCount = matchCount;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!bestCategory) {
|
|
117
|
+
return { category: 'implement', confidence: 0.3 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 매칭 품질 기반 신뢰도 (0.5~0.95 범위) — matchCount 기준으로 정규화 (3개 매칭이면 최대)
|
|
121
|
+
const confidence = Math.min(0.95, 0.5 + (Math.min(bestMatchCount, 3) / 3) * 0.45);
|
|
122
|
+
return { category: bestCategory, confidence };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 전체 의도 분류 — routing 정보 포함
|
|
127
|
+
* Codex triage 경로: codex 설치 시 실행, confidence > 0.8이면 즉시 반환
|
|
128
|
+
* quickClassify가 고신뢰(>0.8)이면 Codex 건너뜀
|
|
129
|
+
* 결과는 md5 해시 기반 Map에 5분 TTL로 캐싱
|
|
130
|
+
* @param {string} prompt
|
|
131
|
+
* @returns {{ category: string, confidence: number, reasoning: string, routing: { agent: string, mcp: string|null, effort: string|null } }}
|
|
132
|
+
*/
|
|
133
|
+
export function classifyIntent(prompt) {
|
|
134
|
+
const hash = _promptHash(String(prompt ?? ''));
|
|
135
|
+
|
|
136
|
+
// 캐시 확인
|
|
137
|
+
const cached = _getCached(hash);
|
|
138
|
+
if (cached) {
|
|
139
|
+
const routing = INTENT_CATEGORIES[cached.category] || INTENT_CATEGORIES.implement;
|
|
140
|
+
return {
|
|
141
|
+
category: cached.category,
|
|
142
|
+
confidence: cached.confidence,
|
|
143
|
+
reasoning: `cache-hit: ${cached.category} (${cached.confidence.toFixed(2)})`,
|
|
144
|
+
routing: { agent: routing.agent, mcp: routing.mcp, effort: routing.effort },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// quickClassify 먼저
|
|
149
|
+
const quick = quickClassify(prompt);
|
|
150
|
+
|
|
151
|
+
let category = quick.category;
|
|
152
|
+
let confidence = quick.confidence;
|
|
153
|
+
let reasoning;
|
|
154
|
+
|
|
155
|
+
// quickClassify가 고신뢰(>0.8)이면 Codex 건너뜀
|
|
156
|
+
if (quick.confidence > 0.8) {
|
|
157
|
+
reasoning = `keyword-match: ${category} (${confidence.toFixed(2)})`;
|
|
158
|
+
} else if (_isCodexAvailable()) {
|
|
159
|
+
// Codex triage
|
|
160
|
+
const codexResult = _tryCodexClassify(String(prompt ?? ''));
|
|
161
|
+
if (codexResult && codexResult.confidence > 0.8) {
|
|
162
|
+
category = codexResult.category;
|
|
163
|
+
confidence = codexResult.confidence;
|
|
164
|
+
reasoning = `codex-triage: ${category} (${confidence.toFixed(2)})`;
|
|
165
|
+
} else {
|
|
166
|
+
reasoning = `keyword-match(codex-fallback): ${category} (${confidence.toFixed(2)})`;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
reasoning = `keyword-match: ${category} (${confidence.toFixed(2)})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 캐시 저장
|
|
173
|
+
_intentCache.set(hash, { category, confidence, ts: Date.now() });
|
|
174
|
+
|
|
175
|
+
const routing = INTENT_CATEGORIES[category] || INTENT_CATEGORIES.implement;
|
|
176
|
+
return {
|
|
177
|
+
category,
|
|
178
|
+
confidence,
|
|
179
|
+
reasoning,
|
|
180
|
+
routing: { agent: routing.agent, mcp: routing.mcp, effort: routing.effort },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 분류 히스토리 기반 학습 (reflexion 연동 가능)
|
|
186
|
+
* @param {string} prompt
|
|
187
|
+
* @param {string} actualCategory - 실제 카테고리
|
|
188
|
+
*/
|
|
189
|
+
export function refineClassification(prompt, actualCategory) {
|
|
190
|
+
// reflexion 연동 시 store에 오분류 기록 저장 예정
|
|
191
|
+
void prompt;
|
|
192
|
+
void actualCategory;
|
|
193
|
+
}
|