dulus 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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
ui/render.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ui/render.py — All terminal rendering for Dulus.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- ANSI color helpers (C, clr, info, ok, warn, err)
|
|
6
|
+
- Rich Markdown streaming (stream_text, flush_response)
|
|
7
|
+
- Spinner management
|
|
8
|
+
- Tool call display (print_tool_start, print_tool_end)
|
|
9
|
+
- Diff rendering (render_diff)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
# ── Optional rich for markdown rendering ──────────────────────────────────
|
|
18
|
+
try:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.markdown import Markdown
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
_RICH = True
|
|
23
|
+
console = Console()
|
|
24
|
+
except ImportError:
|
|
25
|
+
_RICH = False
|
|
26
|
+
console = None
|
|
27
|
+
Live = None
|
|
28
|
+
Markdown = None
|
|
29
|
+
|
|
30
|
+
# ── ANSI helpers ───────────────────────────────────────────────────────────
|
|
31
|
+
# Prefer the global theme-aware palette from common.py; fall back to Dulus
|
|
32
|
+
# orange (default theme accent) so this module never emits generic cyan.
|
|
33
|
+
try:
|
|
34
|
+
from common import C, clr, info, ok, warn, err
|
|
35
|
+
except ImportError:
|
|
36
|
+
_DULUS_ORANGE = "\033[38;2;255;135;0m"
|
|
37
|
+
_WARN = "\033[38;2;255;175;0m"
|
|
38
|
+
C = {
|
|
39
|
+
"cyan": _DULUS_ORANGE, "green": _DULUS_ORANGE, "blue": _DULUS_ORANGE,
|
|
40
|
+
"yellow": _WARN, "magenta": _WARN, "red": "\033[38;5;196m",
|
|
41
|
+
"white": "\033[97m", "gray": "\033[90m",
|
|
42
|
+
"bold": "\033[1m", "dim": "\033[2m", "reset": "\033[0m",
|
|
43
|
+
}
|
|
44
|
+
def clr(text: str, *keys: str) -> str:
|
|
45
|
+
return "".join(C[k] for k in keys) + str(text) + C["reset"]
|
|
46
|
+
def info(msg: str): print(clr(msg, "cyan"))
|
|
47
|
+
def ok(msg: str): print(clr(msg, "green"))
|
|
48
|
+
def warn(msg: str): print(clr(f"Warning: {msg}", "yellow"))
|
|
49
|
+
def err(msg: str): print(clr(f"Error: {msg}", "red"), file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
def _truncate_err_global(s: str, max_len: int = 200) -> str:
|
|
52
|
+
if len(s) <= max_len:
|
|
53
|
+
return s
|
|
54
|
+
return s[:max_len - 3] + "..."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Diff rendering ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def render_diff(text: str):
|
|
60
|
+
"""Print diff text with ANSI colors: red for removals, green for additions."""
|
|
61
|
+
for line in text.splitlines():
|
|
62
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
63
|
+
print(C["bold"] + line + C["reset"])
|
|
64
|
+
elif line.startswith("+"):
|
|
65
|
+
print(C["green"] + line + C["reset"])
|
|
66
|
+
elif line.startswith("-"):
|
|
67
|
+
print(C["red"] + line + C["reset"])
|
|
68
|
+
elif line.startswith("@@"):
|
|
69
|
+
print(C["cyan"] + line + C["reset"])
|
|
70
|
+
else:
|
|
71
|
+
print(line)
|
|
72
|
+
|
|
73
|
+
def _has_diff(text: str) -> bool:
|
|
74
|
+
"""Check if text contains a unified diff."""
|
|
75
|
+
return "--- a/" in text and "+++ b/" in text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Conversation rendering ─────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
_accumulated_text: list[str] = [] # buffer text during streaming
|
|
81
|
+
_current_live = None # active Rich Live instance (one at a time)
|
|
82
|
+
_RICH_LIVE = True # set False (via config rich_live=false) to disable
|
|
83
|
+
|
|
84
|
+
def set_rich_live(enabled: bool) -> None:
|
|
85
|
+
"""Called from repl.py to apply the rich_live config setting."""
|
|
86
|
+
global _RICH_LIVE
|
|
87
|
+
_RICH_LIVE = _RICH and enabled
|
|
88
|
+
|
|
89
|
+
def _make_renderable(text: str):
|
|
90
|
+
"""Return a Rich renderable: Markdown if text contains markup, else plain."""
|
|
91
|
+
if any(c in text for c in ("#", "*", "`", "_", "[")):
|
|
92
|
+
return Markdown(text)
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
def _start_live() -> None:
|
|
96
|
+
"""Start a Rich Live block for in-place Markdown streaming (no-op if not Rich)."""
|
|
97
|
+
global _current_live
|
|
98
|
+
if _RICH and _RICH_LIVE and _current_live is None:
|
|
99
|
+
_current_live = Live(console=console, auto_refresh=False,
|
|
100
|
+
vertical_overflow="visible")
|
|
101
|
+
_current_live.start()
|
|
102
|
+
|
|
103
|
+
_LIVE_LINE_LIMIT = 80 # auto-switch to plain streaming beyond this many lines
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def stream_text(chunk: str) -> None:
|
|
107
|
+
"""Buffer chunk; update Live in-place when Rich available, else print directly.
|
|
108
|
+
|
|
109
|
+
Safety: if accumulated text exceeds _LIVE_LINE_LIMIT lines, auto-switch
|
|
110
|
+
from Rich Live to plain streaming to prevent terminal re-render duplication
|
|
111
|
+
on terminals that can't handle large Live areas (macOS Terminal, etc.).
|
|
112
|
+
"""
|
|
113
|
+
global _current_live
|
|
114
|
+
_accumulated_text.append(chunk)
|
|
115
|
+
|
|
116
|
+
if _RICH and _RICH_LIVE:
|
|
117
|
+
full = "".join(_accumulated_text)
|
|
118
|
+
line_count = full.count("\n")
|
|
119
|
+
|
|
120
|
+
# Safety: too many lines → kill Live and fall back to plain streaming
|
|
121
|
+
if _current_live is not None and line_count > _LIVE_LINE_LIMIT:
|
|
122
|
+
_current_live.stop()
|
|
123
|
+
_current_live = None
|
|
124
|
+
# Print the full text once (Live already displayed partial content,
|
|
125
|
+
# but stopping Live clears it — so we re-print cleanly)
|
|
126
|
+
console.print(_make_renderable(full))
|
|
127
|
+
_accumulated_text.clear()
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if line_count <= _LIVE_LINE_LIMIT:
|
|
131
|
+
if _current_live is None:
|
|
132
|
+
_start_live()
|
|
133
|
+
_current_live.update(_make_renderable(full), refresh=True)
|
|
134
|
+
else:
|
|
135
|
+
# Already past limit, no Live — just append new chunk
|
|
136
|
+
print(chunk, end="", flush=True)
|
|
137
|
+
else:
|
|
138
|
+
print(chunk, end="", flush=True)
|
|
139
|
+
|
|
140
|
+
def stream_thinking(chunk: str, verbose: bool):
|
|
141
|
+
if verbose:
|
|
142
|
+
clean_chunk = chunk.replace("\n", " ")
|
|
143
|
+
if clean_chunk:
|
|
144
|
+
print(f"{C['dim']}{clean_chunk}", end="", flush=True)
|
|
145
|
+
|
|
146
|
+
def flush_response() -> None:
|
|
147
|
+
"""Commit buffered text to screen: stop Live (freezes rendered Markdown in place)."""
|
|
148
|
+
global _current_live
|
|
149
|
+
full = "".join(_accumulated_text)
|
|
150
|
+
_accumulated_text.clear()
|
|
151
|
+
if _current_live is not None:
|
|
152
|
+
_current_live.stop()
|
|
153
|
+
_current_live = None
|
|
154
|
+
elif _RICH and _RICH_LIVE and full.strip():
|
|
155
|
+
console.print(_make_renderable(full))
|
|
156
|
+
else:
|
|
157
|
+
print() # ensure newline after plain-text stream
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Spinner ────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
from spinner import TOOL_SPINNER_PHRASES as _TOOL_SPINNER_PHRASES
|
|
163
|
+
from spinner import DEBATE_SPINNER_PHRASES as _DEBATE_SPINNER_PHRASES
|
|
164
|
+
|
|
165
|
+
_tool_spinner_thread = None
|
|
166
|
+
_tool_spinner_stop = threading.Event()
|
|
167
|
+
_spinner_phrase = ""
|
|
168
|
+
_spinner_lock = threading.Lock()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run_tool_spinner():
|
|
172
|
+
"""Background spinner on a single line using carriage return."""
|
|
173
|
+
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
174
|
+
i = 0
|
|
175
|
+
while not _tool_spinner_stop.is_set():
|
|
176
|
+
with _spinner_lock:
|
|
177
|
+
phrase = _spinner_phrase
|
|
178
|
+
frame = chars[i % len(chars)]
|
|
179
|
+
sys.stdout.write(f"\r {frame} {clr(phrase, 'dim')} ")
|
|
180
|
+
sys.stdout.flush()
|
|
181
|
+
i += 1
|
|
182
|
+
_tool_spinner_stop.wait(0.1)
|
|
183
|
+
|
|
184
|
+
def _start_tool_spinner():
|
|
185
|
+
global _tool_spinner_thread
|
|
186
|
+
if _tool_spinner_thread and _tool_spinner_thread.is_alive():
|
|
187
|
+
return
|
|
188
|
+
import random
|
|
189
|
+
with _spinner_lock:
|
|
190
|
+
global _spinner_phrase
|
|
191
|
+
_spinner_phrase = random.choice(_TOOL_SPINNER_PHRASES)
|
|
192
|
+
_tool_spinner_stop.clear()
|
|
193
|
+
_tool_spinner_thread = threading.Thread(target=_run_tool_spinner, daemon=True)
|
|
194
|
+
_tool_spinner_thread.start()
|
|
195
|
+
|
|
196
|
+
def _change_spinner_phrase():
|
|
197
|
+
"""Change the spinner phrase without stopping it."""
|
|
198
|
+
import random
|
|
199
|
+
with _spinner_lock:
|
|
200
|
+
global _spinner_phrase
|
|
201
|
+
_spinner_phrase = random.choice(_TOOL_SPINNER_PHRASES)
|
|
202
|
+
|
|
203
|
+
def set_spinner_phrase(phrase: str) -> None:
|
|
204
|
+
"""Set a specific spinner phrase (used by SSJ debate mode)."""
|
|
205
|
+
global _spinner_phrase
|
|
206
|
+
with _spinner_lock:
|
|
207
|
+
_spinner_phrase = phrase
|
|
208
|
+
|
|
209
|
+
def _stop_tool_spinner():
|
|
210
|
+
global _tool_spinner_thread
|
|
211
|
+
if not _tool_spinner_thread:
|
|
212
|
+
return
|
|
213
|
+
_tool_spinner_stop.set()
|
|
214
|
+
_tool_spinner_thread.join(timeout=1)
|
|
215
|
+
_tool_spinner_thread = None
|
|
216
|
+
sys.stdout.write(f"\r{' ' * 50}\r")
|
|
217
|
+
sys.stdout.flush()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── Tool call display ──────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def _tool_desc(name: str, inputs: dict) -> str:
|
|
223
|
+
if name == "Read": return f"Read({inputs.get('file_path','')})"
|
|
224
|
+
if name == "Write": return f"Write({inputs.get('file_path','')})"
|
|
225
|
+
if name == "Edit": return f"Edit({inputs.get('file_path','')})"
|
|
226
|
+
if name == "Bash": return f"Bash({inputs.get('command','')[:80]})"
|
|
227
|
+
if name == "Glob": return f"Glob({inputs.get('pattern','')})"
|
|
228
|
+
if name == "Grep": return f"Grep({inputs.get('pattern','')})"
|
|
229
|
+
if name == "WebFetch": return f"WebFetch({inputs.get('url','')[:60]})"
|
|
230
|
+
if name == "WebSearch": return f"WebSearch({inputs.get('query','')})"
|
|
231
|
+
if name == "Agent":
|
|
232
|
+
atype = inputs.get("subagent_type", "")
|
|
233
|
+
aname = inputs.get("name", "")
|
|
234
|
+
iso = inputs.get("isolation", "")
|
|
235
|
+
parts = []
|
|
236
|
+
if atype: parts.append(atype)
|
|
237
|
+
if aname: parts.append(f"name={aname}")
|
|
238
|
+
if iso: parts.append(f"isolation={iso}")
|
|
239
|
+
suffix = f"({', '.join(parts)})" if parts else ""
|
|
240
|
+
prompt_short = inputs.get("prompt", "")[:60]
|
|
241
|
+
return f"Agent{suffix}: {prompt_short}"
|
|
242
|
+
if name == "SendMessage":
|
|
243
|
+
return f"SendMessage(to={inputs.get('to','')}: {inputs.get('message','')[:50]})"
|
|
244
|
+
if name == "CheckAgentResult": return f"CheckAgentResult({inputs.get('task_id','')})"
|
|
245
|
+
if name == "ListAgentTasks": return "ListAgentTasks()"
|
|
246
|
+
if name == "ListAgentTypes": return "ListAgentTypes()"
|
|
247
|
+
return f"{name}({list(inputs.values())[:1]})"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def print_tool_start(name: str, inputs: dict, verbose: bool):
|
|
251
|
+
"""Show tool invocation."""
|
|
252
|
+
desc = _tool_desc(name, inputs)
|
|
253
|
+
print(clr(f" ⚙ {desc}", "dim", "cyan"), flush=True)
|
|
254
|
+
if verbose:
|
|
255
|
+
print(clr(f" inputs: {json.dumps(inputs, ensure_ascii=False)[:200]}", "dim"))
|
|
256
|
+
|
|
257
|
+
def print_tool_end(name: str, result: str, verbose: bool):
|
|
258
|
+
lines = result.count("\n") + 1
|
|
259
|
+
size = len(result)
|
|
260
|
+
summary = f"→ {lines} lines ({size} chars)"
|
|
261
|
+
if not result.startswith("Error") and not result.startswith("Denied"):
|
|
262
|
+
print(clr(f" ✓ {summary}", "dim", "green"), flush=True)
|
|
263
|
+
if name in ("Edit", "Write") and _has_diff(result):
|
|
264
|
+
parts = result.split("\n\n", 1)
|
|
265
|
+
if len(parts) == 2:
|
|
266
|
+
print(clr(f" {parts[0]}", "dim"))
|
|
267
|
+
render_diff(parts[1])
|
|
268
|
+
else:
|
|
269
|
+
print(clr(f" ✗ {result[:120]}", "dim", "red"), flush=True)
|
|
270
|
+
if verbose and not result.startswith("Denied"):
|
|
271
|
+
preview = result[:500] + ("…" if len(result) > 500 else "")
|
|
272
|
+
print(clr(f" {preview.replace(chr(10), chr(10)+' ')}", "dim"))
|
voice/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Voice package for dulus.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
check_voice_deps() → (available: bool, reason: str | None)
|
|
6
|
+
record_once(...) → raw PCM bytes (int16, 16 kHz, mono)
|
|
7
|
+
transcribe(...) → text string
|
|
8
|
+
voice_input(...) → transcribed text (record + transcribe in one call)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .recorder import check_recording_availability, record_until_silence, list_input_devices
|
|
12
|
+
from .stt import check_stt_availability, transcribe, transcribe_audio_file
|
|
13
|
+
from .tts import check_tts_availability, say
|
|
14
|
+
from .keyterms import get_voice_keyterms
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_voice_deps() -> tuple[bool, str | None]:
|
|
18
|
+
"""Return (available, reason_if_not)."""
|
|
19
|
+
rec_ok, rec_reason = check_recording_availability()
|
|
20
|
+
if not rec_ok:
|
|
21
|
+
return False, rec_reason
|
|
22
|
+
stt_ok, stt_reason = check_stt_availability()
|
|
23
|
+
if not stt_ok:
|
|
24
|
+
return False, stt_reason
|
|
25
|
+
# TTS is optional, so we don't fail here if it's missing,
|
|
26
|
+
# but we could add a check if needed.
|
|
27
|
+
return True, None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def voice_input(
|
|
31
|
+
language: str = "auto",
|
|
32
|
+
max_seconds: int = 30,
|
|
33
|
+
on_energy: "callable | None" = None,
|
|
34
|
+
device_index: "int | None" = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Record until silence, then transcribe. Returns transcribed text."""
|
|
37
|
+
keyterms = get_voice_keyterms()
|
|
38
|
+
pcm = record_until_silence(max_seconds=max_seconds, on_energy=on_energy, device_index=device_index)
|
|
39
|
+
if not pcm:
|
|
40
|
+
return ""
|
|
41
|
+
return transcribe(pcm, keyterms=keyterms, language=language)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"check_voice_deps",
|
|
46
|
+
"check_recording_availability",
|
|
47
|
+
"check_stt_availability",
|
|
48
|
+
"check_tts_availability",
|
|
49
|
+
"record_until_silence",
|
|
50
|
+
"list_input_devices",
|
|
51
|
+
"transcribe",
|
|
52
|
+
"transcribe_audio_file",
|
|
53
|
+
"get_voice_keyterms",
|
|
54
|
+
"voice_input",
|
|
55
|
+
"say",
|
|
56
|
+
]
|
voice/keyterms.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Voice keyterms: domain-specific vocabulary hints for STT accuracy.
|
|
2
|
+
|
|
3
|
+
Passed as Whisper's `initial_prompt` so that coding terminology
|
|
4
|
+
(grep, MCP, TypeScript, JSON, …) is recognised correctly instead of being
|
|
5
|
+
mistranscribed as phonetically similar common words.
|
|
6
|
+
|
|
7
|
+
Inspired by Claude Code's voiceKeyterms.ts, but expanded for a multi-provider
|
|
8
|
+
setting and adapted to pull context from the Python runtime environment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# ── Global coding keyterms ────────────────────────────────────────────────
|
|
18
|
+
# Terms that speech engines consistently mishear during coding dictation.
|
|
19
|
+
# Exclude anything trivially recognised (e.g. "file", "code") — only add
|
|
20
|
+
# terms where phonetic ambiguity is high.
|
|
21
|
+
|
|
22
|
+
GLOBAL_KEYTERMS: list[str] = [
|
|
23
|
+
# Tools and protocols
|
|
24
|
+
"MCP",
|
|
25
|
+
"grep",
|
|
26
|
+
"regex",
|
|
27
|
+
"regex pattern",
|
|
28
|
+
"ripgrep",
|
|
29
|
+
"localhost",
|
|
30
|
+
"codebase",
|
|
31
|
+
"webhook",
|
|
32
|
+
"OAuth",
|
|
33
|
+
"gRPC",
|
|
34
|
+
"JSON",
|
|
35
|
+
"YAML",
|
|
36
|
+
"dotfiles",
|
|
37
|
+
"symlink",
|
|
38
|
+
"subprocess",
|
|
39
|
+
"subagent",
|
|
40
|
+
"worktree",
|
|
41
|
+
# Languages / runtimes
|
|
42
|
+
"TypeScript",
|
|
43
|
+
"JavaScript",
|
|
44
|
+
"Python",
|
|
45
|
+
"Rust",
|
|
46
|
+
"Golang",
|
|
47
|
+
"Dockerfile",
|
|
48
|
+
"bash",
|
|
49
|
+
# Common coding words with phonetic twins
|
|
50
|
+
"pytest",
|
|
51
|
+
"linter",
|
|
52
|
+
"formatter",
|
|
53
|
+
"middleware",
|
|
54
|
+
"endpoint",
|
|
55
|
+
"namespace",
|
|
56
|
+
"async",
|
|
57
|
+
"await",
|
|
58
|
+
"refactor",
|
|
59
|
+
"deprecate",
|
|
60
|
+
"serialize",
|
|
61
|
+
"deserialize",
|
|
62
|
+
"Pydantic",
|
|
63
|
+
"FastAPI",
|
|
64
|
+
"SQLAlchemy",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
MAX_KEYTERMS = 50
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def split_identifier(name: str) -> list[str]:
|
|
73
|
+
"""Split camelCase / PascalCase / kebab-case / snake_case into words.
|
|
74
|
+
|
|
75
|
+
Fragments ≤ 2 chars or > 20 chars are discarded.
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
"dulus" → ["nano", "claude", "code"]
|
|
79
|
+
"MyWebhookHandler" → ["My", "Webhook", "Handler"]
|
|
80
|
+
"""
|
|
81
|
+
# camelCase / PascalCase
|
|
82
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
|
83
|
+
parts = re.split(r"[-_./\s]+", spaced)
|
|
84
|
+
return [p.strip() for p in parts if 3 <= len(p.strip()) <= 20]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _git_branch() -> str | None:
|
|
88
|
+
try:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
91
|
+
capture_output=True, text=True, timeout=3,
|
|
92
|
+
)
|
|
93
|
+
branch = result.stdout.strip()
|
|
94
|
+
return branch if branch and branch != "HEAD" else None
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _project_root() -> Path | None:
|
|
100
|
+
"""Find the git root or fall back to cwd."""
|
|
101
|
+
try:
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
104
|
+
capture_output=True, text=True, timeout=3,
|
|
105
|
+
)
|
|
106
|
+
root = result.stdout.strip()
|
|
107
|
+
if root:
|
|
108
|
+
return Path(root)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
return Path.cwd()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _recent_py_files(root: Path, limit: int = 20) -> list[Path]:
|
|
115
|
+
"""Return the most-recently modified Python/TS/JS files in the repo."""
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
119
|
+
capture_output=True, text=True, timeout=5, cwd=str(root),
|
|
120
|
+
)
|
|
121
|
+
files = [
|
|
122
|
+
root / f for f in result.stdout.splitlines()
|
|
123
|
+
if f.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs"))
|
|
124
|
+
]
|
|
125
|
+
# Sort by mtime descending
|
|
126
|
+
files.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
|
127
|
+
return files[:limit]
|
|
128
|
+
except Exception:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def get_voice_keyterms(recent_files: list[str] | None = None) -> list[str]:
|
|
135
|
+
"""Build a list of keyterms for the STT engine.
|
|
136
|
+
|
|
137
|
+
Combines:
|
|
138
|
+
• Hardcoded global coding vocabulary
|
|
139
|
+
• Project root directory name
|
|
140
|
+
• Git branch words
|
|
141
|
+
• Recent source file stem words
|
|
142
|
+
|
|
143
|
+
Returns up to MAX_KEYTERMS unique terms.
|
|
144
|
+
"""
|
|
145
|
+
terms: list[str] = list(GLOBAL_KEYTERMS)
|
|
146
|
+
|
|
147
|
+
# Project name
|
|
148
|
+
root = _project_root()
|
|
149
|
+
if root and root.name:
|
|
150
|
+
name = root.name
|
|
151
|
+
if 2 < len(name) <= 50:
|
|
152
|
+
terms.append(name)
|
|
153
|
+
terms.extend(split_identifier(name))
|
|
154
|
+
|
|
155
|
+
# Git branch words (e.g. "feat/voice-input" → ["feat", "voice", "input"])
|
|
156
|
+
branch = _git_branch()
|
|
157
|
+
if branch:
|
|
158
|
+
terms.extend(split_identifier(branch))
|
|
159
|
+
|
|
160
|
+
# Recent file stems
|
|
161
|
+
files = [Path(f) for f in (recent_files or [])] + _recent_py_files(root or Path.cwd())
|
|
162
|
+
for fpath in files:
|
|
163
|
+
if len(terms) >= MAX_KEYTERMS:
|
|
164
|
+
break
|
|
165
|
+
stem = fpath.stem
|
|
166
|
+
if stem:
|
|
167
|
+
terms.extend(split_identifier(stem))
|
|
168
|
+
|
|
169
|
+
# Deduplicate preserving order, trim to limit
|
|
170
|
+
seen: set[str] = set()
|
|
171
|
+
result: list[str] = []
|
|
172
|
+
for t in terms:
|
|
173
|
+
if t not in seen:
|
|
174
|
+
seen.add(t)
|
|
175
|
+
result.append(t)
|
|
176
|
+
if len(result) >= MAX_KEYTERMS:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
return result
|