cipher-security 0.2.0__py3-none-any.whl

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.
gateway/dashboard.py ADDED
@@ -0,0 +1,347 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER Dashboard — cyberpunk terminal UI built with Textual.
4
+
5
+ Usage:
6
+ cipher dashboard
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from textual import on, work
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical, VerticalScroll
14
+ from textual.css.query import NoMatches
15
+ from textual.widgets import (
16
+ Footer,
17
+ Header,
18
+ Input,
19
+ Label,
20
+ Markdown,
21
+ Static,
22
+ )
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Cyberpunk CSS
27
+ # ---------------------------------------------------------------------------
28
+
29
+ CIPHER_CSS = """
30
+ Screen {
31
+ background: #0a0a0a;
32
+ }
33
+
34
+ Header {
35
+ background: #0d1117;
36
+ color: #00ff41;
37
+ text-style: bold;
38
+ }
39
+
40
+ Footer {
41
+ background: #0d1117;
42
+ color: #5a6480;
43
+ }
44
+
45
+ #status-panel {
46
+ height: 7;
47
+ background: #0d1117;
48
+ border: solid #1a1f2e;
49
+ padding: 0 1;
50
+ }
51
+
52
+ .status-key {
53
+ color: #5a6480;
54
+ width: 14;
55
+ }
56
+
57
+ .status-val {
58
+ color: #c9d1d9;
59
+ }
60
+
61
+ .status-val-good {
62
+ color: #00e5cc;
63
+ }
64
+
65
+ #mode-bar {
66
+ height: 3;
67
+ background: #0d1117;
68
+ border: solid #1a1f2e;
69
+ padding: 0 1;
70
+ }
71
+
72
+ .mode-label {
73
+ width: 12;
74
+ content-align: center middle;
75
+ text-style: bold;
76
+ margin: 0 1;
77
+ }
78
+
79
+ .mode-red { background: #2d0a0a; color: #ff2d2d; }
80
+ .mode-blue { background: #0a1a2d; color: #2d9bff; }
81
+ .mode-purple { background: #1a0a2d; color: #9b59b6; }
82
+ .mode-privacy { background: #0a1a0d; color: #27ae60; }
83
+ .mode-recon { background: #1a1a0a; color: #f39c12; }
84
+ .mode-incident { background: #2d0a0a; color: #e74c3c; }
85
+ .mode-architect { background: #0a1a2d; color: #3498db; }
86
+
87
+ #response-area {
88
+ background: #0d1117;
89
+ border: solid #1a1f2e;
90
+ padding: 1 2;
91
+ overflow-y: auto;
92
+ }
93
+
94
+ #response-area Markdown {
95
+ background: #0d1117;
96
+ color: #c9d1d9;
97
+ }
98
+
99
+ #query-input {
100
+ dock: bottom;
101
+ background: #161b22;
102
+ color: #00ff41;
103
+ border: tall #00ff41;
104
+ padding: 0 1;
105
+ }
106
+
107
+ #query-input:focus {
108
+ border: tall #00ff41;
109
+ }
110
+
111
+ #welcome {
112
+ color: #5a6480;
113
+ text-align: center;
114
+ padding: 2;
115
+ }
116
+
117
+ .spinner-label {
118
+ color: #00ff41;
119
+ text-style: italic;
120
+ }
121
+ """
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Dashboard App
126
+ # ---------------------------------------------------------------------------
127
+
128
+ class CipherDashboard(App):
129
+ """CIPHER cyberpunk terminal dashboard."""
130
+
131
+ TITLE = "CIPHER"
132
+ SUB_TITLE = "Security Engineering Assistant"
133
+ CSS = CIPHER_CSS
134
+
135
+ BINDINGS = [
136
+ Binding("ctrl+q", "quit", "Quit"),
137
+ Binding("ctrl+l", "clear", "Clear"),
138
+ Binding("escape", "focus_input", "Focus Input", show=False),
139
+ ]
140
+
141
+ def __init__(self) -> None:
142
+ super().__init__()
143
+ self._gateway = None
144
+ self._history: list[dict] = []
145
+
146
+ def compose(self) -> ComposeResult:
147
+ yield Header()
148
+
149
+ # Status panel
150
+ with Horizontal(id="status-panel"):
151
+ with Vertical():
152
+ yield Label("[b]Backend[/]", classes="status-key")
153
+ yield Label("[b]Model[/]", classes="status-key")
154
+ yield Label("[b]RAG[/]", classes="status-key")
155
+ with Vertical():
156
+ yield Label("loading...", id="val-backend", classes="status-val")
157
+ yield Label("loading...", id="val-model", classes="status-val")
158
+ yield Label("loading...", id="val-rag", classes="status-val")
159
+ with Vertical():
160
+ yield Label("[b]Knowledge[/]", classes="status-key")
161
+ yield Label("[b]Skills[/]", classes="status-key")
162
+ yield Label("[b]Modes[/]", classes="status-key")
163
+ with Vertical():
164
+ yield Label("loading...", id="val-knowledge", classes="status-val")
165
+ yield Label("loading...", id="val-skills", classes="status-val")
166
+ yield Label("7", id="val-modes", classes="status-val-good")
167
+
168
+ # Mode bar
169
+ with Horizontal(id="mode-bar"):
170
+ yield Label("RED", classes="mode-label mode-red")
171
+ yield Label("BLUE", classes="mode-label mode-blue")
172
+ yield Label("PURPLE", classes="mode-label mode-purple")
173
+ yield Label("PRIVACY", classes="mode-label mode-privacy")
174
+ yield Label("RECON", classes="mode-label mode-recon")
175
+ yield Label("INCIDENT", classes="mode-label mode-incident")
176
+ yield Label("ARCHITECT", classes="mode-label mode-architect")
177
+
178
+ # Response area
179
+ with VerticalScroll(id="response-area"):
180
+ yield Static(
181
+ "[dim]Type a security query below and press Enter.\n\n"
182
+ "Examples:\n"
183
+ ' "how do I detect Kerberoasting"\n'
184
+ ' "write a Sigma rule for lateral movement via PsExec"\n'
185
+ ' "GDPR breach notification requirements"\n'
186
+ ' "[MODE: RED] exploit AS-REP roasting"\n\n'
187
+ "Ctrl+L to clear, Ctrl+Q to quit.[/]",
188
+ id="welcome",
189
+ )
190
+
191
+ # Query input
192
+ yield Input(placeholder="Enter security query...", id="query-input")
193
+
194
+ yield Footer()
195
+
196
+ def on_mount(self) -> None:
197
+ """Load status on startup."""
198
+ self._load_status()
199
+ self.query_one("#query-input", Input).focus()
200
+
201
+ @work(thread=True)
202
+ def _load_status(self) -> None:
203
+ """Load system status in background thread."""
204
+ try:
205
+ from gateway.config import load_config
206
+ cfg = load_config()
207
+ self.call_from_thread(self._update_label, "val-backend", cfg.backend, good=True)
208
+ model = cfg.ollama_model if cfg.backend == "ollama" else cfg.claude_model
209
+ self.call_from_thread(self._update_label, "val-model", model, good=True)
210
+ except Exception:
211
+ self.call_from_thread(self._update_label, "val-backend", "not configured", good=False)
212
+ self.call_from_thread(self._update_label, "val-model", "—", good=False)
213
+
214
+ try:
215
+ from gateway.retriever import Retriever
216
+ r = Retriever()
217
+ self.call_from_thread(self._update_label, "val-rag", f"{r.count:,} chunks", good=True)
218
+ except Exception:
219
+ self.call_from_thread(self._update_label, "val-rag", "unavailable", good=False)
220
+
221
+ try:
222
+ from gateway.prompt import REPO_ROOT
223
+ kb = REPO_ROOT / "knowledge"
224
+ count = len(list(kb.glob("*.md"))) if kb.is_dir() else 0
225
+ self.call_from_thread(self._update_label, "val-knowledge", f"{count} docs", good=True)
226
+ skills = REPO_ROOT / "skills"
227
+ scount = len(list(skills.rglob("SKILL.md"))) if skills.is_dir() else 0
228
+ self.call_from_thread(self._update_label, "val-skills", str(scount), good=True)
229
+ except Exception:
230
+ pass
231
+
232
+ def _update_label(self, label_id: str, text: str, good: bool = True) -> None:
233
+ try:
234
+ label = self.query_one(f"#{label_id}", Label)
235
+ cls = "status-val-good" if good else "status-val"
236
+ label.update(text)
237
+ label.set_classes(cls)
238
+ except NoMatches:
239
+ pass
240
+
241
+ @on(Input.Submitted, "#query-input")
242
+ def on_query_submit(self, event: Input.Submitted) -> None:
243
+ """Handle query submission."""
244
+ query = event.value.strip()
245
+ if not query:
246
+ return
247
+
248
+ event.input.value = ""
249
+ self._send_query(query)
250
+
251
+ @work(thread=True)
252
+ def _send_query(self, query: str) -> None:
253
+ """Send query to gateway in background thread."""
254
+ # Show user query
255
+ self.call_from_thread(self._append_message, "user", query)
256
+ self.call_from_thread(self._append_message, "system", "Thinking...")
257
+
258
+ try:
259
+ if self._gateway is None:
260
+ from gateway.gateway import Gateway
261
+ self._gateway = Gateway()
262
+
263
+ result = self._gateway.send(query, history=self._history if self._history else None)
264
+
265
+ # Update history
266
+ self._history.append({"role": "user", "content": query})
267
+ self._history.append({"role": "assistant", "content": result})
268
+
269
+ # Remove "Thinking..." and show response
270
+ self.call_from_thread(self._remove_thinking)
271
+ self.call_from_thread(self._append_response, result)
272
+
273
+ except Exception as exc:
274
+ self.call_from_thread(self._remove_thinking)
275
+ self.call_from_thread(self._append_message, "error", f"Error: {exc}")
276
+
277
+ def _append_message(self, role: str, text: str) -> None:
278
+ container = self.query_one("#response-area", VerticalScroll)
279
+ # Remove welcome on first message
280
+ try:
281
+ welcome = self.query_one("#welcome", Static)
282
+ welcome.remove()
283
+ except NoMatches:
284
+ pass
285
+
286
+ if role == "user":
287
+ container.mount(Static(f"[bold #00ff41]> {text}[/]"))
288
+ elif role == "system":
289
+ container.mount(Static(f"[italic #5a6480]{text}[/]", classes="spinner-label"))
290
+ elif role == "error":
291
+ container.mount(Static(f"[bold #ff4d4d]{text}[/]"))
292
+
293
+ container.scroll_end()
294
+
295
+ def _remove_thinking(self) -> None:
296
+ try:
297
+ for widget in self.query(".spinner-label"):
298
+ widget.remove()
299
+ except NoMatches:
300
+ pass
301
+
302
+ def _append_response(self, text: str) -> None:
303
+ container = self.query_one("#response-area", VerticalScroll)
304
+
305
+ # Colorize mode header
306
+ lines = text.split("\n", 1)
307
+ header = lines[0]
308
+ body = lines[1] if len(lines) > 1 else ""
309
+
310
+ mode_colors = {
311
+ "RED": "#ff2d2d", "BLUE": "#2d9bff", "PURPLE": "#9b59b6",
312
+ "PRIVACY": "#27ae60", "RECON": "#f39c12", "INCIDENT": "#e74c3c",
313
+ "ARCHITECT": "#3498db",
314
+ }
315
+
316
+ if header.startswith("[MODE:"):
317
+ mode = header.split(":")[1].strip().rstrip("]").strip()
318
+ color = mode_colors.get(mode, "#00ff41")
319
+ container.mount(Static(f"[bold {color}]{header}[/]"))
320
+ else:
321
+ body = text
322
+
323
+ if body.strip():
324
+ container.mount(Markdown(body))
325
+
326
+ container.mount(Static("[dim]─────────────────────────────────────────[/]"))
327
+ container.scroll_end()
328
+
329
+ def action_clear(self) -> None:
330
+ """Clear response area and history."""
331
+ container = self.query_one("#response-area", VerticalScroll)
332
+ container.remove_children()
333
+ container.mount(Static("[dim]Cleared. Type a new query.[/]"))
334
+ self._history.clear()
335
+
336
+ def action_focus_input(self) -> None:
337
+ self.query_one("#query-input", Input).focus()
338
+
339
+
340
+ def run_dashboard() -> None:
341
+ """Launch the CIPHER dashboard."""
342
+ app = CipherDashboard()
343
+ app.run()
344
+
345
+
346
+ if __name__ == "__main__":
347
+ run_dashboard()
gateway/gateway.py ADDED
@@ -0,0 +1,171 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER Gateway orchestrator.
4
+
5
+ Wires config, client, prompt, mode, and retriever modules into a single send() interface.
6
+
7
+ Usage:
8
+ from gateway import Gateway
9
+
10
+ gw = Gateway()
11
+ response = gw.send("how do I exploit a SQLi vulnerability")
12
+ print(response) # [MODE: RED] ...
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ import anthropic
19
+
20
+ from gateway.client import make_client
21
+ from gateway.config import load_config
22
+ from gateway.mode import detect_mode, parse_keyword_table, strip_mode_prefix
23
+ from gateway.prompt import assemble_system_prompt
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _load_retriever():
29
+ """Lazy-load the RAG retriever. Returns None if unavailable."""
30
+ try:
31
+ from gateway.retriever import Retriever
32
+ r = Retriever()
33
+ if r.count > 0:
34
+ return r
35
+ logger.info("Gateway: RAG collection empty — running without retrieval")
36
+ except Exception as exc:
37
+ logger.info("Gateway: RAG unavailable (%s) — running without retrieval", exc)
38
+ return None
39
+
40
+
41
+ class Gateway:
42
+ """CIPHER gateway orchestrator.
43
+
44
+ Initialises once from config and routes every message through the
45
+ full pipeline: mode detection → prompt assembly → RAG retrieval → LLM call → response.
46
+ """
47
+
48
+ def __init__(self, backend_override: str | None = None, rag: bool = True) -> None:
49
+ """Initialise gateway.
50
+
51
+ Args:
52
+ backend_override: If provided, replaces config.backend.
53
+ Useful for testing or CLI --backend flag.
54
+ rag: Whether to enable RAG retrieval. Defaults to True.
55
+ """
56
+ cfg = load_config()
57
+ if backend_override is not None:
58
+ # Replace backend in-place — GatewayConfig is a mutable dataclass.
59
+ cfg.backend = backend_override
60
+ logger.info("Gateway: backend overridden to %r", backend_override)
61
+
62
+ self._client, self._model = make_client(cfg)
63
+ logger.info("Gateway: client ready (backend=%r, model=%r)", cfg.backend, self._model)
64
+
65
+ # Cache keyword table at startup so every send() call is O(1) lookup.
66
+ # Parse from raw CLAUDE.md (not the stripped base prompt) since
67
+ # load_base_prompt() removes the Trigger Keywords section.
68
+ from gateway.prompt import REPO_ROOT
69
+ raw_claude_md = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
70
+ self._keyword_table: dict[str, list[str]] = parse_keyword_table(raw_claude_md)
71
+ logger.info("Gateway: keyword table loaded (%d modes)", len(self._keyword_table))
72
+
73
+ # RAG retriever (lazy, graceful fallback if chromadb not installed or empty).
74
+ self._retriever = _load_retriever() if rag else None
75
+ if self._retriever:
76
+ logger.info("Gateway: RAG enabled (%d chunks)", self._retriever.count)
77
+
78
+ def send(self, message: str, history: list[dict] | None = None) -> str:
79
+ """Send a message through the full CIPHER pipeline.
80
+
81
+ Pipeline:
82
+ 1. Detect mode (explicit prefix or keyword scoring).
83
+ 2. If needs_clarification → return disambiguation string (no LLM call).
84
+ 3. Assemble system prompt for detected mode.
85
+ 4. Strip mode prefix from message.
86
+ 5. Build messages list (history + new user message).
87
+ 6. Call LLM.
88
+ 7. Prepend [MODE: X] header if missing.
89
+ 8. Return response.
90
+
91
+ Args:
92
+ message: User message text. May include [MODE: X] prefix.
93
+ history: Optional list of prior message dicts
94
+ ({"role": "user"|"assistant", "content": "..."}).
95
+
96
+ Returns:
97
+ Response string, always starting with [MODE: X] header.
98
+ """
99
+ # 1. Detect mode.
100
+ mode, needs_clarification = detect_mode(message, self._keyword_table)
101
+
102
+ # 2. Disambiguation short-circuit.
103
+ if needs_clarification:
104
+ logger.info("Gateway: mode overlap detected (%r) — requesting clarification", mode)
105
+ return (
106
+ f"Multiple modes detected ({mode}). "
107
+ "Are you approaching this offensively or defensively?"
108
+ )
109
+
110
+ logger.info("Gateway: routing to mode %r", mode)
111
+
112
+ # 3. Assemble system prompt.
113
+ system_prompt = assemble_system_prompt(mode)
114
+
115
+ # 3b. RAG retrieval — inject relevant knowledge chunks.
116
+ if self._retriever:
117
+ chunks = self._retriever.query(message, top_k=5)
118
+ if chunks:
119
+ from gateway.retriever import Retriever
120
+ context_block = Retriever.format_context(chunks)
121
+ if context_block:
122
+ system_prompt = system_prompt + "\n\n---\n\n" + context_block
123
+ logger.info(
124
+ "Gateway: injected %d RAG chunks (%.1f KB)",
125
+ len(chunks),
126
+ len(context_block) / 1024,
127
+ )
128
+
129
+ # 4. Strip mode prefix.
130
+ clean_message = strip_mode_prefix(message)
131
+
132
+ # 5. Prepend [MODE: X] for LLM context.
133
+ user_content = f"[MODE: {mode}] {clean_message}"
134
+
135
+ # 6. Build messages list.
136
+ messages: list[dict] = []
137
+ if history:
138
+ messages.extend(history)
139
+ messages.append({"role": "user", "content": user_content})
140
+
141
+ # 7. Call LLM.
142
+ try:
143
+ response = self._client.messages.create(
144
+ model=self._model,
145
+ max_tokens=4096,
146
+ system=system_prompt,
147
+ messages=messages,
148
+ )
149
+ response_text: str = response.content[0].text
150
+
151
+ except anthropic.APITimeoutError:
152
+ logger.error("Gateway: LLM backend timed out")
153
+ return "[ERROR] Backend timed out. Is the LLM backend running?"
154
+
155
+ except anthropic.APIConnectionError as exc:
156
+ logger.error("Gateway: cannot reach backend — %s", exc)
157
+ return f"[ERROR] Cannot reach backend: {exc}"
158
+
159
+ except anthropic.APIStatusError as exc:
160
+ logger.error("Gateway: backend returned HTTP %s — %s", exc.status_code, exc.message)
161
+ return f"[ERROR] Backend returned {exc.status_code}: {exc.message}"
162
+
163
+ # 8. Ensure exactly one [MODE: X] header is present.
164
+ if not response_text.startswith("[MODE:"):
165
+ response_text = f"[MODE: {mode}]\n{response_text}"
166
+ # Strip duplicate header if LLM echoed it twice.
167
+ lines = response_text.split("\n", 2)
168
+ if len(lines) >= 2 and lines[0].startswith("[MODE:") and lines[1].startswith("[MODE:"):
169
+ response_text = "\n".join([lines[0]] + lines[2:]) if len(lines) > 2 else lines[0]
170
+
171
+ return response_text
gateway/mode.py ADDED
@@ -0,0 +1,127 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """Mode detection for CIPHER gateway.
4
+
5
+ Provides:
6
+ - parse_keyword_table: extract mode→keywords from CLAUDE.md trigger table
7
+ - detect_mode: route message to operating mode via explicit prefix or keyword scoring
8
+ - strip_mode_prefix: remove [MODE: X] prefix from message text
9
+ """
10
+
11
+ import logging
12
+ import re
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Matches [MODE: X] at the start of a message, case-insensitive.
17
+ _EXPLICIT_RE = re.compile(r"^\[MODE:\s*(\w+)\]", re.IGNORECASE)
18
+
19
+ # Matches the trigger keywords table block inside CLAUDE.md.
20
+ # Looks for a header containing "Trigger Keywords", then captures rows.
21
+ _TABLE_HEADER_RE = re.compile(
22
+ r"Trigger Keywords.*?\n\|[-| ]+\|\n(.*?)(?:\n[-]+|\n\n|\Z)",
23
+ re.DOTALL | re.IGNORECASE,
24
+ )
25
+ # Individual data row: | WORD | content |
26
+ # Skips header rows where the first cell is "Mode" (case-insensitive).
27
+ _TABLE_ROW_RE = re.compile(r"^\|\s*(\w+)\s*\|\s*(.+?)\s*\|", re.MULTILINE)
28
+ # Known header/separator cell values to skip.
29
+ _SKIP_ROW_NAMES = {"MODE", "---"}
30
+
31
+
32
+ def parse_keyword_table(claude_md_text: str) -> dict[str, list[str]]:
33
+ """Parse the ``| Mode | Trigger Keywords |`` table from CLAUDE.md text.
34
+
35
+ Returns a dict mapping UPPERCASE mode names to lists of lowercase,
36
+ stripped keyword strings. Returns empty dict if the table is absent or
37
+ malformed.
38
+ """
39
+ if not claude_md_text.strip():
40
+ return {}
41
+
42
+ # Find the trigger keywords section — everything after the separator row.
43
+ header_match = _TABLE_HEADER_RE.search(claude_md_text)
44
+ if not header_match:
45
+ # Try a looser parse: find any table-like block with Mode | Trigger columns.
46
+ # Scan line by line for rows after a header that contains "Trigger Keywords".
47
+ if "Trigger Keywords" not in claude_md_text:
48
+ logger.warning("parse_keyword_table: no 'Trigger Keywords' table found")
49
+ return {}
50
+ # Fall through to row-level scan across the full text.
51
+ rows_text = claude_md_text
52
+ else:
53
+ rows_text = header_match.group(0)
54
+
55
+ result: dict[str, list[str]] = {}
56
+ for row_match in _TABLE_ROW_RE.finditer(rows_text):
57
+ mode = row_match.group(1).strip().upper()
58
+ # Skip table header row (| Mode | Trigger Keywords |) and separators.
59
+ if mode in _SKIP_ROW_NAMES:
60
+ continue
61
+ raw_keywords = row_match.group(2)
62
+ # Skip if the "keyword" cell looks like a column header.
63
+ if "trigger keywords" in raw_keywords.lower():
64
+ continue
65
+ keywords = [kw.strip().lower() for kw in raw_keywords.split(",") if kw.strip()]
66
+ if mode and keywords:
67
+ result[mode] = keywords
68
+
69
+ if not result:
70
+ logger.warning("parse_keyword_table: table parsed to empty dict")
71
+
72
+ return result
73
+
74
+
75
+ def detect_mode(
76
+ message: str,
77
+ keyword_table: dict[str, list[str]],
78
+ ) -> tuple[str, bool]:
79
+ """Detect the CIPHER operating mode for a given message.
80
+
81
+ Detection order:
82
+ 1. Explicit ``[MODE: X]`` prefix — returns immediately, no clarification needed.
83
+ 2. Keyword scoring — scan lowercased message against keyword_table.
84
+ - Single mode match → return (mode, False)
85
+ - Multiple mode matches → return (comma-joined sorted modes, True)
86
+ - No match → return ("ARCHITECT", False)
87
+
88
+ Args:
89
+ message: Raw user message text (may include [MODE: X] prefix).
90
+ keyword_table: Dict of mode→keywords from parse_keyword_table.
91
+
92
+ Returns:
93
+ Tuple of (mode_string, needs_clarification).
94
+ """
95
+ # 1. Check explicit prefix.
96
+ prefix_match = _EXPLICIT_RE.match(message)
97
+ if prefix_match:
98
+ return prefix_match.group(1).upper(), False
99
+
100
+ # 2. Keyword scoring.
101
+ lower_message = message.lower()
102
+ matched_modes: set[str] = set()
103
+
104
+ for mode, keywords in keyword_table.items():
105
+ for keyword in keywords:
106
+ if keyword in lower_message:
107
+ matched_modes.add(mode)
108
+ break # One hit per mode is enough.
109
+
110
+ if len(matched_modes) == 0:
111
+ return "ARCHITECT", False
112
+ if len(matched_modes) == 1:
113
+ return matched_modes.pop(), False
114
+
115
+ # Multiple matches — needs clarification.
116
+ sorted_modes = ",".join(sorted(matched_modes))
117
+ return sorted_modes, True
118
+
119
+
120
+ def strip_mode_prefix(message: str) -> str:
121
+ """Remove ``[MODE: X]`` prefix from message text.
122
+
123
+ Strips any leading whitespace after the prefix. Returns the original
124
+ string unchanged if no prefix is present.
125
+ """
126
+ result = _EXPLICIT_RE.sub("", message)
127
+ return result.lstrip()