elidia-agent-cli 0.1.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 (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/__init__.py ADDED
File without changes
elidia/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from elidia.cli.main import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,3 @@
1
+ from elidia.agent.loop import AgentLoop
2
+
3
+ __all__ = ["AgentLoop"]
elidia/agent/loop.py ADDED
@@ -0,0 +1,449 @@
1
+ import json
2
+ import logging
3
+ from collections.abc import AsyncIterator
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from elidia.api.client import AiUtilsClient, ChatMessage
8
+ from elidia.mcp.registry import MCPRegistry
9
+ from elidia.models.router import ModelRouter
10
+ from elidia.modes.budget import BudgetGovernor
11
+ from elidia.modes.classifier import ExecMode, classify_mode
12
+ from elidia.modes.consensus import run_consensus
13
+ from elidia.modes.deep_think import deep_think_stream, select_reasoning_model
14
+ from elidia.modes.thinking import ThinkingLevel, get_caps
15
+ from elidia.permissions.audit import AuditLogger
16
+ from elidia.permissions.manager import PermissionManager
17
+ from elidia.tools.base import ToolRegistry
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ MAX_TOOL_LOOPS = 25
22
+
23
+
24
+ @dataclass
25
+ class AgentEvent:
26
+ kind: str # "thinking", "content", "tool_call", "tool_result", "usage", "error", "done", "mode_info", "budget_warning"
27
+ data: Any = None
28
+
29
+
30
+ @dataclass
31
+ class AgentState:
32
+ messages: list[ChatMessage] = field(default_factory=list)
33
+ model: str = ""
34
+ mode: str = "chat"
35
+ exec_mode: ExecMode = ExecMode.DIRECT
36
+ thinking_level: ThinkingLevel = ThinkingLevel.MEDIUM
37
+ loop_count: int = 0
38
+ tokens_in: int = 0
39
+ tokens_out: int = 0
40
+ total_cost_dt: float = 0.0
41
+ tools_called: list[str] = field(default_factory=list)
42
+
43
+
44
+ class AgentLoop:
45
+ """Core agent loop: route → act → observe → reflect."""
46
+
47
+ def __init__(
48
+ self,
49
+ client: AiUtilsClient,
50
+ tool_registry: ToolRegistry,
51
+ mcp_registry: MCPRegistry | None = None,
52
+ model_router: ModelRouter | None = None,
53
+ permission_manager: PermissionManager | None = None,
54
+ audit: AuditLogger | None = None,
55
+ budget: BudgetGovernor | None = None,
56
+ max_loops: int = MAX_TOOL_LOOPS,
57
+ thinking_level: ThinkingLevel | None = None,
58
+ ) -> None:
59
+ logger.debug("Entered into AgentLoop.__init__")
60
+ self._client = client
61
+ self._tools = tool_registry
62
+ self._mcp = mcp_registry
63
+ self._router = model_router or ModelRouter()
64
+ self._permissions = permission_manager
65
+ self._audit = audit
66
+ self._budget = budget or BudgetGovernor()
67
+ self._max_loops = max_loops
68
+ self._thinking_level = thinking_level or ThinkingLevel.MEDIUM
69
+
70
+ async def run(
71
+ self,
72
+ messages: list[ChatMessage],
73
+ mode: str = "chat",
74
+ forced_model: str | None = None,
75
+ session_id: str = "",
76
+ thinking_level: ThinkingLevel | None = None,
77
+ ) -> AsyncIterator[AgentEvent]:
78
+ logger.debug(f"Entered into AgentLoop.run: mode={mode}, msg_count={len(messages)}")
79
+
80
+ state = AgentState(messages=list(messages), mode=mode)
81
+ state.thinking_level = thinking_level or self._thinking_level
82
+ caps = get_caps(state.thinking_level)
83
+
84
+ user_text = messages[-1].content if messages else ""
85
+
86
+ try:
87
+ mode_decision = await classify_mode(self._client, user_text)
88
+ state.exec_mode = mode_decision.mode
89
+ yield AgentEvent(kind="mode_info", data={
90
+ "exec_mode": state.exec_mode.value,
91
+ "cost_label": mode_decision.cost_label,
92
+ "reason": mode_decision.reason,
93
+ })
94
+ except Exception as e:
95
+ logger.warning(f"Mode classification failed, defaulting to DIRECT: {e}")
96
+ state.exec_mode = ExecMode.DIRECT
97
+
98
+ decision = self._router.route(user_text, mode=mode)
99
+ state.model = forced_model or decision.model
100
+ yield AgentEvent(kind="thinking", data={"model": state.model, "reason": decision.reason})
101
+
102
+ if state.exec_mode == ExecMode.DEEP:
103
+ async for event in self._run_deep_think(state, user_text, session_id):
104
+ yield event
105
+ return
106
+
107
+ if state.exec_mode == ExecMode.CONSENSUS:
108
+ async for event in self._run_consensus(state, user_text, session_id):
109
+ yield event
110
+ return
111
+
112
+ effective_max_loops = min(self._max_loops, caps.max_loops)
113
+ all_tool_schemas = self._build_tool_schemas() if caps.allow_tools else []
114
+
115
+ while state.loop_count <= effective_max_loops:
116
+ state.loop_count += 1
117
+ logger.debug(f"Agent loop iteration {state.loop_count}, model={state.model}")
118
+
119
+ api_messages = self._build_api_messages(state)
120
+
121
+ request_payload: dict[str, Any] = {
122
+ "model": state.model,
123
+ "messages": api_messages,
124
+ "stream": False,
125
+ "temperature": 0.3 if mode == "code" else 0.7,
126
+ }
127
+
128
+ if all_tool_schemas:
129
+ request_payload["tools"] = all_tool_schemas
130
+ request_payload["tool_choice"] = "auto"
131
+
132
+ if self._budget:
133
+ est_input = sum(len(m.content) // 4 for m in state.messages)
134
+ allowed, cost_est = self._budget.check_and_allow(state.model, est_input)
135
+ if not allowed:
136
+ yield AgentEvent(kind="budget_warning", data={
137
+ "message": cost_est.warning_message,
138
+ "estimated_dt": cost_est.estimated_dt,
139
+ })
140
+ cheaper = self._budget.suggest_cheaper_model(state.model)
141
+ if cheaper:
142
+ state.model = cheaper
143
+ yield AgentEvent(kind="thinking", data={
144
+ "model": state.model,
145
+ "reason": "Budget: switched to cheaper model",
146
+ })
147
+ else:
148
+ yield AgentEvent(kind="error", data=f"Budget exceeded: {cost_est.warning_message}")
149
+ return
150
+ elif cost_est.warning_message:
151
+ yield AgentEvent(kind="budget_warning", data={
152
+ "message": cost_est.warning_message,
153
+ "estimated_dt": cost_est.estimated_dt,
154
+ })
155
+
156
+ try:
157
+ response = await self._client.chat_completion(
158
+ messages=state.messages,
159
+ model=state.model,
160
+ temperature=request_payload.get("temperature", 0.7),
161
+ )
162
+ except RuntimeError as e:
163
+ yield AgentEvent(kind="error", data=str(e))
164
+ return
165
+
166
+ state.tokens_in += response.tokens_in
167
+ state.tokens_out += response.tokens_out
168
+ cost = response.tokens_in * 0.001 + response.tokens_out * 0.002
169
+ state.total_cost_dt += cost
170
+
171
+ if self._budget:
172
+ self._budget.record_usage(response.tokens_in, response.tokens_out, cost)
173
+
174
+ yield AgentEvent(kind="usage", data={
175
+ "tokens_in": response.tokens_in,
176
+ "tokens_out": response.tokens_out,
177
+ "cost_dt": cost,
178
+ "elapsed_ms": response.elapsed_ms,
179
+ })
180
+
181
+ tool_calls = self._extract_tool_calls(response.content)
182
+
183
+ if not tool_calls:
184
+ if response.content:
185
+ yield AgentEvent(kind="content", data=response.content)
186
+ state.messages.append(ChatMessage(role="assistant", content=response.content))
187
+ break
188
+
189
+ state.messages.append(ChatMessage(role="assistant", content=response.content))
190
+
191
+ for tc in tool_calls:
192
+ tool_name = tc["name"]
193
+ tool_args = tc.get("arguments", {})
194
+ call_id = tc.get("id", tool_name)
195
+
196
+ yield AgentEvent(kind="tool_call", data={"name": tool_name, "arguments": tool_args, "id": call_id})
197
+
198
+ if self._permissions:
199
+ action_type = self._classify_tool_action(tool_name, tool_args)
200
+ allowed = self._permissions.check(
201
+ action=action_type,
202
+ session_id=session_id,
203
+ path=tool_args.get("path"),
204
+ command=tool_args.get("command"),
205
+ description=f"Tool call: {tool_name}({json.dumps(tool_args, default=str)[:200]})",
206
+ )
207
+ if not allowed:
208
+ result_text = f"Permission denied for {tool_name}"
209
+ yield AgentEvent(kind="tool_result", data={"name": tool_name, "content": result_text, "is_error": True})
210
+ state.messages.append(ChatMessage(role="user", content=f"[Tool result for {tool_name}]: {result_text}"))
211
+ continue
212
+
213
+ result_text, is_error = await self._execute_tool(tool_name, tool_args)
214
+ state.tools_called.append(tool_name)
215
+
216
+ yield AgentEvent(kind="tool_result", data={"name": tool_name, "content": result_text[:2000], "is_error": is_error})
217
+
218
+ if self._audit:
219
+ self._audit.log_tool_call(
220
+ tool_name=tool_name,
221
+ arguments=tool_args,
222
+ result_preview=result_text[:500],
223
+ session_id=session_id,
224
+ )
225
+
226
+ state.messages.append(ChatMessage(
227
+ role="user",
228
+ content=f"[Tool result for {tool_name}]:\n{result_text[:8000]}",
229
+ ))
230
+
231
+ else:
232
+ yield AgentEvent(kind="error", data=f"Agent exceeded maximum tool loop count ({self._max_loops})")
233
+
234
+ yield AgentEvent(kind="done", data={
235
+ "loops": state.loop_count,
236
+ "tools_called": state.tools_called,
237
+ "total_tokens_in": state.tokens_in,
238
+ "total_tokens_out": state.tokens_out,
239
+ "total_cost_dt": state.total_cost_dt,
240
+ })
241
+
242
+ def _build_tool_schemas(self) -> list[dict[str, Any]]:
243
+ logger.debug("Entered into _build_tool_schemas")
244
+ schemas = self._tools.get_schemas_for_llm()
245
+ if self._mcp:
246
+ schemas.extend(self._mcp.get_tool_schemas_for_llm())
247
+ return schemas
248
+
249
+ def _build_api_messages(self, state: AgentState) -> list[dict[str, str]]:
250
+ logger.debug("Entered into _build_api_messages")
251
+ system_prompt = self._get_system_prompt(state.mode)
252
+ result: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
253
+ for msg in state.messages:
254
+ result.append({"role": msg.role, "content": msg.content})
255
+ return result
256
+
257
+ def _get_system_prompt(self, mode: str) -> str:
258
+ logger.debug(f"Entered into _get_system_prompt: mode={mode}")
259
+
260
+ tool_names = [t.name for t in self._tools.list_tools()]
261
+ if self._mcp:
262
+ tool_names.extend(t.name for t in self._mcp.list_all_tools())
263
+
264
+ tool_list = ", ".join(tool_names) if tool_names else "none"
265
+
266
+ base = (
267
+ "You are Elidia, an AI coding agent. You help users with software engineering tasks "
268
+ "by reading files, writing code, running commands, and searching the web.\n\n"
269
+ f"Available tools: {tool_list}\n\n"
270
+ "To use a tool, respond with a JSON block:\n"
271
+ "```tool\n"
272
+ '{"name": "tool_name", "arguments": {"arg1": "value1"}}\n'
273
+ "```\n\n"
274
+ "You can call multiple tools by including multiple ```tool blocks.\n"
275
+ "After tool results are returned, continue reasoning and call more tools or give your final answer.\n"
276
+ "When you have enough information, give your final response directly without tool blocks.\n"
277
+ )
278
+
279
+ mode_prompts = {
280
+ "code": "Focus on writing clean, correct, production-quality code. Prefer editing existing files over creating new ones.",
281
+ "research": "Focus on thorough research. Search the web, read documentation, and synthesize findings.",
282
+ "think": "Think step by step. Show your reasoning process before arriving at conclusions.",
283
+ "create": "Focus on creative content generation — writing, brainstorming, design.",
284
+ "chat": "Be helpful and conversational. Use tools when needed to answer questions accurately.",
285
+ }
286
+ base += "\n" + mode_prompts.get(mode, mode_prompts["chat"])
287
+
288
+ return base
289
+
290
+ def _extract_tool_calls(self, content: str) -> list[dict[str, Any]]:
291
+ logger.debug("Entered into _extract_tool_calls")
292
+ calls: list[dict[str, Any]] = []
293
+
294
+ import re
295
+ pattern = r"```tool\s*\n(.*?)```"
296
+ matches = re.findall(pattern, content, re.DOTALL)
297
+
298
+ for match in matches:
299
+ try:
300
+ parsed = json.loads(match.strip())
301
+ if isinstance(parsed, dict) and "name" in parsed:
302
+ calls.append(parsed)
303
+ except json.JSONDecodeError:
304
+ logger.warning(f"Failed to parse tool call JSON: {match[:100]}")
305
+
306
+ return calls
307
+
308
+ async def _execute_tool(self, name: str, arguments: dict[str, Any]) -> tuple[str, bool]:
309
+ logger.debug(f"Entered into _execute_tool: name={name}")
310
+
311
+ builtin = self._tools.get(name)
312
+ if builtin:
313
+ result = await self._tools.call(name, arguments)
314
+ return result.content, result.is_error
315
+
316
+ if self._mcp:
317
+ mcp_match = self._mcp.find_tool(name)
318
+ if mcp_match:
319
+ result = await self._mcp.call_tool(name, arguments)
320
+ content = "\n".join(
321
+ item.get("text", str(item)) if isinstance(item, dict) else str(item)
322
+ for item in result.content
323
+ )
324
+ return content, result.is_error
325
+
326
+ return f"Tool '{name}' not found in any registry", True
327
+
328
+ def _classify_tool_action(self, tool_name: str, args: dict[str, Any]) -> str:
329
+ logger.debug(f"Entered into _classify_tool_action: tool_name={tool_name}")
330
+
331
+ action_map = {
332
+ "file_read": "file_read",
333
+ "file_list": "file_read",
334
+ "file_glob": "file_read",
335
+ "file_grep": "file_read",
336
+ "file_write": "file_write",
337
+ "file_edit": "file_write",
338
+ "file_delete": "file_delete",
339
+ "command_exec": "command_exec",
340
+ "git_status": "file_read",
341
+ "git_diff": "file_read",
342
+ "git_log": "file_read",
343
+ "git_branch": "file_read",
344
+ "git_commit": "command_exec",
345
+ "web_search": "web_search",
346
+ "http_fetch": "web_search",
347
+ }
348
+ return action_map.get(tool_name, "mcp_call_session")
349
+
350
+ async def _run_deep_think(
351
+ self, state: AgentState, user_text: str, session_id: str,
352
+ ) -> AsyncIterator[AgentEvent]:
353
+ logger.debug(f"Entered into _run_deep_think: model={state.model}")
354
+ reasoning_model = select_reasoning_model(state.model)
355
+ state.model = reasoning_model
356
+
357
+ yield AgentEvent(kind="thinking", data={
358
+ "model": reasoning_model,
359
+ "reason": "Deep think: using reasoning model",
360
+ })
361
+
362
+ full_reasoning = ""
363
+ full_content = ""
364
+
365
+ try:
366
+ async for event in deep_think_stream(
367
+ client=self._client,
368
+ messages=state.messages,
369
+ model=reasoning_model,
370
+ ):
371
+ if event.kind == "reasoning":
372
+ full_reasoning += event.data
373
+ elif event.kind == "content":
374
+ full_content += event.data
375
+ elif event.kind == "usage":
376
+ state.tokens_in += event.data.get("tokens_in", 0)
377
+ state.tokens_out += event.data.get("tokens_out", 0)
378
+ cost = event.data.get("cost_dt", 0.0)
379
+ state.total_cost_dt += cost
380
+ if self._budget:
381
+ self._budget.record_usage(
382
+ event.data.get("tokens_in", 0),
383
+ event.data.get("tokens_out", 0),
384
+ cost,
385
+ )
386
+ yield AgentEvent(kind="usage", data=event.data)
387
+ elif event.kind == "error":
388
+ yield AgentEvent(kind="error", data=event.data)
389
+ return
390
+ except Exception as e:
391
+ yield AgentEvent(kind="error", data=str(e))
392
+ return
393
+
394
+ output = full_content or full_reasoning
395
+ if output:
396
+ yield AgentEvent(kind="content", data=output)
397
+
398
+ yield AgentEvent(kind="done", data={
399
+ "loops": 1,
400
+ "tools_called": [],
401
+ "total_tokens_in": state.tokens_in,
402
+ "total_tokens_out": state.tokens_out,
403
+ "total_cost_dt": state.total_cost_dt,
404
+ "exec_mode": "deep",
405
+ })
406
+
407
+ async def _run_consensus(
408
+ self, state: AgentState, user_text: str, session_id: str,
409
+ ) -> AsyncIterator[AgentEvent]:
410
+ logger.debug("Entered into _run_consensus")
411
+
412
+ yield AgentEvent(kind="thinking", data={
413
+ "model": "consensus",
414
+ "reason": "Running multiple models in parallel for consensus",
415
+ })
416
+
417
+ try:
418
+ result = await run_consensus(
419
+ client=self._client,
420
+ messages=[ChatMessage(role=m.role, content=m.content) for m in state.messages],
421
+ )
422
+ except Exception as e:
423
+ yield AgentEvent(kind="error", data=f"Consensus failed: {e}")
424
+ return
425
+
426
+ output_parts = [f"**Consensus Result** (agreement: {result.agreement}, confidence: {result.confidence:.0%})\n"]
427
+ output_parts.append(result.synthesis)
428
+ output_parts.append("\n\n---\n**Individual Responses:**")
429
+ for r in result.responses:
430
+ output_parts.append(f"\n**{r.model}**: {r.content[:500]}")
431
+
432
+ full_output = "\n".join(output_parts)
433
+ yield AgentEvent(kind="content", data=full_output)
434
+
435
+ total_tokens = sum(r.tokens_out for r in result.responses)
436
+ yield AgentEvent(kind="usage", data={
437
+ "tokens_in": 0,
438
+ "tokens_out": total_tokens,
439
+ "cost_dt": 0.0,
440
+ })
441
+
442
+ yield AgentEvent(kind="done", data={
443
+ "loops": 1,
444
+ "tools_called": [],
445
+ "total_tokens_in": 0,
446
+ "total_tokens_out": total_tokens,
447
+ "total_cost_dt": 0.0,
448
+ "exec_mode": "consensus",
449
+ })
@@ -0,0 +1,130 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ @dataclass
8
+ class DomainPersona:
9
+ name: str
10
+ slug: str
11
+ system_prompt: str
12
+ greeting: str = ""
13
+ icon: str = ""
14
+ preferred_model: str = ""
15
+ preferred_tools: list[str] = field(default_factory=list)
16
+ temperature: float = 0.7
17
+
18
+
19
+ BUILT_IN_PERSONAS: dict[str, DomainPersona] = {
20
+ "coder": DomainPersona(
21
+ name="Coder",
22
+ slug="coder",
23
+ icon=">>",
24
+ system_prompt=(
25
+ "You are an expert software engineer. You write clean, correct, production-quality code. "
26
+ "You read existing code before modifying it. You prefer editing over rewriting. "
27
+ "You use tools to read files, run tests, and verify your work."
28
+ ),
29
+ greeting="Ready to code. What are we building?",
30
+ preferred_model="claude-sonnet-5",
31
+ preferred_tools=["file_read", "file_write", "file_edit", "file_grep", "command_exec", "git_status", "git_diff"],
32
+ temperature=0.3,
33
+ ),
34
+ "researcher": DomainPersona(
35
+ name="Researcher",
36
+ slug="researcher",
37
+ icon="??",
38
+ system_prompt=(
39
+ "You are a thorough research assistant. You search the web, read documentation, "
40
+ "cross-reference sources, and synthesize findings into clear, structured answers. "
41
+ "Cite your sources."
42
+ ),
43
+ greeting="What would you like me to research?",
44
+ preferred_model="deepseek-chat",
45
+ preferred_tools=["web_search", "http_fetch", "file_read"],
46
+ temperature=0.5,
47
+ ),
48
+ "analyst": DomainPersona(
49
+ name="Data Analyst",
50
+ slug="analyst",
51
+ icon="##",
52
+ system_prompt=(
53
+ "You are a data analyst. You examine data, find patterns, create visualizations, "
54
+ "and provide actionable insights. You use code to process and analyze data."
55
+ ),
56
+ greeting="Share your data or describe what you'd like to analyze.",
57
+ preferred_model="deepseek-chat",
58
+ preferred_tools=["file_read", "command_exec", "file_write"],
59
+ temperature=0.4,
60
+ ),
61
+ "writer": DomainPersona(
62
+ name="Writer",
63
+ slug="writer",
64
+ icon="~~",
65
+ system_prompt=(
66
+ "You are a skilled writer and editor. You help with drafting, editing, "
67
+ "proofreading, and creative writing. You adapt your tone and style to the task."
68
+ ),
69
+ greeting="What would you like to write?",
70
+ preferred_model="gpt-5",
71
+ preferred_tools=["web_search", "file_read", "file_write"],
72
+ temperature=0.8,
73
+ ),
74
+ "devops": DomainPersona(
75
+ name="DevOps Engineer",
76
+ slug="devops",
77
+ icon="!!",
78
+ system_prompt=(
79
+ "You are a DevOps engineer. You help with deployment, CI/CD, infrastructure, "
80
+ "Docker, Kubernetes, monitoring, and system administration. "
81
+ "You always check system state before making changes."
82
+ ),
83
+ greeting="What infrastructure or deployment task do you need help with?",
84
+ preferred_model="claude-sonnet-5",
85
+ preferred_tools=["command_exec", "file_read", "file_write", "file_edit", "git_status"],
86
+ temperature=0.3,
87
+ ),
88
+ }
89
+
90
+
91
+ class PersonaEngine:
92
+ """Manages domain personas for the agent."""
93
+
94
+ def __init__(self) -> None:
95
+ logger.debug("Entered into PersonaEngine.__init__")
96
+ self._personas: dict[str, DomainPersona] = dict(BUILT_IN_PERSONAS)
97
+ self._active: DomainPersona | None = None
98
+
99
+ def list_personas(self) -> list[DomainPersona]:
100
+ logger.debug("Entered into list_personas")
101
+ return list(self._personas.values())
102
+
103
+ def get(self, slug: str) -> DomainPersona | None:
104
+ logger.debug(f"Entered into get: slug={slug}")
105
+ return self._personas.get(slug)
106
+
107
+ def activate(self, slug: str) -> DomainPersona | None:
108
+ logger.debug(f"Entered into activate: slug={slug}")
109
+ persona = self._personas.get(slug)
110
+ if persona:
111
+ self._active = persona
112
+ return persona
113
+
114
+ def deactivate(self) -> None:
115
+ logger.debug("Entered into deactivate")
116
+ self._active = None
117
+
118
+ @property
119
+ def active(self) -> DomainPersona | None:
120
+ return self._active
121
+
122
+ def register(self, persona: DomainPersona) -> None:
123
+ logger.debug(f"Entered into register: slug={persona.slug}")
124
+ self._personas[persona.slug] = persona
125
+
126
+ def get_system_prompt_overlay(self) -> str | None:
127
+ logger.debug("Entered into get_system_prompt_overlay")
128
+ if not self._active:
129
+ return None
130
+ return self._active.system_prompt