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
compaction.py ADDED
@@ -0,0 +1,378 @@
1
+ """Context window management: two-layer compression for long conversations."""
2
+ from __future__ import annotations
3
+
4
+ import providers
5
+
6
+
7
+ # ── Token estimation ──────────────────────────────────────────────────────
8
+
9
+ def estimate_tokens(messages: list, model: str = "", config: dict | None = None) -> int:
10
+ """Estimate token count.
11
+
12
+ For Kimi/Moonshot models, uses the native Kimi API token estimation endpoint
13
+ if API key is available. Otherwise falls back to character-based estimation.
14
+
15
+ Args:
16
+ messages: list of message dicts with "content" field (str or list of dicts)
17
+ model: model string (optional, e.g., "kimi-k2.5")
18
+ config: agent config dict (optional, for accessing API keys)
19
+ Returns:
20
+ approximate token count, int
21
+ """
22
+ # Try Kimi native API estimation if this is a Kimi/Moonshot model
23
+ if model and (providers.detect_provider(model) in ("kimi", "moonshot")):
24
+ api_key = ""
25
+ if config:
26
+ api_key = providers.get_api_key("kimi", config) or providers.get_api_key("moonshot", config)
27
+ if api_key:
28
+ from providers import estimate_tokens_kimi
29
+ kimi_estimate = estimate_tokens_kimi(api_key, providers.bare_model(model), messages)
30
+ if kimi_estimate is not None:
31
+ return kimi_estimate
32
+
33
+ # Fall back to character-based estimation.
34
+ # Formula: chars/2.8 (tighter divisor than the naive /4, more accurate for
35
+ # code+JSON heavy conversations) + per-message framing overhead + 10%
36
+ # safety buffer. Overcount slightly so compaction fires before API rejects.
37
+ total_chars = 0
38
+ msg_count = 0
39
+ for m in messages:
40
+ msg_count += 1
41
+ content = m.get("content", "")
42
+ if isinstance(content, str):
43
+ total_chars += len(content)
44
+ elif isinstance(content, list):
45
+ for block in content:
46
+ if isinstance(block, dict):
47
+ # Sum all string values in the block
48
+ for v in block.values():
49
+ if isinstance(v, str):
50
+ total_chars += len(v)
51
+ # Also count tool_calls if present
52
+ for tc in m.get("tool_calls", []):
53
+ if isinstance(tc, dict):
54
+ for v in tc.values():
55
+ if isinstance(v, str):
56
+ total_chars += len(v)
57
+ content_tokens = int(total_chars / 2.8)
58
+ framing_tokens = msg_count * 4 # role + delimiters overhead per msg
59
+ return int((content_tokens + framing_tokens) * 1.1)
60
+
61
+
62
+ def get_context_limit(model: str) -> int:
63
+ """Look up context window size for a model.
64
+
65
+ Args:
66
+ model: model string (e.g. "claude-opus-4-6", "ollama/llama3.3")
67
+ Returns:
68
+ context limit in tokens
69
+ """
70
+ provider_name = providers.detect_provider(model)
71
+ prov = providers.PROVIDERS.get(provider_name, {})
72
+ return prov.get("context_limit", 128000)
73
+
74
+
75
+ # ── Layer 1: Snip old tool results ────────────────────────────────────────
76
+
77
+ def snip_old_tool_results(
78
+ messages: list,
79
+ max_chars: int = 2000,
80
+ preserve_last_n_turns: int = 6,
81
+ ) -> list:
82
+ """Truncate tool-role messages older than preserve_last_n_turns from end.
83
+
84
+ For old tool messages whose content exceeds max_chars, keep the first half
85
+ and last quarter, inserting '[... N chars snipped ...]' in between.
86
+ Mutates in place and returns the same list.
87
+
88
+ Args:
89
+ messages: list of message dicts (mutated in place)
90
+ max_chars: maximum character length before truncation
91
+ preserve_last_n_turns: number of messages from end to preserve
92
+ Returns:
93
+ the same messages list (mutated)
94
+ """
95
+ cutoff = max(0, len(messages) - preserve_last_n_turns)
96
+ for i in range(cutoff):
97
+ m = messages[i]
98
+ if m.get("role") != "tool":
99
+ continue
100
+ content = m.get("content", "")
101
+ if not isinstance(content, str) or len(content) <= max_chars:
102
+ continue
103
+ first_half = content[: max_chars // 2]
104
+ last_quarter = content[-(max_chars // 4):]
105
+ snipped = len(content) - len(first_half) - len(last_quarter)
106
+ m["content"] = f"{first_half}\n[... {snipped} chars snipped ...]\n{last_quarter}"
107
+ return messages
108
+
109
+
110
+ # ── Smart priority scoring for compaction ─────────────────────────────────
111
+
112
+ # Keywords that indicate high-value content we should preserve
113
+ _HIGH_VALUE_KEYWORDS = (
114
+ "error", "exception", "traceback", "failed", "failure", "bug",
115
+ "fix", "resolved", "solution", "workaround", "broken",
116
+ "decidí", "decidi", "voy a", "plan:", "decision:", "conclusion:",
117
+ "next step", "action:", "todo:", "resolved:", "completed:",
118
+ "created file", "modified file", "deleted file", "moved file",
119
+ "root cause", "solution:", "approach:",
120
+ )
121
+
122
+ # File extensions that indicate code references
123
+ _CODE_EXTENSIONS = (
124
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
125
+ ".c", ".cpp", ".h", ".hpp", ".rb", ".sh", ".json", ".yml",
126
+ ".yaml", ".toml", ".md", ".txt", ".sql", ".html", ".css",
127
+ ".scss", ".dockerfile", ".ini", ".cfg",
128
+ )
129
+
130
+
131
+ def _score_message_priority(message: dict) -> int:
132
+ """Score a message by importance (higher = more important to preserve).
133
+
134
+ Returns an integer priority score. Messages with score >= 3 are
135
+ considered 'high priority' and should be preserved during compaction.
136
+ """
137
+ score = 0
138
+ content = message.get("content", "")
139
+ role = message.get("role", "")
140
+
141
+ if not isinstance(content, str):
142
+ content = str(content) if content else ""
143
+ text_lower = content.lower()
144
+
145
+ # Errors / tracebacks are critical (preserve at all costs)
146
+ if any(k in text_lower for k in ("traceback", "exception", "error:", "failed", "failure")):
147
+ score += 4
148
+
149
+ # Decisions / plans are high value
150
+ if any(k in text_lower for k in _HIGH_VALUE_KEYWORDS):
151
+ score += 2
152
+
153
+ # File references indicate code context
154
+ if any(ext in text_lower for ext in _CODE_EXTENSIONS):
155
+ score += 1
156
+
157
+ # Tool results that contain actual data (not just "no output")
158
+ if role == "tool" and len(content) > 100:
159
+ score += 1
160
+
161
+ # User messages are slightly more important than assistant fluff
162
+ if role == "user":
163
+ score += 1
164
+
165
+ # System messages are least important (except the first one)
166
+ if role == "system":
167
+ score -= 2
168
+
169
+ return max(0, score)
170
+
171
+
172
+ def _is_safe_split(messages: list, idx: int) -> bool:
173
+ """A split is safe only if messages[idx] is not a `tool` message
174
+ (which would be orphaned from its assistant tool_calls partner)."""
175
+ if idx <= 0 or idx >= len(messages):
176
+ return True
177
+ return messages[idx].get("role") != "tool"
178
+
179
+
180
+ def find_split_point(messages: list, keep_ratio: float = 0.3, model: str = "", config: dict | None = None) -> int:
181
+ """Find index that splits messages so ~keep_ratio of tokens are in the recent portion.
182
+
183
+ Walks backwards from end, accumulating token estimates, and returns the
184
+ index where the recent portion reaches ~keep_ratio of total tokens.
185
+
186
+ Args:
187
+ messages: list of message dicts
188
+ keep_ratio: fraction of tokens to keep in the recent portion
189
+ model: model string (optional, for provider-specific estimation)
190
+ config: agent config dict (optional)
191
+ Returns:
192
+ split index (messages[:idx] = old, messages[idx:] = recent).
193
+ Always returns an index that does not orphan a tool message from
194
+ its assistant tool_calls partner.
195
+ """
196
+ total = estimate_tokens(messages, model=model, config=config)
197
+ target = int(total * keep_ratio)
198
+ running = 0
199
+ split = 0
200
+ for i in range(len(messages) - 1, -1, -1):
201
+ running += estimate_tokens([messages[i]], model=model, config=config)
202
+ if running >= target:
203
+ split = i
204
+ break
205
+ # Walk forward until we land on a non-tool message, so the recent
206
+ # portion never starts with an orphaned tool result.
207
+ while split < len(messages) and messages[split].get("role") == "tool":
208
+ split += 1
209
+ return split
210
+
211
+
212
+ def compact_messages(messages: list, config: dict, focus: str = "") -> list:
213
+ """Compress old messages into a summary via LLM call.
214
+
215
+ Splits at find_split_point, summarizes old portion, returns
216
+ [summary_msg, ack_msg, *recent_messages].
217
+
218
+ Smart behavior: messages with high priority score (errors, decisions,
219
+ file references) are preserved verbatim instead of being summarized away.
220
+
221
+ Args:
222
+ messages: full message list
223
+ config: agent config dict (must contain "model")
224
+ focus: optional focus instructions for the summarizer
225
+ Returns:
226
+ new compacted message list
227
+ """
228
+ model = config.get("model", "")
229
+ split = find_split_point(messages, model=model, config=config)
230
+ if split <= 0:
231
+ return messages
232
+
233
+ old = messages[:split]
234
+ recent = messages[split:]
235
+
236
+ # ── Smart separation: keep high-priority messages verbatim ──
237
+ # Skip `tool` messages and `assistant` messages with tool_calls — pinning
238
+ # either alone orphans the pair and triggers
239
+ # `tool_call_id is not found` (HTTP 400) on the next API call.
240
+ pinned = []
241
+ to_summarize = []
242
+ for m in old:
243
+ role = m.get("role", "")
244
+ has_tool_calls = bool(m.get("tool_calls"))
245
+ if role == "tool" or has_tool_calls:
246
+ to_summarize.append(m)
247
+ elif _score_message_priority(m) >= 3:
248
+ pinned.append(m)
249
+ else:
250
+ to_summarize.append(m)
251
+
252
+ # Build summary request from non-pinned messages only
253
+ old_text = ""
254
+ for m in to_summarize:
255
+ role = m.get("role", "?")
256
+ content = m.get("content", "")
257
+ if isinstance(content, str):
258
+ old_text += f"[{role}]: {content[:500]}\n"
259
+ elif isinstance(content, list):
260
+ old_text += f"[{role}]: (structured content)\n"
261
+
262
+ summary_prompt = (
263
+ "Summarize the following conversation history concisely. "
264
+ "Preserve key decisions, file paths, tool results, and context "
265
+ "needed to continue the conversation."
266
+ )
267
+ if focus:
268
+ summary_prompt += f"\n\nFocus especially on: {focus}"
269
+ if pinned:
270
+ summary_prompt += (
271
+ f"\n\nNote: {len(pinned)} high-priority messages (errors, "
272
+ f"decisions, file references) will be preserved verbatim."
273
+ )
274
+ summary_prompt += "\n\n" + old_text
275
+
276
+ # Call LLM for summary
277
+ summary_text = ""
278
+ for event in providers.stream(
279
+ model=config["model"],
280
+ system="You are a concise summarizer.",
281
+ messages=[{"role": "user", "content": summary_prompt}],
282
+ tool_schemas=[],
283
+ config=config,
284
+ ):
285
+ if isinstance(event, providers.TextChunk):
286
+ summary_text += event.text
287
+
288
+ summary_msg = {
289
+ "role": "user",
290
+ "content": f"[Previous conversation summary]\n{summary_text}",
291
+ }
292
+ ack_msg = {
293
+ "role": "assistant",
294
+ "content": "Understood. I have the context from the previous conversation. Let's continue.",
295
+ }
296
+
297
+ # Result: summary + ack + pinned high-priority old messages + recent
298
+ result = [summary_msg, ack_msg]
299
+ if pinned:
300
+ result.append({
301
+ "role": "user",
302
+ "content": f"[Preserved context: {len(pinned)} high-priority messages follow]",
303
+ })
304
+ result.extend(pinned)
305
+ result.extend(recent)
306
+ return result
307
+
308
+
309
+ # ── Main entry ────────────────────────────────────────────────────────────
310
+
311
+ def maybe_compact(state, config: dict) -> bool:
312
+ """Check if context window is getting full and compress if needed.
313
+
314
+ Runs snip_old_tool_results first, then auto-compact if still over threshold.
315
+
316
+ Args:
317
+ state: AgentState with .messages list
318
+ config: agent config dict (must contain "model")
319
+ Returns:
320
+ True if compaction was performed
321
+ """
322
+ model = config.get("model", "")
323
+ limit = get_context_limit(model)
324
+ threshold = limit * 0.7
325
+
326
+ if estimate_tokens(state.messages, model=model, config=config) <= threshold:
327
+ return False
328
+
329
+ # Layer 1: snip old tool results
330
+ snip_old_tool_results(state.messages)
331
+
332
+ if estimate_tokens(state.messages, model=model, config=config) <= threshold:
333
+ return True
334
+
335
+ # Layer 2: auto-compact
336
+ state.messages = compact_messages(state.messages, config)
337
+ state.messages.extend(_restore_plan_context(config))
338
+ return True
339
+
340
+
341
+ # ── Plan context restoration ─────────────────────────────────────────────
342
+
343
+ def _restore_plan_context(config: dict) -> list:
344
+ """If in plan mode, return messages that restore plan file context."""
345
+ from pathlib import Path
346
+ plan_file = config.get("_plan_file", "")
347
+ if not plan_file or config.get("permission_mode") != "plan":
348
+ return []
349
+ p = Path(plan_file)
350
+ if not p.exists():
351
+ return []
352
+ content = p.read_text(encoding="utf-8").strip()
353
+ if not content:
354
+ return []
355
+ return [
356
+ {"role": "user", "content": f"[Plan file restored after compaction: {plan_file}]\n\n{content}"},
357
+ {"role": "assistant", "content": "I have the plan context. Let's continue."},
358
+ ]
359
+
360
+
361
+ # ── Manual compact ───────────────────────────────────────────────────────
362
+
363
+ def manual_compact(state, config: dict, focus: str = "") -> tuple[bool, str]:
364
+ """User-triggered compaction via /compact. Not gated by threshold.
365
+
366
+ Returns (success, info_message).
367
+ """
368
+ if len(state.messages) < 4:
369
+ return False, "Not enough messages to compact."
370
+
371
+ model = config.get("model", "")
372
+ before = estimate_tokens(state.messages, model=model, config=config)
373
+ snip_old_tool_results(state.messages)
374
+ state.messages = compact_messages(state.messages, config, focus=focus)
375
+ state.messages.extend(_restore_plan_context(config))
376
+ after = estimate_tokens(state.messages, model=model, config=config)
377
+ saved = before - after
378
+ return True, f"Compacted: ~{before} → ~{after} tokens (~{saved} saved)"
config.py ADDED
@@ -0,0 +1,180 @@
1
+ """Configuration management for Dulus (multi-provider)."""
2
+ import os
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".dulus"
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+ HISTORY_FILE = CONFIG_DIR / "input_history.txt"
9
+ SESSIONS_DIR = CONFIG_DIR / "sessions"
10
+ DAILY_DIR = SESSIONS_DIR / "daily" # daily/YYYY-MM-DD/session_*.json
11
+ SESSION_HIST_FILE = SESSIONS_DIR / "history.json" # master: all sessions ever
12
+ OUTPUT_DIR = CONFIG_DIR / "output" # WebFetch compressed cache
13
+
14
+ # kept for backward-compat (/resume still reads from here)
15
+ MR_SESSION_DIR = SESSIONS_DIR / "mr_sessions"
16
+
17
+ DEFAULTS = {
18
+ "model": "ollama/gemma4:latest",
19
+ "max_tokens": 250000,
20
+ "permission_mode": "auto", # auto | accept-all | manual
21
+ "verbose": False,
22
+ "thinking": False,
23
+ "git_status": False,
24
+ "thinking_budget": 50000,
25
+ "custom_base_url": "", # for "custom" provider
26
+ "max_tool_output": 2500,
27
+ "max_agent_depth": 3,
28
+ "max_concurrent_agents": 3,
29
+ "adapter_max_fix_attempts": 20, # max fix attempts per task in autoadapter worker
30
+ "session_limit_daily": 10, # max sessions kept per day in daily/
31
+ "session_limit_history": 200, # max sessions kept in history.json
32
+ "license_key": "", # Dulus license key (PRO/ENTERPRISE)
33
+ # Shell configuration (Windows only)
34
+ # Valid types: "auto" (detects gitbash/wsl), "gitbash", "wsl", "powershell", "cmd", "custom"
35
+ # For "custom", you MUST provide the full path to the shell executable
36
+ "shell": {
37
+ "type": "auto", # auto | gitbash | wsl | powershell | cmd | custom
38
+ "path": "" # e.g.: "C:\\Program Files\\Git\\bin\\bash.exe"
39
+ },
40
+ # DeepSeek-specific overrides (for models that struggle with tools)
41
+ "deep_override": False, # Use simplified system prompt for DeepSeek
42
+ "deep_tools": False, # Enable auto JSON wrapping for DeepSeek tool calls
43
+ # Brave Search API Key
44
+ "brave_search_key": "",
45
+ "brave_search_enabled": False,
46
+ "tts_enabled": False,
47
+ "tts_provider": "auto", # auto | azure | openai | gtts | pyttsx3 | riva
48
+ "azure_speech_key": "",
49
+ "azure_speech_region": "",
50
+ "azure_tts_voice": "", # e.g. es-ES-AlvaroNeural, es-MX-JorgeNeural
51
+ # WebFetch/WebSearch settings
52
+ "webfetch_compress": False, # Enable Ollama compression for WebFetch
53
+ "webfetch_translate": False, # Translate to Spanish when compressing
54
+ "search_region": "do-es", # Default search region (e.g. 'do-es', 'us-en', 'mx-es')
55
+ # Per-provider API keys (optional; env vars take priority)
56
+ # "anthropic_api_key": "sk-ant-..."
57
+ # "openai_api_key": "sk-..."
58
+ # "gemini_api_key": "..."
59
+ # "kimi_api_key": "..."
60
+ # "qwen_api_key": "..."
61
+ # "zhipu_api_key": "..."
62
+ # "deepseek_api_key": "..."
63
+ # License key (Pro / Enterprise)
64
+ "license_key": "",
65
+ # Qwen-web (chat.qwen.ai consumer session) — populated by /harvest-qwen
66
+ "qwen_web_auth_path": "",
67
+ "qwen_web_chat_id": "",
68
+ "qwen_web_parent_id": "",
69
+ # RTK (Rust Token Killer) — transparently rewrites covered shell commands
70
+ # via the rtk binary for token-optimized output. Soft-fallback if rtk is
71
+ # missing. Linux/Mac users: bash rtk/install.sh to fetch the binary.
72
+ "rtk_enabled": True,
73
+ }
74
+
75
+
76
+ # ── Simple secret encryption (XOR + base64) — no external deps ────────────
77
+ _SECRET_KEY = os.environ.get("DULUS_SECRET", "dulus-default-key")
78
+
79
+ def _encrypt(value: str) -> str:
80
+ """Encrypt a string with XOR + base64."""
81
+ if not value or value.startswith("enc:"):
82
+ return value
83
+ key = _SECRET_KEY.encode("utf-8")
84
+ data = value.encode("utf-8")
85
+ enc = bytes(data[i] ^ key[i % len(key)] for i in range(len(data)))
86
+ return "enc:" + __import__("base64").b64encode(enc).decode("ascii")
87
+
88
+
89
+ def _decrypt(value: str) -> str:
90
+ """Decrypt a string encrypted with _encrypt."""
91
+ if not value or not value.startswith("enc:"):
92
+ return value
93
+ try:
94
+ key = _SECRET_KEY.encode("utf-8")
95
+ enc = __import__("base64").b64decode(value[4:])
96
+ data = bytes(enc[i] ^ key[i % len(key)] for i in range(len(enc)))
97
+ return data.decode("utf-8")
98
+ except Exception:
99
+ return value
100
+
101
+
102
+ def _secure_keys(cfg: dict) -> dict:
103
+ """Encrypt all *_api_key values before saving."""
104
+ for k, v in list(cfg.items()):
105
+ if k.endswith("_api_key") and v and isinstance(v, str):
106
+ cfg[k] = _encrypt(v)
107
+ return cfg
108
+
109
+
110
+ def _unsecure_keys(cfg: dict) -> dict:
111
+ """Decrypt all *_api_key values after loading."""
112
+ for k, v in list(cfg.items()):
113
+ if k.endswith("_api_key") and v and isinstance(v, str):
114
+ cfg[k] = _decrypt(v)
115
+ return cfg
116
+
117
+
118
+ def load_config() -> dict:
119
+ CONFIG_DIR.mkdir(exist_ok=True)
120
+ SESSIONS_DIR.mkdir(exist_ok=True)
121
+ OUTPUT_DIR.mkdir(exist_ok=True)
122
+ cfg = dict(DEFAULTS)
123
+ if CONFIG_FILE.exists():
124
+ try:
125
+ cfg.update(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
126
+ except Exception:
127
+ pass
128
+ # Decrypt secured keys
129
+ cfg = _unsecure_keys(cfg)
130
+ # Backward-compat: legacy single api_key → anthropic_api_key
131
+ if cfg.get("api_key") and not cfg.get("anthropic_api_key"):
132
+ cfg["anthropic_api_key"] = cfg.pop("api_key")
133
+ # Also accept ANTHROPIC_API_KEY env for backward-compat
134
+ if not cfg.get("anthropic_api_key"):
135
+ cfg["anthropic_api_key"] = os.environ.get("ANTHROPIC_API_KEY", "")
136
+ # Bridge config-stored provider keys → env vars so submodules that read
137
+ # from os.environ (e.g. voice/stt.py for NVIDIA Riva) work without
138
+ # duplicating the key. Only sets vars that aren't already in env.
139
+ _ENV_BRIDGE = {
140
+ "nvidia-web_api_key": "NVIDIA_API_KEY",
141
+ "openai_api_key": "OPENAI_API_KEY",
142
+ "gemini_api_key": "GEMINI_API_KEY",
143
+ "deepseek_api_key": "DEEPSEEK_API_KEY",
144
+ "kimi_api_key": "MOONSHOT_API_KEY",
145
+ "kimi_code_api_key": "KIMI_CODE_API_KEY",
146
+ "azure_speech_key": "AZURE_SPEECH_KEY",
147
+ "composio_api_key": "COMPOSIO_API_KEY",
148
+ }
149
+ for cfg_key, env_var in _ENV_BRIDGE.items():
150
+ val = cfg.get(cfg_key)
151
+ if val and not os.environ.get(env_var):
152
+ os.environ[env_var] = val
153
+ return cfg
154
+
155
+
156
+ def save_config(cfg: dict):
157
+ CONFIG_DIR.mkdir(exist_ok=True)
158
+ # Strip internal runtime keys (e.g. _run_query_callback) before saving
159
+ data = {k: v for k, v in cfg.items() if not k.startswith("_")}
160
+ # Encrypt API keys before saving
161
+ data = _secure_keys(dict(data))
162
+ CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
163
+
164
+
165
+ def current_provider(cfg: dict) -> str:
166
+ from providers import detect_provider
167
+ return detect_provider(cfg.get("model", "claude-opus-4-6"))
168
+
169
+
170
+ def has_api_key(cfg: dict) -> bool:
171
+ """Check whether the active provider has an API key configured."""
172
+ from providers import get_api_key
173
+ pname = current_provider(cfg)
174
+ key = get_api_key(pname, cfg)
175
+ return bool(key)
176
+
177
+
178
+ def calc_cost(model: str, in_tokens: int, out_tokens: int) -> float:
179
+ from providers import calc_cost as _cc
180
+ return _cc(model, in_tokens, out_tokens)