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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. 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