mrstack 1.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 (42) hide show
  1. mrstack/__init__.py +4 -0
  2. mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
  3. mrstack/_data/config/mcp-config.example.json +23 -0
  4. mrstack/_data/config/start-daemon.sh +53 -0
  5. mrstack/_data/config/start.sh +29 -0
  6. mrstack/_data/schedulers/manage-jobs.sh +87 -0
  7. mrstack/_data/schedulers/morning-briefing.sh +29 -0
  8. mrstack/_data/schedulers/register-jobs.py +182 -0
  9. mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
  10. mrstack/_data/schedulers/weekly-review.sh +26 -0
  11. mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
  12. mrstack/_data/templates/alert.md +56 -0
  13. mrstack/_data/templates/evening-summary.md +73 -0
  14. mrstack/_data/templates/jarvis-alert.md +64 -0
  15. mrstack/_data/templates/morning-briefing.md +53 -0
  16. mrstack/_data/templates/weekly-review.md +79 -0
  17. mrstack/_overlay/api/dashboard.py +223 -0
  18. mrstack/_overlay/api/templates/dashboard.html +328 -0
  19. mrstack/_overlay/bot/handlers/callback.py +1432 -0
  20. mrstack/_overlay/bot/handlers/command.py +1541 -0
  21. mrstack/_overlay/bot/utils/keyboards.py +125 -0
  22. mrstack/_overlay/bot/utils/ui_components.py +166 -0
  23. mrstack/_overlay/claude/session.py +341 -0
  24. mrstack/_overlay/jarvis/__init__.py +77 -0
  25. mrstack/_overlay/jarvis/coach.py +122 -0
  26. mrstack/_overlay/jarvis/context_engine.py +463 -0
  27. mrstack/_overlay/jarvis/pattern_learner.py +255 -0
  28. mrstack/_overlay/jarvis/persona.py +84 -0
  29. mrstack/_overlay/jarvis/platform.py +182 -0
  30. mrstack/_overlay/knowledge/__init__.py +6 -0
  31. mrstack/_overlay/knowledge/manager.py +464 -0
  32. mrstack/_overlay/knowledge/memory_index.py +180 -0
  33. mrstack/cli.py +330 -0
  34. mrstack/constants.py +77 -0
  35. mrstack/daemon.py +325 -0
  36. mrstack/patcher.py +169 -0
  37. mrstack/wizard.py +271 -0
  38. mrstack-1.1.0.dist-info/METADATA +640 -0
  39. mrstack-1.1.0.dist-info/RECORD +42 -0
  40. mrstack-1.1.0.dist-info/WHEEL +4 -0
  41. mrstack-1.1.0.dist-info/entry_points.txt +2 -0
  42. mrstack-1.1.0.dist-info/licenses/LICENSE +21 -0
mrstack/daemon.py ADDED
@@ -0,0 +1,325 @@
1
+ """Daemon lifecycle management — launchd (macOS), systemd (Linux)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import textwrap
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+
13
+ from .constants import (
14
+ BOT_COMMAND,
15
+ DATA_DIR,
16
+ ENV_FILE,
17
+ IS_LINUX,
18
+ IS_MACOS,
19
+ LOG_DIR,
20
+ PID_FILE,
21
+ PLIST_DIR,
22
+ PLIST_FILE,
23
+ PLIST_LABEL,
24
+ SERVICE_FILE,
25
+ SYSTEMD_USER_DIR,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ # ── PID helpers ────────────────────────────────────────
32
+ def _read_pid() -> int | None:
33
+ if PID_FILE.is_file():
34
+ try:
35
+ pid = int(PID_FILE.read_text().strip())
36
+ os.kill(pid, 0) # check alive
37
+ return pid
38
+ except (ValueError, OSError):
39
+ PID_FILE.unlink(missing_ok=True)
40
+ return None
41
+
42
+
43
+ def _write_pid(pid: int) -> None:
44
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
45
+ PID_FILE.write_text(str(pid))
46
+
47
+
48
+ # ── Process discovery ──────────────────────────────────
49
+ def find_bot_pid() -> int | None:
50
+ """Find running claude-telegram-bot process."""
51
+ pid = _read_pid()
52
+ if pid:
53
+ return pid
54
+ # Fallback: pgrep
55
+ try:
56
+ out = subprocess.check_output(
57
+ ["pgrep", "-f", BOT_COMMAND], text=True, stderr=subprocess.DEVNULL
58
+ ).strip()
59
+ if out:
60
+ return int(out.splitlines()[0])
61
+ except (subprocess.CalledProcessError, ValueError):
62
+ pass
63
+ return None
64
+
65
+
66
+ def is_running() -> bool:
67
+ return find_bot_pid() is not None
68
+
69
+
70
+ # ── Foreground start ───────────────────────────────────
71
+ def start_foreground() -> None:
72
+ """Start the bot in the foreground (blocking)."""
73
+ if not ENV_FILE.is_file():
74
+ console.print("[red].env not found.[/] Run [bold]mrstack init[/] first.")
75
+ raise SystemExit(1)
76
+
77
+ env = _load_env()
78
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
79
+
80
+ console.print(f"[green]Starting {BOT_COMMAND}...[/]")
81
+ proc = subprocess.Popen(
82
+ [BOT_COMMAND],
83
+ cwd=str(DATA_DIR),
84
+ env=env,
85
+ )
86
+ _write_pid(proc.pid)
87
+ try:
88
+ proc.wait()
89
+ except KeyboardInterrupt:
90
+ proc.terminate()
91
+ proc.wait(timeout=10)
92
+ finally:
93
+ PID_FILE.unlink(missing_ok=True)
94
+
95
+
96
+ # ── Background start ──────────────────────────────────
97
+ def start_background() -> int:
98
+ """Start the bot in the background. Returns PID."""
99
+ existing_pid = find_bot_pid()
100
+ if existing_pid is not None:
101
+ console.print(f"[yellow]Already running (PID {existing_pid})[/]")
102
+ return existing_pid
103
+
104
+ if not ENV_FILE.is_file():
105
+ console.print("[red].env not found.[/] Run [bold]mrstack init[/] first.")
106
+ raise SystemExit(1)
107
+
108
+ env = _load_env()
109
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
110
+
111
+ stdout_log = LOG_DIR / "daemon-stdout.log"
112
+ stderr_log = LOG_DIR / "daemon-stderr.log"
113
+
114
+ with open(stdout_log, "a") as out, open(stderr_log, "a") as err:
115
+ proc = subprocess.Popen(
116
+ [BOT_COMMAND],
117
+ cwd=str(DATA_DIR),
118
+ env=env,
119
+ stdout=out,
120
+ stderr=err,
121
+ start_new_session=True,
122
+ )
123
+ _write_pid(proc.pid)
124
+ console.print(f"[green]Bot started (PID {proc.pid})[/]")
125
+ return proc.pid
126
+
127
+
128
+ # ── Stop ───────────────────────────────────────────────
129
+ def stop_bot() -> bool:
130
+ """Stop the bot. Returns True if a process was stopped."""
131
+ pid = find_bot_pid()
132
+ if pid is None:
133
+ console.print("[yellow]Bot is not running.[/]")
134
+ return False
135
+ try:
136
+ os.kill(pid, signal.SIGTERM)
137
+ console.print(f"[green]Sent SIGTERM to PID {pid}[/]")
138
+ except OSError as exc:
139
+ console.print(f"[red]Failed to stop PID {pid}: {exc}[/]")
140
+ return False
141
+ # Wait for process to actually exit (max 10s)
142
+ import time
143
+
144
+ for _ in range(20):
145
+ time.sleep(0.5)
146
+ try:
147
+ os.kill(pid, 0)
148
+ except OSError:
149
+ break
150
+ PID_FILE.unlink(missing_ok=True)
151
+ return True
152
+
153
+
154
+ # ── Daemon registration ───────────────────────────────
155
+ def daemon_install() -> None:
156
+ """Register and start the bot as a system daemon."""
157
+ if IS_MACOS:
158
+ _install_launchd()
159
+ elif IS_LINUX:
160
+ _install_systemd()
161
+ else:
162
+ console.print("[yellow]Daemon not supported on this platform.[/]")
163
+ console.print("Use [bold]mrstack start[/] to run in foreground instead.")
164
+
165
+
166
+ def daemon_uninstall() -> None:
167
+ """Unregister the daemon."""
168
+ if IS_MACOS:
169
+ _uninstall_launchd()
170
+ elif IS_LINUX:
171
+ _uninstall_systemd()
172
+ else:
173
+ console.print("[yellow]No daemon to uninstall.[/]")
174
+
175
+
176
+ # ── macOS launchd ──────────────────────────────────────
177
+ def _install_launchd() -> None:
178
+ PLIST_DIR.mkdir(parents=True, exist_ok=True)
179
+
180
+ bot_path = _which(BOT_COMMAND)
181
+ if not bot_path:
182
+ console.print(f"[red]{BOT_COMMAND} not found in PATH[/]")
183
+ raise SystemExit(1)
184
+
185
+ daemon_sh = DATA_DIR / "start-daemon.sh"
186
+ if not daemon_sh.is_file():
187
+ _write_daemon_sh(daemon_sh)
188
+
189
+ plist = textwrap.dedent(f"""\
190
+ <?xml version="1.0" encoding="UTF-8"?>
191
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
192
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
193
+ <plist version="1.0">
194
+ <dict>
195
+ <key>Label</key>
196
+ <string>{PLIST_LABEL}</string>
197
+ <key>ProgramArguments</key>
198
+ <array>
199
+ <string>/bin/bash</string>
200
+ <string>{daemon_sh}</string>
201
+ </array>
202
+ <key>RunAtLoad</key>
203
+ <true/>
204
+ <key>KeepAlive</key>
205
+ <true/>
206
+ <key>ThrottleInterval</key>
207
+ <integer>30</integer>
208
+ <key>StandardOutPath</key>
209
+ <string>{LOG_DIR}/daemon-stdout.log</string>
210
+ <key>StandardErrorPath</key>
211
+ <string>{LOG_DIR}/daemon-stderr.log</string>
212
+ <key>WorkingDirectory</key>
213
+ <string>{DATA_DIR}</string>
214
+ </dict>
215
+ </plist>
216
+ """)
217
+
218
+ # Unload first if exists
219
+ if PLIST_FILE.is_file():
220
+ subprocess.run(
221
+ ["launchctl", "unload", str(PLIST_FILE)],
222
+ capture_output=True,
223
+ )
224
+
225
+ PLIST_FILE.write_text(plist)
226
+ subprocess.run(["launchctl", "load", str(PLIST_FILE)], check=True)
227
+ console.print(f"[green]LaunchAgent installed and loaded.[/]")
228
+ console.print(f" Plist: {PLIST_FILE}")
229
+
230
+
231
+ def _uninstall_launchd() -> None:
232
+ if PLIST_FILE.is_file():
233
+ subprocess.run(["launchctl", "unload", str(PLIST_FILE)], capture_output=True)
234
+ PLIST_FILE.unlink()
235
+ console.print("[green]LaunchAgent removed.[/]")
236
+ else:
237
+ console.print("[yellow]No LaunchAgent found.[/]")
238
+
239
+
240
+ # ── Linux systemd ─────────────────────────────────────
241
+ def _install_systemd() -> None:
242
+ SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
243
+
244
+ bot_path = _which(BOT_COMMAND)
245
+ if not bot_path:
246
+ console.print(f"[red]{BOT_COMMAND} not found in PATH[/]")
247
+ raise SystemExit(1)
248
+
249
+ unit = textwrap.dedent(f"""\
250
+ [Unit]
251
+ Description=Mr.Stack — Proactive AI Butler
252
+ After=network-online.target
253
+ Wants=network-online.target
254
+
255
+ [Service]
256
+ Type=simple
257
+ ExecStart={bot_path}
258
+ WorkingDirectory={DATA_DIR}
259
+ EnvironmentFile={ENV_FILE}
260
+ Restart=on-failure
261
+ RestartSec=30
262
+
263
+ [Install]
264
+ WantedBy=default.target
265
+ """)
266
+
267
+ SERVICE_FILE.write_text(unit)
268
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
269
+ subprocess.run(["systemctl", "--user", "enable", "--now", "mrstack"], check=True)
270
+ console.print("[green]systemd user service installed and started.[/]")
271
+ console.print(f" Unit: {SERVICE_FILE}")
272
+
273
+
274
+ def _uninstall_systemd() -> None:
275
+ if SERVICE_FILE.is_file():
276
+ subprocess.run(
277
+ ["systemctl", "--user", "disable", "--now", "mrstack"],
278
+ capture_output=True,
279
+ )
280
+ SERVICE_FILE.unlink()
281
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
282
+ console.print("[green]systemd service removed.[/]")
283
+ else:
284
+ console.print("[yellow]No systemd service found.[/]")
285
+
286
+
287
+ # ── Helpers ────────────────────────────────────────────
288
+ def _which(cmd: str) -> str | None:
289
+ import shutil
290
+
291
+ return shutil.which(cmd)
292
+
293
+
294
+ def _load_env() -> dict[str, str]:
295
+ """Load environment from .env file merged with current environ."""
296
+ env = os.environ.copy()
297
+ # Ensure common paths
298
+ extra_path = f"{Path.home() / '.local' / 'bin'}:/opt/homebrew/bin:/usr/local/bin"
299
+ env["PATH"] = f"{extra_path}:{env.get('PATH', '')}"
300
+ if ENV_FILE.is_file():
301
+ for line in ENV_FILE.read_text().splitlines():
302
+ line = line.strip()
303
+ if not line or line.startswith("#"):
304
+ continue
305
+ if "=" in line:
306
+ k, _, v = line.partition("=")
307
+ val = v.strip().strip("\"'")
308
+ if " #" in val:
309
+ val = val[: val.index(" #")].rstrip()
310
+ env[k.strip()] = val
311
+ return env
312
+
313
+
314
+ def _write_daemon_sh(path: Path) -> None:
315
+ """Write start-daemon.sh if it doesn't exist."""
316
+ path.write_text(textwrap.dedent(f"""\
317
+ #!/bin/bash
318
+ cd "{DATA_DIR}"
319
+ set -a
320
+ source .env
321
+ set +a
322
+ export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
323
+ {BOT_COMMAND}
324
+ """))
325
+ path.chmod(0o755)
mrstack/patcher.py ADDED
@@ -0,0 +1,169 @@
1
+ """Install Mr.Stack overlay modules into claude-code-telegram."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import textwrap
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ from .constants import OVERLAY_DIR, find_site_packages
13
+
14
+ console = Console()
15
+
16
+ # Directories inside the overlay that should be copied into site-packages/src/
17
+ OVERLAY_MODULES = ["jarvis", "knowledge", "bot", "api", "claude"]
18
+
19
+
20
+ def locate_overlay_dir() -> Path:
21
+ """Return the overlay directory, checking both bundled and repo-local paths."""
22
+ # Bundled (pip/uv install)
23
+ if OVERLAY_DIR.is_dir() and any(
24
+ p for p in OVERLAY_DIR.iterdir() if p.name != "__pycache__"
25
+ ):
26
+ return OVERLAY_DIR
27
+ # Repo-local (git clone)
28
+ repo_src = Path(__file__).parent.parent / "src"
29
+ if repo_src.is_dir():
30
+ return repo_src
31
+ raise FileNotFoundError(
32
+ "Overlay files not found. Reinstall mrstack or run from the git repo."
33
+ )
34
+
35
+
36
+ def patch_install(site_pkg: Path | None = None, force: bool = False) -> bool:
37
+ """Copy overlay modules into the claude-code-telegram installation.
38
+
39
+ Returns True on success.
40
+ """
41
+ if site_pkg is None:
42
+ site_pkg = find_site_packages()
43
+ if site_pkg is None:
44
+ console.print(
45
+ "[red]claude-code-telegram not found.[/] "
46
+ "Install it first: [bold]uv tool install claude-code-telegram[/]"
47
+ )
48
+ return False
49
+
50
+ src_dir = site_pkg / "src"
51
+ if not (src_dir / "bot" / "orchestrator.py").is_file():
52
+ console.print(f"[red]Invalid installation at {site_pkg}[/]")
53
+ return False
54
+
55
+ overlay = locate_overlay_dir()
56
+
57
+ for mod in OVERLAY_MODULES:
58
+ mod_src = overlay / mod
59
+ if not mod_src.is_dir():
60
+ continue
61
+ mod_dst = src_dir / mod
62
+ if mod_dst.is_dir() and not force:
63
+ # Merge: copy individual files, don't nuke the whole directory
64
+ for f in mod_src.rglob("*"):
65
+ if f.is_file():
66
+ rel = f.relative_to(mod_src)
67
+ dst = mod_dst / rel
68
+ dst.parent.mkdir(parents=True, exist_ok=True)
69
+ shutil.copy2(f, dst)
70
+ else:
71
+ if mod_dst.is_dir():
72
+ shutil.rmtree(mod_dst)
73
+ shutil.copytree(mod_src, mod_dst)
74
+
75
+ console.print("[green]Overlay modules installed.[/]")
76
+
77
+ # Patch settings.py — add enable_jarvis field
78
+ _patch_settings(src_dir)
79
+ # Patch main.py — wire up Jarvis engine
80
+ _patch_main(src_dir)
81
+ return True
82
+
83
+
84
+ def _patch_settings(src_dir: Path) -> None:
85
+ settings = src_dir / "config" / "settings.py"
86
+ if not settings.is_file():
87
+ return
88
+ text = settings.read_text()
89
+ if "enable_jarvis" in text:
90
+ console.print(" settings.py — enable_jarvis already present")
91
+ return
92
+ # Insert after enable_clipboard_monitor field
93
+ marker = "enable_clipboard_monitor"
94
+ if marker not in text:
95
+ console.print("[yellow] settings.py — marker not found, skip patch[/]")
96
+ return
97
+ # Find the closing ')' of that Field(...)
98
+ idx = text.index(marker)
99
+ # Find next unmatched ')' after the marker
100
+ depth = 0
101
+ i = text.index("Field(", idx)
102
+ for i in range(i, len(text)):
103
+ if text[i] == "(":
104
+ depth += 1
105
+ elif text[i] == ")":
106
+ depth -= 1
107
+ if depth == 0:
108
+ break
109
+ insert_pos = i + 1
110
+ patch = textwrap.dedent("""
111
+ enable_jarvis: bool = Field(
112
+ False,
113
+ description="Enable Jarvis proactive context engine",
114
+ )""")
115
+ text = text[:insert_pos] + "\n" + patch + text[insert_pos:]
116
+ settings.write_text(text)
117
+ console.print(" settings.py — patched")
118
+
119
+
120
+ def _patch_main(src_dir: Path) -> None:
121
+ main = src_dir / "main.py"
122
+ if not main.is_file():
123
+ return
124
+ text = main.read_text()
125
+ if "jarvis_engine" in text:
126
+ console.print(" main.py — jarvis_engine already present")
127
+ return
128
+
129
+ # Add variable declaration
130
+ text = text.replace(
131
+ "clipboard_monitor = None",
132
+ "clipboard_monitor = None\n jarvis_engine = None",
133
+ )
134
+
135
+ # Add startup block after clipboard monitor
136
+ startup = textwrap.dedent("""\
137
+
138
+ # Jarvis engine (if enabled)
139
+ if config.enable_jarvis:
140
+ from src.jarvis import JarvisEngine
141
+
142
+ jarvis_engine = JarvisEngine(
143
+ event_bus=event_bus,
144
+ target_chat_ids=config.notification_chat_ids or [],
145
+ working_directory=str(config.approved_directory),
146
+ )
147
+ await jarvis_engine.start()
148
+ bot.deps["jarvis_engine"] = jarvis_engine
149
+ logger.info("Jarvis engine enabled")
150
+ """)
151
+ clipboard_marker = 'logger.info("Clipboard monitor enabled")'
152
+ shutdown_marker = "# Shutdown task"
153
+ if clipboard_marker in text:
154
+ text = text.replace(clipboard_marker, clipboard_marker + "\n" + startup)
155
+ elif shutdown_marker in text:
156
+ text = text.replace(shutdown_marker, startup + "\n " + shutdown_marker)
157
+ else:
158
+ console.print("[red] main.py — no known injection point found, skipping[/]")
159
+ return
160
+
161
+ # Add shutdown
162
+ text = text.replace(
163
+ "if clipboard_monitor:\n await clipboard_monitor.stop()",
164
+ "if jarvis_engine:\n await jarvis_engine.stop()\n"
165
+ " if clipboard_monitor:\n await clipboard_monitor.stop()",
166
+ )
167
+
168
+ main.write_text(text)
169
+ console.print(" main.py — patched")