openmuse 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 (58) hide show
  1. openmuse/__init__.py +10 -0
  2. openmuse/__main__.py +4 -0
  3. openmuse/agent/__init__.py +3 -0
  4. openmuse/agent/core.py +257 -0
  5. openmuse/app.py +150 -0
  6. openmuse/cli.py +563 -0
  7. openmuse/config.example.toml +155 -0
  8. openmuse/config.py +308 -0
  9. openmuse/console.py +136 -0
  10. openmuse/goals/__init__.py +3 -0
  11. openmuse/goals/store.py +233 -0
  12. openmuse/llm/__init__.py +15 -0
  13. openmuse/llm/base.py +119 -0
  14. openmuse/llm/factory.py +24 -0
  15. openmuse/llm/mock.py +39 -0
  16. openmuse/llm/openai_chat.py +188 -0
  17. openmuse/llm/openai_responses.py +213 -0
  18. openmuse/llm/prompt_tools.py +187 -0
  19. openmuse/logger.py +41 -0
  20. openmuse/memory/__init__.py +3 -0
  21. openmuse/memory/store.py +158 -0
  22. openmuse/prompts.py +116 -0
  23. openmuse/schema.py +185 -0
  24. openmuse/sentinel/__init__.py +5 -0
  25. openmuse/sentinel/audit.py +63 -0
  26. openmuse/sentinel/gate.py +191 -0
  27. openmuse/sentinel/policy.py +137 -0
  28. openmuse/server/__init__.py +86 -0
  29. openmuse/server/api.py +436 -0
  30. openmuse/server/events.py +142 -0
  31. openmuse/server/service.py +685 -0
  32. openmuse/server/static/assets/index-C0fjpYij.js +299 -0
  33. openmuse/server/static/assets/index-D2k1_9-v.css +1 -0
  34. openmuse/server/static/icon-192.png +0 -0
  35. openmuse/server/static/icon-512.png +0 -0
  36. openmuse/server/static/icon.svg +11 -0
  37. openmuse/server/static/index.html +25 -0
  38. openmuse/server/static/manifest.webmanifest +16 -0
  39. openmuse/server/webui.py +301 -0
  40. openmuse/tools/__init__.py +34 -0
  41. openmuse/tools/base.py +159 -0
  42. openmuse/tools/browser.py +238 -0
  43. openmuse/tools/email_tool.py +256 -0
  44. openmuse/tools/files.py +144 -0
  45. openmuse/tools/goal_tools.py +137 -0
  46. openmuse/tools/mcp_tools.py +157 -0
  47. openmuse/tools/memory_tools.py +110 -0
  48. openmuse/tools/shell.py +154 -0
  49. openmuse/tools/terminate.py +58 -0
  50. openmuse/tools/web.py +196 -0
  51. openmuse/ui.py +90 -0
  52. openmuse/vault/__init__.py +3 -0
  53. openmuse/vault/vault.py +144 -0
  54. openmuse-0.1.0.dist-info/METADATA +295 -0
  55. openmuse-0.1.0.dist-info/RECORD +58 -0
  56. openmuse-0.1.0.dist-info/WHEEL +4 -0
  57. openmuse-0.1.0.dist-info/entry_points.txt +2 -0
  58. openmuse-0.1.0.dist-info/licenses/LICENSE +21 -0
openmuse/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """OpenMuse: an open-source version of Meta's Muse personal agent.
2
+
3
+ The agent does the work (search, browse, files, code, email, long-running goals);
4
+ a separate Sentinel decides what may run and what leaves the machine; a credential
5
+ vault keeps secrets out of the model's sight; every action lands in an audit log.
6
+ ``openmuse serve`` adds the phone app.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["__version__"]
openmuse/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from openmuse.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,3 @@
1
+ from openmuse.agent.core import MuseAgent
2
+
3
+ __all__ = ["MuseAgent"]
openmuse/agent/core.py ADDED
@@ -0,0 +1,257 @@
1
+ """The agent loop: think (LLM) → act (tools through the Sentinel) → repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from openmuse import prompts
12
+ from openmuse.config import Settings
13
+ from openmuse.goals import GoalStore
14
+ from openmuse.llm.base import BaseLLM
15
+ from openmuse.logger import logger
16
+ from openmuse.memory import MemoryStore
17
+ from openmuse.schema import AgentState, Message, Role, ToolResult
18
+ from openmuse.sentinel import AuditLog, Sentinel
19
+ from openmuse.tools.base import ToolCollection
20
+ from openmuse.ui import UI
21
+
22
+
23
+ class MuseAgent:
24
+ def __init__(
25
+ self,
26
+ settings: Settings,
27
+ llm: BaseLLM,
28
+ tools: ToolCollection,
29
+ sentinel: Sentinel,
30
+ ui: UI,
31
+ audit: AuditLog,
32
+ memory: MemoryStore | None = None,
33
+ goals: GoalStore | None = None,
34
+ session_file: Path | None = None,
35
+ ):
36
+ self.settings = settings
37
+ self.llm = llm
38
+ self.tools = tools
39
+ self.sentinel = sentinel
40
+ self.ui = ui
41
+ self.audit = audit
42
+ self.memory = memory
43
+ self.goals = goals
44
+ self.session_file = session_file
45
+ self.messages: list[Message] = []
46
+ self.state = AgentState.IDLE
47
+ self.turns = 0
48
+ # Optional queue of user messages that arrive *while* a run is in progress (the app
49
+ # lets you interrupt or pile on requests). They are folded into the conversation
50
+ # before the next model call instead of waiting for the current run to finish.
51
+ self.inbox: asyncio.Queue[str] | None = None
52
+
53
+ def _drain_inbox(self) -> int:
54
+ if self.inbox is None:
55
+ return 0
56
+ count = 0
57
+ while not self.inbox.empty():
58
+ try:
59
+ text = self.inbox.get_nowait()
60
+ except asyncio.QueueEmpty: # pragma: no cover
61
+ break
62
+ self.messages.append(Message.user(text))
63
+ self.audit.record("user_message", content=text, interjected=True)
64
+ count += 1
65
+ return count
66
+
67
+ # ------------------------------------------------------------------ prompt
68
+ def build_system_prompt(self, user_input: str) -> str:
69
+ a = self.settings.agent
70
+ language_rule = (
71
+ prompts.LANGUAGE_AUTO.format(detected=prompts.detect_language(user_input))
72
+ if a.language in ("", "auto")
73
+ else prompts.LANGUAGE_FIXED.format(language=a.language)
74
+ )
75
+ memories = ""
76
+ if self.memory is not None and self.settings.memory.enabled:
77
+ items = self.memory.relevant(user_input, limit=self.settings.memory.max_inject)
78
+ if items:
79
+ memories = prompts.MEMORY_SECTION.format(
80
+ items="\n".join(f"- {m.render()}" for m in items)
81
+ )
82
+ goals = ""
83
+ if self.goals is not None:
84
+ active = self.goals.list("active")[:5]
85
+ if active:
86
+ lines = []
87
+ for g in active:
88
+ nxt = g.next_step
89
+ lines.append(
90
+ f"- {g.id}: {g.title} (progress {g.progress}"
91
+ + (f", next: {nxt.idx}. {nxt.title}" if nxt else "")
92
+ + ")"
93
+ )
94
+ goals = prompts.GOALS_SECTION.format(items="\n".join(lines))
95
+ profile = (
96
+ prompts.USER_PROFILE_SECTION.format(profile=a.user_profile.strip())
97
+ if a.user_profile.strip()
98
+ else ""
99
+ )
100
+ extra = (
101
+ f"\n## Additional instructions\n{a.instructions.strip()}\n"
102
+ if a.instructions.strip()
103
+ else ""
104
+ )
105
+ return prompts.SYSTEM_PROMPT.format(
106
+ name=a.name,
107
+ language_rule=language_rule,
108
+ now=datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%A, UTC%z)"),
109
+ workspace=str(a.workspace.resolve()),
110
+ sentinel_mode=self.settings.sentinel.mode,
111
+ tool_names=", ".join(t.name for t in self.tools),
112
+ user_profile=profile,
113
+ memories=memories,
114
+ goals=goals,
115
+ extra=extra,
116
+ )
117
+
118
+ # ------------------------------------------------------------------ context window
119
+ def context_messages(self) -> list[Message]:
120
+ limit = self.settings.agent.max_context_messages
121
+ msgs = self.messages
122
+ if len(msgs) <= limit:
123
+ return list(msgs)
124
+ cutoff = len(msgs) - limit
125
+ # Never start in the middle of a tool exchange: advance to the next user message.
126
+ while cutoff < len(msgs) and msgs[cutoff].role != Role.USER:
127
+ cutoff += 1
128
+ return list(msgs[cutoff:]) if cutoff < len(msgs) else list(msgs[-limit:])
129
+
130
+ # ------------------------------------------------------------------ stuck detection
131
+ def _is_stuck(self) -> bool:
132
+ assistants = [m for m in self.messages if m.role == Role.ASSISTANT][-3:]
133
+ if len(assistants) < 3:
134
+ return False
135
+
136
+ def sig(m: Message) -> str:
137
+ calls = [(tc.function.name, tc.function.arguments) for tc in (m.tool_calls or [])]
138
+ return json.dumps([m.content, calls], ensure_ascii=False, sort_keys=True)
139
+
140
+ return len({sig(m) for m in assistants}) == 1
141
+
142
+ # ------------------------------------------------------------------ main loop
143
+ async def run(self, user_input: str) -> str:
144
+ if self.state == AgentState.RUNNING:
145
+ raise RuntimeError("agent is already running")
146
+ self.state = AgentState.RUNNING
147
+ self.turns += 1
148
+ self.messages.append(Message.user(user_input))
149
+ self.audit.record("user_message", content=user_input)
150
+ system_prompt = self.build_system_prompt(user_input)
151
+ tool_params = self.tools.to_params()
152
+ final: str | None = None
153
+ step = 0
154
+ empty_replies = 0
155
+ try:
156
+ while step < self.settings.agent.max_steps:
157
+ step += 1
158
+ if self._drain_inbox():
159
+ logger.debug("folded queued user message(s) into the running turn")
160
+ # the language rule and the memory section follow the latest message
161
+ system_prompt = self.build_system_prompt(self.messages[-1].content or "")
162
+ context = [Message.system(system_prompt), *self.context_messages()]
163
+ response = await self.llm.ask(
164
+ context, tools=tool_params, on_delta=self.ui.on_text_delta
165
+ )
166
+ assistant = response.to_message()
167
+ assistant.meta.update({"step": step, "usage": response.usage})
168
+ self.messages.append(assistant)
169
+ self.ui.on_assistant_message(response.content, response.reasoning)
170
+ self.audit.record(
171
+ "assistant_message",
172
+ step=step,
173
+ content=response.content or "",
174
+ tool_calls=[tc.function.name for tc in response.tool_calls],
175
+ usage=response.usage,
176
+ )
177
+
178
+ if not response.tool_calls:
179
+ if response.content:
180
+ final = response.content
181
+ break
182
+ empty_replies += 1
183
+ if empty_replies >= 2:
184
+ final = ""
185
+ break
186
+ self.messages.append(
187
+ Message.user(
188
+ "(Your reply was empty. Continue the task, or call `terminate` if it is done.)"
189
+ )
190
+ )
191
+ continue
192
+
193
+ stop = False
194
+ for call in response.tool_calls:
195
+ tool = self.tools.get(call.name)
196
+ summary = tool.assess(call.arguments).summary if tool else f"{call.name}(?)"
197
+ self.ui.on_tool_call(call, summary)
198
+ if tool is None:
199
+ result = ToolResult.fail(
200
+ f"unknown tool '{call.name}'. Available tools: {', '.join(t.name for t in self.tools)}"
201
+ )
202
+ else:
203
+ result = await self.sentinel.guard(call, tool)
204
+ self.ui.on_tool_result(call, result)
205
+ self.messages.append(Message.tool(result.for_model(), call.id, call.name))
206
+ if result.stop:
207
+ final = result.output
208
+ stop = True
209
+ if stop:
210
+ break
211
+ if self._is_stuck():
212
+ logger.warning("agent seems stuck – nudging")
213
+ self.messages.append(Message.user(prompts.STUCK_PROMPT))
214
+ else:
215
+ # Step budget exhausted: ask for a wrap-up without tools.
216
+ self.messages.append(Message.user(prompts.MAX_STEPS_PROMPT))
217
+ context = [Message.system(system_prompt), *self.context_messages()]
218
+ response = await self.llm.ask(context, tools=None, on_delta=self.ui.on_text_delta)
219
+ self.messages.append(response.to_message())
220
+ self.ui.on_assistant_message(response.content, response.reasoning)
221
+ final = response.content or "(step limit reached)"
222
+ self.state = AgentState.FINISHED
223
+ except Exception:
224
+ self.state = AgentState.ERROR
225
+ raise
226
+ finally:
227
+ self._save_session()
228
+ return final or ""
229
+
230
+ # ------------------------------------------------------------------ session persistence
231
+ def reset(self) -> None:
232
+ self.messages.clear()
233
+ self.sentinel.tainted = False
234
+ self.state = AgentState.IDLE
235
+
236
+ def _save_session(self) -> None:
237
+ if not self.session_file:
238
+ return
239
+ try:
240
+ self.session_file.parent.mkdir(parents=True, exist_ok=True)
241
+ data: dict[str, Any] = {
242
+ "saved_at": datetime.now().astimezone().isoformat(timespec="seconds"),
243
+ "turns": self.turns,
244
+ "messages": [m.model_dump(mode="json") for m in self.messages],
245
+ }
246
+ self.session_file.write_text(json.dumps(data, ensure_ascii=False, indent=1), "utf-8")
247
+ except OSError as exc: # pragma: no cover
248
+ logger.warning("could not save session: {}", exc)
249
+
250
+ def load_session(self, path: Path) -> int:
251
+ data = json.loads(Path(path).read_text("utf-8"))
252
+ self.messages = [Message.model_validate(m) for m in data.get("messages", [])]
253
+ self.turns = int(data.get("turns", 0))
254
+ return len(self.messages)
255
+
256
+
257
+ __all__ = ["MuseAgent"]
openmuse/app.py ADDED
@@ -0,0 +1,150 @@
1
+ """Composition root: wires settings → stores, vault, Sentinel, tools, LLM, agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from openmuse import prompts
10
+ from openmuse.agent import MuseAgent
11
+ from openmuse.config import Settings
12
+ from openmuse.goals import GoalStore
13
+ from openmuse.llm import BaseLLM, create_llm
14
+ from openmuse.logger import logger, setup_logging
15
+ from openmuse.memory import MemoryStore
16
+ from openmuse.sentinel import AuditLog, Sentinel
17
+ from openmuse.tools import (
18
+ AskUser,
19
+ Browser,
20
+ Files,
21
+ Forget,
22
+ Goals,
23
+ MCPManager,
24
+ PythonExecute,
25
+ ReadEmails,
26
+ Recall,
27
+ Remember,
28
+ SendEmail,
29
+ Shell,
30
+ Terminate,
31
+ ToolCollection,
32
+ WebFetch,
33
+ WebSearch,
34
+ playwright_available,
35
+ )
36
+ from openmuse.ui import UI
37
+ from openmuse.vault import CredentialVault
38
+
39
+
40
+ class OpenMuseApp:
41
+ def __init__(
42
+ self, settings: Settings, ui: UI, llm: BaseLLM | None = None, session_id: str | None = None
43
+ ):
44
+ self.settings = settings
45
+ self.ui = ui
46
+ setup_logging(settings.log_level, settings.data_dir / "logs")
47
+ settings.ensure_dirs()
48
+ self.session_id = (
49
+ session_id or datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:4]
50
+ )
51
+
52
+ self.vault = CredentialVault(settings.vault_file, settings.vault_key_file)
53
+ self.memory = MemoryStore(settings.memory_db) if settings.memory.enabled else None
54
+ self.goals = GoalStore(settings.goals_db)
55
+ self.audit = AuditLog(settings.audit_file, session_id=self.session_id)
56
+ self.sentinel = Sentinel(
57
+ settings.sentinel,
58
+ audit=self.audit,
59
+ ui=ui,
60
+ vault=self.vault,
61
+ persistent_approvals_file=settings.data_dir / "approvals.json",
62
+ )
63
+ self.llm = llm or create_llm(settings.llm)
64
+ self.mcp = MCPManager(settings.mcp.servers) if settings.mcp.servers else None
65
+ self.tools = self._build_tools()
66
+ self.agent = MuseAgent(
67
+ settings=settings,
68
+ llm=self.llm,
69
+ tools=self.tools,
70
+ sentinel=self.sentinel,
71
+ ui=ui,
72
+ audit=self.audit,
73
+ memory=self.memory,
74
+ goals=self.goals,
75
+ session_file=settings.data_dir / "sessions" / f"{self.session_id}.json",
76
+ )
77
+
78
+ # ------------------------------------------------------------------ tools
79
+ def _build_tools(self) -> ToolCollection:
80
+ s = self.settings
81
+ ws: Path = s.agent.workspace
82
+ tools = ToolCollection(
83
+ Terminate(),
84
+ AskUser(ui=self.ui),
85
+ Files(workspace=ws),
86
+ Shell(workspace=ws),
87
+ PythonExecute(workspace=ws),
88
+ WebSearch(),
89
+ WebFetch(),
90
+ Goals(store=self.goals),
91
+ )
92
+ if self.memory is not None:
93
+ tools.add(
94
+ Remember(store=self.memory), Recall(store=self.memory), Forget(store=self.memory)
95
+ )
96
+ if s.connectors.email.enabled:
97
+ tools.add(
98
+ ReadEmails(settings=s.connectors.email, vault=self.vault),
99
+ SendEmail(settings=s.connectors.email, vault=self.vault),
100
+ )
101
+ if s.browser.enabled:
102
+ if playwright_available():
103
+ tools.add(
104
+ Browser(
105
+ headless=s.browser.headless, timeout_ms=s.browser.timeout_ms, workspace=ws
106
+ )
107
+ )
108
+ else:
109
+ logger.warning(
110
+ "browser.enabled=true but playwright is missing: pip install 'openmuse[browser]'"
111
+ )
112
+ return tools
113
+
114
+ async def start(self) -> OpenMuseApp:
115
+ """Connect optional MCP servers. Call once before using the agent."""
116
+ if self.mcp is not None:
117
+ for tool in await self.mcp.connect():
118
+ self.tools.add(tool)
119
+ return self
120
+
121
+ async def close(self) -> None:
122
+ await self.tools.cleanup()
123
+ if self.mcp is not None:
124
+ await self.mcp.close()
125
+ await self.llm.close()
126
+ if self.memory is not None:
127
+ self.memory.close()
128
+ self.goals.close()
129
+
130
+ async def __aenter__(self) -> OpenMuseApp:
131
+ return await self.start()
132
+
133
+ async def __aexit__(self, *exc: object) -> None:
134
+ await self.close()
135
+
136
+ # ------------------------------------------------------------------ high-level ops
137
+ async def run(self, task: str) -> str:
138
+ return await self.agent.run(task)
139
+
140
+ async def advance_goal(self, goal_id: str) -> str:
141
+ goal = self.goals.get(goal_id)
142
+ if goal is None:
143
+ raise ValueError(f"no goal {goal_id}")
144
+ if goal.status != "active":
145
+ raise ValueError(f"goal {goal_id} is {goal.status}")
146
+ self.agent.reset()
147
+ return await self.agent.run(prompts.ADVANCE_GOAL_PROMPT.format(goal=goal.render()))
148
+
149
+
150
+ __all__ = ["OpenMuseApp"]