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
messaging/session.py ADDED
@@ -0,0 +1,334 @@
1
+ """
2
+ Session Store for Messaging Platforms
3
+
4
+ Provides persistent storage for mapping platform messages to Claude CLI session IDs
5
+ and message trees for conversation continuation.
6
+ """
7
+
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import tempfile
12
+ import threading
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ from loguru import logger
17
+
18
+
19
+ class SessionStore:
20
+ """
21
+ Persistent storage for message ↔ Claude session mappings and message trees.
22
+
23
+ Uses a JSON file for storage with thread-safe operations.
24
+ Platform-agnostic: works with any messaging platform.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ storage_path: str = "sessions.json",
30
+ *,
31
+ message_log_cap: int | None = None,
32
+ ):
33
+ self.storage_path = storage_path
34
+ self._lock = threading.Lock()
35
+ self._trees: dict[str, dict] = {} # root_id -> tree data
36
+ self._node_to_tree: dict[str, str] = {} # node_id -> root_id
37
+ # Per-chat message ID log used to support best-effort UI clearing (/clear).
38
+ # Key: "{platform}:{chat_id}" -> list of records
39
+ self._message_log: dict[str, list[dict[str, Any]]] = {}
40
+ self._message_log_ids: dict[str, set[str]] = {}
41
+ self._dirty = False
42
+ self._save_timer: threading.Timer | None = None
43
+ self._save_debounce_secs = 0.5
44
+ self._message_log_cap: int | None = message_log_cap
45
+ self._load()
46
+
47
+ def _make_chat_key(self, platform: str, chat_id: str) -> str:
48
+ return f"{platform}:{chat_id}"
49
+
50
+ def _load(self) -> None:
51
+ """Load sessions and trees from disk."""
52
+ if not os.path.exists(self.storage_path):
53
+ return
54
+
55
+ try:
56
+ with open(self.storage_path, encoding="utf-8") as f:
57
+ data = json.load(f)
58
+
59
+ # Load trees
60
+ self._trees = data.get("trees", {})
61
+ self._node_to_tree = data.get("node_to_tree", {})
62
+
63
+ # Load message log (optional/backward compatible)
64
+ raw_log = data.get("message_log", {}) or {}
65
+ if isinstance(raw_log, dict):
66
+ self._message_log = {}
67
+ self._message_log_ids = {}
68
+ for chat_key, items in raw_log.items():
69
+ if not isinstance(chat_key, str) or not isinstance(items, list):
70
+ continue
71
+ cleaned: list[dict[str, Any]] = []
72
+ seen: set[str] = set()
73
+ for it in items:
74
+ if not isinstance(it, dict):
75
+ continue
76
+ mid = it.get("message_id")
77
+ if mid is None:
78
+ continue
79
+ mid_s = str(mid)
80
+ if mid_s in seen:
81
+ continue
82
+ seen.add(mid_s)
83
+ cleaned.append(
84
+ {
85
+ "message_id": mid_s,
86
+ "ts": str(it.get("ts") or ""),
87
+ "direction": str(it.get("direction") or ""),
88
+ "kind": str(it.get("kind") or ""),
89
+ }
90
+ )
91
+ self._message_log[chat_key] = cleaned
92
+ self._message_log_ids[chat_key] = seen
93
+
94
+ logger.info(
95
+ f"Loaded {len(self._trees)} trees and "
96
+ f"{sum(len(v) for v in self._message_log.values())} msg_ids from {self.storage_path}"
97
+ )
98
+ except Exception as e:
99
+ logger.error(f"Failed to load sessions: {e}")
100
+
101
+ def _snapshot(self) -> dict:
102
+ """Snapshot current state for serialization. Caller must hold self._lock."""
103
+ return {
104
+ "trees": dict(self._trees),
105
+ "node_to_tree": dict(self._node_to_tree),
106
+ "message_log": {k: list(v) for k, v in self._message_log.items()},
107
+ }
108
+
109
+ def _write_data(self, data: dict) -> None:
110
+ """Atomically write data dict to disk. Must be called WITHOUT holding self._lock."""
111
+ abs_target = os.path.abspath(self.storage_path)
112
+ dir_name = os.path.dirname(abs_target) or "."
113
+ fd, tmp_path = tempfile.mkstemp(
114
+ dir=dir_name, prefix=".sessions.", suffix=".tmp.json"
115
+ )
116
+ try:
117
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
118
+ json.dump(data, f, indent=2)
119
+ f.flush()
120
+ os.fsync(f.fileno())
121
+ os.replace(tmp_path, abs_target)
122
+ except BaseException:
123
+ with contextlib.suppress(OSError):
124
+ os.unlink(tmp_path)
125
+ raise
126
+
127
+ def _schedule_save(self) -> None:
128
+ """Schedule a debounced save. Caller must hold self._lock."""
129
+ self._dirty = True
130
+ if self._save_timer is not None:
131
+ self._save_timer.cancel()
132
+ self._save_timer = None
133
+ self._save_timer = threading.Timer(
134
+ self._save_debounce_secs, self._save_from_timer
135
+ )
136
+ self._save_timer.daemon = True
137
+ self._save_timer.start()
138
+
139
+ def _save_from_timer(self) -> None:
140
+ """Timer callback: save if dirty. Runs in timer thread."""
141
+ with self._lock:
142
+ if not self._dirty:
143
+ self._save_timer = None
144
+ return
145
+ snapshot = self._snapshot()
146
+ self._dirty = False
147
+ self._save_timer = None
148
+ try:
149
+ self._write_data(snapshot)
150
+ except Exception as e:
151
+ logger.error(f"Failed to save sessions: {e}")
152
+ with self._lock:
153
+ self._dirty = True
154
+
155
+ def _flush_save(self) -> dict:
156
+ """Cancel pending timer and snapshot current state. Caller must hold self._lock.
157
+ Returns snapshot dict; caller must call _write_data(snapshot) outside the lock."""
158
+ if self._save_timer is not None:
159
+ self._save_timer.cancel()
160
+ self._save_timer = None
161
+ self._dirty = False
162
+ return self._snapshot()
163
+
164
+ def flush_pending_save(self) -> None:
165
+ """Flush any pending debounced save. Call on shutdown to avoid losing data."""
166
+ with self._lock:
167
+ snapshot = self._flush_save()
168
+ try:
169
+ self._write_data(snapshot)
170
+ except Exception as e:
171
+ logger.error(f"Failed to save sessions: {e}")
172
+ with self._lock:
173
+ self._dirty = True
174
+
175
+ def record_message_id(
176
+ self,
177
+ platform: str,
178
+ chat_id: str,
179
+ message_id: str,
180
+ direction: str,
181
+ kind: str,
182
+ ) -> None:
183
+ """Record a message_id for later best-effort deletion (/clear)."""
184
+ if message_id is None:
185
+ return
186
+
187
+ chat_key = self._make_chat_key(str(platform), str(chat_id))
188
+ mid = str(message_id)
189
+
190
+ with self._lock:
191
+ seen = self._message_log_ids.setdefault(chat_key, set())
192
+ if mid in seen:
193
+ return
194
+
195
+ rec = {
196
+ "message_id": mid,
197
+ "ts": datetime.now(UTC).isoformat(),
198
+ "direction": str(direction),
199
+ "kind": str(kind),
200
+ }
201
+ self._message_log.setdefault(chat_key, []).append(rec)
202
+ seen.add(mid)
203
+
204
+ # Optional cap to prevent unbounded growth if configured.
205
+ if self._message_log_cap is not None and self._message_log_cap > 0:
206
+ items = self._message_log.get(chat_key, [])
207
+ if len(items) > self._message_log_cap:
208
+ self._message_log[chat_key] = items[-self._message_log_cap :]
209
+ self._message_log_ids[chat_key] = {
210
+ str(x.get("message_id")) for x in self._message_log[chat_key]
211
+ }
212
+
213
+ self._schedule_save()
214
+
215
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> list[str]:
216
+ """Get all recorded message IDs for a chat (in insertion order)."""
217
+ chat_key = self._make_chat_key(str(platform), str(chat_id))
218
+ with self._lock:
219
+ items = self._message_log.get(chat_key, [])
220
+ return [
221
+ str(x.get("message_id"))
222
+ for x in items
223
+ if x.get("message_id") is not None
224
+ ]
225
+
226
+ def clear_all(self) -> None:
227
+ """Clear all stored sessions/trees/mappings and persist an empty store."""
228
+ with self._lock:
229
+ self._trees.clear()
230
+ self._node_to_tree.clear()
231
+ self._message_log.clear()
232
+ self._message_log_ids.clear()
233
+ snapshot = self._flush_save()
234
+ try:
235
+ self._write_data(snapshot)
236
+ except Exception as e:
237
+ logger.error(f"Failed to save sessions: {e}")
238
+ with self._lock:
239
+ self._dirty = True
240
+
241
+ # ==================== Tree Methods ====================
242
+
243
+ @staticmethod
244
+ def _tree_lookup_ids(tree_data: dict) -> set[str]:
245
+ """Return lookup IDs represented by serialized tree nodes."""
246
+ lookup_ids: set[str] = set()
247
+ nodes = tree_data.get("nodes", {})
248
+ if not isinstance(nodes, dict):
249
+ return lookup_ids
250
+
251
+ for node_key, node_data in nodes.items():
252
+ lookup_ids.add(str(node_key))
253
+ if not isinstance(node_data, dict):
254
+ continue
255
+ node_id = node_data.get("node_id")
256
+ if node_id is not None:
257
+ lookup_ids.add(str(node_id))
258
+ status_message_id = node_data.get("status_message_id")
259
+ if status_message_id is not None:
260
+ lookup_ids.add(str(status_message_id))
261
+ return lookup_ids
262
+
263
+ def _remove_tree_lookup_ids_unlocked(self, root_id: str) -> None:
264
+ """Remove all lookup IDs currently pointing at a root. Caller holds lock."""
265
+ stale_lookup_ids = [
266
+ lookup_id
267
+ for lookup_id, mapped_root_id in self._node_to_tree.items()
268
+ if mapped_root_id == root_id
269
+ ]
270
+ for lookup_id in stale_lookup_ids:
271
+ self._node_to_tree.pop(lookup_id, None)
272
+
273
+ def save_tree(self, root_id: str, tree_data: dict) -> None:
274
+ """
275
+ Save a message tree.
276
+
277
+ Args:
278
+ root_id: Root node ID of the tree
279
+ tree_data: Serialized tree data from tree.to_dict()
280
+ """
281
+ with self._lock:
282
+ self._trees[root_id] = tree_data
283
+
284
+ self._remove_tree_lookup_ids_unlocked(root_id)
285
+ for lookup_id in self._tree_lookup_ids(tree_data):
286
+ self._node_to_tree[lookup_id] = root_id
287
+
288
+ self._schedule_save()
289
+ logger.debug(f"Saved tree {root_id}")
290
+
291
+ def get_tree(self, root_id: str) -> dict | None:
292
+ """Get a tree by its root ID."""
293
+ with self._lock:
294
+ return self._trees.get(root_id)
295
+
296
+ def register_node(self, node_id: str, root_id: str) -> None:
297
+ """Register a node ID to a tree root."""
298
+ with self._lock:
299
+ self._node_to_tree[node_id] = root_id
300
+ self._schedule_save()
301
+
302
+ def remove_node_mappings(self, node_ids: list[str]) -> None:
303
+ """Remove node IDs from the node-to-tree mapping."""
304
+ with self._lock:
305
+ for nid in node_ids:
306
+ self._node_to_tree.pop(nid, None)
307
+ self._schedule_save()
308
+
309
+ def remove_tree(self, root_id: str) -> None:
310
+ """Remove a tree and all its node mappings from the store."""
311
+ with self._lock:
312
+ tree_data = self._trees.pop(root_id, None)
313
+ if tree_data:
314
+ self._remove_tree_lookup_ids_unlocked(root_id)
315
+ self._schedule_save()
316
+
317
+ def get_all_trees(self) -> dict[str, dict]:
318
+ """Get all stored trees (public accessor)."""
319
+ with self._lock:
320
+ return dict(self._trees)
321
+
322
+ def get_node_mapping(self) -> dict[str, str]:
323
+ """Get the node-to-tree mapping (public accessor)."""
324
+ with self._lock:
325
+ return dict(self._node_to_tree)
326
+
327
+ def sync_from_tree_data(
328
+ self, trees: dict[str, dict], node_to_tree: dict[str, str]
329
+ ) -> None:
330
+ """Sync internal tree state from external data and persist."""
331
+ with self._lock:
332
+ self._trees = trees
333
+ self._node_to_tree = node_to_tree
334
+ self._schedule_save()