triflux 3.2.0-dev.3 → 3.2.0-dev.5

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.
@@ -0,0 +1,257 @@
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 createMcpRouteContext(match, prompt) {
115
+ return `[TRIFLUX MCP ROUTE: ${match.mcp_route}]
116
+
117
+ PRIORITY: This TRIFLUX routing takes precedence over any OMC [MAGIC KEYWORD:] instructions.
118
+ If you see both [TRIFLUX MCP ROUTE:] and [MAGIC KEYWORD:], follow TRIFLUX only.
119
+
120
+ 이 작업은 ${match.mcp_route}로 라우팅해야 합니다.
121
+ tfx-route.sh를 통해 ${match.mcp_route}로 실행하세요.
122
+
123
+ User request:
124
+ ${prompt.trim()}`;
125
+ }
126
+
127
+ function isSkipRequested() {
128
+ if (process.env.TRIFLUX_DISABLE_MAGICWORDS === "1") return true;
129
+ const skipHooks = (process.env.TRIFLUX_SKIP_HOOKS || "")
130
+ .split(",")
131
+ .map((item) => item.trim())
132
+ .filter(Boolean);
133
+ return skipHooks.includes("keyword-detector");
134
+ }
135
+
136
+ function activateState(baseDir, stateConfig, prompt, payload) {
137
+ if (!stateConfig || stateConfig.activate !== true || !stateConfig.name) return;
138
+
139
+ try {
140
+ const stateRoot = join(baseDir, ".triflux", "state");
141
+ mkdirSync(stateRoot, { recursive: true });
142
+
143
+ const sessionId = typeof payload?.session_id === "string"
144
+ ? payload.session_id
145
+ : typeof payload?.sessionId === "string"
146
+ ? payload.sessionId
147
+ : "";
148
+
149
+ let stateDir = stateRoot;
150
+ if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
151
+ stateDir = join(stateRoot, "sessions", sessionId);
152
+ mkdirSync(stateDir, { recursive: true });
153
+ }
154
+
155
+ const statePath = join(stateDir, `${stateConfig.name}-state.json`);
156
+ const statePayload = {
157
+ active: true,
158
+ name: stateConfig.name,
159
+ started_at: new Date().toISOString(),
160
+ original_prompt: prompt
161
+ };
162
+
163
+ writeFileSync(statePath, JSON.stringify(statePayload, null, 2), "utf8");
164
+ } catch (error) {
165
+ console.error(`[triflux-keyword-detector] 상태 저장 실패: ${error.message}`);
166
+ }
167
+ }
168
+
169
+ function getRulesPath() {
170
+ if (process.env.TRIFLUX_KEYWORD_RULES_PATH) {
171
+ return process.env.TRIFLUX_KEYWORD_RULES_PATH;
172
+ }
173
+ return DEFAULT_RULES_PATH;
174
+ }
175
+
176
+ function main() {
177
+ if (isSkipRequested()) {
178
+ console.log(JSON.stringify(createSuppressOutput()));
179
+ return;
180
+ }
181
+
182
+ const rawInput = readHookInput();
183
+ if (!rawInput.trim()) {
184
+ console.log(JSON.stringify(createSuppressOutput()));
185
+ return;
186
+ }
187
+
188
+ const payload = parseInput(rawInput);
189
+ if (!payload) {
190
+ console.log(JSON.stringify(createSuppressOutput()));
191
+ return;
192
+ }
193
+
194
+ const prompt = extractPrompt(payload);
195
+ if (!prompt) {
196
+ console.log(JSON.stringify(createSuppressOutput()));
197
+ return;
198
+ }
199
+
200
+ const cleanText = sanitizeForKeywordDetection(prompt);
201
+ if (!cleanText) {
202
+ console.log(JSON.stringify(createSuppressOutput()));
203
+ return;
204
+ }
205
+
206
+ const rules = loadRules(getRulesPath());
207
+ if (rules.length === 0) {
208
+ console.log(JSON.stringify(createSuppressOutput()));
209
+ return;
210
+ }
211
+
212
+ const compiledRules = compileRules(rules);
213
+ if (compiledRules.length === 0) {
214
+ console.log(JSON.stringify(createSuppressOutput()));
215
+ return;
216
+ }
217
+
218
+ const matches = matchRules(compiledRules, cleanText);
219
+ if (matches.length === 0) {
220
+ console.log(JSON.stringify(createSuppressOutput()));
221
+ return;
222
+ }
223
+
224
+ const resolvedMatches = resolveConflicts(matches);
225
+ if (resolvedMatches.length === 0) {
226
+ console.log(JSON.stringify(createSuppressOutput()));
227
+ return;
228
+ }
229
+
230
+ const selected = resolvedMatches[0];
231
+ const baseDir = typeof payload.cwd === "string" && payload.cwd
232
+ ? payload.cwd
233
+ : typeof payload.directory === "string" && payload.directory
234
+ ? payload.directory
235
+ : process.cwd();
236
+
237
+ activateState(baseDir, selected.state, prompt, payload);
238
+
239
+ if (selected.skill) {
240
+ console.log(JSON.stringify(createHookOutput(createSkillContext(selected, prompt))));
241
+ return;
242
+ }
243
+
244
+ if (selected.mcp_route) {
245
+ console.log(JSON.stringify(createHookOutput(createMcpRouteContext(selected, prompt))));
246
+ return;
247
+ }
248
+
249
+ console.log(JSON.stringify(createSuppressOutput()));
250
+ }
251
+
252
+ try {
253
+ main();
254
+ } catch (error) {
255
+ console.error(`[triflux-keyword-detector] 예외 발생: ${error.message}`);
256
+ console.log(JSON.stringify(createSuppressOutput()));
257
+ }