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.
- lyra/__init__.py +11 -0
- lyra/client/__init__.py +1 -0
- lyra/client/__main__.py +183 -0
- lyra/client/launcher.py +121 -0
- lyra/client/reminders.py +26 -0
- lyra/client/render.py +102 -0
- lyra/client/repl.py +237 -0
- lyra/core/__init__.py +16 -0
- lyra/core/config.py +211 -0
- lyra/core/constants.py +94 -0
- lyra/core/fastpath.py +51 -0
- lyra/core/formatters.py +306 -0
- lyra/core/menus.py +214 -0
- lyra/core/paths.py +72 -0
- lyra/core/pipeline.py +1383 -0
- lyra/core/retrieval.py +396 -0
- lyra/core/settings.py +184 -0
- lyra/core/settings_menu.py +280 -0
- lyra/core/settings_tui.py +213 -0
- lyra/core/types.py +123 -0
- lyra/core/validation.py +212 -0
- lyra/core/workflows/__init__.py +6 -0
- lyra/core/workflows/context.py +73 -0
- lyra/core/workflows/vm_clone.py +427 -0
- lyra/core/workflows/vm_clone_exec.py +191 -0
- lyra/core/workflows/vm_export.py +256 -0
- lyra/core/workflows/vm_snapshot.py +175 -0
- lyra/core/workflows/vm_snapshot_exec.py +291 -0
- lyra/core/workflows/vm_start.py +95 -0
- lyra/core/workflows/vm_stop.py +78 -0
- lyra/daemon/__init__.py +1 -0
- lyra/daemon/__main__.py +33 -0
- lyra/daemon/actions.py +230 -0
- lyra/daemon/protocol.py +105 -0
- lyra/daemon/remote_ui.py +142 -0
- lyra/daemon/server.py +386 -0
- lyra/daemon/state.py +145 -0
- lyra/hestia/__init__.py +18 -0
- lyra/hestia/background_tasks.py +563 -0
- lyra/hestia/executor.py +479 -0
- lyra/hestia/metrics.py +157 -0
- lyra/hestia/notion_logger.py +142 -0
- lyra/hestia/tracking_client.py +194 -0
- lyra/models/__init__.py +28 -0
- lyra/models/_analysis.py +33 -0
- lyra/models/cartes.py +370 -0
- lyra/models/ephaistos.py +958 -0
- lyra/models/ephaistos_exp.py +862 -0
- lyra/models/intent_classifier.py +255 -0
- lyra/models/lyra_voice.py +839 -0
- lyra/models/model_manager.py +291 -0
- lyra/rag/__init__.py +24 -0
- lyra/rag/fusion.py +141 -0
- lyra/rag/indexer.py +327 -0
- lyra/rag/keyword_retriever.py +146 -0
- lyra/rag/semantic_retriever.py +275 -0
- lyra/rag/session_memory.py +265 -0
- lyra/rag_enhanced/README.md +236 -0
- lyra/rag_enhanced/__init__.py +140 -0
- lyra/rag_enhanced/confidence_cascader.py +193 -0
- lyra/rag_enhanced/config.py +262 -0
- lyra/rag_enhanced/constants.py +64 -0
- lyra/rag_enhanced/context_db.py +237 -0
- lyra/rag_enhanced/context_injector.py +225 -0
- lyra/rag_enhanced/feedback_loop.py +468 -0
- lyra/rag_enhanced/pipeline_enhanced.py +667 -0
- lyra/rag_enhanced/rag_3tier.py +631 -0
- lyra/rag_enhanced/slang_normalizer.py +234 -0
- lyra/rag_enhanced/synonym_expander.py +247 -0
- lyra/rag_enhanced/types.py +71 -0
- lyra/rules/__init__.py +43 -0
- lyra/rules/backup.py +56 -0
- lyra/rules/base.py +23 -0
- lyra/rules/catt.py +85 -0
- lyra/rules/denon.py +87 -0
- lyra/rules/hue.py +128 -0
- lyra/rules/ironman.py +35 -0
- lyra/rules/screen_manager.py +198 -0
- lyra/rules/tracking.py +76 -0
- lyra/rules/tv.py +137 -0
- lyra/rules/vm.py +388 -0
- lyra/utils/__init__.py +0 -0
- lyra/utils/error_log.py +93 -0
- lyra/utils/mermaid_viewer.py +196 -0
- lyra/utils/toon.py +193 -0
- lyra_assistant-1.3.1.dist-info/METADATA +324 -0
- lyra_assistant-1.3.1.dist-info/RECORD +96 -0
- lyra_assistant-1.3.1.dist-info/WHEEL +4 -0
- lyra_assistant-1.3.1.dist-info/entry_points.txt +2 -0
- lyra_assistant-1.3.1.dist-info/licenses/LICENSE +668 -0
- modules/__init__.py +16 -0
- modules/audio.py +463 -0
- modules/llm.py +233 -0
- modules/mcp.py +1104 -0
- modules/n8n.py +443 -0
- 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"
|
lyra/client/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lyra Client - clients legers du demon (CLI one-shot, REPL, vocal)."""
|
lyra/client/__main__.py
ADDED
|
@@ -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())
|
lyra/client/launcher.py
ADDED
|
@@ -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
|
lyra/client/reminders.py
ADDED
|
@@ -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
|