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.
- mrstack/__init__.py +4 -0
- mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
- mrstack/_data/config/mcp-config.example.json +23 -0
- mrstack/_data/config/start-daemon.sh +53 -0
- mrstack/_data/config/start.sh +29 -0
- mrstack/_data/schedulers/manage-jobs.sh +87 -0
- mrstack/_data/schedulers/morning-briefing.sh +29 -0
- mrstack/_data/schedulers/register-jobs.py +182 -0
- mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
- mrstack/_data/schedulers/weekly-review.sh +26 -0
- mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
- mrstack/_data/templates/alert.md +56 -0
- mrstack/_data/templates/evening-summary.md +73 -0
- mrstack/_data/templates/jarvis-alert.md +64 -0
- mrstack/_data/templates/morning-briefing.md +53 -0
- mrstack/_data/templates/weekly-review.md +79 -0
- mrstack/_overlay/api/dashboard.py +223 -0
- mrstack/_overlay/api/templates/dashboard.html +328 -0
- mrstack/_overlay/bot/handlers/callback.py +1432 -0
- mrstack/_overlay/bot/handlers/command.py +1541 -0
- mrstack/_overlay/bot/utils/keyboards.py +125 -0
- mrstack/_overlay/bot/utils/ui_components.py +166 -0
- mrstack/_overlay/claude/session.py +341 -0
- mrstack/_overlay/jarvis/__init__.py +77 -0
- mrstack/_overlay/jarvis/coach.py +122 -0
- mrstack/_overlay/jarvis/context_engine.py +463 -0
- mrstack/_overlay/jarvis/pattern_learner.py +255 -0
- mrstack/_overlay/jarvis/persona.py +84 -0
- mrstack/_overlay/jarvis/platform.py +182 -0
- mrstack/_overlay/knowledge/__init__.py +6 -0
- mrstack/_overlay/knowledge/manager.py +464 -0
- mrstack/_overlay/knowledge/memory_index.py +180 -0
- mrstack/cli.py +330 -0
- mrstack/constants.py +77 -0
- mrstack/daemon.py +325 -0
- mrstack/patcher.py +169 -0
- mrstack/wizard.py +271 -0
- mrstack-1.1.0.dist-info/METADATA +640 -0
- mrstack-1.1.0.dist-info/RECORD +42 -0
- mrstack-1.1.0.dist-info/WHEEL +4 -0
- mrstack-1.1.0.dist-info/entry_points.txt +2 -0
- 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")
|