lyra-assistant 1.3.1__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 (96) hide show
  1. lyra/__init__.py +11 -0
  2. lyra/client/__init__.py +1 -0
  3. lyra/client/__main__.py +183 -0
  4. lyra/client/launcher.py +121 -0
  5. lyra/client/reminders.py +26 -0
  6. lyra/client/render.py +102 -0
  7. lyra/client/repl.py +237 -0
  8. lyra/core/__init__.py +16 -0
  9. lyra/core/config.py +211 -0
  10. lyra/core/constants.py +94 -0
  11. lyra/core/fastpath.py +51 -0
  12. lyra/core/formatters.py +306 -0
  13. lyra/core/menus.py +214 -0
  14. lyra/core/paths.py +72 -0
  15. lyra/core/pipeline.py +1383 -0
  16. lyra/core/retrieval.py +396 -0
  17. lyra/core/settings.py +184 -0
  18. lyra/core/settings_menu.py +280 -0
  19. lyra/core/settings_tui.py +213 -0
  20. lyra/core/types.py +123 -0
  21. lyra/core/validation.py +212 -0
  22. lyra/core/workflows/__init__.py +6 -0
  23. lyra/core/workflows/context.py +73 -0
  24. lyra/core/workflows/vm_clone.py +427 -0
  25. lyra/core/workflows/vm_clone_exec.py +191 -0
  26. lyra/core/workflows/vm_export.py +256 -0
  27. lyra/core/workflows/vm_snapshot.py +175 -0
  28. lyra/core/workflows/vm_snapshot_exec.py +291 -0
  29. lyra/core/workflows/vm_start.py +95 -0
  30. lyra/core/workflows/vm_stop.py +78 -0
  31. lyra/daemon/__init__.py +1 -0
  32. lyra/daemon/__main__.py +33 -0
  33. lyra/daemon/actions.py +230 -0
  34. lyra/daemon/protocol.py +105 -0
  35. lyra/daemon/remote_ui.py +142 -0
  36. lyra/daemon/server.py +386 -0
  37. lyra/daemon/state.py +145 -0
  38. lyra/hestia/__init__.py +18 -0
  39. lyra/hestia/background_tasks.py +563 -0
  40. lyra/hestia/executor.py +479 -0
  41. lyra/hestia/metrics.py +157 -0
  42. lyra/hestia/notion_logger.py +142 -0
  43. lyra/hestia/tracking_client.py +194 -0
  44. lyra/models/__init__.py +28 -0
  45. lyra/models/_analysis.py +33 -0
  46. lyra/models/cartes.py +370 -0
  47. lyra/models/ephaistos.py +958 -0
  48. lyra/models/ephaistos_exp.py +862 -0
  49. lyra/models/intent_classifier.py +255 -0
  50. lyra/models/lyra_voice.py +839 -0
  51. lyra/models/model_manager.py +291 -0
  52. lyra/rag/__init__.py +24 -0
  53. lyra/rag/fusion.py +141 -0
  54. lyra/rag/indexer.py +327 -0
  55. lyra/rag/keyword_retriever.py +146 -0
  56. lyra/rag/semantic_retriever.py +275 -0
  57. lyra/rag/session_memory.py +265 -0
  58. lyra/rag_enhanced/README.md +236 -0
  59. lyra/rag_enhanced/__init__.py +140 -0
  60. lyra/rag_enhanced/confidence_cascader.py +193 -0
  61. lyra/rag_enhanced/config.py +262 -0
  62. lyra/rag_enhanced/constants.py +64 -0
  63. lyra/rag_enhanced/context_db.py +237 -0
  64. lyra/rag_enhanced/context_injector.py +225 -0
  65. lyra/rag_enhanced/feedback_loop.py +468 -0
  66. lyra/rag_enhanced/pipeline_enhanced.py +667 -0
  67. lyra/rag_enhanced/rag_3tier.py +631 -0
  68. lyra/rag_enhanced/slang_normalizer.py +234 -0
  69. lyra/rag_enhanced/synonym_expander.py +247 -0
  70. lyra/rag_enhanced/types.py +71 -0
  71. lyra/rules/__init__.py +43 -0
  72. lyra/rules/backup.py +56 -0
  73. lyra/rules/base.py +23 -0
  74. lyra/rules/catt.py +85 -0
  75. lyra/rules/denon.py +87 -0
  76. lyra/rules/hue.py +128 -0
  77. lyra/rules/ironman.py +35 -0
  78. lyra/rules/screen_manager.py +198 -0
  79. lyra/rules/tracking.py +76 -0
  80. lyra/rules/tv.py +137 -0
  81. lyra/rules/vm.py +388 -0
  82. lyra/utils/__init__.py +0 -0
  83. lyra/utils/error_log.py +93 -0
  84. lyra/utils/mermaid_viewer.py +196 -0
  85. lyra/utils/toon.py +193 -0
  86. lyra_assistant-1.3.1.dist-info/METADATA +324 -0
  87. lyra_assistant-1.3.1.dist-info/RECORD +96 -0
  88. lyra_assistant-1.3.1.dist-info/WHEEL +4 -0
  89. lyra_assistant-1.3.1.dist-info/entry_points.txt +2 -0
  90. lyra_assistant-1.3.1.dist-info/licenses/LICENSE +668 -0
  91. modules/__init__.py +16 -0
  92. modules/audio.py +463 -0
  93. modules/llm.py +233 -0
  94. modules/mcp.py +1104 -0
  95. modules/n8n.py +443 -0
  96. modules/ui.py +812 -0
lyra/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Lyra RAG - Architecture hybride avec dual models et RAG.
3
+
4
+ Modules:
5
+ - rag: Systeme de retrieval hybride (ChromaDB + BM25 + RRF)
6
+ - models: Dual models EPHAISTOS (Qwen 7B) + LYRA (Llama 3B)
7
+ - hestia: Executeur MCP avec logging optionnel
8
+ - core: Pipeline et configuration
9
+ """
10
+
11
+ __version__ = "2.0.0"
@@ -0,0 +1 @@
1
+ """Lyra Client - clients legers du demon (CLI one-shot, REPL, vocal)."""
@@ -0,0 +1,183 @@
1
+ """
2
+ Lyra Client - Point d'entree CLI.
3
+
4
+ Route les invocations :
5
+ - one-shot texte -> demon (relance auto si mort) puis fallback standalone
6
+ - interactif / vocal / legacy / --standalone -> main_rag historique (Phase 4
7
+ du chantier demon : le REPL deviendra client a son tour)
8
+
9
+ Codes de sortie identiques a run_one_shot : 0=ok, 1=erreur, 2=annule, 3=args.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.metadata
16
+ import os
17
+ import sys
18
+ import tomllib
19
+ from pathlib import Path
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
22
+ PACKAGE_NAME = "lyra-assistant"
23
+
24
+
25
+ def _package_version() -> str:
26
+ """Version installee du paquet ; depuis les sources, lue dans pyproject.toml."""
27
+ try:
28
+ return importlib.metadata.version(PACKAGE_NAME)
29
+ except importlib.metadata.PackageNotFoundError:
30
+ pass
31
+ try:
32
+ with open(REPO_ROOT / "pyproject.toml", "rb") as f:
33
+ version = tomllib.load(f)["project"]["version"]
34
+ except (OSError, tomllib.TOMLDecodeError, KeyError):
35
+ version = "unknown"
36
+ return f"{version} (sources)"
37
+
38
+
39
+ def _print_incomplete_integrations_reminder() -> None:
40
+ """Rappel des MCPs incomplets (voir lyra/client/reminders.py) --
41
+ aussi affiche en one-shot, pas seulement en REPL, sinon un utilisateur
42
+ qui n'utilise jamais le mode interactif ne le voit jamais."""
43
+ try:
44
+ import yaml
45
+
46
+ from lyra.client.reminders import print_incomplete_integrations
47
+ with open(REPO_ROOT / "config.yaml") as f:
48
+ cfg = yaml.safe_load(f)
49
+ print_incomplete_integrations(cfg)
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def _exec_standalone(argv: list[str]) -> None:
55
+ """Remplace le process par le main_rag historique (fallback ou interactif)."""
56
+ os.chdir(REPO_ROOT)
57
+ os.execv(sys.executable,
58
+ [sys.executable, str(REPO_ROOT / "main_rag.py"), "--rag-enhanced",
59
+ *argv])
60
+
61
+
62
+ def run_oneshot_via_daemon(request: str, args: argparse.Namespace) -> int:
63
+ """Requete one-shot via le demon. Retourne un code de sortie, ou -1 si le
64
+ demon est indisponible (le caller bascule en standalone)."""
65
+ from lyra.client.launcher import ensure_daemon
66
+ from lyra.client.render import answer_ask, render_output, render_progress
67
+ from lyra.daemon.protocol import ChannelClosed
68
+ from modules import ui
69
+
70
+ waiting = {"shown": False}
71
+
72
+ def on_wait():
73
+ if not waiting["shown"]:
74
+ print(f"{ui.Colors.DIM} (re)demarrage du demon Lyra...{ui.Colors.RESET}",
75
+ flush=True)
76
+ waiting["shown"] = True
77
+
78
+ channel, greeting = ensure_daemon(on_wait=on_wait)
79
+ if greeting:
80
+ ui.print_lyra(greeting)
81
+ if channel is None:
82
+ return -1 # demon introuvable -> standalone
83
+
84
+ _print_incomplete_integrations_reminder()
85
+ print(f"{ui.Colors.CYAN}LYRA{ui.Colors.RESET} {request}", flush=True)
86
+
87
+ mode = "performance" if args.performance else "default"
88
+ try:
89
+ channel.send({"type": "hello", "session": "default", "client": "oneshot"})
90
+ ready = channel.recv(timeout=10)
91
+ if ready.get("type") != "ready":
92
+ return -1
93
+ channel.send({
94
+ "type": "request",
95
+ "text": request,
96
+ "options": {"mode": mode, "yes": args.yes,
97
+ "verbose": args.verbose, "interactive": False},
98
+ })
99
+ while True:
100
+ message = channel.recv(timeout=600)
101
+ mtype = message.get("type")
102
+ if mtype == "output":
103
+ render_output(message)
104
+ elif mtype == "progress":
105
+ render_progress(message, verbose=args.verbose)
106
+ elif mtype == "busy":
107
+ ui.print_info(message.get("text", "Lyra est occupee..."))
108
+ elif mtype == "ask":
109
+ answer = answer_ask(message)
110
+ if answer is None:
111
+ channel.send({"type": "cancel"})
112
+ ui.print_warning("Annule.")
113
+ return 2
114
+ channel.send({"type": "answer", "value": answer})
115
+ elif mtype == "result":
116
+ return int(message.get("exit_code", 1))
117
+ elif mtype == "error":
118
+ ui.print_error(message.get("text", "erreur protocole"))
119
+ return 1
120
+ except (ChannelClosed, TimeoutError, ValueError) as e:
121
+ ui.print_error(f"Connexion au demon perdue: {e}")
122
+ return 1
123
+ except KeyboardInterrupt:
124
+ try:
125
+ channel.send({"type": "cancel"})
126
+ except Exception:
127
+ pass
128
+ print()
129
+ ui.print_warning("Annule.")
130
+ return 2
131
+ finally:
132
+ channel.close()
133
+
134
+
135
+ def main() -> int:
136
+ parser = argparse.ArgumentParser(description="Lyra client", add_help=True)
137
+ parser.add_argument("--version", action="version",
138
+ version=f"{PACKAGE_NAME} {_package_version()}")
139
+ parser.add_argument("request", nargs="?", default=None)
140
+ parser.add_argument("--vocal", action="store_true")
141
+ parser.add_argument("-p", "--performance", action="store_true")
142
+ parser.add_argument("--config", "-c", default="config.yaml")
143
+ parser.add_argument("--check", action="store_true")
144
+ parser.add_argument("--rag-enhanced", action="store_true") # implicite
145
+ parser.add_argument("--debug", "-d", action="store_true")
146
+ parser.add_argument("--verbose", "-v", action="store_true")
147
+ parser.add_argument("--yes", "-y", action="store_true")
148
+ parser.add_argument("--standalone", action="store_true",
149
+ help="Forcer le mode historique sans demon")
150
+ args, unknown = parser.parse_known_args()
151
+
152
+ # Reconstituer les argv a transmettre au standalone (sans --standalone)
153
+ passthrough = [a for a in sys.argv[1:] if a not in ("--standalone", "--rag-enhanced")]
154
+
155
+ one_shot = args.request is not None
156
+ daemon_capable = (
157
+ not args.standalone and not args.vocal and not args.check
158
+ and not args.debug and not unknown and args.config == "config.yaml"
159
+ )
160
+
161
+ if daemon_capable and one_shot:
162
+ code = run_oneshot_via_daemon(args.request, args)
163
+ if code >= 0:
164
+ return code
165
+ print(" [!] Demon indisponible, bascule en mode standalone (plus lent)",
166
+ file=sys.stderr)
167
+ elif daemon_capable and not one_shot:
168
+ # REPL interactif branche sur le demon (vocal reste standalone)
169
+ from lyra.client.repl import run_repl
170
+ code = run_repl(args)
171
+ if code >= 0:
172
+ return code
173
+ print(" [!] Demon indisponible, bascule en mode standalone (plus lent)",
174
+ file=sys.stderr)
175
+
176
+ _exec_standalone(passthrough)
177
+ return 1 # jamais atteint (execv)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.path.insert(0, str(REPO_ROOT))
182
+ os.chdir(REPO_ROOT)
183
+ sys.exit(main())
@@ -0,0 +1,121 @@
1
+ """
2
+ Lyra Client - Cycle de vie du demon cote client.
3
+
4
+ Spec utilisateur : si le demon est mort, le client previent (notification +
5
+ message d'accueil facon Lyra expliquant la raison du crash), le relance
6
+ (systemd d'abord, spawn direct sinon), et bascule en standalone si le demon
7
+ refuse de demarrer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from lyra.daemon import state as daemon_state
19
+ from lyra.daemon.protocol import SOCKET_PATH, LineChannel, connect
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
22
+ DAEMON_LOG = Path.home() / ".lyra" / "logs" / "daemon.log"
23
+ START_TIMEOUT = 240.0 # init complete (modeles + ChromaDB) ~15-25s, marge large
24
+
25
+
26
+ def try_connect() -> Optional[LineChannel]:
27
+ try:
28
+ return connect(SOCKET_PATH)
29
+ except OSError:
30
+ return None
31
+
32
+
33
+ def _systemd_unit_exists() -> bool:
34
+ try:
35
+ result = subprocess.run(
36
+ ["systemctl", "--user", "cat", "lyra-daemon.service"],
37
+ capture_output=True, timeout=3,
38
+ )
39
+ return result.returncode == 0
40
+ except (OSError, subprocess.TimeoutExpired):
41
+ return False
42
+
43
+
44
+ def _notify_crash(reason: str) -> None:
45
+ """Notification desktop best-effort (spec: prevenir en cas de crash)."""
46
+ try:
47
+ subprocess.run(
48
+ ["notify-send", "-a", "Lyra", "Lyra a redemarre",
49
+ f"Raison du dernier arret : {reason}"],
50
+ capture_output=True, timeout=3,
51
+ )
52
+ except (OSError, subprocess.TimeoutExpired):
53
+ pass
54
+
55
+
56
+ def start_daemon() -> bool:
57
+ """Demarre le demon (systemd si l'unit existe, sinon spawn detache)."""
58
+ if _systemd_unit_exists():
59
+ try:
60
+ result = subprocess.run(
61
+ ["systemctl", "--user", "start", "lyra-daemon.service"],
62
+ capture_output=True, timeout=10,
63
+ )
64
+ if result.returncode == 0:
65
+ return True
66
+ except (OSError, subprocess.TimeoutExpired):
67
+ pass
68
+ # systemd a echoue -> tenter le spawn direct quand meme
69
+
70
+ DAEMON_LOG.parent.mkdir(parents=True, exist_ok=True)
71
+ try:
72
+ with open(DAEMON_LOG, "a") as log:
73
+ subprocess.Popen(
74
+ [sys.executable, "-m", "lyra.daemon"],
75
+ cwd=REPO_ROOT,
76
+ stdout=log,
77
+ stderr=log,
78
+ start_new_session=True, # survit a la fin du client
79
+ )
80
+ return True
81
+ except OSError:
82
+ return False
83
+
84
+
85
+ def wait_for_daemon(timeout: float = START_TIMEOUT,
86
+ on_wait=None) -> Optional[LineChannel]:
87
+ """Attend que le socket accepte les connexions."""
88
+ deadline = time.time() + timeout
89
+ while time.time() < deadline:
90
+ channel = try_connect()
91
+ if channel is not None:
92
+ return channel
93
+ if on_wait:
94
+ on_wait()
95
+ time.sleep(0.5)
96
+ return None
97
+
98
+
99
+ def ensure_daemon(on_wait=None) -> tuple[Optional[LineChannel], Optional[str]]:
100
+ """Connexion au demon, en le (re)lancant si besoin.
101
+
102
+ Returns:
103
+ (channel, greeting) — greeting est le message d'accueil facon Lyra a
104
+ afficher si le demon avait crashe (None sinon). channel est None si le
105
+ demon n'a pas pu demarrer (le client doit basculer en standalone).
106
+ """
107
+ channel = try_connect()
108
+ if channel is not None:
109
+ return channel, None
110
+
111
+ crash = daemon_state.read_crash_info()
112
+ greeting = None
113
+ if crash is not None:
114
+ greeting = daemon_state.crash_greeting(crash)
115
+ _notify_crash(crash.get("reason", "inconnue"))
116
+
117
+ if not start_daemon():
118
+ return None, greeting
119
+
120
+ channel = wait_for_daemon(on_wait=on_wait)
121
+ return channel, greeting
@@ -0,0 +1,26 @@
1
+ """Rappels affiches au demarrage du client (REPL et one-shot).
2
+
3
+ incomplete_integrations est ecrit dans config.yaml par installer/core/
4
+ steps/config.py quand un MCP selectionne a l'install est reste incomplet
5
+ (device injoignable, paquet casse...) -- voir installer/core/pipeline.py
6
+ StepDef.optional. Non bloquant pour Lyra, mais l'utilisateur doit savoir
7
+ que cette partie ne fonctionnera pas tant que non reconfiguree.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from modules import ui
12
+
13
+
14
+ def print_incomplete_integrations(cfg: dict) -> None:
15
+ incomplete = cfg.get("incomplete_integrations") or []
16
+ if not incomplete:
17
+ return
18
+ print(f"{ui.Colors.YELLOW}[!] Integrations incompletes :{ui.Colors.RESET}")
19
+ for item in incomplete:
20
+ label = item.get("label", item.get("id", "?"))
21
+ reason = item.get("reason", "raison inconnue")
22
+ print(f"{ui.Colors.YELLOW} - {label} : {reason}{ui.Colors.RESET}")
23
+ print(f"{ui.Colors.YELLOW} Cette partie ne fonctionnera pas tant que "
24
+ f"non configuree. Relance ./installer/install.sh pour la "
25
+ f"reconfigurer, ou complete config.yaml/secrets.yaml a la "
26
+ f"main.{ui.Colors.RESET}")
lyra/client/render.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Lyra Client - Rendu terminal des messages du protocole.
3
+
4
+ Reutilise modules/ui.py (stdlib pur, demarrage instantane) pour un rendu
5
+ identique au mode historique : memes panneaux, memes couleurs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import Optional
12
+
13
+ from modules import ui
14
+
15
+
16
+ def render_output(message: dict) -> None:
17
+ """Affiche un message {"type": "output", ...}."""
18
+ kind = message.get("kind", "plain")
19
+ text = message.get("text", "")
20
+ if kind == "info":
21
+ ui.print_info(text)
22
+ elif kind == "success":
23
+ ui.print_success(text)
24
+ elif kind == "warning":
25
+ ui.print_warning(text)
26
+ elif kind == "error":
27
+ print(f"\n{ui.Colors.RED}{text}{ui.Colors.RESET}")
28
+ elif kind == "lyra":
29
+ ui.print_lyra(text)
30
+ elif kind == "lyra_tag":
31
+ ui.print_lyra_tag(text)
32
+ elif kind == "tool_call":
33
+ ui.print_tool_call(message.get("tool", "?"),
34
+ message.get("arguments", {}),
35
+ message.get("vm_state"))
36
+ elif kind == "tool_result":
37
+ ui.print_tool_result(text, success=message.get("success", True),
38
+ raw_error=message.get("raw_error"))
39
+ else: # plain
40
+ print(text)
41
+
42
+
43
+ def render_progress(message: dict, verbose: bool = False) -> None:
44
+ """Affiche un message {"type": "progress", ...} (verbose uniquement)."""
45
+ if not verbose:
46
+ return
47
+ step = message.get("step", "")
48
+ data = message.get("data", {})
49
+ if step == "acknowledgement":
50
+ print(f" {ui.Colors.CYAN}[lyra] {data.get('message', '')}{ui.Colors.RESET}",
51
+ flush=True)
52
+ return
53
+ score = data.get("score", 0.0)
54
+ if score > 0.80:
55
+ color = ui.Colors.GREEN
56
+ elif score >= 0.50:
57
+ color = ui.Colors.YELLOW
58
+ else:
59
+ color = ui.Colors.RED
60
+ if step == "before_llm":
61
+ level = data.get("confidence_level", "medium")
62
+ tool = data.get("tool", "?").split(".")[-1].replace("_", " ")
63
+ score_str = f"{score:.2f}" if score else "?"
64
+ print(f" {color}[rag] {tool} score={score_str} ({level}){ui.Colors.RESET}",
65
+ flush=True)
66
+ else:
67
+ tool = data.get("tool", "") or data.get("server", "")
68
+ if tool:
69
+ score_str = f"{score:.2f}" if score else "?"
70
+ print(f" {color}[rag] {step}: {tool} score={score_str}"
71
+ f"{ui.Colors.RESET}", flush=True)
72
+
73
+
74
+ def answer_ask(message: dict) -> Optional[str]:
75
+ """Affiche un ask et lit la reponse clavier.
76
+
77
+ Returns:
78
+ La reponse, ou None si l'utilisateur a annule (Ctrl+C / EOF).
79
+ """
80
+ kind = message.get("kind", "input")
81
+ prompt = message.get("prompt", "")
82
+ payload = message.get("payload", {})
83
+
84
+ if kind == "confirm":
85
+ if payload.get("dangerous"):
86
+ sys.stdout.write(f"\n{ui.Colors.RED}{prompt}{ui.Colors.RESET} ")
87
+ else:
88
+ tag = f"{ui.Colors.BG_ORANGE}{ui.Colors.FG_DARK} Lyra {ui.Colors.RESET}"
89
+ sys.stdout.write(f"\n{tag} {prompt} ")
90
+ sys.stdout.flush()
91
+ try:
92
+ return input().strip()
93
+ except (EOFError, KeyboardInterrupt):
94
+ print()
95
+ return None
96
+
97
+ # kind == "input" : question libre d'un workflow
98
+ try:
99
+ return input(f"{prompt} ")
100
+ except (EOFError, KeyboardInterrupt):
101
+ print()
102
+ return None
lyra/client/repl.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ Lyra Client - REPL interactif branche sur le demon.
3
+
4
+ Reprend l'experience de main_rag (banniere, live_input + bandeau de taches,
5
+ commandes internes, /setting) mais route les requetes vers le demon :
6
+ demarrage en <1s, pipeline deja chaud, session multi-tours cote demon.
7
+
8
+ Ce qui reste local au client : l'affichage, le bandeau de taches (lu depuis
9
+ le registre fichier partage ~/.lyra/active_tasks.json), les commandes
10
+ internes et la TUI /setting. Le vocal reste en mode standalone (phase
11
+ ulterieure du chantier demon).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from lyra.client.launcher import ensure_daemon
21
+ from lyra.client.reminders import print_incomplete_integrations
22
+ from lyra.client.render import answer_ask, render_output, render_progress
23
+ from lyra.daemon.protocol import ChannelClosed, LineChannel
24
+ from modules import ui
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
27
+
28
+
29
+ def _print_banner() -> None:
30
+ """Banniere v2 enhanced avec les noms de modeles (lecture yaml legere)."""
31
+ try:
32
+ import yaml
33
+ with open(REPO_ROOT / "config.yaml") as f:
34
+ cfg = yaml.safe_load(f)
35
+ models = cfg.get("models", {})
36
+ from main_rag import print_banner
37
+ print_banner(mode="enhanced",
38
+ ephaistos_model=models.get("ephaistos", {}).get("name", "?"),
39
+ lyra_model=models.get("lyra", {}).get("name", "?"))
40
+ print_incomplete_integrations(cfg)
41
+ except Exception:
42
+ print(f"{ui.Colors.CYAN}LYRA (demon){ui.Colors.RESET}")
43
+
44
+
45
+ def _process_request(channel: LineChannel, text: str, mode: str,
46
+ verbose: bool) -> str:
47
+ """Envoie une requete et deroule les messages jusqu'au result.
48
+
49
+ Returns:
50
+ "done" : requete terminee, connexion propre et reutilisable
51
+ "reconnect" : la connexion doit etre remplacee. C'est le cas apres
52
+ TOUTE annulation : si on arretait de lire apres un cancel, le
53
+ "result" tardif du demon resterait dans le tampon et serait lu
54
+ comme premiere reponse de la requete SUIVANTE — toutes les
55
+ requetes deviendraient silencieuses, decalees d'un cran
56
+ (bug du 2026-08-11). Connexion neuve = flux garanti propre.
57
+ """
58
+ try:
59
+ channel.send({
60
+ "type": "request",
61
+ "text": text,
62
+ "options": {"mode": mode, "yes": False, "verbose": verbose,
63
+ "interactive": True},
64
+ })
65
+ while True:
66
+ message = channel.recv(timeout=600)
67
+ mtype = message.get("type")
68
+ if mtype == "output":
69
+ render_output(message)
70
+ elif mtype == "progress":
71
+ render_progress(message, verbose=verbose)
72
+ elif mtype == "busy":
73
+ ui.print_info(message.get("text", "Lyra est occupee..."))
74
+ elif mtype == "ask":
75
+ answer = answer_ask(message)
76
+ if answer is None:
77
+ ui.print_warning("Annule.")
78
+ return "reconnect"
79
+ channel.send({"type": "answer", "value": answer})
80
+ elif mtype == "result":
81
+ return "done"
82
+ elif mtype == "error":
83
+ ui.print_error(message.get("text", "erreur protocole"))
84
+ return "done"
85
+ except KeyboardInterrupt:
86
+ print()
87
+ ui.print_warning("Requete annulee.")
88
+ return "reconnect"
89
+ except (ChannelClosed, TimeoutError, ValueError) as e:
90
+ ui.print_error(f"Connexion au demon perdue: {e}")
91
+ return "reconnect"
92
+
93
+
94
+ def _connect(show_greeting: bool = True):
95
+ """Connexion + hello. Retourne le channel pret, ou None si demon KO."""
96
+ channel, greeting = ensure_daemon(on_wait=lambda: None)
97
+ if greeting and show_greeting:
98
+ ui.print_lyra(greeting)
99
+ if channel is None:
100
+ return None
101
+ try:
102
+ channel.send({"type": "hello", "session": "default", "client": "repl"})
103
+ channel.recv(timeout=10) # ready
104
+ return channel
105
+ except (ChannelClosed, TimeoutError, ValueError):
106
+ channel.close()
107
+ return None
108
+
109
+
110
+ def run_repl(args) -> int:
111
+ """Boucle interactive cliente du demon."""
112
+ from lyra.core.settings import UserSettings
113
+ from lyra.core.settings_menu import SettingsCallbacks, SettingsMenu
114
+ from lyra.hestia.background_tasks import BackgroundTaskManager
115
+
116
+ user_settings = UserSettings()
117
+ mode = "performance" if args.performance else user_settings.active_mode()
118
+
119
+ _print_banner()
120
+
121
+ channel, greeting = ensure_daemon(
122
+ on_wait=lambda: None)
123
+ if greeting:
124
+ ui.print_lyra(greeting)
125
+ if channel is None:
126
+ return -1 # bascule standalone geree par l'appelant
127
+
128
+ try:
129
+ channel.send({"type": "hello", "session": "default", "client": "repl"})
130
+ channel.recv(timeout=10) # ready
131
+ channel.send({"type": "health"})
132
+ health = channel.recv(timeout=180).get("data", {})
133
+ servers = health.get("mcp_servers", [])
134
+ if servers:
135
+ ui.print_success(f"MCP: {', '.join(servers)} ({len(servers)} serveurs)"
136
+ f" — demon pret (uptime {health.get('uptime_s', '?')}s)")
137
+ except (ChannelClosed, TimeoutError, ValueError):
138
+ return -1
139
+
140
+ # Bandeau de taches : manager LOCAL alimente par le registre partage
141
+ task_manager = BackgroundTaskManager()
142
+ task_manager.restore_from_registry()
143
+
144
+ def _set_mode(new_mode: str):
145
+ nonlocal mode
146
+ mode = new_mode
147
+
148
+ settings_menu = SettingsMenu(
149
+ settings=user_settings,
150
+ models_dir=REPO_ROOT / "models",
151
+ callbacks=SettingsCallbacks(get_mode=lambda: mode, set_mode=_set_mode),
152
+ )
153
+
154
+ last_interrupt = 0.0 # double Ctrl+C pour quitter
155
+
156
+ while True:
157
+ try:
158
+ task_manager.restore_from_registry() # taches lancees via le demon
159
+ notifs = [n for n in task_manager.get_completed_notifications()
160
+ if n.get("success")]
161
+ if notifs:
162
+ ui.print_completed_task_notifications(notifs)
163
+ task_manager.cleanup_completed(max_age_seconds=300)
164
+
165
+ user_input = ui.live_input(">>> ", task_manager, mode=mode).strip()
166
+ if not user_input:
167
+ continue
168
+ task_manager.clear_notifications()
169
+
170
+ # Menu /setting ouvert : il consomme l'entree
171
+ if settings_menu.active:
172
+ ui.print_lyra(settings_menu.handle(user_input))
173
+ continue
174
+
175
+ cmd = user_input.lower().strip().lstrip("/")
176
+
177
+ if cmd in ("setting", "settings", "reglages", "parametres"):
178
+ if sys.stdin.isatty():
179
+ from lyra.core.settings_tui import run_settings_tui
180
+ run_settings_tui(settings_menu, println=ui.print_lyra)
181
+ else:
182
+ ui.print_lyra(settings_menu.open())
183
+ continue
184
+ if cmd in ("quit", "stop", "exit", "q"):
185
+ print("\nAu revoir!")
186
+ return 0
187
+ if cmd in ("clearscreen", "cls"):
188
+ ui.clear_screen()
189
+ continue
190
+ if cmd == "mode":
191
+ ui.print_info(f"Mode actuel: {mode}")
192
+ continue
193
+ if cmd == "mode performance":
194
+ mode = "performance"
195
+ ui.print_success("Mode performance active")
196
+ continue
197
+ if cmd in ("mode default", "mode normal"):
198
+ mode = "default"
199
+ ui.print_success("Mode default active")
200
+ continue
201
+ if cmd == "help":
202
+ print("""
203
+ Commandes internes (avec ou sans prefixe "/"):
204
+ quit, stop, exit - Quitter Lyra
205
+ clearscreen - Effacer l'ecran
206
+ /setting - Reglages (voix TTS, vitesse, mode)
207
+ mode [performance|default] - Mode actif
208
+ help - Cette aide
209
+ Le reste part au demon (pipeline chaud, session multi-tours conservee).
210
+ """)
211
+ continue
212
+
213
+ # Requete -> demon. Apres toute annulation ("reconnect"), on
214
+ # remplace la connexion : un flux a moitie lu contaminerait
215
+ # toutes les requetes suivantes (reponses decalees/silencieuses).
216
+ status = _process_request(channel, user_input, mode, args.verbose)
217
+ if status == "reconnect":
218
+ channel.close()
219
+ channel = _connect(show_greeting=False)
220
+ if channel is None:
221
+ ui.print_error("Demon injoignable — relance lyra pour "
222
+ "basculer en standalone.")
223
+ return 1
224
+
225
+ except KeyboardInterrupt:
226
+ # Double Ctrl+C (<1.5s) = quitter, comme le mode historique
227
+ now = time.monotonic()
228
+ if now - last_interrupt < 1.5:
229
+ print("\nAu revoir!")
230
+ return 0
231
+ last_interrupt = now
232
+ print()
233
+ ui.print_info("Ctrl+C a nouveau pour quitter")
234
+ continue
235
+ except EOFError:
236
+ print("\nAu revoir!")
237
+ return 0