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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- 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)
|