triflux 3.2.0-dev.1 → 3.2.0-dev.10
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.ko.md +26 -18
- package/README.md +26 -18
- package/bin/triflux.mjs +1614 -1084
- package/hooks/hooks.json +12 -0
- package/hooks/keyword-rules.json +354 -0
- package/hub/bridge.mjs +371 -193
- package/hub/hitl.mjs +45 -31
- package/hub/pipe.mjs +457 -0
- package/hub/router.mjs +422 -161
- package/hub/server.mjs +429 -344
- package/hub/store.mjs +388 -314
- package/hub/team/cli-team-common.mjs +348 -0
- package/hub/team/cli-team-control.mjs +393 -0
- package/hub/team/cli-team-start.mjs +516 -0
- package/hub/team/cli-team-status.mjs +269 -0
- package/hub/team/cli.mjs +99 -368
- package/hub/team/dashboard.mjs +165 -64
- package/hub/team/native-supervisor.mjs +300 -0
- package/hub/team/native.mjs +62 -0
- package/hub/team/nativeProxy.mjs +534 -0
- package/hub/team/orchestrator.mjs +99 -35
- package/hub/team/pane.mjs +138 -101
- package/hub/team/psmux.mjs +297 -0
- package/hub/team/session.mjs +608 -186
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +299 -0
- package/hub/tools.mjs +140 -53
- package/hub/workers/claude-worker.mjs +446 -0
- package/hub/workers/codex-mcp.mjs +414 -0
- package/hub/workers/factory.mjs +18 -0
- package/hub/workers/gemini-worker.mjs +349 -0
- package/hub/workers/interface.mjs +41 -0
- package/hud/hud-qos-status.mjs +1789 -1732
- package/package.json +6 -2
- package/scripts/__tests__/keyword-detector.test.mjs +234 -0
- package/scripts/hub-ensure.mjs +83 -0
- package/scripts/keyword-detector.mjs +272 -0
- package/scripts/keyword-rules-expander.mjs +521 -0
- package/scripts/lib/keyword-rules.mjs +168 -0
- package/scripts/psmux-steering-prototype.sh +368 -0
- package/scripts/run.cjs +62 -0
- package/scripts/setup.mjs +189 -7
- package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
- package/scripts/tfx-route-worker.mjs +161 -0
- package/scripts/tfx-route.sh +943 -508
- package/skills/tfx-auto/SKILL.md +90 -564
- package/skills/tfx-auto-codex/SKILL.md +77 -0
- package/skills/tfx-codex/SKILL.md +1 -4
- package/skills/tfx-doctor/SKILL.md +1 -0
- package/skills/tfx-gemini/SKILL.md +1 -4
- package/skills/tfx-multi/SKILL.md +296 -0
- package/skills/tfx-setup/SKILL.md +1 -4
- package/skills/tfx-team/SKILL.md +0 -172
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "3.2.0-dev.
|
|
3
|
+
"version": "3.2.0-dev.10",
|
|
4
4
|
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"setup": "node scripts/setup.mjs",
|
|
29
|
-
"postinstall": "node scripts/setup.mjs"
|
|
29
|
+
"postinstall": "node scripts/setup.mjs",
|
|
30
|
+
"test": "node --test tests/**/*.test.mjs",
|
|
31
|
+
"test:unit": "node --test tests/unit/**/*.test.mjs",
|
|
32
|
+
"test:integration": "node --test tests/integration/**/*.test.mjs",
|
|
33
|
+
"test:route-smoke": "node --test scripts/test-tfx-route-no-claude-native.mjs"
|
|
30
34
|
},
|
|
31
35
|
"engines": {
|
|
32
36
|
"node": ">=18.0.0"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { compileRules, loadRules, matchRules, resolveConflicts } from "../lib/keyword-rules.mjs";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const projectRoot = resolve(__dirname, "..", "..");
|
|
12
|
+
const rulesPath = join(projectRoot, "hooks", "keyword-rules.json");
|
|
13
|
+
const detectorScriptPath = join(projectRoot, "scripts", "keyword-detector.mjs");
|
|
14
|
+
|
|
15
|
+
// keyword-detector는 import 시 main()이 실행되므로, 테스트 로딩 단계에서만 안전하게 비활성화한다.
|
|
16
|
+
const previousDisable = process.env.TRIFLUX_DISABLE_MAGICWORDS;
|
|
17
|
+
const previousLog = console.log;
|
|
18
|
+
process.env.TRIFLUX_DISABLE_MAGICWORDS = "1";
|
|
19
|
+
console.log = () => {};
|
|
20
|
+
const detectorModule = await import("../keyword-detector.mjs");
|
|
21
|
+
console.log = previousLog;
|
|
22
|
+
if (previousDisable === undefined) {
|
|
23
|
+
delete process.env.TRIFLUX_DISABLE_MAGICWORDS;
|
|
24
|
+
} else {
|
|
25
|
+
process.env.TRIFLUX_DISABLE_MAGICWORDS = previousDisable;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { extractPrompt, sanitizeForKeywordDetection } = detectorModule;
|
|
29
|
+
|
|
30
|
+
function loadCompiledRules() {
|
|
31
|
+
const rules = loadRules(rulesPath);
|
|
32
|
+
assert.equal(rules.length, 19);
|
|
33
|
+
return compileRules(rules);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runDetector(prompt) {
|
|
37
|
+
const payload = { prompt, cwd: projectRoot };
|
|
38
|
+
const result = spawnSync(process.execPath, [detectorScriptPath], {
|
|
39
|
+
input: JSON.stringify(payload),
|
|
40
|
+
encoding: "utf8"
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(result.status, 0, result.stderr);
|
|
44
|
+
assert.ok(result.stdout.trim(), "keyword-detector 출력이 비어 있습니다.");
|
|
45
|
+
return JSON.parse(result.stdout.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("extractPrompt: prompt/message.content/parts[].text 우선순위", () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
extractPrompt({
|
|
51
|
+
prompt: "from prompt",
|
|
52
|
+
message: { content: "from message" },
|
|
53
|
+
parts: [{ text: "from parts" }]
|
|
54
|
+
}),
|
|
55
|
+
"from prompt"
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.equal(
|
|
59
|
+
extractPrompt({
|
|
60
|
+
prompt: " ",
|
|
61
|
+
message: { content: "from message" },
|
|
62
|
+
parts: [{ text: "from parts" }]
|
|
63
|
+
}),
|
|
64
|
+
"from message"
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
assert.equal(
|
|
68
|
+
extractPrompt({
|
|
69
|
+
message: { content: [{ text: "from message-part" }] },
|
|
70
|
+
parts: [{ text: "from parts" }]
|
|
71
|
+
}),
|
|
72
|
+
"from message-part"
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.equal(extractPrompt({ parts: [{ text: "from parts" }] }), "from parts");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제거", () => {
|
|
79
|
+
const input = [
|
|
80
|
+
"정상 문장",
|
|
81
|
+
"```sh",
|
|
82
|
+
"tfx multi",
|
|
83
|
+
"```",
|
|
84
|
+
"https://example.com/path?q=1",
|
|
85
|
+
"C:\\Users\\SSAFY\\Desktop\\Projects\\tools\\triflux",
|
|
86
|
+
"./hooks/keyword-rules.json",
|
|
87
|
+
"<tag>jira 이슈 생성</tag>"
|
|
88
|
+
].join("\n");
|
|
89
|
+
|
|
90
|
+
const sanitized = sanitizeForKeywordDetection(input);
|
|
91
|
+
|
|
92
|
+
assert.ok(sanitized.includes("정상 문장"));
|
|
93
|
+
assert.ok(!sanitized.includes("tfx multi"));
|
|
94
|
+
assert.ok(!sanitized.includes("https://"));
|
|
95
|
+
assert.ok(!sanitized.includes("C:\\Users\\"));
|
|
96
|
+
assert.ok(!sanitized.includes("./hooks/keyword-rules.json"));
|
|
97
|
+
assert.ok(!sanitized.includes("<tag>"));
|
|
98
|
+
assert.ok(!sanitized.includes("jira 이슈 생성"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("loadRules: 유효한 JSON 로드", () => {
|
|
102
|
+
const rules = loadRules(rulesPath);
|
|
103
|
+
assert.equal(rules.length, 19);
|
|
104
|
+
assert.equal(rules.filter((rule) => rule.skill).length, 9);
|
|
105
|
+
assert.equal(rules.filter((rule) => rule.mcp_route).length, 10);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("loadRules: 잘못된 파일 처리", () => {
|
|
109
|
+
const tempDir = mkdtempSync(join(tmpdir(), "triflux-rules-"));
|
|
110
|
+
const invalidPath = join(tempDir, "invalid.json");
|
|
111
|
+
writeFileSync(invalidPath, "{ invalid json", "utf8");
|
|
112
|
+
|
|
113
|
+
const malformed = loadRules(invalidPath);
|
|
114
|
+
const missing = loadRules(join(tempDir, "missing.json"));
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(malformed, []);
|
|
117
|
+
assert.deepEqual(missing, []);
|
|
118
|
+
|
|
119
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("compileRules: 정규식 컴파일 성공", () => {
|
|
123
|
+
const rules = loadRules(rulesPath);
|
|
124
|
+
const compiled = compileRules(rules);
|
|
125
|
+
assert.equal(compiled.length, 19);
|
|
126
|
+
for (const rule of compiled) {
|
|
127
|
+
assert.ok(Array.isArray(rule.compiledPatterns));
|
|
128
|
+
assert.ok(rule.compiledPatterns.length > 0);
|
|
129
|
+
for (const pattern of rule.compiledPatterns) {
|
|
130
|
+
assert.ok(pattern instanceof RegExp);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("compileRules: 정규식 컴파일 실패", () => {
|
|
136
|
+
const compiled = compileRules([
|
|
137
|
+
{
|
|
138
|
+
id: "bad-pattern",
|
|
139
|
+
priority: 1,
|
|
140
|
+
patterns: [{ source: "[", flags: "" }],
|
|
141
|
+
skill: "tfx-multi",
|
|
142
|
+
supersedes: [],
|
|
143
|
+
exclusive: false,
|
|
144
|
+
state: null,
|
|
145
|
+
mcp_route: null
|
|
146
|
+
}
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(compiled, []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("matchRules: tfx 키워드 매칭", () => {
|
|
153
|
+
const compiledRules = loadCompiledRules();
|
|
154
|
+
const cases = [
|
|
155
|
+
{ text: "tfx multi 세션 시작", expectedId: "tfx-multi" },
|
|
156
|
+
{ text: "tfx auto 돌려줘", expectedId: "tfx-auto" },
|
|
157
|
+
{ text: "tfx codex 로 실행", expectedId: "tfx-codex" },
|
|
158
|
+
{ text: "tfx gemini 로 실행", expectedId: "tfx-gemini" },
|
|
159
|
+
{ text: "canceltfx", expectedId: "tfx-cancel" }
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const { text, expectedId } of cases) {
|
|
163
|
+
const clean = sanitizeForKeywordDetection(text);
|
|
164
|
+
const matches = matchRules(compiledRules, clean);
|
|
165
|
+
assert.ok(matches.some((match) => match.id === expectedId), `${text} => ${expectedId} 미매칭`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("matchRules: MCP 라우팅 매칭", () => {
|
|
170
|
+
const compiledRules = loadCompiledRules();
|
|
171
|
+
const cases = [
|
|
172
|
+
{ text: "노션 페이지 조회해줘", expectedId: "notion-route", expectedRoute: "gemini" },
|
|
173
|
+
{ text: "jira 이슈 생성", expectedId: "jira-route", expectedRoute: "codex" },
|
|
174
|
+
{ text: "크롬 열고 로그인", expectedId: "chrome-route", expectedRoute: "gemini" },
|
|
175
|
+
{ text: "이메일 보내줘", expectedId: "mail-route", expectedRoute: "gemini" },
|
|
176
|
+
{ text: "캘린더 일정 생성", expectedId: "calendar-route", expectedRoute: "gemini" },
|
|
177
|
+
{ text: "playwright 테스트 작성", expectedId: "playwright-route", expectedRoute: "gemini" },
|
|
178
|
+
{ text: "canva 디자인 생성", expectedId: "canva-route", expectedRoute: "gemini" }
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
for (const { text, expectedId, expectedRoute } of cases) {
|
|
182
|
+
const matches = matchRules(compiledRules, sanitizeForKeywordDetection(text));
|
|
183
|
+
const matched = matches.find((match) => match.id === expectedId);
|
|
184
|
+
assert.ok(matched, `${text} => ${expectedId} 미매칭`);
|
|
185
|
+
assert.equal(matched.mcp_route, expectedRoute);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("matchRules: 일반 대화는 매칭 없음", () => {
|
|
190
|
+
const compiledRules = loadCompiledRules();
|
|
191
|
+
const matches = matchRules(compiledRules, sanitizeForKeywordDetection("오늘 점심 메뉴 추천해줘"));
|
|
192
|
+
assert.deepEqual(matches, []);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("resolveConflicts: priority 정렬 및 supersedes 처리", () => {
|
|
196
|
+
const resolved = resolveConflicts([
|
|
197
|
+
{ id: "rule-c", priority: 3, supersedes: [], exclusive: false },
|
|
198
|
+
{ id: "rule-b", priority: 2, supersedes: ["rule-c"], exclusive: false },
|
|
199
|
+
{ id: "rule-a", priority: 1, supersedes: [], exclusive: false },
|
|
200
|
+
{ id: "rule-a", priority: 1, supersedes: [], exclusive: false }
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
resolved.map((rule) => rule.id),
|
|
205
|
+
["rule-a", "rule-b"]
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("resolveConflicts: exclusive 처리", () => {
|
|
210
|
+
const resolved = resolveConflicts([
|
|
211
|
+
{ id: "normal", priority: 1, supersedes: [], exclusive: false },
|
|
212
|
+
{ id: "exclusive", priority: 0, supersedes: [], exclusive: true },
|
|
213
|
+
{ id: "later", priority: 2, supersedes: [], exclusive: false }
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(resolved.map((rule) => rule.id), ["exclusive"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("코드블록 내 키워드: sanitize 후 매칭 안 됨", () => {
|
|
220
|
+
const compiledRules = loadCompiledRules();
|
|
221
|
+
const input = ["```txt", "tfx multi", "jira 이슈 생성", "```"].join("\n");
|
|
222
|
+
const clean = sanitizeForKeywordDetection(input);
|
|
223
|
+
const matches = matchRules(compiledRules, clean);
|
|
224
|
+
assert.deepEqual(matches, []);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("OMC 키워드와 triflux 키워드 비간섭 + TRIFLUX 네임스페이스", () => {
|
|
228
|
+
const omcLike = runDetector("my tfx multi 세션 보여줘");
|
|
229
|
+
assert.equal(omcLike.suppressOutput, true);
|
|
230
|
+
|
|
231
|
+
const triflux = runDetector("tfx multi 세션 시작");
|
|
232
|
+
const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
|
|
233
|
+
assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-multi\]/);
|
|
234
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart 훅에서 호출되는 Hub 보장 스크립트.
|
|
3
|
+
// - /status 기반 헬스체크
|
|
4
|
+
// - 비정상 시 Hub를 detached로 기동
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { join, dirname } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
13
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
15
|
+
|
|
16
|
+
function formatHostForUrl(host) {
|
|
17
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildHubBaseUrl(host, port) {
|
|
21
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveHubTarget() {
|
|
25
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
26
|
+
const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
27
|
+
const target = {
|
|
28
|
+
host: "127.0.0.1",
|
|
29
|
+
port: envPort || 27888,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
33
|
+
try {
|
|
34
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
35
|
+
if (!envPort) {
|
|
36
|
+
const pidPort = Number(info?.port);
|
|
37
|
+
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
38
|
+
}
|
|
39
|
+
if (typeof info?.host === "string") {
|
|
40
|
+
const host = info.host.trim();
|
|
41
|
+
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore parse errors and use env/default
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function isHubHealthy(host, port) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
54
|
+
signal: AbortSignal.timeout(1000),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) return false;
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
return data?.hub?.state === "healthy";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startHubDetached(port) {
|
|
65
|
+
const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
|
|
66
|
+
if (!existsSync(serverPath)) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
70
|
+
env: { ...process.env, TFX_HUB_PORT: String(port) },
|
|
71
|
+
detached: true,
|
|
72
|
+
stdio: "ignore",
|
|
73
|
+
});
|
|
74
|
+
child.unref();
|
|
75
|
+
} catch {
|
|
76
|
+
// best effort
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { host, port } = resolveHubTarget();
|
|
81
|
+
if (!(await isHubHealthy(host, port))) {
|
|
82
|
+
startHubDetached(port);
|
|
83
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { compileRules, loadRules, matchRules, resolveConflicts } from "./lib/keyword-rules.mjs";
|
|
7
|
+
|
|
8
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PROJECT_ROOT = dirname(SCRIPT_DIR);
|
|
10
|
+
const DEFAULT_RULES_PATH = join(PROJECT_ROOT, "hooks", "keyword-rules.json");
|
|
11
|
+
|
|
12
|
+
function readHookInput() {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(0, "utf8");
|
|
15
|
+
} catch {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseInput(rawInput) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(rawInput);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// prompt > message.content > parts[].text 우선순위로 추출
|
|
29
|
+
export function extractPrompt(payload) {
|
|
30
|
+
if (!payload || typeof payload !== "object") return "";
|
|
31
|
+
|
|
32
|
+
if (typeof payload.prompt === "string" && payload.prompt.trim()) {
|
|
33
|
+
return payload.prompt;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof payload.message?.content === "string" && payload.message.content.trim()) {
|
|
37
|
+
return payload.message.content;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(payload.message?.content)) {
|
|
41
|
+
const messageText = payload.message.content
|
|
42
|
+
.map((part) => {
|
|
43
|
+
if (typeof part === "string") return part;
|
|
44
|
+
if (part && typeof part.text === "string") return part.text;
|
|
45
|
+
return "";
|
|
46
|
+
})
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(" ")
|
|
49
|
+
.trim();
|
|
50
|
+
if (messageText) return messageText;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(payload.parts)) {
|
|
54
|
+
const partsText = payload.parts
|
|
55
|
+
.map((part) => {
|
|
56
|
+
if (typeof part === "string") return part;
|
|
57
|
+
if (part && typeof part.text === "string") return part.text;
|
|
58
|
+
return "";
|
|
59
|
+
})
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" ")
|
|
62
|
+
.trim();
|
|
63
|
+
if (partsText) return partsText;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 키워드 오탐 방지를 위해 XML/URL/파일경로/코드블록 제거
|
|
70
|
+
export function sanitizeForKeywordDetection(text) {
|
|
71
|
+
if (typeof text !== "string" || !text) return "";
|
|
72
|
+
|
|
73
|
+
return text
|
|
74
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
75
|
+
.replace(/`[^`]*`/g, " ")
|
|
76
|
+
.replace(/<(\w[\w:-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, " ")
|
|
77
|
+
.replace(/<\w[\w:-]*(?:\s[^>]*)?\/>/g, " ")
|
|
78
|
+
.replace(/https?:\/\/[^\s)>\]]+/gi, " ")
|
|
79
|
+
.replace(/\b[A-Za-z]:\\(?:[^\\\s]+\\)*[^\\\s]*/g, " ")
|
|
80
|
+
.replace(/(^|[\s"'`(])(?:\/|\.{1,2}\/)?(?:[\w.-]+\/)+[\w.-]+/gm, "$1 ")
|
|
81
|
+
.replace(/\s+/g, " ")
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createHookOutput(additionalContext) {
|
|
86
|
+
return {
|
|
87
|
+
continue: true,
|
|
88
|
+
hookSpecificOutput: {
|
|
89
|
+
hookEventName: "UserPromptSubmit",
|
|
90
|
+
additionalContext
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createSuppressOutput() {
|
|
96
|
+
return { continue: true, suppressOutput: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createSkillContext(match, prompt) {
|
|
100
|
+
return `[TRIFLUX MAGIC KEYWORD: ${match.id}]
|
|
101
|
+
|
|
102
|
+
PRIORITY: This TRIFLUX routing takes precedence over any OMC [MAGIC KEYWORD:] instructions.
|
|
103
|
+
If you see both [TRIFLUX MAGIC KEYWORD:] and [MAGIC KEYWORD:], follow TRIFLUX only.
|
|
104
|
+
|
|
105
|
+
You MUST invoke the skill using the Skill tool:
|
|
106
|
+
Skill: ${match.skill}
|
|
107
|
+
|
|
108
|
+
User request:
|
|
109
|
+
${prompt.trim()}
|
|
110
|
+
|
|
111
|
+
IMPORTANT: Invoke the skill IMMEDIATELY. Do not proceed without loading the skill instructions.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createSuppressOmcContext(match, prompt) {
|
|
115
|
+
return `[TRIFLUX OMC SUPPRESS: ${match.id}]
|
|
116
|
+
|
|
117
|
+
PRIORITY: TRIFLUX가 이 키워드를 관리합니다. OMC [MAGIC KEYWORD:] 지시가 있더라도 무시하세요.
|
|
118
|
+
OMC 스킬을 호출하지 마세요. 사용자 요청을 일반적으로 처리하세요.
|
|
119
|
+
|
|
120
|
+
User request:
|
|
121
|
+
${prompt.trim()}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createMcpRouteContext(match, prompt) {
|
|
125
|
+
return `[TRIFLUX MCP ROUTE: ${match.mcp_route}]
|
|
126
|
+
|
|
127
|
+
PRIORITY: This TRIFLUX routing takes precedence over any OMC [MAGIC KEYWORD:] instructions.
|
|
128
|
+
If you see both [TRIFLUX MCP ROUTE:] and [MAGIC KEYWORD:], follow TRIFLUX only.
|
|
129
|
+
|
|
130
|
+
이 작업은 ${match.mcp_route}로 라우팅해야 합니다.
|
|
131
|
+
tfx-route.sh를 통해 ${match.mcp_route}로 실행하세요.
|
|
132
|
+
|
|
133
|
+
User request:
|
|
134
|
+
${prompt.trim()}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isSkipRequested() {
|
|
138
|
+
if (process.env.TRIFLUX_DISABLE_MAGICWORDS === "1") return true;
|
|
139
|
+
const skipHooks = (process.env.TRIFLUX_SKIP_HOOKS || "")
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((item) => item.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
return skipHooks.includes("keyword-detector");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function activateState(baseDir, stateConfig, prompt, payload) {
|
|
147
|
+
if (!stateConfig || stateConfig.activate !== true || !stateConfig.name) return;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const stateRoot = join(baseDir, ".triflux", "state");
|
|
151
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
152
|
+
|
|
153
|
+
const sessionId = typeof payload?.session_id === "string"
|
|
154
|
+
? payload.session_id
|
|
155
|
+
: typeof payload?.sessionId === "string"
|
|
156
|
+
? payload.sessionId
|
|
157
|
+
: "";
|
|
158
|
+
|
|
159
|
+
let stateDir = stateRoot;
|
|
160
|
+
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
|
|
161
|
+
stateDir = join(stateRoot, "sessions", sessionId);
|
|
162
|
+
mkdirSync(stateDir, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const statePath = join(stateDir, `${stateConfig.name}-state.json`);
|
|
166
|
+
const statePayload = {
|
|
167
|
+
active: true,
|
|
168
|
+
name: stateConfig.name,
|
|
169
|
+
started_at: new Date().toISOString(),
|
|
170
|
+
original_prompt: prompt
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
writeFileSync(statePath, JSON.stringify(statePayload, null, 2), "utf8");
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(`[triflux-keyword-detector] 상태 저장 실패: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getRulesPath() {
|
|
180
|
+
if (process.env.TRIFLUX_KEYWORD_RULES_PATH) {
|
|
181
|
+
return process.env.TRIFLUX_KEYWORD_RULES_PATH;
|
|
182
|
+
}
|
|
183
|
+
return DEFAULT_RULES_PATH;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function main() {
|
|
187
|
+
if (isSkipRequested()) {
|
|
188
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const rawInput = readHookInput();
|
|
193
|
+
if (!rawInput.trim()) {
|
|
194
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const payload = parseInput(rawInput);
|
|
199
|
+
if (!payload) {
|
|
200
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const prompt = extractPrompt(payload);
|
|
205
|
+
if (!prompt) {
|
|
206
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const cleanText = sanitizeForKeywordDetection(prompt);
|
|
211
|
+
if (!cleanText) {
|
|
212
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rules = loadRules(getRulesPath());
|
|
217
|
+
if (rules.length === 0) {
|
|
218
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const compiledRules = compileRules(rules);
|
|
223
|
+
if (compiledRules.length === 0) {
|
|
224
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const matches = matchRules(compiledRules, cleanText);
|
|
229
|
+
if (matches.length === 0) {
|
|
230
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const resolvedMatches = resolveConflicts(matches);
|
|
235
|
+
if (resolvedMatches.length === 0) {
|
|
236
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const selected = resolvedMatches[0];
|
|
241
|
+
const baseDir = typeof payload.cwd === "string" && payload.cwd
|
|
242
|
+
? payload.cwd
|
|
243
|
+
: typeof payload.directory === "string" && payload.directory
|
|
244
|
+
? payload.directory
|
|
245
|
+
: process.cwd();
|
|
246
|
+
|
|
247
|
+
activateState(baseDir, selected.state, prompt, payload);
|
|
248
|
+
|
|
249
|
+
if (selected.action === "suppress_omc") {
|
|
250
|
+
console.log(JSON.stringify(createHookOutput(createSuppressOmcContext(selected, prompt))));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (selected.skill) {
|
|
255
|
+
console.log(JSON.stringify(createHookOutput(createSkillContext(selected, prompt))));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (selected.mcp_route) {
|
|
260
|
+
console.log(JSON.stringify(createHookOutput(createMcpRouteContext(selected, prompt))));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
main();
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.error(`[triflux-keyword-detector] 예외 발생: ${error.message}`);
|
|
271
|
+
console.log(JSON.stringify(createSuppressOutput()));
|
|
272
|
+
}
|