devcopilot 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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,177 @@
1
+ """In-memory repository for messaging trees and node indexes."""
2
+
3
+ from loguru import logger
4
+
5
+ from .data import MessageNode, MessageState, MessageTree
6
+
7
+
8
+ class TreeRepository:
9
+ """
10
+ In-memory index of trees and node-to-root mappings.
11
+
12
+ Used only by TreeQueueManager; kept as a named type for focused tests.
13
+ """
14
+
15
+ def __init__(self) -> None:
16
+ self._trees: dict[str, MessageTree] = {}
17
+ self._node_to_tree: dict[str, str] = {}
18
+
19
+ def get_tree(self, root_id: str) -> MessageTree | None:
20
+ """Get a tree by its root ID."""
21
+ return self._trees.get(root_id)
22
+
23
+ def get_tree_for_node(self, node_id: str) -> MessageTree | None:
24
+ """Get the tree containing a given node."""
25
+ root_id = self._node_to_tree.get(node_id)
26
+ if not root_id:
27
+ return None
28
+ return self._trees.get(root_id)
29
+
30
+ def get_node(self, node_id: str) -> MessageNode | None:
31
+ """Get a node from any tree."""
32
+ tree = self.get_tree_for_node(node_id)
33
+ return tree.get_node(node_id) if tree else None
34
+
35
+ def add_tree(self, root_id: str, tree: MessageTree) -> None:
36
+ """Add a new tree to the repository."""
37
+ self._trees[root_id] = tree
38
+ self._node_to_tree[root_id] = root_id
39
+ logger.debug("TREE_REPO: add_tree root_id={}", root_id)
40
+
41
+ def register_node(self, node_id: str, root_id: str) -> None:
42
+ """Register a node ID to a tree."""
43
+ self._node_to_tree[node_id] = root_id
44
+ logger.debug("TREE_REPO: register_node node_id={} root_id={}", node_id, root_id)
45
+
46
+ def has_node(self, node_id: str) -> bool:
47
+ """Check if a node is registered in any tree."""
48
+ return node_id in self._node_to_tree
49
+
50
+ def tree_count(self) -> int:
51
+ """Get the number of trees in the repository."""
52
+ return len(self._trees)
53
+
54
+ def is_tree_busy(self, root_id: str) -> bool:
55
+ """Check if a tree is currently processing."""
56
+ tree = self._trees.get(root_id)
57
+ return tree.is_processing if tree else False
58
+
59
+ def is_node_tree_busy(self, node_id: str) -> bool:
60
+ """Check if the tree containing a node is busy."""
61
+ tree = self.get_tree_for_node(node_id)
62
+ return tree.is_processing if tree else False
63
+
64
+ def get_queue_size(self, node_id: str) -> int:
65
+ """Get queue size for the tree containing a node."""
66
+ tree = self.get_tree_for_node(node_id)
67
+ return tree.get_queue_size() if tree else 0
68
+
69
+ def resolve_parent_node_id(self, msg_id: str) -> str | None:
70
+ """
71
+ Resolve a message ID to the actual parent node ID.
72
+
73
+ Handles the case where msg_id is a status message ID
74
+ (which maps to the tree but isn't an actual node).
75
+ """
76
+ tree = self.get_tree_for_node(msg_id)
77
+ if not tree:
78
+ return None
79
+
80
+ if tree.has_node(msg_id):
81
+ return msg_id
82
+
83
+ node = tree.find_node_by_status_message(msg_id)
84
+ if node:
85
+ return node.node_id
86
+
87
+ return None
88
+
89
+ def get_pending_children(self, node_id: str) -> list[MessageNode]:
90
+ """Get all pending child nodes recursively for error propagation."""
91
+ tree = self.get_tree_for_node(node_id)
92
+ if not tree:
93
+ return []
94
+
95
+ pending: list[MessageNode] = []
96
+ stack = [node_id]
97
+
98
+ while stack:
99
+ current_id = stack.pop()
100
+ node = tree.get_node(current_id)
101
+ if not node:
102
+ continue
103
+ for child_id in node.children_ids:
104
+ child = tree.get_node(child_id)
105
+ if child and child.state == MessageState.PENDING:
106
+ pending.append(child)
107
+ stack.append(child_id)
108
+
109
+ return pending
110
+
111
+ def all_trees(self) -> list[MessageTree]:
112
+ """Get all trees in the repository."""
113
+ return list(self._trees.values())
114
+
115
+ def tree_ids(self) -> list[str]:
116
+ """Get all tree root IDs."""
117
+ return list(self._trees.keys())
118
+
119
+ def unregister_nodes(self, node_ids: list[str]) -> None:
120
+ """Remove lookup IDs from the node-to-tree mapping."""
121
+ for nid in node_ids:
122
+ self._node_to_tree.pop(nid, None)
123
+
124
+ def unregister_node_lookups(self, nodes: list[MessageNode]) -> None:
125
+ """Remove node and status-message lookup IDs for removed nodes."""
126
+ lookup_ids: list[str] = []
127
+ for node in nodes:
128
+ lookup_ids.append(node.node_id)
129
+ if node.status_message_id:
130
+ lookup_ids.append(node.status_message_id)
131
+ self.unregister_nodes(lookup_ids)
132
+
133
+ def remove_tree(self, root_id: str) -> MessageTree | None:
134
+ """
135
+ Remove a tree and all its node mappings from the repository.
136
+
137
+ Returns the removed tree, or None if not found.
138
+ """
139
+ tree = self._trees.pop(root_id, None)
140
+ if not tree:
141
+ return None
142
+ self.unregister_node_lookups(tree.all_nodes())
143
+ logger.debug("TREE_REPO: remove_tree root_id={}", root_id)
144
+ return tree
145
+
146
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]:
147
+ """Get all message IDs (incoming + status) for a platform/chat."""
148
+ msg_ids: set[str] = set()
149
+ for tree in self._trees.values():
150
+ for node in tree.all_nodes():
151
+ if str(node.incoming.platform) == str(platform) and str(
152
+ node.incoming.chat_id
153
+ ) == str(chat_id):
154
+ if node.incoming.message_id is not None:
155
+ msg_ids.add(str(node.incoming.message_id))
156
+ if node.status_message_id:
157
+ msg_ids.add(str(node.status_message_id))
158
+ return msg_ids
159
+
160
+ def to_dict(self) -> dict:
161
+ """Serialize all trees."""
162
+ return {
163
+ "trees": {rid: tree.to_dict() for rid, tree in self._trees.items()},
164
+ "node_to_tree": self._node_to_tree.copy(),
165
+ }
166
+
167
+ @classmethod
168
+ def from_dict(cls, data: dict) -> TreeRepository:
169
+ """Deserialize from dictionary."""
170
+ repo = cls()
171
+ for root_id, tree_data in data.get("trees", {}).items():
172
+ repo._trees[root_id] = MessageTree.from_dict(tree_data)
173
+ repo._node_to_tree = data.get("node_to_tree", {})
174
+ return repo
175
+
176
+
177
+ __all__ = ["TreeRepository"]
@@ -0,0 +1,235 @@
1
+ """Inbound messaging turn intake and queue admission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from loguru import logger
8
+
9
+ from core.trace import trace_event
10
+
11
+ from .cli_event_constants import STATUS_MESSAGE_PREFIXES
12
+ from .command_context import MessagingCommandContext
13
+ from .command_dispatcher import (
14
+ dispatch_command,
15
+ message_kind_for_command,
16
+ parse_command_base,
17
+ )
18
+ from .models import IncomingMessage
19
+ from .platforms.base import MessagingPlatform
20
+ from .safe_diagnostics import format_exception_for_log
21
+ from .session import SessionStore
22
+ from .trees import MessageNode, MessageState, MessageTree, TreeQueueManager
23
+
24
+
25
+ class MessagingTurnIntake:
26
+ """Owns inbound turn classification and queue admission."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ platform: MessagingPlatform,
32
+ session_store: SessionStore,
33
+ command_context: MessagingCommandContext,
34
+ get_tree_queue: Callable[[], TreeQueueManager],
35
+ process_node: Callable[[str, MessageNode], Awaitable[None]],
36
+ format_status: Callable[[str, str, str | None], str],
37
+ get_parse_mode: Callable[[], str | None],
38
+ record_outgoing_message: Callable[[str, str, str | None, str], None],
39
+ log_messaging_error_details: bool = False,
40
+ ) -> None:
41
+ self.platform = platform
42
+ self.session_store = session_store
43
+ self._command_context = command_context
44
+ self._get_tree_queue = get_tree_queue
45
+ self._process_node = process_node
46
+ self._format_status = format_status
47
+ self._get_parse_mode = get_parse_mode
48
+ self._record_outgoing_message = record_outgoing_message
49
+ self._log_messaging_error_details = log_messaging_error_details
50
+
51
+ async def handle_message(self, incoming: IncomingMessage) -> None:
52
+ """
53
+ Handle an inbound platform message and queue it if it is a user prompt.
54
+ """
55
+ cmd_base = parse_command_base(incoming.text)
56
+
57
+ try:
58
+ if incoming.message_id is not None:
59
+ self.session_store.record_message_id(
60
+ incoming.platform,
61
+ incoming.chat_id,
62
+ str(incoming.message_id),
63
+ direction="in",
64
+ kind=message_kind_for_command(cmd_base),
65
+ )
66
+ except Exception as e:
67
+ logger.debug(
68
+ "Failed to record incoming message_id: {}",
69
+ format_exception_for_log(
70
+ e, log_full_message=self._log_messaging_error_details
71
+ ),
72
+ )
73
+
74
+ if await dispatch_command(self._command_context, incoming, cmd_base):
75
+ return
76
+
77
+ text = incoming.text or ""
78
+ if any(text.startswith(p) for p in STATUS_MESSAGE_PREFIXES):
79
+ return
80
+
81
+ parent_node_id = None
82
+ tree = None
83
+ tree_queue = self._get_tree_queue()
84
+
85
+ if incoming.is_reply() and incoming.reply_to_message_id:
86
+ reply_id = incoming.reply_to_message_id
87
+ tree = tree_queue.get_tree_for_node(reply_id)
88
+ if tree:
89
+ parent_node_id = tree_queue.resolve_parent_node_id(reply_id)
90
+ if parent_node_id:
91
+ logger.info(f"Found tree for reply, parent node: {parent_node_id}")
92
+ else:
93
+ logger.warning(
94
+ f"Reply to {incoming.reply_to_message_id} found tree but no valid parent node"
95
+ )
96
+ tree = None
97
+
98
+ node_id = incoming.message_id
99
+ status_text = self._get_initial_status(tree, parent_node_id)
100
+ if incoming.status_message_id:
101
+ status_msg_id = incoming.status_message_id
102
+ await self.platform.queue_edit_message(
103
+ incoming.chat_id,
104
+ status_msg_id,
105
+ status_text,
106
+ parse_mode=self._get_parse_mode(),
107
+ fire_and_forget=False,
108
+ )
109
+ else:
110
+ status_msg_id = await self.platform.queue_send_message(
111
+ incoming.chat_id,
112
+ status_text,
113
+ reply_to=incoming.message_id,
114
+ fire_and_forget=False,
115
+ message_thread_id=incoming.message_thread_id,
116
+ )
117
+ self._record_outgoing_message(
118
+ incoming.platform, incoming.chat_id, status_msg_id, "status"
119
+ )
120
+
121
+ tree_queue = self._get_tree_queue()
122
+ if parent_node_id and tree and status_msg_id:
123
+ tree, _node = await tree_queue.add_to_tree(
124
+ parent_node_id=parent_node_id,
125
+ node_id=node_id,
126
+ incoming=incoming,
127
+ status_message_id=status_msg_id,
128
+ )
129
+ tree_queue.register_node(status_msg_id, tree.root_id)
130
+ self.session_store.register_node(status_msg_id, tree.root_id)
131
+ self.session_store.register_node(node_id, tree.root_id)
132
+ elif status_msg_id:
133
+ tree = await tree_queue.create_tree(
134
+ node_id=node_id,
135
+ incoming=incoming,
136
+ status_message_id=status_msg_id,
137
+ )
138
+ tree_queue.register_node(status_msg_id, tree.root_id)
139
+ self.session_store.register_node(node_id, tree.root_id)
140
+ self.session_store.register_node(status_msg_id, tree.root_id)
141
+
142
+ if tree:
143
+ self.session_store.save_tree(tree.root_id, tree.to_dict())
144
+
145
+ was_queued = await tree_queue.enqueue(
146
+ node_id=node_id,
147
+ processor=self._process_node,
148
+ )
149
+
150
+ if was_queued and status_msg_id:
151
+ queue_size = tree_queue.get_queue_size(node_id)
152
+ trace_event(
153
+ stage="routing",
154
+ event="turn.queued",
155
+ source=getattr(self.platform, "name", "messaging"),
156
+ chat_id=incoming.chat_id,
157
+ platform_message_id=node_id,
158
+ status_message_id=status_msg_id,
159
+ queue_size=queue_size,
160
+ )
161
+ await self.platform.queue_edit_message(
162
+ incoming.chat_id,
163
+ status_msg_id,
164
+ self._format_status(
165
+ "📋", "Queued", f"(position {queue_size}) - waiting..."
166
+ ),
167
+ parse_mode=self._get_parse_mode(),
168
+ )
169
+
170
+ async def update_queue_positions(self, tree: MessageTree) -> None:
171
+ """Refresh queued status messages after a dequeue."""
172
+ try:
173
+ queued_ids = await tree.get_queue_snapshot()
174
+ except Exception as e:
175
+ logger.warning(
176
+ "Failed to read queue snapshot: {}",
177
+ format_exception_for_log(
178
+ e, log_full_message=self._log_messaging_error_details
179
+ ),
180
+ )
181
+ return
182
+
183
+ if not queued_ids:
184
+ return
185
+
186
+ position = 0
187
+ for node_id in queued_ids:
188
+ node = tree.get_node(node_id)
189
+ if not node or node.state != MessageState.PENDING:
190
+ continue
191
+ position += 1
192
+ self.platform.fire_and_forget(
193
+ self.platform.queue_edit_message(
194
+ node.incoming.chat_id,
195
+ node.status_message_id,
196
+ self._format_status(
197
+ "📋", "Queued", f"(position {position}) - waiting..."
198
+ ),
199
+ parse_mode=self._get_parse_mode(),
200
+ )
201
+ )
202
+
203
+ async def mark_node_processing(self, tree: MessageTree, node_id: str) -> None:
204
+ """Update the dequeued node's status to processing immediately."""
205
+ node = tree.get_node(node_id)
206
+ if not node or node.state == MessageState.ERROR:
207
+ return
208
+ self.platform.fire_and_forget(
209
+ self.platform.queue_edit_message(
210
+ node.incoming.chat_id,
211
+ node.status_message_id,
212
+ self._format_status("🔄", "Processing...", None),
213
+ parse_mode=self._get_parse_mode(),
214
+ )
215
+ )
216
+
217
+ def _get_initial_status(
218
+ self,
219
+ tree: object | None,
220
+ parent_node_id: str | None,
221
+ ) -> str:
222
+ """Get initial status message text."""
223
+ tree_queue = self._get_tree_queue()
224
+ if tree and parent_node_id:
225
+ if tree_queue.is_node_tree_busy(parent_node_id):
226
+ queue_size = tree_queue.get_queue_size(parent_node_id) + 1
227
+ return self._format_status(
228
+ "📋", "Queued", f"(position {queue_size}) - waiting..."
229
+ )
230
+ return self._format_status("🔄", "Continuing conversation...", None)
231
+
232
+ return self._format_status("⏳", "Launching new Claude CLI instance...", None)
233
+
234
+
235
+ __all__ = ["MessagingTurnIntake"]
@@ -0,0 +1,101 @@
1
+ """Throttled platform UI updates driven by transcript rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+
8
+ from loguru import logger
9
+
10
+ from .platforms.base import MessagingPlatform
11
+ from .safe_diagnostics import format_exception_for_log
12
+ from .transcript import RenderCtx, TranscriptBuffer
13
+
14
+
15
+ class ThrottledTranscriptEditor:
16
+ """Rate-limited status message edits from a growing transcript."""
17
+
18
+ def __init__(
19
+ self,
20
+ *,
21
+ platform: MessagingPlatform,
22
+ parse_mode: str | None,
23
+ get_limit_chars: Callable[[], int],
24
+ transcript: TranscriptBuffer,
25
+ render_ctx: RenderCtx,
26
+ node_id: str,
27
+ chat_id: str,
28
+ status_msg_id: str,
29
+ debug_platform_edits: bool,
30
+ log_messaging_error_details: bool = False,
31
+ ) -> None:
32
+ self._platform = platform
33
+ self._parse_mode = parse_mode
34
+ self._get_limit_chars = get_limit_chars
35
+ self._transcript = transcript
36
+ self._render_ctx = render_ctx
37
+ self._node_id = node_id
38
+ self._chat_id = chat_id
39
+ self._status_msg_id = status_msg_id
40
+ self._debug_platform_edits = debug_platform_edits
41
+ self._log_messaging_error_details = log_messaging_error_details
42
+ self._last_ui_update = 0.0
43
+ self._last_displayed_text: str | None = None
44
+ self._last_status: str | None = None
45
+
46
+ @property
47
+ def last_status(self) -> str | None:
48
+ return self._last_status
49
+
50
+ async def update(self, status: str | None = None, *, force: bool = False) -> None:
51
+ """Render transcript + optional status line and edit the platform message."""
52
+ now = time.time()
53
+ if not force and now - self._last_ui_update < 1.0:
54
+ return
55
+
56
+ self._last_ui_update = now
57
+ if status is not None:
58
+ self._last_status = status
59
+ try:
60
+ display = self._transcript.render(
61
+ self._render_ctx,
62
+ limit_chars=self._get_limit_chars(),
63
+ status=status,
64
+ )
65
+ except Exception as e:
66
+ logger.warning(
67
+ "Transcript render failed for node {}: {}",
68
+ self._node_id,
69
+ format_exception_for_log(
70
+ e, log_full_message=self._log_messaging_error_details
71
+ ),
72
+ )
73
+ return
74
+ if display and display != self._last_displayed_text:
75
+ logger.debug(
76
+ "PLATFORM_EDIT: node_id={} chat_id={} msg_id={} force={} status={!r} chars={}",
77
+ self._node_id,
78
+ self._chat_id,
79
+ self._status_msg_id,
80
+ bool(force),
81
+ status,
82
+ len(display),
83
+ )
84
+ if self._debug_platform_edits:
85
+ logger.debug("PLATFORM_EDIT_TEXT:\n{}", display)
86
+ self._last_displayed_text = display
87
+ try:
88
+ await self._platform.queue_edit_message(
89
+ self._chat_id,
90
+ self._status_msg_id,
91
+ display,
92
+ parse_mode=self._parse_mode,
93
+ )
94
+ except Exception as e:
95
+ logger.warning(
96
+ "Failed to update platform for node {}: {}",
97
+ self._node_id,
98
+ format_exception_for_log(
99
+ e, log_full_message=self._log_messaging_error_details
100
+ ),
101
+ )
messaging/voice.py ADDED
@@ -0,0 +1,76 @@
1
+ """Platform-neutral voice note helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+
9
+ class PendingVoiceRegistry:
10
+ """Track voice notes that are still waiting on transcription."""
11
+
12
+ def __init__(self) -> None:
13
+ self._pending: dict[tuple[str, str], tuple[str, str]] = {}
14
+ self._lock = asyncio.Lock()
15
+
16
+ async def register(
17
+ self, chat_id: str, voice_msg_id: str, status_msg_id: str
18
+ ) -> None:
19
+ async with self._lock:
20
+ entry = (voice_msg_id, status_msg_id)
21
+ self._pending[(chat_id, voice_msg_id)] = entry
22
+ self._pending[(chat_id, status_msg_id)] = entry
23
+
24
+ async def cancel(self, chat_id: str, reply_id: str) -> tuple[str, str] | None:
25
+ async with self._lock:
26
+ entry = self._pending.pop((chat_id, reply_id), None)
27
+ if entry is None:
28
+ return None
29
+ voice_msg_id, status_msg_id = entry
30
+ self._pending.pop((chat_id, voice_msg_id), None)
31
+ self._pending.pop((chat_id, status_msg_id), None)
32
+ return entry
33
+
34
+ async def is_pending(self, chat_id: str, voice_msg_id: str) -> bool:
35
+ async with self._lock:
36
+ return (chat_id, voice_msg_id) in self._pending
37
+
38
+ async def complete(
39
+ self, chat_id: str, voice_msg_id: str, status_msg_id: str
40
+ ) -> None:
41
+ async with self._lock:
42
+ self._pending.pop((chat_id, voice_msg_id), None)
43
+ self._pending.pop((chat_id, status_msg_id), None)
44
+
45
+
46
+ class VoiceTranscriptionService:
47
+ """Run configured transcription backends off the event loop."""
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ hf_token: str = "",
53
+ nvidia_nim_api_key: str = "",
54
+ ) -> None:
55
+ self._hf_token = hf_token
56
+ self._nvidia_nim_api_key = nvidia_nim_api_key
57
+
58
+ async def transcribe(
59
+ self,
60
+ file_path: Path,
61
+ mime_type: str,
62
+ *,
63
+ whisper_model: str,
64
+ whisper_device: str,
65
+ ) -> str:
66
+ from .transcription import transcribe_audio
67
+
68
+ return await asyncio.to_thread(
69
+ transcribe_audio,
70
+ file_path,
71
+ mime_type,
72
+ whisper_model=whisper_model,
73
+ whisper_device=whisper_device,
74
+ hf_token=self._hf_token,
75
+ nvidia_nim_api_key=self._nvidia_nim_api_key,
76
+ )