llmcode-cli 1.0.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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,279 @@
1
+ """Vim state machine — key handling, mode transitions, repeat, undo.
2
+
3
+ The central function is handle_key(state, key) -> VimState.
4
+ It dispatches based on current mode and accumulated pending_keys.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import replace
9
+
10
+ from llm_code.vim.types import VimMode, VimState, ParsedCommand
11
+ from llm_code.vim.motions import (
12
+ move_h, move_l, move_w, move_b, move_e,
13
+ move_W, move_B, move_E,
14
+ move_0, move_caret, move_dollar,
15
+ move_gg, move_G,
16
+ move_f, move_F, move_t, move_T,
17
+ )
18
+ from llm_code.vim.operators import (
19
+ op_delete, op_change, op_yank,
20
+ op_delete_line, op_change_line, op_yank_line,
21
+ op_x, op_replace, op_tilde, op_join,
22
+ op_put_after, op_put_before,
23
+ op_open_below, op_open_above,
24
+ op_indent_right, op_indent_left,
25
+ )
26
+ from llm_code.vim.text_objects import select_text_object
27
+
28
+
29
+ _MOTION_KEYS = frozenset("hlwbeWBE0^$")
30
+ _OPERATOR_KEYS = frozenset("dcy")
31
+ _SINGLE_CHAR_OPS = frozenset("xrR~JpPoO")
32
+
33
+
34
+ def handle_key(state: VimState, key: str) -> VimState:
35
+ """Process a single key press and return the new VimState."""
36
+ if state.mode == VimMode.INSERT:
37
+ return _handle_insert(state, key)
38
+ return _handle_normal(state, key)
39
+
40
+
41
+ def _handle_insert(state: VimState, key: str) -> VimState:
42
+ """INSERT mode: type characters, Esc to exit."""
43
+ if key == "\x1b": # Escape
44
+ cursor = max(0, state.cursor - 1)
45
+ return replace(state, mode=VimMode.NORMAL, cursor=cursor)
46
+ if key in ("\x7f", "\x08"): # Backspace
47
+ if state.cursor == 0:
48
+ return state
49
+ new_buf = state.buffer[:state.cursor - 1] + state.buffer[state.cursor:]
50
+ return replace(state, buffer=new_buf, cursor=state.cursor - 1)
51
+ # Regular character insertion
52
+ new_buf = state.buffer[:state.cursor] + key + state.buffer[state.cursor:]
53
+ return replace(state, buffer=new_buf, cursor=state.cursor + 1)
54
+
55
+
56
+ def _handle_normal(state: VimState, key: str) -> VimState:
57
+ """NORMAL mode: motions, operators, mode switches."""
58
+ pending = state.pending_keys + key
59
+
60
+ # ── Count prefix ──────────────────────────────────────────────
61
+ if pending.isdigit() and pending != "0":
62
+ return replace(state, pending_keys=pending)
63
+
64
+ # ── Parse accumulated pending keys ────────────────────────────
65
+ count = 1
66
+ rest = pending
67
+ # Extract leading digits as count
68
+ i = 0
69
+ while i < len(rest) and rest[i].isdigit() and (i > 0 or rest[i] != "0"):
70
+ i += 1
71
+ if i > 0:
72
+ count = int(rest[:i])
73
+ rest = rest[i:]
74
+
75
+ if not rest:
76
+ return replace(state, pending_keys=pending)
77
+
78
+ # ── Dot repeat ────────────────────────────────────────────────
79
+ if rest == ".":
80
+ if state.last_command is not None:
81
+ return _replay_command(replace(state, pending_keys=""), state.last_command)
82
+ return replace(state, pending_keys="")
83
+
84
+ # ── Undo ──────────────────────────────────────────────────────
85
+ if rest == "u":
86
+ return replace(state.pop_undo(), pending_keys="")
87
+
88
+ # ── Mode switches ─────────────────────────────────────────────
89
+ if rest == "i":
90
+ return replace(state, mode=VimMode.INSERT, pending_keys="")
91
+ if rest == "a":
92
+ cursor = min(state.cursor + 1, len(state.buffer))
93
+ return replace(state, mode=VimMode.INSERT, cursor=cursor, pending_keys="")
94
+ if rest == "A":
95
+ return replace(state, mode=VimMode.INSERT, cursor=len(state.buffer), pending_keys="")
96
+ if rest == "I":
97
+ pos = move_caret(state)
98
+ return replace(state, mode=VimMode.INSERT, cursor=pos, pending_keys="")
99
+
100
+ # ── Single-key operators ──────────────────────────────────────
101
+ if rest == "x":
102
+ result = state
103
+ for _ in range(count):
104
+ result = op_x(result)
105
+ cmd = ParsedCommand(count=count, operator="x")
106
+ return replace(result, pending_keys="", last_command=cmd)
107
+
108
+ if rest == "~":
109
+ result = state
110
+ for _ in range(count):
111
+ result = op_tilde(result)
112
+ return replace(result, pending_keys="")
113
+
114
+ if rest == "J":
115
+ return replace(op_join(state), pending_keys="")
116
+
117
+ if rest == "p":
118
+ return replace(op_put_after(state), pending_keys="")
119
+
120
+ if rest == "P":
121
+ return replace(op_put_before(state), pending_keys="")
122
+
123
+ if rest == "o":
124
+ return replace(op_open_below(state), pending_keys="")
125
+
126
+ if rest == "O":
127
+ return replace(op_open_above(state), pending_keys="")
128
+
129
+ # ── Operator waiting for motion/text-object ───────────────────
130
+ if len(rest) == 1 and rest in _OPERATOR_KEYS:
131
+ return replace(state, pending_keys=pending)
132
+
133
+ # ── Line-wise doubled operators: dd, cc, yy ──────────────────
134
+ if rest == "dd":
135
+ result = op_delete_line(state)
136
+ cmd = ParsedCommand(count=count, operator="dd")
137
+ return replace(result, pending_keys="", last_command=cmd)
138
+ if rest == "cc":
139
+ result = op_change_line(state)
140
+ return replace(result, pending_keys="")
141
+ if rest == "yy":
142
+ result = op_yank_line(state)
143
+ return replace(result, pending_keys="")
144
+
145
+ # ── Indent ────────────────────────────────────────────────────
146
+ if rest == ">>":
147
+ result = state
148
+ for _ in range(count):
149
+ result = op_indent_right(result)
150
+ return replace(result, pending_keys="")
151
+ if rest == "<<":
152
+ result = state
153
+ for _ in range(count):
154
+ result = op_indent_left(result)
155
+ return replace(result, pending_keys="")
156
+ if rest in (">", "<"):
157
+ return replace(state, pending_keys=pending)
158
+
159
+ # ── Replace: r{char} ─────────────────────────────────────────
160
+ if len(rest) == 1 and rest == "r":
161
+ return replace(state, pending_keys=pending)
162
+ if len(rest) == 2 and rest[0] == "r":
163
+ result = op_replace(state, rest[1])
164
+ return replace(result, pending_keys="")
165
+
166
+ # ── Char-search: f/F/t/T{char} ──────────────────────────────
167
+ if len(rest) == 1 and rest in "fFtT":
168
+ return replace(state, pending_keys=pending)
169
+ if len(rest) == 2 and rest[0] in "fFtT":
170
+ motion_fn = {"f": move_f, "F": move_F, "t": move_t, "T": move_T}[rest[0]]
171
+ new_cursor = motion_fn(state, rest[1])
172
+ return replace(state, cursor=new_cursor, pending_keys="")
173
+
174
+ # ── g-prefixed: gg, gj, gk ──────────────────────────────────
175
+ if rest == "g":
176
+ return replace(state, pending_keys=pending)
177
+ if rest == "gg":
178
+ return replace(state, cursor=move_gg(state), pending_keys="")
179
+ if rest == "G":
180
+ return replace(state, cursor=move_G(state), pending_keys="")
181
+
182
+ # ── Operator + text object (e.g., "diw", "ca(") ─────────────
183
+ if len(rest) >= 3 and rest[0] in _OPERATOR_KEYS and rest[1] in "ia":
184
+ text_obj = rest[1:]
185
+ region = select_text_object(state, text_obj)
186
+ if region is None:
187
+ return replace(state, pending_keys="")
188
+ start, end = region
189
+ op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[rest[0]]
190
+ result = op_fn(state, start, end)
191
+ cmd = ParsedCommand(count=count, operator=rest[0], text_object=text_obj)
192
+ return replace(result, pending_keys="", last_command=cmd)
193
+
194
+ # ── Operator + motion (e.g., "dw", "cw", "yw") ──────────────
195
+ if len(rest) >= 2 and rest[0] in _OPERATOR_KEYS:
196
+ motion_key = rest[1:]
197
+ # cw/cW acts like ce/cE in vim (stops before trailing whitespace)
198
+ effective_motion = motion_key
199
+ if rest[0] == "c" and motion_key == "w":
200
+ effective_motion = "e"
201
+ elif rest[0] == "c" and motion_key == "W":
202
+ effective_motion = "E"
203
+ new_cursor = _resolve_motion(state, effective_motion, count)
204
+ if new_cursor is not None:
205
+ start = min(state.cursor, new_cursor)
206
+ # For e/E motions, end is inclusive so add 1
207
+ end = max(state.cursor, new_cursor)
208
+ if effective_motion in ("e", "E"):
209
+ end = end + 1
210
+ op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[rest[0]]
211
+ result = op_fn(state, start, end)
212
+ cmd = ParsedCommand(count=count, operator=rest[0], motion=motion_key)
213
+ return replace(result, pending_keys="", last_command=cmd)
214
+ # Unknown motion after operator — wait for more input or reset
215
+ if len(rest) < 4:
216
+ return replace(state, pending_keys=pending)
217
+ return replace(state, pending_keys="")
218
+
219
+ # ── Simple motions ────────────────────────────────────────────
220
+ new_cursor = _resolve_motion(state, rest, count)
221
+ if new_cursor is not None:
222
+ return replace(state, cursor=new_cursor, pending_keys="")
223
+
224
+ # ── Unknown key sequence — reset pending ──────────────────────
225
+ return replace(state, pending_keys="")
226
+
227
+
228
+ def _resolve_motion(state: VimState, key: str, count: int) -> int | None:
229
+ """Resolve a motion key to a cursor position, or None if not a motion."""
230
+ if key == "h":
231
+ return move_h(state, count)
232
+ if key == "l":
233
+ return move_l(state, count)
234
+ if key == "w":
235
+ return move_w(state, count)
236
+ if key == "b":
237
+ return move_b(state, count)
238
+ if key == "e":
239
+ return move_e(state, count)
240
+ if key == "W":
241
+ return move_W(state, count)
242
+ if key == "B":
243
+ return move_B(state, count)
244
+ if key == "E":
245
+ return move_E(state, count)
246
+ if key == "0":
247
+ return move_0(state)
248
+ if key == "^":
249
+ return move_caret(state)
250
+ if key == "$":
251
+ return move_dollar(state)
252
+ return None
253
+
254
+
255
+ def _replay_command(state: VimState, cmd: ParsedCommand) -> VimState:
256
+ """Replay a recorded command (dot repeat)."""
257
+ if cmd.operator == "x":
258
+ result = state
259
+ for _ in range(cmd.count):
260
+ result = op_x(result)
261
+ return replace(result, last_command=cmd)
262
+ if cmd.operator == "dd":
263
+ return replace(op_delete_line(state), last_command=cmd)
264
+ if cmd.operator and cmd.text_object:
265
+ region = select_text_object(state, cmd.text_object)
266
+ if region is None:
267
+ return state
268
+ start, end = region
269
+ op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[cmd.operator]
270
+ return replace(op_fn(state, start, end), last_command=cmd)
271
+ if cmd.operator and cmd.motion:
272
+ new_cursor = _resolve_motion(state, cmd.motion, cmd.count)
273
+ if new_cursor is None:
274
+ return state
275
+ start = min(state.cursor, new_cursor)
276
+ end = max(state.cursor, new_cursor)
277
+ op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[cmd.operator]
278
+ return replace(op_fn(state, start, end), last_command=cmd)
279
+ return state
llm_code/vim/types.py ADDED
@@ -0,0 +1,68 @@
1
+ """Core vim types — mode, state, register, parsed command."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ from dataclasses import dataclass, replace
6
+
7
+
8
+ class VimMode(enum.Enum):
9
+ NORMAL = "normal"
10
+ INSERT = "insert"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Register:
15
+ content: str = ""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ParsedCommand:
20
+ count: int = 1
21
+ operator: str | None = None
22
+ motion: str | None = None
23
+ text_object: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class VimState:
28
+ buffer: str
29
+ cursor: int
30
+ mode: VimMode
31
+ register: Register
32
+ undo_stack: tuple[tuple[str, int], ...] = ()
33
+ last_command: ParsedCommand | None = None
34
+ pending_keys: str = ""
35
+
36
+ def with_cursor(self, pos: int) -> VimState:
37
+ clamped = max(0, min(pos, len(self.buffer)))
38
+ return replace(self, cursor=clamped)
39
+
40
+ def with_buffer(self, buf: str, cursor: int | None = None) -> VimState:
41
+ c = cursor if cursor is not None else self.cursor
42
+ clamped = max(0, min(c, len(buf)))
43
+ return replace(self, buffer=buf, cursor=clamped)
44
+
45
+ def with_mode(self, mode: VimMode) -> VimState:
46
+ return replace(self, mode=mode)
47
+
48
+ def with_register(self, content: str) -> VimState:
49
+ return replace(self, register=Register(content=content))
50
+
51
+ def push_undo(self) -> VimState:
52
+ entry = (self.buffer, self.cursor)
53
+ return replace(self, undo_stack=self.undo_stack + (entry,))
54
+
55
+ def pop_undo(self) -> VimState:
56
+ if not self.undo_stack:
57
+ return self
58
+ *rest, (buf, cur) = self.undo_stack
59
+ return replace(self, buffer=buf, cursor=cur, undo_stack=tuple(rest))
60
+
61
+
62
+ def initial_state(buffer: str) -> VimState:
63
+ return VimState(
64
+ buffer=buffer,
65
+ cursor=len(buffer),
66
+ mode=VimMode.INSERT,
67
+ register=Register(),
68
+ )
@@ -0,0 +1 @@
1
+ """Voice input (STT) module for llm-code."""
@@ -0,0 +1,43 @@
1
+ """Supported STT language codes."""
2
+ from __future__ import annotations
3
+
4
+ LANGUAGE_MAP: dict[str, str] = {
5
+ "en": "English",
6
+ "zh": "Chinese",
7
+ "ja": "Japanese",
8
+ "ko": "Korean",
9
+ "es": "Spanish",
10
+ "fr": "French",
11
+ "de": "German",
12
+ "pt": "Portuguese",
13
+ "ru": "Russian",
14
+ "ar": "Arabic",
15
+ "it": "Italian",
16
+ "nl": "Dutch",
17
+ "pl": "Polish",
18
+ "sv": "Swedish",
19
+ "da": "Danish",
20
+ "no": "Norwegian",
21
+ "fi": "Finnish",
22
+ "tr": "Turkish",
23
+ "th": "Thai",
24
+ "vi": "Vietnamese",
25
+ "id": "Indonesian",
26
+ "ms": "Malay",
27
+ "hi": "Hindi",
28
+ "uk": "Ukrainian",
29
+ "cs": "Czech",
30
+ "el": "Greek",
31
+ "he": "Hebrew",
32
+ "hu": "Hungarian",
33
+ "ro": "Romanian",
34
+ }
35
+
36
+
37
+ def validate_language(code: str) -> str:
38
+ """Return the code if valid, raise ValueError otherwise."""
39
+ if code not in LANGUAGE_MAP:
40
+ raise ValueError(
41
+ f"Unsupported language code: {code!r}. Valid: {sorted(LANGUAGE_MAP)}"
42
+ )
43
+ return code
@@ -0,0 +1,136 @@
1
+ """Audio recording with sounddevice (primary) and sox/arecord fallback."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ import shutil
6
+ import subprocess
7
+ import threading
8
+ import time
9
+
10
+
11
+ class RecorderBackend(enum.Enum):
12
+ SOUNDDEVICE = "sounddevice"
13
+ SOX = "sox"
14
+ ARECORD = "arecord"
15
+
16
+
17
+ def detect_backend() -> RecorderBackend:
18
+ """Detect the best available recording backend."""
19
+ try:
20
+ import sounddevice as _sd # noqa: F401
21
+ return RecorderBackend.SOUNDDEVICE
22
+ except (ImportError, TypeError):
23
+ pass
24
+
25
+ if shutil.which("sox"):
26
+ return RecorderBackend.SOX
27
+ if shutil.which("arecord"):
28
+ return RecorderBackend.ARECORD
29
+
30
+ raise RuntimeError(
31
+ "No audio recording backend available. "
32
+ "Install sounddevice (`pip install llm-code[voice]`) or ensure sox/arecord is on PATH."
33
+ )
34
+
35
+
36
+ class AudioRecorder:
37
+ """Records 16kHz mono 16-bit PCM audio from the microphone."""
38
+
39
+ def __init__(
40
+ self,
41
+ backend: RecorderBackend | None = None,
42
+ sample_rate: int = 16000,
43
+ channels: int = 1,
44
+ ):
45
+ self._backend = backend or RecorderBackend.SOUNDDEVICE
46
+ self.sample_rate = sample_rate
47
+ self.channels = channels
48
+ self._buffer = bytearray()
49
+ self._recording = False
50
+ self._start_time: float | None = None
51
+ self._stream = None
52
+ self._process: subprocess.Popen | None = None
53
+ self._thread: threading.Thread | None = None
54
+
55
+ def start(self) -> None:
56
+ """Begin recording audio."""
57
+ self._buffer = bytearray()
58
+ self._recording = True
59
+ self._start_time = time.monotonic()
60
+
61
+ if self._backend == RecorderBackend.SOUNDDEVICE:
62
+ self._start_sounddevice()
63
+ elif self._backend == RecorderBackend.SOX:
64
+ self._start_external([
65
+ "sox", "-d", "-t", "raw", "-r", str(self.sample_rate),
66
+ "-e", "signed", "-b", "16", "-c", str(self.channels), "-",
67
+ ])
68
+ elif self._backend == RecorderBackend.ARECORD:
69
+ self._start_external([
70
+ "arecord", "-f", "S16_LE", "-r", str(self.sample_rate),
71
+ "-c", str(self.channels), "-t", "raw", "-",
72
+ ])
73
+
74
+ def stop(self) -> bytes:
75
+ """Stop recording and return the captured PCM bytes."""
76
+ if not self._recording:
77
+ return b""
78
+
79
+ self._recording = False
80
+
81
+ if self._backend == RecorderBackend.SOUNDDEVICE and self._stream is not None:
82
+ self._stream.stop()
83
+ self._stream.close()
84
+ self._stream = None
85
+ elif self._process is not None:
86
+ self._process.terminate()
87
+ self._process.wait(timeout=3)
88
+ self._process = None
89
+ if self._thread is not None:
90
+ self._thread.join(timeout=3)
91
+ self._thread = None
92
+
93
+ result = bytes(self._buffer)
94
+ self._buffer = bytearray()
95
+ self._start_time = None
96
+ return result
97
+
98
+ def elapsed_seconds(self) -> float:
99
+ """Return seconds since recording started, or 0.0 if not recording."""
100
+ if self._start_time is None or not self._recording:
101
+ return 0.0
102
+ return time.monotonic() - self._start_time
103
+
104
+ def _start_sounddevice(self) -> None:
105
+ import sounddevice as sd # type: ignore[import]
106
+
107
+ def callback(indata, frames, time_info, status):
108
+ if self._recording:
109
+ self._buffer.extend(indata.tobytes())
110
+
111
+ self._stream = sd.RawInputStream(
112
+ samplerate=self.sample_rate,
113
+ channels=self.channels,
114
+ dtype="int16",
115
+ callback=callback,
116
+ )
117
+ self._stream.start()
118
+
119
+ def _start_external(self, cmd: list[str]) -> None:
120
+ self._process = subprocess.Popen(
121
+ cmd,
122
+ stdout=subprocess.PIPE,
123
+ stderr=subprocess.DEVNULL,
124
+ )
125
+
126
+ def _read_loop():
127
+ assert self._process is not None
128
+ assert self._process.stdout is not None
129
+ while self._recording:
130
+ chunk = self._process.stdout.read(4096)
131
+ if not chunk:
132
+ break
133
+ self._buffer.extend(chunk)
134
+
135
+ self._thread = threading.Thread(target=_read_loop, daemon=True)
136
+ self._thread.start()
llm_code/voice/stt.py ADDED
@@ -0,0 +1,36 @@
1
+ """STT engine protocol and factory."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from llm_code.runtime.config import VoiceConfig
7
+
8
+
9
+ @runtime_checkable
10
+ class STTEngine(Protocol):
11
+ """Protocol for speech-to-text backends."""
12
+
13
+ def transcribe(self, audio_bytes: bytes, language: str) -> str:
14
+ """Transcribe raw PCM audio bytes to text."""
15
+ ...
16
+
17
+
18
+ def create_stt_engine(config: VoiceConfig) -> STTEngine:
19
+ """Factory: create an STT engine from config."""
20
+ backend = config.backend
21
+
22
+ if backend == "whisper":
23
+ from llm_code.voice.stt_whisper import WhisperSTT
24
+ return WhisperSTT(url=config.whisper_url)
25
+
26
+ if backend == "google":
27
+ from llm_code.voice.stt_google import GoogleSTT
28
+ return GoogleSTT(language_code=config.google_language_code or config.language)
29
+
30
+ if backend == "anthropic":
31
+ from llm_code.voice.stt_anthropic import AnthropicSTT
32
+ return AnthropicSTT(ws_url=config.anthropic_ws_url)
33
+
34
+ raise ValueError(
35
+ f"Unknown STT backend: {backend!r}. Valid: whisper, google, anthropic"
36
+ )
@@ -0,0 +1,66 @@
1
+ """Anthropic WebSocket STT backend."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import base64
6
+ import json
7
+ import os
8
+
9
+
10
+ def _ws_transcribe(ws_url: str, audio_bytes: bytes, language: str) -> str:
11
+ """Connect to Anthropic voice_stream WebSocket and transcribe."""
12
+ return asyncio.get_event_loop().run_until_complete(
13
+ _async_ws_transcribe(ws_url, audio_bytes, language)
14
+ )
15
+
16
+
17
+ async def _async_ws_transcribe(ws_url: str, audio_bytes: bytes, language: str) -> str:
18
+ """Async WebSocket transcription."""
19
+ import websockets # type: ignore[import]
20
+
21
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
22
+ url = f"{ws_url}/v1/voice_stream"
23
+
24
+ async with websockets.connect(
25
+ url,
26
+ additional_headers={"x-api-key": api_key, "anthropic-version": "2024-01-01"},
27
+ ) as ws:
28
+ await ws.send(json.dumps({
29
+ "type": "audio_start",
30
+ "language": language,
31
+ "encoding": "pcm_s16le",
32
+ "sample_rate": 16000,
33
+ }))
34
+
35
+ # Send audio in chunks
36
+ chunk_size = 32000 # 1 second of 16kHz 16-bit
37
+ for i in range(0, len(audio_bytes), chunk_size):
38
+ chunk = audio_bytes[i: i + chunk_size]
39
+ await ws.send(json.dumps({
40
+ "type": "audio_data",
41
+ "data": base64.b64encode(chunk).decode(),
42
+ }))
43
+
44
+ await ws.send(json.dumps({"type": "audio_end"}))
45
+
46
+ # Collect transcription
47
+ transcript_parts: list[str] = []
48
+ async for msg in ws:
49
+ data = json.loads(msg)
50
+ if data.get("type") == "transcription":
51
+ transcript_parts.append(data.get("text", ""))
52
+ elif data.get("type") == "transcription_complete":
53
+ break
54
+
55
+ return " ".join(transcript_parts).strip()
56
+
57
+
58
+ class AnthropicSTT:
59
+ """Transcribe audio via Anthropic WebSocket voice_stream."""
60
+
61
+ def __init__(self, ws_url: str = "wss://api.anthropic.com"):
62
+ self._ws_url = ws_url
63
+
64
+ def transcribe(self, audio_bytes: bytes, language: str) -> str:
65
+ """Send audio to Anthropic WebSocket STT."""
66
+ return _ws_transcribe(self._ws_url, audio_bytes, language)
@@ -0,0 +1,32 @@
1
+ """Google Cloud Speech STT backend."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def _get_client():
6
+ """Lazy import and create Google Speech client."""
7
+ from google.cloud import speech # type: ignore[import]
8
+ return speech.SpeechClient()
9
+
10
+
11
+ class GoogleSTT:
12
+ """Transcribe audio via Google Cloud Speech-to-Text API."""
13
+
14
+ def __init__(self, language_code: str = "en-US"):
15
+ self._language_code = language_code
16
+
17
+ def transcribe(self, audio_bytes: bytes, language: str) -> str:
18
+ """Send PCM audio to Google Cloud Speech."""
19
+ from google.cloud import speech # type: ignore[import]
20
+
21
+ client = _get_client()
22
+ audio = speech.RecognitionAudio(content=audio_bytes)
23
+ config = speech.RecognitionConfig(
24
+ encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
25
+ sample_rate_hertz=16000,
26
+ language_code=self._language_code if self._language_code else language,
27
+ )
28
+ response = client.recognize(config=config, audio=audio)
29
+
30
+ if not response.results:
31
+ return ""
32
+ return response.results[0].alternatives[0].transcript