@nosserb/prmt 0.0.0-stage → 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Politique d'exécution, périmètre, journal, recherche web, état de la plateforme et fond de l'interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import work
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
from .. import journal, preferences, recherche
|
|
14
|
+
from ..etat import SIGNES, collecter, pluriel, rendre
|
|
15
|
+
from ..politique import MODES, Indisponible, Refus
|
|
16
|
+
from ..theme import DIM, FONDS, GREEN, RED, YELLOW
|
|
17
|
+
|
|
18
|
+
ISSUES = {"ok": ("ok", "réussie"), "error": ("probleme", "en échec"), "refused": ("probleme", "refusée"),
|
|
19
|
+
"cancelled": ("inactif", "annulée"), "info": ("info", "")}
|
|
20
|
+
HOTE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$|^[0-9a-fA-F:]{2,39}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Plateforme:
|
|
24
|
+
"""Partie de PrometheeApp : politique d'exécution, périmètre, journal, recherche web, état de la plateforme et fond de
|
|
25
|
+
l'interface."""
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------ politique (fils de travail)
|
|
28
|
+
@work(thread=True, exclusive=True, group="politique")
|
|
29
|
+
def demarrer_politique(self) -> None:
|
|
30
|
+
"""Lance le moteur de politique dès l'ouverture : le premier verdict n'attend pas son démarrage."""
|
|
31
|
+
ready = self.politique.demarrer()
|
|
32
|
+
self.call_from_thread(self._politique_demarree, ready)
|
|
33
|
+
|
|
34
|
+
def _politique_demarree(self, ready: bool) -> None:
|
|
35
|
+
self.update_status()
|
|
36
|
+
if not ready and self.politique.reliee:
|
|
37
|
+
self._policy_warned = True
|
|
38
|
+
self.add_notice(f"Politique indisponible ({self.politique.raison}) : tu valides chaque commande toi-même, "
|
|
39
|
+
"sans liste blanche. /etat pour le détail.", YELLOW)
|
|
40
|
+
|
|
41
|
+
def _verdict(self, command: str):
|
|
42
|
+
"""Verdict de la politique pour une commande, ou None (validation manuelle). Appelé depuis un fil de travail."""
|
|
43
|
+
before = self.politique.etat
|
|
44
|
+
verdict = self.politique.verdict(command, self.cwd)
|
|
45
|
+
if verdict is None and self.politique.reliee and not self._policy_warned:
|
|
46
|
+
self._policy_warned = True
|
|
47
|
+
self.call_from_thread(self.add_notice, f"Politique indisponible ({self.politique.raison}) : tu valides chaque "
|
|
48
|
+
"commande toi-même, sans liste blanche.", YELLOW)
|
|
49
|
+
elif verdict is not None:
|
|
50
|
+
self._policy_warned = False
|
|
51
|
+
if self.politique.etat != before:
|
|
52
|
+
self.call_from_thread(self.update_status)
|
|
53
|
+
return verdict
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ journal
|
|
56
|
+
def noter_commande(self, command: str, code: int | None, output: str, note: str, started: float, cwd: Path,
|
|
57
|
+
verdict, confirmation: str, source: str) -> None:
|
|
58
|
+
"""Une ligne de journal par commande : exécutée, en échec, arrêtée, ou refusée par toi."""
|
|
59
|
+
duration = round((time.monotonic() - started) * 1000)
|
|
60
|
+
if confirmation == "refus" or note.startswith("arrêtée par toi"):
|
|
61
|
+
outcome, detail = "cancelled", (note[:1].upper() + note[1:] + ".") if note else "Annulée."
|
|
62
|
+
elif note:
|
|
63
|
+
outcome, detail = "error", note[:1].upper() + note[1:] + "."
|
|
64
|
+
else:
|
|
65
|
+
outcome, detail = ("ok" if code == 0 else "error"), f"Code de sortie {code} en {duration} ms."
|
|
66
|
+
journal.noter("command", outcome, command, detail, command=command, cwd=str(cwd),
|
|
67
|
+
level=verdict.issue if verdict is not None else None,
|
|
68
|
+
policy=self.politique.mode if verdict is not None else "manuelle",
|
|
69
|
+
confirmation=confirmation, source=source, agent=self._agent_name or None, session=self.session_id,
|
|
70
|
+
constrained=True if verdict is not None and verdict.mode_contraint and confirmation != "refus" else None,
|
|
71
|
+
exitCode=code, durationMs=duration if confirmation != "refus" else None,
|
|
72
|
+
timedOut=True if "délai dépassé" in note else None,
|
|
73
|
+
tail=output[-journal.QUEUE_SORTIE:] if output else None)
|
|
74
|
+
|
|
75
|
+
def noter_refus(self, command: str, verdict, source: str) -> None:
|
|
76
|
+
journal.noter("refusal", "refused", command, "\n".join(r.message for r in verdict.blocages), command=command,
|
|
77
|
+
cwd=str(self.cwd), codes=[r.code for r in verdict.blocages], policy=verdict.mode, source=source,
|
|
78
|
+
agent=self._agent_name or None, session=self.session_id)
|
|
79
|
+
|
|
80
|
+
def noter_fichier(self, tool: str, path: Path, outcome: str, detail: str, **data) -> None:
|
|
81
|
+
journal.noter("command", outcome, f"{tool} {path}", detail, tool=tool, path=str(path), source="modèle",
|
|
82
|
+
agent=self._agent_name or None, session=self.session_id, **data)
|
|
83
|
+
|
|
84
|
+
def noter_recherche(self, plan: dict, search: dict) -> None:
|
|
85
|
+
"""Une ligne de journal par recherche web, au format de la console : la console la lit aussi."""
|
|
86
|
+
place = search.get("place")
|
|
87
|
+
if search["state"] == "done":
|
|
88
|
+
n = len(search["sources"])
|
|
89
|
+
detail = f"{n} source{'s' if n > 1 else ''}" + (f", lieu : {place['name']}" if place else "")
|
|
90
|
+
else:
|
|
91
|
+
detail = search.get("error") or ""
|
|
92
|
+
outcome = "ok" if search["state"] == "done" else "cancelled" if self.cancel_event.is_set() else "error"
|
|
93
|
+
journal.noter("search", outcome, f"Recherche web : {recherche.cut(plan['query'], 100)}", detail,
|
|
94
|
+
donnees={"query": plan["query"], "place": place["name"] if place else plan.get("place"),
|
|
95
|
+
"sources": [s["url"] for s in search["sources"]]},
|
|
96
|
+
session=self.session_id)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ recherche web
|
|
99
|
+
def reglage_recherche(self) -> dict:
|
|
100
|
+
"""{enabled, url} : dans les réglages de la console si la TUI y est reliée, sinon dans ses préférences."""
|
|
101
|
+
return recherche.lire_reglage(self.politique.lien)
|
|
102
|
+
|
|
103
|
+
def client_recherche(self, reglage: dict | None = None) -> recherche.Searxng:
|
|
104
|
+
lien = self.politique.lien or {}
|
|
105
|
+
return recherche.Searxng((reglage or self.reglage_recherche())["url"], str(lien.get("racine") or "") or None)
|
|
106
|
+
|
|
107
|
+
@work(thread=True, group="plateforme")
|
|
108
|
+
def cmd_recherche(self, arg: str = "") -> None:
|
|
109
|
+
word = arg.strip().lower()
|
|
110
|
+
lien = self.politique.lien
|
|
111
|
+
fichier = recherche.fichier_partage(lien)
|
|
112
|
+
where = f"réglage partagé avec la console : {fichier}" if fichier else "réglage gardé dans les préférences de la TUI"
|
|
113
|
+
if word in ("on", "oui", "activer", "active"):
|
|
114
|
+
enabled = True
|
|
115
|
+
elif word in ("off", "non", "couper", "coupe", "desactiver", "désactiver"):
|
|
116
|
+
enabled = False
|
|
117
|
+
elif word:
|
|
118
|
+
self.call_from_thread(self.add_notice, "Usage : /recherche · /recherche on · /recherche off", YELLOW)
|
|
119
|
+
return
|
|
120
|
+
else:
|
|
121
|
+
reglage = self.reglage_recherche()
|
|
122
|
+
self.call_from_thread(self._montrer_recherche, reglage, self.client_recherche(reglage).health(), where)
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
reglage = recherche.ecrire_reglage(lien, enabled=enabled)
|
|
126
|
+
except (OSError, ValueError, recherche.ErreurRecherche) as err:
|
|
127
|
+
self.call_from_thread(self.add_notice, f"Recherche web inchangée : {err}", RED)
|
|
128
|
+
return
|
|
129
|
+
journal.noter("settings", "info", f"Recherche web {'activée' if enabled else 'coupée'}", session=self.session_id,
|
|
130
|
+
search=reglage)
|
|
131
|
+
if not enabled:
|
|
132
|
+
self.call_from_thread(self.add_notice, f"Recherche web coupée : aucune requête ne part sur internet ({where}). "
|
|
133
|
+
"/recherche on pour la rétablir.")
|
|
134
|
+
return
|
|
135
|
+
self.call_from_thread(self.add_notice, "Recherche web activée : quand une question le demande, Prométhée cherche "
|
|
136
|
+
f"sur internet par SearXNG et te montre la requête et les sources ({where}).", GREEN)
|
|
137
|
+
sante = self.client_recherche(reglage).health()
|
|
138
|
+
if not sante["available"]:
|
|
139
|
+
self.call_from_thread(self.add_notice, f"Attention : {sante['error']}", YELLOW)
|
|
140
|
+
|
|
141
|
+
def _montrer_recherche(self, reglage: dict, sante: dict, where: str) -> None:
|
|
142
|
+
"""/recherche seul : active ou non, l'adresse de SearXNG, et s'il répond."""
|
|
143
|
+
text = Text()
|
|
144
|
+
text.append("Recherche web : ", style="bold")
|
|
145
|
+
text.append("active" if reglage["enabled"] else "coupée", style=GREEN if reglage["enabled"] else YELLOW)
|
|
146
|
+
text.append(f" · {where}", style=DIM)
|
|
147
|
+
if sante["available"]:
|
|
148
|
+
text.append(f"\n ● SearXNG {reglage['url']} répond en {sante['latencyMs']} ms", style=GREEN)
|
|
149
|
+
else:
|
|
150
|
+
text.append(f"\n ✕ SearXNG {reglage['url']} ne répond pas : {sante['error']}", style=RED)
|
|
151
|
+
text.append("\n Quand une question demande une information récente ou un lieu, Prométhée cherche de lui-même, te "
|
|
152
|
+
"montre la requête et les sources, puis répond sans proposer de commande.", style=DIM)
|
|
153
|
+
text.append("\n /recherche on · /recherche off", style=f"italic {DIM}")
|
|
154
|
+
self.chat.mount(Static(text, classes="notice"))
|
|
155
|
+
self.call_after_refresh(self._follow)
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------ /politique
|
|
158
|
+
@work(thread=True, group="plateforme")
|
|
159
|
+
def cmd_politique(self, arg: str = "") -> None:
|
|
160
|
+
politique = self.politique
|
|
161
|
+
word = arg.strip().lower()
|
|
162
|
+
if word in MODES:
|
|
163
|
+
if word != politique.mode:
|
|
164
|
+
politique.mode = word
|
|
165
|
+
try:
|
|
166
|
+
preferences.ecrire(politique=word)
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
journal.noter("settings", "info", f"Politique de la TUI : {word}", session=self.session_id, policy=word)
|
|
170
|
+
self.call_from_thread(self.update_status)
|
|
171
|
+
self.call_from_thread(self.add_notice, (
|
|
172
|
+
"Politique stricte : tout refus de la liste blanche est appliqué, comme dans la console."
|
|
173
|
+
if word == "stricte" else
|
|
174
|
+
"Politique souple : ce que l'analyse ne sait pas vérifier (scripts, compilateurs, programmes lancés par "
|
|
175
|
+
"leur chemin) t'est montré comme « hors politique » et tu le valides. Restent refusés : commande "
|
|
176
|
+
"destructrice non débloquée, cible hors périmètre, protections de Windows, chemin réseau."),
|
|
177
|
+
GREEN if word == "stricte" else YELLOW)
|
|
178
|
+
return
|
|
179
|
+
if word in ("relancer", "restart"):
|
|
180
|
+
politique.fermer()
|
|
181
|
+
politique._retenter_a = 0.0
|
|
182
|
+
ready = politique.demarrer()
|
|
183
|
+
self.call_from_thread(self.update_status)
|
|
184
|
+
self.call_from_thread(self.add_notice, "Moteur de politique relancé." if ready
|
|
185
|
+
else f"Le moteur de politique ne démarre pas : {politique.raison}", GREEN if ready else RED)
|
|
186
|
+
return
|
|
187
|
+
if word:
|
|
188
|
+
self.call_from_thread(self.add_notice, "Usage : /politique · /politique stricte · /politique souple · "
|
|
189
|
+
"/politique relancer", YELLOW)
|
|
190
|
+
return
|
|
191
|
+
resume = politique.resume()
|
|
192
|
+
self.call_from_thread(self.update_status)
|
|
193
|
+
if resume is None:
|
|
194
|
+
self.call_from_thread(self.add_notice_md, (
|
|
195
|
+
f"**Politique : validation manuelle**\n\nLe moteur de la console n'est pas disponible "
|
|
196
|
+
f"({politique.raison or 'raison inconnue'}). Chaque commande t'est montrée et tu la valides, sans liste "
|
|
197
|
+
"blanche ni contrôle du périmètre.\n\n*`/politique relancer` pour réessayer · `/etat` pour le détail*"))
|
|
198
|
+
return
|
|
199
|
+
counts = resume.get("counts") or {}
|
|
200
|
+
custom = ", ".join(f"`{c.get('name')}`" for c in resume.get("custom") or []) or "aucune"
|
|
201
|
+
unlocked = ", ".join(f"`{u}`" for u in resume.get("unlocked") or []) or "aucune"
|
|
202
|
+
self.call_from_thread(self.add_notice_md, (
|
|
203
|
+
f"**Politique {politique.mode}** (moteur de la console {politique.version})\n\n"
|
|
204
|
+
f"- Liste blanche : {counts.get('read', 0)} commandes de lecture, {counts.get('sensitive', 0)} sensibles, "
|
|
205
|
+
f"{counts.get('destructive', 0)} destructrices, {counts.get('forbidden', 0)} interdites.\n"
|
|
206
|
+
f"- Débloquées par toi : {unlocked} · ajoutées par toi : {custom}\n"
|
|
207
|
+
f"- Écriture permise dans : {', '.join(f'`{r}`' for r in resume.get('roots') or [])}\n"
|
|
208
|
+
f"- Cibles réseau déclarées : {len(resume.get('targets') or [])} (`/perimetre`)\n\n"
|
|
209
|
+
"`stricte` : tout refus de la liste blanche est appliqué. `souple` : le hors-liste passe à ta validation ; "
|
|
210
|
+
"commande destructrice non débloquée, cible hors périmètre, protections de Windows et chemin réseau restent "
|
|
211
|
+
"refusés.\n\n"
|
|
212
|
+
"*`/politique stricte` · `/politique souple` · `/permissions autoriser <commande>` · `/perimetre ajouter <hôte>`*"))
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------ /permissions
|
|
215
|
+
@work(thread=True, group="plateforme")
|
|
216
|
+
def cmd_permissions(self, arg: str = "") -> None:
|
|
217
|
+
action, _, rest = arg.partition(" ")
|
|
218
|
+
action, names = action.lower(), rest.split()
|
|
219
|
+
if action in ("reset", "effacer", "clear"):
|
|
220
|
+
self.allowed.clear()
|
|
221
|
+
self.allowed_lines.clear()
|
|
222
|
+
self.allow_edits = False
|
|
223
|
+
self.call_from_thread(self.add_notice, "Toutes les commandes et toutes les écritures de fichiers demanderont "
|
|
224
|
+
"de nouveau ta validation.", GREEN)
|
|
225
|
+
return
|
|
226
|
+
if action in ("autoriser", "allow", "ajouter", "debloquer", "débloquer") and names:
|
|
227
|
+
self._changer_liste(names, True)
|
|
228
|
+
return
|
|
229
|
+
if action in ("retirer", "revoke", "verrouiller", "enlever") and names:
|
|
230
|
+
self._changer_liste(names, False)
|
|
231
|
+
return
|
|
232
|
+
if action:
|
|
233
|
+
self.call_from_thread(self.add_notice, "Usage : /permissions · /permissions reset · /permissions autoriser "
|
|
234
|
+
"<commande…> · /permissions retirer <commande…>", YELLOW)
|
|
235
|
+
return
|
|
236
|
+
session = sorted(self.allowed) + [f"« {line} »" for line in sorted(self.allowed_lines)]
|
|
237
|
+
if session:
|
|
238
|
+
self.call_from_thread(self.add_notice, "Autorisées sans validation (session) : " + ", ".join(session)
|
|
239
|
+
+ " · /permissions reset pour tout redemander")
|
|
240
|
+
else:
|
|
241
|
+
self.call_from_thread(self.add_notice, "Aucune commande autorisée automatiquement : chaque commande demande "
|
|
242
|
+
"ta validation.")
|
|
243
|
+
if self.allow_edits:
|
|
244
|
+
self.call_from_thread(self.add_notice, "Écritures de fichiers autorisées pour la session (sauf fichiers sensibles "
|
|
245
|
+
"ou hors de ton dossier) · /permissions reset pour tout redemander")
|
|
246
|
+
resume = self.politique.resume()
|
|
247
|
+
if resume is not None:
|
|
248
|
+
custom = [str(c.get("name")) for c in resume.get("custom") or []]
|
|
249
|
+
unlocked = [str(u) for u in resume.get("unlocked") or []]
|
|
250
|
+
self.call_from_thread(self.add_notice, "Liste blanche (partagée avec la console) : débloquées "
|
|
251
|
+
+ (", ".join(unlocked) or "aucune") + " · ajoutées " + (", ".join(custom) or "aucune")
|
|
252
|
+
+ " · /permissions autoriser <commande> · /permissions retirer <commande>")
|
|
253
|
+
|
|
254
|
+
def _changer_liste(self, names: list[str], allow: bool) -> None:
|
|
255
|
+
try:
|
|
256
|
+
result = self.politique.autoriser(names) if allow else self.politique.retirer(names)
|
|
257
|
+
except (Indisponible, Refus) as err:
|
|
258
|
+
self.call_from_thread(self.add_notice, f"Liste blanche inchangée : {err}", RED)
|
|
259
|
+
return
|
|
260
|
+
if not allow:
|
|
261
|
+
removed = result.get("removed") or []
|
|
262
|
+
self.call_from_thread(self.add_notice, ("Autorisation retirée : " + ", ".join(removed) + ".") if removed
|
|
263
|
+
else "Aucune de ces commandes n'était autorisée par toi.", GREEN if removed else YELLOW)
|
|
264
|
+
return
|
|
265
|
+
if result.get("unlocked"):
|
|
266
|
+
self.call_from_thread(self.add_notice, "Débloquée : " + ", ".join(result["unlocked"]) + ". Chaque exécution "
|
|
267
|
+
"te sera montrée et signalée en rouge. /permissions retirer pour la reverrouiller.", YELLOW)
|
|
268
|
+
if result.get("added"):
|
|
269
|
+
self.call_from_thread(self.add_notice, "Ajoutée à ta liste blanche : " + ", ".join(result["added"])
|
|
270
|
+
+ ". Ses arguments ne sont pas contrôlés : chaque exécution te sera montrée.", YELLOW)
|
|
271
|
+
if result.get("already"):
|
|
272
|
+
self.call_from_thread(self.add_notice, "Déjà autorisée : " + ", ".join(result["already"]) + ".")
|
|
273
|
+
for refused in result.get("refused") or []:
|
|
274
|
+
self.call_from_thread(self.add_notice, f"Non autorisée : {refused.get('name')} ({refused.get('reason')}).", RED)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ /perimetre
|
|
277
|
+
def cmd_perimetre(self, arg: str = "") -> None:
|
|
278
|
+
action, _, rest = arg.partition(" ")
|
|
279
|
+
action, rest = action.lower(), rest.strip()
|
|
280
|
+
if action in ("ajouter", "add", "declarer", "déclarer") and rest:
|
|
281
|
+
host, _, note = rest.partition(" ")
|
|
282
|
+
self._demander_attestation(host.strip().lower(), note.strip())
|
|
283
|
+
elif action in ("retirer", "remove", "enlever", "supprimer") and rest:
|
|
284
|
+
self._retirer_cible(rest.split()[0].lower())
|
|
285
|
+
elif action:
|
|
286
|
+
self.add_notice("Usage : /perimetre · /perimetre ajouter <hôte> [note] · /perimetre retirer <hôte>", YELLOW)
|
|
287
|
+
else:
|
|
288
|
+
self._montrer_perimetre()
|
|
289
|
+
|
|
290
|
+
@work(thread=True, group="plateforme")
|
|
291
|
+
def _montrer_perimetre(self) -> None:
|
|
292
|
+
resume = self.politique.resume()
|
|
293
|
+
if resume is None:
|
|
294
|
+
self.call_from_thread(self.add_notice, "Périmètre non contrôlé : la politique n'est pas disponible "
|
|
295
|
+
f"({self.politique.raison or 'raison inconnue'}).", YELLOW)
|
|
296
|
+
return
|
|
297
|
+
targets = resume.get("targets") or []
|
|
298
|
+
lines = "\n".join(f"- `{t.get('host')}`" + (" (sous-domaines compris)" if t.get("includeSubdomains") else "")
|
|
299
|
+
+ (f" · {t.get('note')}" if t.get("note") else "") + f" · attestée le {str(t.get('attestedAt') or '')[:10]}"
|
|
300
|
+
for t in targets)
|
|
301
|
+
self.call_from_thread(self.add_notice_md, (
|
|
302
|
+
"**Périmètre autorisé**\n\n"
|
|
303
|
+
"Les tests de ports et les audits ne sont permis que sur cette machine et sur les cibles que tu as déclarées, "
|
|
304
|
+
"parce qu'elles t'appartiennent ou que tu as une autorisation écrite.\n\n"
|
|
305
|
+
+ (lines if lines else "Aucune cible déclarée : seuls les tests sur cette machine (127.0.0.1) sont permis.")
|
|
306
|
+
+ "\n\n*`/perimetre ajouter <hôte> [note]` · `/perimetre retirer <hôte>` · la console web montre la même liste*"))
|
|
307
|
+
|
|
308
|
+
def _demander_attestation(self, host: str, note: str) -> None:
|
|
309
|
+
if not HOTE.match(host) or "/" in host:
|
|
310
|
+
self.add_notice(f"« {host} » n'est pas un hôte unique (nom ou adresse IP, sans plage ni masque).", RED)
|
|
311
|
+
return
|
|
312
|
+
domain = not re.fullmatch(r"[0-9.]+|[0-9a-fA-F:]+", host)
|
|
313
|
+
body = Text.assemble((f" {host}\n", "bold"), (f" {note}\n" if note else "", DIM),
|
|
314
|
+
("\n Les tests ne sont permis que sur des systèmes qui t'appartiennent ou pour lesquels tu as une\n"
|
|
315
|
+
" autorisation écrite. Cette attestation est datée et inscrite au journal.", DIM))
|
|
316
|
+
options = [("yes", "J'atteste que cette cible m'appartient ou que j'ai une autorisation écrite")]
|
|
317
|
+
if domain:
|
|
318
|
+
options.append(("sub", "J'atteste la même chose pour ce domaine et ses sous-domaines"))
|
|
319
|
+
options.append(("no", "Annuler (Esc)"))
|
|
320
|
+
|
|
321
|
+
def chosen(decision: str) -> None:
|
|
322
|
+
if decision in ("yes", "sub"):
|
|
323
|
+
self._ajouter_cible(host, note, decision == "sub")
|
|
324
|
+
|
|
325
|
+
self.ask("Déclarer une cible", body, "Ajouter cette cible à ton périmètre ?", options, chosen)
|
|
326
|
+
|
|
327
|
+
@work(thread=True, group="plateforme")
|
|
328
|
+
def _ajouter_cible(self, host: str, note: str, subdomains: bool) -> None:
|
|
329
|
+
try:
|
|
330
|
+
self.politique.ajouter_cible(host, note, subdomains)
|
|
331
|
+
except (Indisponible, Refus) as err:
|
|
332
|
+
self.call_from_thread(self.add_notice, f"Cible non ajoutée : {err}", RED)
|
|
333
|
+
return
|
|
334
|
+
self.call_from_thread(self.add_notice, f"Cible ajoutée au périmètre : {host}"
|
|
335
|
+
+ (" et ses sous-domaines" if subdomains else "") + ". /perimetre retirer pour l'enlever.", GREEN)
|
|
336
|
+
|
|
337
|
+
@work(thread=True, group="plateforme")
|
|
338
|
+
def _retirer_cible(self, host: str) -> None:
|
|
339
|
+
try:
|
|
340
|
+
self.politique.retirer_cible(host)
|
|
341
|
+
except (Indisponible, Refus) as err:
|
|
342
|
+
self.call_from_thread(self.add_notice, f"Périmètre inchangé : {err}", RED)
|
|
343
|
+
return
|
|
344
|
+
self.call_from_thread(self.add_notice, f"Cible retirée du périmètre : {host}.", GREEN)
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ /journal
|
|
347
|
+
def cmd_journal(self, arg: str = "") -> None:
|
|
348
|
+
fichiers = [journal.FICHIER]
|
|
349
|
+
if self.politique.lien is not None:
|
|
350
|
+
fichiers.append(Path(str(self.politique.lien["donnees"])) / "journal.jsonl")
|
|
351
|
+
entries = journal.lire(fichiers)
|
|
352
|
+
word = arg.strip().lower()
|
|
353
|
+
if word in ("refus", "refusees", "refusées"):
|
|
354
|
+
entries = [e for e in entries if e["kind"] == "refusal"]
|
|
355
|
+
count = int(word) if word.isdigit() else 15
|
|
356
|
+
shown = entries[-max(1, min(count, 60)):]
|
|
357
|
+
if not shown:
|
|
358
|
+
self.add_notice("Journal vide : il se remplit dès la première commande." if not word.startswith("refus")
|
|
359
|
+
else "Aucun refus dans le journal.")
|
|
360
|
+
return
|
|
361
|
+
text = Text()
|
|
362
|
+
text.append(f"Journal · {pluriel(len(shown), 'dernière entrée', 'dernières entrées')} sur {len(entries)}", style=f"bold {DIM}")
|
|
363
|
+
for entry in shown:
|
|
364
|
+
when = journal._moment(entry)
|
|
365
|
+
state, label = ISSUES[entry["outcome"]]
|
|
366
|
+
sign, color = SIGNES[state]
|
|
367
|
+
origin = "TUI" if (entry.get("data") or {}).get("origin") == "tui" else "console"
|
|
368
|
+
stamp = f"{when:%d/%m %H:%M}" if when and when.date() != dt.date.today() else f"{when:%H:%M}" if when else "?"
|
|
369
|
+
text.append(f"\n {stamp:<11}", style=DIM)
|
|
370
|
+
text.append(f"{sign} ", style=color)
|
|
371
|
+
text.append(f"{label:<9}" if label else " " * 9, style=color)
|
|
372
|
+
text.append(entry["title"][:90])
|
|
373
|
+
text.append(f" ({origin})", style=DIM)
|
|
374
|
+
text.append(f"\n fichier : {journal.FICHIER} · /journal 40 · /journal refus", style=f"italic {DIM}")
|
|
375
|
+
self.chat.mount(Static(text, classes="notice"))
|
|
376
|
+
self.call_after_refresh(self._follow)
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------ /etat
|
|
379
|
+
@work(thread=True, exclusive=True, group="etat")
|
|
380
|
+
def cmd_etat(self, arg: str = "") -> None:
|
|
381
|
+
self.call_from_thread(self.set_hints, "Mesure de l'état de la plateforme…")
|
|
382
|
+
sections = collecter(self)
|
|
383
|
+
self.call_from_thread(self._montrer_etat, sections)
|
|
384
|
+
|
|
385
|
+
def _montrer_etat(self, sections) -> None:
|
|
386
|
+
panel = Static(rendre(sections), classes="etat")
|
|
387
|
+
panel.border_title = "État de la plateforme"
|
|
388
|
+
panel.border_subtitle = f"mesuré à {dt.datetime.now():%H:%M:%S}"
|
|
389
|
+
self.chat.mount(panel)
|
|
390
|
+
self.set_hints()
|
|
391
|
+
self.update_status()
|
|
392
|
+
self.call_after_refresh(self._follow)
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------ /theme
|
|
395
|
+
def cmd_theme(self, arg: str = "") -> None:
|
|
396
|
+
word = arg.strip().lower()
|
|
397
|
+
if word in ("promethee", "prométhée", "nuit"):
|
|
398
|
+
word = "promethee"
|
|
399
|
+
if word not in FONDS:
|
|
400
|
+
current = next((k for k, v in FONDS.items() if v == self.theme), "promethee")
|
|
401
|
+
self.add_notice(f"Fond actuel : {current}. /theme promethee : le fond de Prométhée (la nuit) · "
|
|
402
|
+
"/theme terminal : le fond de ton terminal (transparence, image).")
|
|
403
|
+
return
|
|
404
|
+
self.theme = FONDS[word]
|
|
405
|
+
try:
|
|
406
|
+
preferences.ecrire(fond=word)
|
|
407
|
+
except OSError:
|
|
408
|
+
pass
|
|
409
|
+
self.add_notice("Fond de Prométhée appliqué." if word == "promethee" else "Fond de ton terminal appliqué.", GREEN)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Autocompletion de la saisie ( / et @ )."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from ..commandes import MENU_ROWS
|
|
10
|
+
from ..completion import (
|
|
11
|
+
AT_TOKEN, agent_completions, command_completions, custom_command_completions, file_completions,
|
|
12
|
+
)
|
|
13
|
+
from ..theme import DIM, ORANGE
|
|
14
|
+
from ..widgets import PromptInput
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Saisie:
|
|
18
|
+
"""Partie de PrometheeApp : autocompletion de la saisie ( / et @ )."""
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------------ autocomplétion ( / et @ )
|
|
21
|
+
def at_token(self) -> tuple[str, int] | None:
|
|
22
|
+
"""« @chemin » en cours de frappe juste avant le curseur : (chemin, colonne du @)."""
|
|
23
|
+
row, col = self.input.cursor_location
|
|
24
|
+
match = AT_TOKEN.search(self.input.document.get_line(row)[:col])
|
|
25
|
+
return (match.group(1), match.start()) if match else None
|
|
26
|
+
|
|
27
|
+
def update_completions(self) -> None:
|
|
28
|
+
items: list[tuple[str, str, str]] = []
|
|
29
|
+
kind = ""
|
|
30
|
+
text = self.input.text
|
|
31
|
+
if self._perm is None and text != self._comp_dismissed:
|
|
32
|
+
self._comp_dismissed = None
|
|
33
|
+
if text.startswith("/") and not re.search(r"\s", text):
|
|
34
|
+
items, kind = command_completions(text) + custom_command_completions(text, self.cwd), "/"
|
|
35
|
+
else:
|
|
36
|
+
token = self.at_token()
|
|
37
|
+
if token is not None:
|
|
38
|
+
# Fichiers d'abord (le plus courant) ; les agents passent devant dès « @ag… »
|
|
39
|
+
agents, files = agent_completions(token[0], self.cwd), file_completions(token[0], self.cwd)
|
|
40
|
+
first = len(token[0]) >= 2 and "agent-".startswith(token[0].lower()[:6])
|
|
41
|
+
items, kind = (agents + files if first else files + agents), "@"
|
|
42
|
+
if [i[0] for i in items] != [i[0] for i in self._comp_items]:
|
|
43
|
+
self._comp_index = 0
|
|
44
|
+
self._comp_items, self._comp_kind = items, kind
|
|
45
|
+
self.render_completions()
|
|
46
|
+
self.set_hints()
|
|
47
|
+
|
|
48
|
+
def render_completions(self) -> None:
|
|
49
|
+
menu = self.query_one("#completions", Static)
|
|
50
|
+
items = self._comp_items
|
|
51
|
+
if not items:
|
|
52
|
+
menu.display = False
|
|
53
|
+
return
|
|
54
|
+
start = max(0, min(self._comp_index - MENU_ROWS // 2, len(items) - MENU_ROWS))
|
|
55
|
+
shown = items[start:start + MENU_ROWS]
|
|
56
|
+
width = max(len(label) for _, label, _ in shown) + 3
|
|
57
|
+
text = Text()
|
|
58
|
+
for k, (_, label, desc) in enumerate(shown):
|
|
59
|
+
selected = start + k == self._comp_index
|
|
60
|
+
text.append(("❯ " if selected else " ") + label.ljust(width), style=f"bold {ORANGE}" if selected else "")
|
|
61
|
+
text.append(desc, style=ORANGE if selected else DIM)
|
|
62
|
+
if k < len(shown) - 1:
|
|
63
|
+
text.append("\n")
|
|
64
|
+
if len(items) > len(shown):
|
|
65
|
+
text.append(f"\n … {len(items)} au total", style=f"italic {DIM}")
|
|
66
|
+
menu.update(text)
|
|
67
|
+
menu.display = True
|
|
68
|
+
|
|
69
|
+
def move_completion(self, delta: int) -> None:
|
|
70
|
+
if self._comp_items:
|
|
71
|
+
self._comp_index = (self._comp_index + delta) % len(self._comp_items)
|
|
72
|
+
self.render_completions()
|
|
73
|
+
|
|
74
|
+
def hide_completions(self) -> None:
|
|
75
|
+
self._comp_dismissed = self.input.text
|
|
76
|
+
self._comp_items = []
|
|
77
|
+
self.render_completions()
|
|
78
|
+
self.set_hints()
|
|
79
|
+
|
|
80
|
+
def accept_completion(self, submit: bool = False) -> None:
|
|
81
|
+
if not self._comp_items:
|
|
82
|
+
return
|
|
83
|
+
value = self._comp_items[self._comp_index][0]
|
|
84
|
+
if self._comp_kind == "/":
|
|
85
|
+
self.input.load_text(value if submit else value + " ")
|
|
86
|
+
self.input.move_cursor(self.input.document.end)
|
|
87
|
+
if submit:
|
|
88
|
+
self.input.post_message(PromptInput.Submitted(value))
|
|
89
|
+
return
|
|
90
|
+
token = self.at_token()
|
|
91
|
+
if token is None:
|
|
92
|
+
return
|
|
93
|
+
row, col = self.input.cursor_location
|
|
94
|
+
insert = "@" + value + ("" if value.endswith("\\") else " ") # dossier : on continue la complétion
|
|
95
|
+
self.input.replace(insert, (row, token[1]), (row, col))
|
|
96
|
+
self.input.move_cursor((row, token[1] + len(insert)))
|
|
97
|
+
|
|
98
|
+
def action_complete(self) -> None:
|
|
99
|
+
if self._comp_items and self._perm is None:
|
|
100
|
+
self.accept_completion(submit=False)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Mode plan et liste de tâches."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from ..plan import PLAN_APPROVED, command_keywords, plan_steps
|
|
10
|
+
from ..theme import DIM, GREEN, ORANGE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Taches:
|
|
14
|
+
"""Partie de PrometheeApp : mode plan et liste de tâches."""
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ mode plan
|
|
17
|
+
def action_toggle_plan(self) -> None:
|
|
18
|
+
if self._perm is None:
|
|
19
|
+
self.set_plan_mode(not self.plan_mode)
|
|
20
|
+
|
|
21
|
+
def set_plan_mode(self, on: bool) -> None:
|
|
22
|
+
self.plan_mode = on
|
|
23
|
+
self.query_one("#input-box").set_class(on, "-plan")
|
|
24
|
+
self.set_hints()
|
|
25
|
+
|
|
26
|
+
def offer_plan(self) -> None:
|
|
27
|
+
"""Après un plan : l'exécuter (retour en mode normal) ou continuer à l'affiner."""
|
|
28
|
+
steps = plan_steps(self.last_answer)
|
|
29
|
+
|
|
30
|
+
def chosen(decision: str) -> None:
|
|
31
|
+
if decision != "go":
|
|
32
|
+
return
|
|
33
|
+
self.set_plan_mode(False)
|
|
34
|
+
if len(steps) >= 2:
|
|
35
|
+
self.start_tasks("plan", [{"title": t, "text": full, "action": action} for t, full, action in steps])
|
|
36
|
+
self.send_message("Exécute le plan.", PLAN_APPROVED)
|
|
37
|
+
|
|
38
|
+
options = [("go", "Oui : quitter le mode plan et l'exécuter (tu valideras chaque commande)"),
|
|
39
|
+
("keep", "Non : rester en mode plan pour l'affiner (Esc)")]
|
|
40
|
+
detail = Text(f" {len(steps)} étapes : " + " · ".join(t for t, _, _ in steps), style=DIM) if steps else None
|
|
41
|
+
self.ask("Plan prêt", detail, "Exécuter ce plan ?", options, chosen, esc="continuer à planifier")
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ liste de tâches
|
|
44
|
+
def start_tasks(self, kind: str, tasks: list[dict]) -> None:
|
|
45
|
+
self.task_kind = kind
|
|
46
|
+
self.tasks = [{"title": t["title"], "text": t.get("text", t["title"]), "pattern": t.get("pattern"),
|
|
47
|
+
"action": t.get("action", True), "state": "todo"} for t in tasks]
|
|
48
|
+
self.render_tasks()
|
|
49
|
+
|
|
50
|
+
def plan_steps_left(self) -> int:
|
|
51
|
+
"""Étapes du plan accepté qui demandent encore une commande ou un fichier (lu depuis le thread de l'agent)."""
|
|
52
|
+
if self.task_kind != "plan":
|
|
53
|
+
return 0
|
|
54
|
+
return sum(1 for t in list(self.tasks) if t["state"] != "done" and t["action"])
|
|
55
|
+
|
|
56
|
+
def task_progress(self, command: str, ok: bool) -> None:
|
|
57
|
+
"""Coche l'étape correspondant à une commande (ou un fichier) réellement exécutée avec succès."""
|
|
58
|
+
todo = [t for t in self.tasks if t["state"] != "done"]
|
|
59
|
+
if not ok or not todo:
|
|
60
|
+
return
|
|
61
|
+
if self.task_kind == "audit":
|
|
62
|
+
hit = next((t for t in todo if t["pattern"] and re.search(t["pattern"], command, re.I)), None)
|
|
63
|
+
else:
|
|
64
|
+
words = command_keywords(command)
|
|
65
|
+
steps = [t for t in todo if t["action"]] or todo
|
|
66
|
+
hit = next((t for t in steps if any(w in t["text"].lower() for w in words)), None) or steps[0]
|
|
67
|
+
if hit is not None:
|
|
68
|
+
hit["state"] = "done"
|
|
69
|
+
self.render_tasks()
|
|
70
|
+
|
|
71
|
+
def render_tasks(self) -> None:
|
|
72
|
+
box = self.query_one("#tasks", Static)
|
|
73
|
+
if not self.tasks:
|
|
74
|
+
box.display = False
|
|
75
|
+
return
|
|
76
|
+
done = sum(t["state"] == "done" for t in self.tasks)
|
|
77
|
+
current = next((t for t in self.tasks if t["state"] != "done"), None) if self.generating else None
|
|
78
|
+
text = Text()
|
|
79
|
+
text.append(f"Tâches {done}/{len(self.tasks)}", style=f"bold {DIM}")
|
|
80
|
+
for t in self.tasks:
|
|
81
|
+
text.append("\n")
|
|
82
|
+
if t["state"] == "done":
|
|
83
|
+
text.append(" ☑ ", style=GREEN)
|
|
84
|
+
text.append(t["title"], style=f"strike {DIM}")
|
|
85
|
+
elif t is current:
|
|
86
|
+
text.append(" ☐ ", style=ORANGE)
|
|
87
|
+
text.append(t["title"], style=f"bold {ORANGE}")
|
|
88
|
+
else:
|
|
89
|
+
text.append(" ☐ ", style=DIM)
|
|
90
|
+
text.append(t["title"])
|
|
91
|
+
box.update(text)
|
|
92
|
+
box.display = True
|