mandri 0.1.0a1__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 (228) hide show
  1. mandri/__init__.py +0 -0
  2. mandri/adapters/__init__.py +0 -0
  3. mandri/adapters/approval/__init__.py +9 -0
  4. mandri/adapters/approval/autopilot.py +150 -0
  5. mandri/adapters/approval/batcher.py +54 -0
  6. mandri/adapters/approval/manual.py +19 -0
  7. mandri/adapters/approval/yolo.py +9 -0
  8. mandri/adapters/config/__init__.py +3 -0
  9. mandri/adapters/config/toml/__init__.py +39 -0
  10. mandri/adapters/config/toml/decode.py +195 -0
  11. mandri/adapters/config/toml/encode.py +95 -0
  12. mandri/adapters/config/toml/store.py +37 -0
  13. mandri/adapters/credentials/__init__.py +4 -0
  14. mandri/adapters/credentials/permissions.py +26 -0
  15. mandri/adapters/credentials/toml.py +40 -0
  16. mandri/adapters/credentials/windows_acl.py +209 -0
  17. mandri/adapters/credentials/windows_dacl.py +54 -0
  18. mandri/adapters/filesystem/__init__.py +3 -0
  19. mandri/adapters/filesystem/atomic.py +25 -0
  20. mandri/adapters/llm/__init__.py +12 -0
  21. mandri/adapters/llm/lmstudio.py +53 -0
  22. mandri/adapters/llm/openai_compatible/__init__.py +17 -0
  23. mandri/adapters/llm/openai_compatible/constants.py +9 -0
  24. mandri/adapters/llm/openai_compatible/http_client.py +97 -0
  25. mandri/adapters/llm/openai_compatible/provider.py +140 -0
  26. mandri/adapters/llm/openai_compatible/request_codec.py +222 -0
  27. mandri/adapters/llm/openai_compatible/response_codec.py +59 -0
  28. mandri/adapters/llm/openai_compatible/stream.py +159 -0
  29. mandri/adapters/llm/openai_compatible/values.py +30 -0
  30. mandri/adapters/llm/opencode.py +39 -0
  31. mandri/adapters/llm/openrouter.py +110 -0
  32. mandri/adapters/llm/sse.py +14 -0
  33. mandri/adapters/memory/__init__.py +3 -0
  34. mandri/adapters/memory/filesystem.py +42 -0
  35. mandri/adapters/memory_search/__init__.py +3 -0
  36. mandri/adapters/memory_search/filesystem.py +52 -0
  37. mandri/adapters/question/__init__.py +10 -0
  38. mandri/adapters/question/autopilot.py +164 -0
  39. mandri/adapters/question/manual.py +13 -0
  40. mandri/adapters/question/yolo.py +20 -0
  41. mandri/adapters/retry/__init__.py +5 -0
  42. mandri/adapters/retry/failfast.py +10 -0
  43. mandri/adapters/session/__init__.py +3 -0
  44. mandri/adapters/session/filesystem/__init__.py +45 -0
  45. mandri/adapters/session/filesystem/codec.py +186 -0
  46. mandri/adapters/session/filesystem/store.py +91 -0
  47. mandri/adapters/session/filesystem/values.py +46 -0
  48. mandri/adapters/subagent/__init__.py +12 -0
  49. mandri/adapters/subagent/claude/__init__.py +83 -0
  50. mandri/adapters/subagent/claude/approval.py +91 -0
  51. mandri/adapters/subagent/claude/config.py +47 -0
  52. mandri/adapters/subagent/claude/constants.py +18 -0
  53. mandri/adapters/subagent/claude/factory.py +94 -0
  54. mandri/adapters/subagent/claude/process.py +50 -0
  55. mandri/adapters/subagent/claude/questions.py +64 -0
  56. mandri/adapters/subagent/claude/relay.py +62 -0
  57. mandri/adapters/subagent/claude/subagent.py +255 -0
  58. mandri/adapters/subagent/claude/translation.py +123 -0
  59. mandri/adapters/subagent/codex/__init__.py +87 -0
  60. mandri/adapters/subagent/codex/client.py +218 -0
  61. mandri/adapters/subagent/codex/config.py +128 -0
  62. mandri/adapters/subagent/codex/constants.py +2 -0
  63. mandri/adapters/subagent/codex/decisions.py +113 -0
  64. mandri/adapters/subagent/codex/factory.py +96 -0
  65. mandri/adapters/subagent/codex/process.py +94 -0
  66. mandri/adapters/subagent/codex/subagent.py +221 -0
  67. mandri/adapters/subagent/codex/translation.py +101 -0
  68. mandri/adapters/subagent/codex_workspace.py +75 -0
  69. mandri/adapters/subagent/opencode/__init__.py +41 -0
  70. mandri/adapters/subagent/opencode/client.py +246 -0
  71. mandri/adapters/subagent/opencode/config.py +53 -0
  72. mandri/adapters/subagent/opencode/constants.py +2 -0
  73. mandri/adapters/subagent/opencode/factory.py +114 -0
  74. mandri/adapters/subagent/opencode/subagent.py +178 -0
  75. mandri/adapters/subagent/opencode/translation.py +127 -0
  76. mandri/adapters/user_profile/__init__.py +3 -0
  77. mandri/adapters/user_profile/filesystem.py +28 -0
  78. mandri/core/__init__.py +89 -0
  79. mandri/core/agent/__init__.py +19 -0
  80. mandri/core/agent/blackboard.py +25 -0
  81. mandri/core/agent/context.py +33 -0
  82. mandri/core/agent/input.py +13 -0
  83. mandri/core/agent/loop.py +590 -0
  84. mandri/core/agent/manager.py +313 -0
  85. mandri/core/agent/output.py +51 -0
  86. mandri/core/agent/runtime.py +59 -0
  87. mandri/core/agent/session.py +278 -0
  88. mandri/core/agent/steering.py +29 -0
  89. mandri/core/background/__init__.py +13 -0
  90. mandri/core/background/manager.py +260 -0
  91. mandri/core/session/__init__.py +3 -0
  92. mandri/core/session/saver.py +60 -0
  93. mandri/core/tools/__init__.py +19 -0
  94. mandri/core/tools/agent/__init__.py +13 -0
  95. mandri/core/tools/agent/result.py +28 -0
  96. mandri/core/tools/agent/send.py +45 -0
  97. mandri/core/tools/agent/spawn.py +54 -0
  98. mandri/core/tools/agent/status.py +38 -0
  99. mandri/core/tools/agent/stop.py +38 -0
  100. mandri/core/tools/agent/wait.py +38 -0
  101. mandri/core/tools/executor.py +104 -0
  102. mandri/core/tools/registry.py +29 -0
  103. mandri/core/user_profile/__init__.py +3 -0
  104. mandri/core/user_profile/profile_updater.py +136 -0
  105. mandri/gateway/__init__.py +5 -0
  106. mandri/gateway/anthropic/__init__.py +61 -0
  107. mandri/gateway/anthropic/request.py +249 -0
  108. mandri/gateway/anthropic/response.py +96 -0
  109. mandri/gateway/anthropic/stream.py +130 -0
  110. mandri/gateway/anthropic/values.py +82 -0
  111. mandri/gateway/decode.py +240 -0
  112. mandri/gateway/encode.py +198 -0
  113. mandri/gateway/errors.py +43 -0
  114. mandri/gateway/execution.py +121 -0
  115. mandri/gateway/handlers.py +157 -0
  116. mandri/gateway/http.py +85 -0
  117. mandri/gateway/responses/__init__.py +92 -0
  118. mandri/gateway/responses/messages.py +190 -0
  119. mandri/gateway/responses/request.py +143 -0
  120. mandri/gateway/responses/response.py +143 -0
  121. mandri/gateway/responses/stream.py +304 -0
  122. mandri/gateway/responses/tools.py +155 -0
  123. mandri/gateway/responses/tunnel.py +16 -0
  124. mandri/gateway/responses/values.py +106 -0
  125. mandri/gateway/routing.py +104 -0
  126. mandri/gateway/server.py +109 -0
  127. mandri/gateway/wire.py +12 -0
  128. mandri/logging.py +82 -0
  129. mandri/mcp/__init__.py +3 -0
  130. mandri/mcp/server.py +131 -0
  131. mandri/ports/__init__.py +0 -0
  132. mandri/ports/agent/__init__.py +61 -0
  133. mandri/ports/agent/context.py +7 -0
  134. mandri/ports/agent/events.py +171 -0
  135. mandri/ports/agent/input.py +9 -0
  136. mandri/ports/agent/runtime.py +25 -0
  137. mandri/ports/approval/__init__.py +7 -0
  138. mandri/ports/approval/port.py +23 -0
  139. mandri/ports/background/__init__.py +8 -0
  140. mandri/ports/background/handle.py +34 -0
  141. mandri/ports/background/port.py +66 -0
  142. mandri/ports/config/__init__.py +31 -0
  143. mandri/ports/config/port.py +212 -0
  144. mandri/ports/credentials/__init__.py +3 -0
  145. mandri/ports/credentials/port.py +7 -0
  146. mandri/ports/gateway/__init__.py +8 -0
  147. mandri/ports/gateway/port.py +32 -0
  148. mandri/ports/llm/__init__.py +90 -0
  149. mandri/ports/llm/capabilities.py +25 -0
  150. mandri/ports/llm/content.py +65 -0
  151. mandri/ports/llm/errors.py +44 -0
  152. mandri/ports/llm/messages.py +19 -0
  153. mandri/ports/llm/output.py +16 -0
  154. mandri/ports/llm/passthrough.py +5 -0
  155. mandri/ports/llm/port.py +20 -0
  156. mandri/ports/llm/request.py +32 -0
  157. mandri/ports/llm/response.py +36 -0
  158. mandri/ports/llm/stream.py +61 -0
  159. mandri/ports/llm/tools.py +28 -0
  160. mandri/ports/mcp/__init__.py +3 -0
  161. mandri/ports/mcp/port.py +12 -0
  162. mandri/ports/memory/__init__.py +3 -0
  163. mandri/ports/memory/port.py +28 -0
  164. mandri/ports/memory_search/__init__.py +3 -0
  165. mandri/ports/memory_search/port.py +9 -0
  166. mandri/ports/question/__init__.py +19 -0
  167. mandri/ports/question/models.py +36 -0
  168. mandri/ports/question/port.py +18 -0
  169. mandri/ports/retry/__init__.py +6 -0
  170. mandri/ports/retry/port.py +10 -0
  171. mandri/ports/session/__init__.py +3 -0
  172. mandri/ports/session/port.py +30 -0
  173. mandri/ports/subagent/__init__.py +24 -0
  174. mandri/ports/subagent/observer.py +16 -0
  175. mandri/ports/subagent/port.py +70 -0
  176. mandri/ports/tools/__init__.py +10 -0
  177. mandri/ports/tools/executor.py +15 -0
  178. mandri/ports/tools/port.py +32 -0
  179. mandri/ports/user_profile/__init__.py +3 -0
  180. mandri/ports/user_profile/port.py +16 -0
  181. mandri/prompts/__init__.py +3 -0
  182. mandri/prompts/approval.py +8 -0
  183. mandri/prompts/question.py +3 -0
  184. mandri/prompts/system_prompt.py +37 -0
  185. mandri/prompts/user_profile.py +5 -0
  186. mandri/py.typed +0 -0
  187. mandri-0.1.0a1.dist-info/METADATA +74 -0
  188. mandri-0.1.0a1.dist-info/RECORD +228 -0
  189. mandri-0.1.0a1.dist-info/WHEEL +4 -0
  190. mandri-0.1.0a1.dist-info/entry_points.txt +2 -0
  191. mandri-0.1.0a1.dist-info/licenses/LICENSE +202 -0
  192. mandri_tui/__init__.py +3 -0
  193. mandri_tui/__main__.py +27 -0
  194. mandri_tui/app/__init__.py +22 -0
  195. mandri_tui/app/base.py +301 -0
  196. mandri_tui/app/input.py +186 -0
  197. mandri_tui/app/lifecycle.py +157 -0
  198. mandri_tui/app/models.py +255 -0
  199. mandri_tui/app/observer.py +175 -0
  200. mandri_tui/app/sessions.py +217 -0
  201. mandri_tui/app/startup.py +229 -0
  202. mandri_tui/app/turns.py +63 -0
  203. mandri_tui/approval.py +170 -0
  204. mandri_tui/attachments.py +153 -0
  205. mandri_tui/background_indicator.py +183 -0
  206. mandri_tui/blocks.py +234 -0
  207. mandri_tui/catalog.py +165 -0
  208. mandri_tui/commands.py +90 -0
  209. mandri_tui/composer.py +84 -0
  210. mandri_tui/formatting.py +89 -0
  211. mandri_tui/model_selection.py +43 -0
  212. mandri_tui/runtime.py +232 -0
  213. mandri_tui/screens/__init__.py +18 -0
  214. mandri_tui/screens/approval.py +82 -0
  215. mandri_tui/screens/model.py +128 -0
  216. mandri_tui/screens/provider.py +102 -0
  217. mandri_tui/screens/question.py +79 -0
  218. mandri_tui/screens/session.py +53 -0
  219. mandri_tui/suggestions.py +13 -0
  220. mandri_tui/text_attachments.py +94 -0
  221. mandri_tui/transcript/__init__.py +6 -0
  222. mandri_tui/transcript/log.py +90 -0
  223. mandri_tui/transcript/renderer.py +311 -0
  224. mandri_tui/transcript/screen.py +44 -0
  225. mandri_tui/transcript/subagent.py +105 -0
  226. mandri_tui/tui.tcss +983 -0
  227. mandri_tui/welcome.py +72 -0
  228. mandri_tui/wiring.py +42 -0
mandri/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,9 @@
1
+ from .autopilot import AutopilotApprovalPolicy
2
+ from .manual import ManualApprovalPolicy
3
+ from .yolo import YoloApprovalPolicy
4
+
5
+ __all__ = [
6
+ "AutopilotApprovalPolicy",
7
+ "ManualApprovalPolicy",
8
+ "YoloApprovalPolicy",
9
+ ]
@@ -0,0 +1,150 @@
1
+ import json
2
+ import logging
3
+
4
+ from mandri.ports.approval import ApprovalDecision
5
+ from mandri.ports.llm import (
6
+ JsonObjectOutput,
7
+ LLMProviderPort,
8
+ LLMRequest,
9
+ Message,
10
+ Role,
11
+ TextPart,
12
+ ToolCallPart,
13
+ )
14
+ from mandri.prompts.approval import AUTOPILOT_APPROVAL_PROMPT
15
+
16
+ _logger = logging.getLogger(__name__)
17
+ _SAFE_RISKS = frozenset(("LOW", "MEDIUM"))
18
+ _DECISION_SCHEMA = {
19
+ "type": "object",
20
+ "properties": {
21
+ "tool_call_id": {"type": "string"},
22
+ "risk": {
23
+ "type": "string",
24
+ "enum": ["LOW", "MEDIUM", "HIGH", "UNKNOWN"],
25
+ },
26
+ "reason": {"type": "string"},
27
+ },
28
+ "required": ["tool_call_id", "risk", "reason"],
29
+ "additionalProperties": False,
30
+ }
31
+ _VERDICT_SCHEMA = {
32
+ "type": "object",
33
+ "properties": {
34
+ "decisions": {
35
+ "type": "array",
36
+ "items": _DECISION_SCHEMA,
37
+ }
38
+ },
39
+ "required": ["decisions"],
40
+ "additionalProperties": False,
41
+ }
42
+
43
+
44
+ class AutopilotApprovalPolicy:
45
+ def __init__(
46
+ self,
47
+ provider: LLMProviderPort,
48
+ *,
49
+ model: str,
50
+ ) -> None:
51
+ self._provider = provider
52
+ self._model = model
53
+
54
+ async def request(
55
+ self, calls: tuple[ToolCallPart, ...]
56
+ ) -> tuple[ApprovalDecision, ...]:
57
+ if not calls:
58
+ return ()
59
+ try:
60
+ response = await self._provider.generate(self._request(calls))
61
+ verdicts = _verdicts(response.message, calls)
62
+ except Exception:
63
+ _logger.exception(
64
+ "autopilot approval failed tools=%s",
65
+ ",".join(call.tool_name for call in calls),
66
+ )
67
+ return tuple(
68
+ ApprovalDecision(
69
+ approved=False,
70
+ feedback="Autopilot could not assess this tool call",
71
+ )
72
+ for _ in calls
73
+ )
74
+ return tuple(_decision(risk, reason) for risk, reason in verdicts)
75
+
76
+ def _request(self, calls: tuple[ToolCallPart, ...]) -> LLMRequest:
77
+ payload = json.dumps(
78
+ {
79
+ "tool_calls": [
80
+ {
81
+ "tool_call_id": call.tool_call_id,
82
+ "tool": call.tool_name,
83
+ "arguments": call.arguments,
84
+ }
85
+ for call in calls
86
+ ],
87
+ "output_format": "json",
88
+ "output_schema": _VERDICT_SCHEMA,
89
+ },
90
+ ensure_ascii=False,
91
+ )
92
+ return LLMRequest(
93
+ model=self._model,
94
+ messages=(
95
+ Message(
96
+ role=Role.SYSTEM,
97
+ parts=(TextPart(text=AUTOPILOT_APPROVAL_PROMPT),),
98
+ ),
99
+ Message(role=Role.USER, parts=(TextPart(text=payload),)),
100
+ ),
101
+ output=JsonObjectOutput(),
102
+ max_output_tokens=4096,
103
+ temperature=0.0,
104
+ )
105
+
106
+
107
+ def _verdicts(
108
+ message: Message, calls: tuple[ToolCallPart, ...]
109
+ ) -> tuple[tuple[str, str], ...]:
110
+ content = "".join(part.text for part in message.parts if isinstance(part, TextPart))
111
+ verdict = json.loads(content)
112
+ if not isinstance(verdict, dict):
113
+ raise ValueError("autopilot verdict must be an object")
114
+ decisions = verdict.get("decisions")
115
+ if not isinstance(decisions, list) or len(decisions) != len(calls):
116
+ raise ValueError("autopilot verdict count does not match tool calls")
117
+ by_id = {_decision_id(item): item for item in decisions}
118
+ if len(by_id) != len(decisions):
119
+ raise ValueError("autopilot verdict has duplicate tool call ids")
120
+ return tuple(_risk_reason(by_id.get(call.tool_call_id)) for call in calls)
121
+
122
+
123
+ def _decision_id(verdict: object) -> str:
124
+ if not isinstance(verdict, dict):
125
+ raise ValueError("autopilot decision must be an object")
126
+ tool_call_id = verdict.get("tool_call_id")
127
+ if not isinstance(tool_call_id, str) or not tool_call_id:
128
+ raise ValueError("autopilot decision has no tool call id")
129
+ return tool_call_id
130
+
131
+
132
+ def _risk_reason(verdict: object) -> tuple[str, str]:
133
+ if not isinstance(verdict, dict):
134
+ raise ValueError("autopilot decision must be an object")
135
+ risk = verdict.get("risk")
136
+ reason = verdict.get("reason")
137
+ if risk not in {"LOW", "MEDIUM", "HIGH", "UNKNOWN"}:
138
+ raise ValueError("autopilot verdict has an invalid risk")
139
+ if not isinstance(reason, str) or not reason.strip():
140
+ raise ValueError("autopilot verdict has no reason")
141
+ return risk, reason.strip()
142
+
143
+
144
+ def _decision(risk: str, reason: str) -> ApprovalDecision:
145
+ if risk in _SAFE_RISKS:
146
+ return ApprovalDecision(approved=True)
147
+ return ApprovalDecision(
148
+ approved=False,
149
+ feedback=f"Autopilot classified this tool call as {risk}: {reason}",
150
+ )
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+
3
+ from mandri.ports.approval import ApprovalDecision, ApprovalPolicyPort
4
+ from mandri.ports.llm import ToolCallPart
5
+
6
+
7
+ class ApprovalBatcher:
8
+ def __init__(self, policy: ApprovalPolicyPort) -> None:
9
+ self._policy = policy
10
+ self._decisions: dict[str, ApprovalDecision] = {}
11
+ self._lock = asyncio.Lock()
12
+
13
+ def set_policy(self, policy: ApprovalPolicyPort) -> None:
14
+ self._policy = policy
15
+
16
+ async def request(
17
+ self,
18
+ call: ToolCallPart,
19
+ batch: tuple[ToolCallPart, ...] = (),
20
+ ) -> ApprovalDecision:
21
+ async with self._lock:
22
+ cached = self._decisions.pop(call.tool_call_id, None)
23
+ if cached is not None:
24
+ return cached
25
+ calls = self._unresolved(call, batch)
26
+ decisions = await self._policy.request(calls)
27
+ if len(decisions) != len(calls):
28
+ raise ValueError("approval decision count does not match calls")
29
+ self._decisions.update(
30
+ (candidate.tool_call_id, decision)
31
+ for candidate, decision in zip(calls, decisions, strict=True)
32
+ )
33
+ decision = self._decisions.pop(call.tool_call_id, None)
34
+ if decision is None:
35
+ raise ValueError("approval batch does not contain requested call")
36
+ return decision
37
+
38
+ def discard(self, tool_call_id: str) -> None:
39
+ self._decisions.pop(tool_call_id, None)
40
+
41
+ def clear(self) -> None:
42
+ self._decisions.clear()
43
+
44
+ def _unresolved(
45
+ self,
46
+ call: ToolCallPart,
47
+ batch: tuple[ToolCallPart, ...],
48
+ ) -> tuple[ToolCallPart, ...]:
49
+ calls = {
50
+ candidate.tool_call_id: candidate
51
+ for candidate in (*batch, call)
52
+ if candidate.tool_call_id not in self._decisions
53
+ }
54
+ return tuple(calls.values())
@@ -0,0 +1,19 @@
1
+ from collections.abc import Awaitable, Callable
2
+
3
+ from mandri.ports.approval import ApprovalDecision
4
+ from mandri.ports.llm import ToolCallPart
5
+
6
+ type ApprovalPrompt = Callable[[ToolCallPart], Awaitable[ApprovalDecision]]
7
+
8
+
9
+ class ManualApprovalPolicy:
10
+ def __init__(self, prompt: ApprovalPrompt) -> None:
11
+ self._prompt = prompt
12
+
13
+ async def request(
14
+ self, calls: tuple[ToolCallPart, ...]
15
+ ) -> tuple[ApprovalDecision, ...]:
16
+ decisions: list[ApprovalDecision] = []
17
+ for call in calls:
18
+ decisions.append(await self._prompt(call))
19
+ return tuple(decisions)
@@ -0,0 +1,9 @@
1
+ from mandri.ports.approval import ApprovalDecision
2
+ from mandri.ports.llm import ToolCallPart
3
+
4
+
5
+ class YoloApprovalPolicy:
6
+ async def request(
7
+ self, calls: tuple[ToolCallPart, ...]
8
+ ) -> tuple[ApprovalDecision, ...]:
9
+ return tuple(ApprovalDecision(approved=True) for _ in calls)
@@ -0,0 +1,3 @@
1
+ from .toml import TomlConfig
2
+
3
+ __all__ = ["TomlConfig"]
@@ -0,0 +1,39 @@
1
+ from .decode import (
2
+ _approval_mode,
3
+ _model_config,
4
+ _non_negative_int,
5
+ _optional_string,
6
+ _orchestrator,
7
+ _positive_int,
8
+ _providers,
9
+ _required_string,
10
+ _role,
11
+ _table,
12
+ _user_profile,
13
+ from_toml,
14
+ )
15
+ from .encode import (
16
+ _serialize_role,
17
+ to_toml,
18
+ )
19
+ from .store import (
20
+ TomlConfig,
21
+ )
22
+
23
+ __all__ = [
24
+ "TomlConfig",
25
+ "_approval_mode",
26
+ "_model_config",
27
+ "_non_negative_int",
28
+ "_optional_string",
29
+ "_orchestrator",
30
+ "_positive_int",
31
+ "_providers",
32
+ "_required_string",
33
+ "_role",
34
+ "_serialize_role",
35
+ "_table",
36
+ "_user_profile",
37
+ "from_toml",
38
+ "to_toml",
39
+ ]
@@ -0,0 +1,195 @@
1
+ from typing import Any, Literal, cast
2
+
3
+ from mandri.ports.approval import ApprovalMode
4
+ from mandri.ports.config import (
5
+ AgentConfig,
6
+ ApprovalConfig,
7
+ AttachmentConfig,
8
+ BackgroundConfig,
9
+ MandriConfig,
10
+ ModelConfig,
11
+ ModelRole,
12
+ ProviderConfig,
13
+ ProviderKind,
14
+ ToolTimeoutOverride,
15
+ UserPreferences,
16
+ UserProfileConfig,
17
+ )
18
+
19
+
20
+ def from_toml(data: dict[str, Any]) -> MandriConfig:
21
+ user_profile = _user_profile(data)
22
+ attachments = _table(data, "attachments")
23
+ agents = _table(data, "agents")
24
+ approval = _table(data, "approval")
25
+ background = _table(data, "background")
26
+ tool_timeouts = _table(data, "tool_timeouts")
27
+ user_preferences = _table(data, "user_preferences")
28
+ models = _table(data, "models")
29
+ orchestrator = _orchestrator(data, models)
30
+ return MandriConfig(
31
+ orchestrator=orchestrator,
32
+ judge=_role(models, "judge"),
33
+ opencode=_role(models, "opencode"),
34
+ codex=_role(models, "codex"),
35
+ claude=_role(models, "claude"),
36
+ user_profile=UserProfileConfig(
37
+ profile_update_interval=_positive_int(
38
+ user_profile,
39
+ "profile_update_interval",
40
+ 5,
41
+ ),
42
+ user_profile_char_limit=_positive_int(
43
+ user_profile,
44
+ "user_profile_char_limit",
45
+ 1375,
46
+ ),
47
+ ),
48
+ attachments=AttachmentConfig(
49
+ max_text_file_bytes=_positive_int(
50
+ attachments, "max_text_file_bytes", 128 * 1024
51
+ ),
52
+ max_total_text_bytes=_positive_int(
53
+ attachments, "max_total_text_bytes", 256 * 1024
54
+ ),
55
+ ),
56
+ agents=AgentConfig(
57
+ max_output_chars=_positive_int(
58
+ agents,
59
+ "max_output_chars",
60
+ AgentConfig().max_output_chars,
61
+ )
62
+ ),
63
+ approval=ApprovalConfig(mode=_approval_mode(approval)),
64
+ background=BackgroundConfig(
65
+ max_concurrent=_positive_int(
66
+ background,
67
+ "max_concurrent",
68
+ BackgroundConfig().max_concurrent,
69
+ ),
70
+ default_timeout_seconds=_non_negative_int(
71
+ background,
72
+ "default_timeout_seconds",
73
+ BackgroundConfig().default_timeout_seconds,
74
+ ),
75
+ ),
76
+ tool_timeouts=[
77
+ ToolTimeoutOverride(
78
+ tool_name=tool_name,
79
+ timeout_seconds=_non_negative_int(
80
+ tool_timeouts,
81
+ tool_name,
82
+ ToolTimeoutOverride().timeout_seconds,
83
+ ),
84
+ )
85
+ for tool_name in tool_timeouts
86
+ ],
87
+ user_preferences=UserPreferences(
88
+ prompt=_optional_string(user_preferences, "prompt")
89
+ ),
90
+ providers=_providers(data),
91
+ )
92
+
93
+
94
+ def _providers(data: dict[str, Any]) -> tuple[ProviderConfig, ...]:
95
+ raw = _table(data, "providers")
96
+ providers: list[ProviderConfig] = []
97
+ for provider_id, value in raw.items():
98
+ if not isinstance(value, dict):
99
+ raise ValueError(f"provider config must be a table: {provider_id}")
100
+ kind = _required_string(value, "kind", f"providers.{provider_id}")
101
+ if kind not in {"lmstudio", "openrouter", "opencode", "openai-compatible"}:
102
+ raise ValueError(f"invalid provider kind: {kind}")
103
+ providers.append(
104
+ ProviderConfig(
105
+ id=provider_id,
106
+ kind=cast(ProviderKind, kind),
107
+ name=_optional_string(value, "name"),
108
+ base_url=_optional_string(value, "base_url"),
109
+ )
110
+ )
111
+ return tuple(providers)
112
+
113
+
114
+ def _user_profile(data: dict[str, Any]) -> dict[str, Any]:
115
+ if "user_profile" in data:
116
+ return _table(data, "user_profile")
117
+ return _table(data, "memory")
118
+
119
+
120
+ def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
121
+ value = data.get(key, {})
122
+ if not isinstance(value, dict):
123
+ raise ValueError(f"config section must be a table: {key}")
124
+ return value
125
+
126
+
127
+ def _orchestrator(data: dict[str, Any], models: dict[str, Any]) -> ModelConfig:
128
+ if "orchestrator" in models:
129
+ config = _role(models, "orchestrator")
130
+ if config is None:
131
+ raise ValueError("missing orchestrator config")
132
+ return config
133
+ if "model" in data:
134
+ legacy = _table(data, "model")
135
+ if not legacy:
136
+ return ModelConfig()
137
+ return _model_config(legacy, "model")
138
+ return ModelConfig()
139
+
140
+
141
+ def _role(data: dict[str, Any], role: ModelRole) -> ModelConfig | None:
142
+ if role not in data:
143
+ return None
144
+ return _model_config(_table(data, role), role)
145
+
146
+
147
+ def _model_config(
148
+ data: dict[str, Any], role: ModelRole | Literal["model"]
149
+ ) -> ModelConfig:
150
+ provider = _required_string(data, "provider", role)
151
+ if provider == "native" and role not in {"codex", "claude"}:
152
+ raise ValueError(f"native provider is not supported for role: {role}")
153
+ return ModelConfig(
154
+ provider=provider,
155
+ model=_required_string(data, "model", role),
156
+ reasoning_effort=_optional_string(data, "reasoning_effort"),
157
+ )
158
+
159
+
160
+ def _optional_string(data: dict[str, Any], key: str) -> str | None:
161
+ value = data.get(key)
162
+ if value is None:
163
+ return None
164
+ if not isinstance(value, str):
165
+ raise ValueError(f"config value must be a string: {key}")
166
+ return value or None
167
+
168
+
169
+ def _required_string(data: dict[str, Any], key: str, section: str) -> str:
170
+ value = _optional_string(data, key)
171
+ if value is None:
172
+ raise ValueError(f"config value is required: {section}.{key}")
173
+ return value
174
+
175
+
176
+ def _positive_int(data: dict[str, Any], key: str, default: int) -> int:
177
+ value = data.get(key, default)
178
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
179
+ raise ValueError(f"config value must be a positive integer: {key}")
180
+ return value
181
+
182
+
183
+ def _non_negative_int(data: dict[str, Any], key: str, default: int) -> int:
184
+ value = data.get(key, default)
185
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
186
+ raise ValueError(f"config value must be a non-negative integer: {key}")
187
+ return value
188
+
189
+
190
+ def _approval_mode(data: dict[str, Any]) -> ApprovalMode:
191
+ value = _optional_string(data, "mode") or ApprovalMode.MANUAL.value
192
+ try:
193
+ return ApprovalMode(value)
194
+ except ValueError as exc:
195
+ raise ValueError(f"invalid approval mode: {value}") from exc
@@ -0,0 +1,95 @@
1
+ import json
2
+
3
+ from mandri.ports.config import (
4
+ BackgroundConfig,
5
+ MandriConfig,
6
+ ModelConfig,
7
+ ModelRole,
8
+ )
9
+
10
+
11
+ def to_toml(config: MandriConfig) -> str:
12
+ lines: list[str] = []
13
+ preferences_prompt = config.user_preferences.prompt
14
+ if preferences_prompt is not None:
15
+ value = json.dumps(preferences_prompt, ensure_ascii=False)
16
+ lines.extend(("[user_preferences]", f"prompt = {value}", ""))
17
+ for provider in config.providers:
18
+ lines.extend(
19
+ (
20
+ f"[providers.{json.dumps(provider.id)}]",
21
+ f"kind = {json.dumps(provider.kind)}",
22
+ )
23
+ )
24
+ if provider.name is not None:
25
+ lines.append(f"name = {json.dumps(provider.name, ensure_ascii=False)}")
26
+ if provider.base_url is not None:
27
+ lines.append(f"base_url = {json.dumps(provider.base_url)}")
28
+ lines.append("")
29
+ for role in ("orchestrator", "judge", "opencode", "codex", "claude"):
30
+ model = config.configured_role(role)
31
+ if model is not None:
32
+ lines.extend(_serialize_role(role, model))
33
+ lines.extend(
34
+ (
35
+ "[user_profile]",
36
+ (
37
+ "profile_update_interval = "
38
+ f"{config.user_profile.profile_update_interval}"
39
+ ),
40
+ (
41
+ "user_profile_char_limit = "
42
+ f"{config.user_profile.user_profile_char_limit}"
43
+ ),
44
+ "",
45
+ "[attachments]",
46
+ f"max_text_file_bytes = {config.attachments.max_text_file_bytes}",
47
+ f"max_total_text_bytes = {config.attachments.max_total_text_bytes}",
48
+ "",
49
+ "[agents]",
50
+ f"max_output_chars = {config.agents.max_output_chars}",
51
+ "",
52
+ "[approval]",
53
+ f"mode = {json.dumps(config.approval.mode.value)}",
54
+ "",
55
+ )
56
+ )
57
+ if config.background != BackgroundConfig():
58
+ lines.extend(
59
+ (
60
+ "[background]",
61
+ f"max_concurrent = {config.background.max_concurrent}",
62
+ (
63
+ "default_timeout_seconds = "
64
+ f"{config.background.default_timeout_seconds}"
65
+ ),
66
+ "",
67
+ )
68
+ )
69
+ if config.tool_timeouts:
70
+ lines.append("[tool_timeouts]")
71
+ lines.extend(
72
+ f"{json.dumps(override.tool_name)} = {override.timeout_seconds}"
73
+ for override in config.tool_timeouts
74
+ )
75
+ lines.append("")
76
+ return "\n".join(lines)
77
+
78
+
79
+ def _serialize_role(role: ModelRole, config: ModelConfig) -> list[str]:
80
+ if config.provider is None:
81
+ raise ValueError(f"config value is required: {role}.provider")
82
+ if config.model is None:
83
+ raise ValueError(f"config value is required: {role}.model")
84
+ if config.provider == "native" and role not in {"codex", "claude"}:
85
+ raise ValueError(f"native provider is not supported for role: {role}")
86
+ lines = [
87
+ f"[models.{role}]",
88
+ f"provider = {json.dumps(config.provider, ensure_ascii=False)}",
89
+ f"model = {json.dumps(config.model, ensure_ascii=False)}",
90
+ ]
91
+ if config.reasoning_effort is not None:
92
+ value = json.dumps(config.reasoning_effort, ensure_ascii=False)
93
+ lines.append(f"reasoning_effort = {value}")
94
+ lines.append("")
95
+ return lines
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+ import tomllib
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from mandri.adapters.filesystem import atomic_write_text
7
+ from mandri.ports.config import (
8
+ MandriConfig,
9
+ )
10
+
11
+ from .decode import (
12
+ from_toml,
13
+ )
14
+ from .encode import (
15
+ to_toml,
16
+ )
17
+
18
+
19
+ class TomlConfig:
20
+ def __init__(self, path: Path) -> None:
21
+ self._path = path
22
+ self._lock = asyncio.Lock()
23
+
24
+ async def load(self) -> MandriConfig:
25
+ if not await asyncio.to_thread(self._path.is_file):
26
+ return MandriConfig()
27
+ data = await asyncio.to_thread(self._read)
28
+ return from_toml(data)
29
+
30
+ async def save(self, config: MandriConfig) -> None:
31
+ content = to_toml(config)
32
+ async with self._lock:
33
+ await asyncio.to_thread(atomic_write_text, self._path, content)
34
+
35
+ def _read(self) -> dict[str, Any]:
36
+ with self._path.open("rb") as handle:
37
+ return tomllib.load(handle)
@@ -0,0 +1,4 @@
1
+ from .permissions import credentials_file_is_private
2
+ from .toml import TomlCredentials
3
+
4
+ __all__ = ["TomlCredentials", "credentials_file_is_private"]
@@ -0,0 +1,26 @@
1
+ import os
2
+ import stat
3
+ from pathlib import Path
4
+
5
+ if os.name == "nt":
6
+ from .windows_acl import restrict_windows_file, windows_file_is_private
7
+ else:
8
+
9
+ def restrict_windows_file(path: Path) -> None:
10
+ raise OSError(f"Windows ACLs are unavailable for {path}")
11
+
12
+ def windows_file_is_private(path: Path) -> bool:
13
+ return False
14
+
15
+
16
+ def restrict_credentials_file(path: Path) -> None:
17
+ if os.name == "nt":
18
+ restrict_windows_file(path)
19
+ return
20
+ os.chmod(path, 0o600)
21
+
22
+
23
+ def credentials_file_is_private(path: Path) -> bool:
24
+ if os.name == "nt":
25
+ return windows_file_is_private(path)
26
+ return stat.S_IMODE(path.stat().st_mode) == 0o600