@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.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,210 @@
1
+ """Validation par l'utilisateur et exécution des commandes."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from rich.text import Text
12
+ from textual.containers import Vertical
13
+
14
+ from ..config import CMD_TIMEOUT, CREATE_NO_WINDOW
15
+ from ..consignes import WRITE_TOOL
16
+ from ..fichiers import render_diff
17
+ from ..securite import can_auto_allow, is_dangerous, session_scope
18
+ from ..shell import CWD_MARK, build_script, decode_output, refreshed_env, tidy_output
19
+ from ..theme import DIM, RED
20
+ from ..widgets import ChoicePrompt, PermissionPrompt
21
+
22
+
23
+ class Validation:
24
+ """Partie de PrometheeApp : validation par l'utilisateur et exécution des commandes."""
25
+
26
+ # ------------------------------------------------------------------ validation des commandes et fichiers
27
+ def _ask_permission(self, command: str, explanation: str, verdict=None) -> str:
28
+ """Affiche la demande de validation et attend la réponse (appelé depuis le thread de l'agent)."""
29
+ return self._wait_choice(lambda done: self.show_permission(command, explanation, done, verdict))
30
+
31
+ def _wait_choice(self, show) -> str:
32
+ answered = threading.Event()
33
+ box: dict[str, str] = {}
34
+
35
+ def done(decision: str) -> None:
36
+ box["decision"] = decision
37
+ answered.set()
38
+
39
+ self.call_from_thread(show, done)
40
+ while not answered.wait(0.1):
41
+ if self.cancel_event.is_set():
42
+ return "no"
43
+ self.call_from_thread(self.hide_permission)
44
+ return box.get("decision", "no")
45
+
46
+ def show_permission(self, command: str, explanation: str, callback, verdict=None, direct: bool = False) -> None:
47
+ """verdict : celui de la politique (None : validation manuelle). direct : commande « ! » tapée par toi."""
48
+ # Une commande destructrice que tu as débloquée reste signalée en rouge
49
+ dangerous = is_dangerous(command) or (verdict is not None and any(r.code == "unlocked" for r in verdict.raisons))
50
+ # Option « ne plus demander » seulement pour les commandes simples, non risquées et, si la politique répond,
51
+ # classées en lecture. Elle vaut pour le nom d'une lecture reconnue, sinon pour la ligne exacte.
52
+ scope, items = session_scope(command) if can_auto_allow(command) and not direct else ("noms", [])
53
+ if direct:
54
+ prompt = PermissionPrompt(command, explanation, dangerous, [], callback, verdict=verdict,
55
+ title="Exécuter ta commande", question="Tu l'as tapée toi-même : l'exécuter ?",
56
+ refusal="Non (Esc)")
57
+ else:
58
+ prompt = PermissionPrompt(command, explanation, dangerous, items, callback, verdict=verdict, scope=scope)
59
+ if self._agent_name:
60
+ prompt.title += f" · demandé par l'agent {self._agent_name}"
61
+ self.show_prompt(prompt)
62
+
63
+ def show_edit_permission(self, tool: str, path: Path, old: str | None, new: str, explanation: str,
64
+ outside: bool, callback, sensitive: str = "") -> None:
65
+ diff, _, _ = render_diff(old, new, max_lines=max(8, self.size.height - 20))
66
+ body = Text()
67
+ body.append(f" {path}\n", style="bold")
68
+ if explanation:
69
+ body.append(f" {explanation}\n", style=DIM)
70
+ body.append("\n")
71
+ body.append_text(diff)
72
+ if outside:
73
+ body.append("\n\n ⚠ Ce fichier est en dehors de ton dossier utilisateur : vérifie bien le chemin.",
74
+ style=f"bold {RED}")
75
+ elif sensitive:
76
+ body.append(f"\n\n ⚠ Fichier sensible ({sensitive}) : il te sera toujours montré avant d'être écrit.",
77
+ style=f"bold {RED}")
78
+ if old is None:
79
+ title, question = "Créer un fichier", "Créer ce fichier ?"
80
+ elif tool == WRITE_TOOL:
81
+ title, question = "Remplacer le contenu d'un fichier", "Remplacer tout le contenu de ce fichier ?"
82
+ else:
83
+ title, question = "Modifier un fichier", "Appliquer ces modifications ?"
84
+ options = [("yes", "Oui")]
85
+ if not outside and not sensitive:
86
+ options.append(("always", "Oui, et autoriser toutes les modifications de fichiers pendant cette session"))
87
+ options.append(("no", "Non, et dire à Prométhée quoi faire autrement (Esc)"))
88
+ if self._agent_name:
89
+ title += f" · demandé par l'agent {self._agent_name}"
90
+ self.show_prompt(ChoicePrompt(title, body, question, options, callback, danger=outside or bool(sensitive),
91
+ command=str(path)))
92
+
93
+ def ask(self, title: str, body: Text | None, question: str, options: list[tuple[str, str]], on_choice,
94
+ esc: str = "annuler", enter: str = "valider", danger: bool = False) -> ChoicePrompt:
95
+ """Pose une question à choix sous la conversation. L'invite se referme, puis on_choice reçoit la réponse
96
+ (« no » si tu fais Esc). esc et enter disent, dans l'aide du bas, ce que font ces deux touches."""
97
+ def chosen(decision: str) -> None:
98
+ if self._perm is prompt:
99
+ self.hide_permission()
100
+ on_choice(decision)
101
+
102
+ prompt = ChoicePrompt(title, body, question, options, chosen, danger=danger)
103
+ prompt.hint = f"↑↓ choisir · Entrée {enter} (ou tape le numéro) · Esc {esc}"
104
+ self.show_prompt(prompt)
105
+ return prompt
106
+
107
+ def show_prompt(self, prompt: ChoicePrompt) -> None:
108
+ self._perm = prompt
109
+ self._comp_items = []
110
+ self.render_completions()
111
+ self._spinner(False)
112
+ self.query_one("#input-box").display = False
113
+ self.query_one("#bottom", Vertical).mount(prompt, before="#status-line")
114
+ self.call_after_refresh(prompt.focus)
115
+ self.set_hints()
116
+
117
+ def hide_permission(self) -> None:
118
+ prompt, self._perm = self._perm, None
119
+ if prompt is not None:
120
+ prompt.remove()
121
+ self.query_one("#input-box").display = True
122
+ self.input.focus()
123
+ self.set_hints()
124
+
125
+ def is_allowed(self, command: str, verdict=None) -> bool:
126
+ """La commande peut-elle partir sans te la redemander ? Jamais si la politique ne la classe pas en lecture."""
127
+ if not can_auto_allow(command) or (verdict is not None and not verdict.lecture):
128
+ return False
129
+ scope, items = session_scope(command)
130
+ return all(w in self.allowed for w in items) if scope == "noms" else items[0] in self.allowed_lines
131
+
132
+ def allow(self, command: str) -> None:
133
+ if can_auto_allow(command):
134
+ scope, items = session_scope(command)
135
+ (self.allowed if scope == "noms" else self.allowed_lines).update(items)
136
+
137
+ # ------------------------------------------------------------------ exécution (thread)
138
+ def _execute(self, command: str, verdict=None) -> tuple[int | None, str, str]:
139
+ # Script dans un .ps1 temporaire (UTF-8 avec BOM pour PowerShell 5.1) lancé avec -File :
140
+ # avec -EncodedCommand, PowerShell renvoie les erreurs et Write-Host en XML (CLIXML).
141
+ # verdict : une commande autorisée par la liste blanche s'exécute en mode de langage contraint.
142
+ constrained = verdict is not None and verdict.mode_contraint
143
+ script_path = ""
144
+ try:
145
+ fd, script_path = tempfile.mkstemp(prefix="prmt_", suffix=".ps1")
146
+ with os.fdopen(fd, "w", encoding="utf-8-sig", newline="\r\n") as f:
147
+ f.write(build_script(command, verdict.modules if constrained else (), constrained))
148
+ proc = subprocess.Popen(
149
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script_path],
150
+ cwd=str(self.cwd),
151
+ env=refreshed_env(),
152
+ stdin=subprocess.DEVNULL,
153
+ stdout=subprocess.PIPE,
154
+ stderr=subprocess.STDOUT,
155
+ creationflags=CREATE_NO_WINDOW,
156
+ )
157
+ return self._wait(proc)
158
+ except OSError as err:
159
+ return None, f"Impossible de lancer PowerShell : {err}", ""
160
+ finally:
161
+ if script_path:
162
+ try:
163
+ os.remove(script_path)
164
+ except OSError:
165
+ pass
166
+
167
+ def _wait(self, proc: subprocess.Popen) -> tuple[int | None, str, str]:
168
+ self._proc = proc
169
+ start = time.monotonic()
170
+ chunks: list[bytes] = []
171
+ note = ""
172
+ try:
173
+ while True:
174
+ try:
175
+ out, _ = proc.communicate(timeout=0.25)
176
+ chunks.append(out or b"")
177
+ break
178
+ except subprocess.TimeoutExpired:
179
+ if self.cancel_event.is_set():
180
+ note = "arrêtée par toi"
181
+ elif time.monotonic() - start > CMD_TIMEOUT:
182
+ note = f"arrêtée après {CMD_TIMEOUT // 60} min (délai dépassé)"
183
+ else:
184
+ continue
185
+ self._kill_proc()
186
+ try:
187
+ out, _ = proc.communicate(timeout=5)
188
+ except subprocess.TimeoutExpired:
189
+ out = b"" # un processus enfant garde la sortie ouverte : on n'attend pas davantage
190
+ chunks.append(out or b"")
191
+ break
192
+ finally:
193
+ self._proc = None
194
+ output = decode_output(b"".join(chunks))
195
+ # Suivi du dossier courant (cd / Set-Location) : on retire le marqueur où qu'il soit
196
+ idx = output.rfind(CWD_MARK)
197
+ if idx != -1:
198
+ end = output.find("\n", idx)
199
+ new_cwd = Path(output[idx + len(CWD_MARK): end if end != -1 else len(output)].strip())
200
+ if new_cwd.is_dir():
201
+ self.cwd = new_cwd
202
+ output = output[:idx] + (output[end + 1:] if end != -1 else "")
203
+ output = tidy_output(output)
204
+ code = None if note else proc.returncode
205
+ return code, output, note
206
+
207
+ def _kill_proc(self) -> None:
208
+ proc = self._proc
209
+ if proc is not None and proc.poll() is None:
210
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, creationflags=CREATE_NO_WINDOW)
@@ -0,0 +1,35 @@
1
+ """Appels d'outils du modèle : lecture des arguments, tri des appels lancés à l'aveugle."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .agents import AGENT_TOOL
8
+ from .consignes import READ_TOOL, SEARCH_TOOL_NAMES
9
+ from .fichiers import resolve_path
10
+
11
+
12
+ def call_args(call: dict) -> dict:
13
+ args = (call.get("function") or {}).get("arguments") or {}
14
+ if isinstance(args, str):
15
+ try:
16
+ args = json.loads(args)
17
+ except json.JSONDecodeError:
18
+ return {}
19
+ return args if isinstance(args, dict) else {}
20
+
21
+
22
+ def prune_calls(calls: list[dict], cwd: Path) -> list[dict]:
23
+ """Appels lancés « à l'aveugle » dans un même tour, que le modèle 8B fait souvent :
24
+ - lancer_agent en plus de ses propres outils : il confie alors à l'agent une tâche hors sujet, puis croit son
25
+ rapport plutôt que la vraie sortie → on garde seulement ses propres outils ;
26
+ - chercher_fichiers ET lire_fichier de fichiers inventés (« fichier1.txt ») avant d'avoir vu le résultat
27
+ → on garde la recherche et les lectures de fichiers qui existent : il lira les vrais au tour suivant."""
28
+ names = [(c.get("function") or {}).get("name") for c in calls]
29
+ if AGENT_TOOL in names and any(n != AGENT_TOOL for n in names):
30
+ calls = [c for c, n in zip(calls, names) if n != AGENT_TOOL]
31
+ names = [n for n in names if n != AGENT_TOOL]
32
+ if any(n in SEARCH_TOOL_NAMES for n in names) and READ_TOOL in names:
33
+ calls = [c for c, n in zip(calls, names) if n != READ_TOOL
34
+ or resolve_path(str(call_args(c).get("chemin") or "?"), cwd).is_file()]
35
+ return calls
@@ -0,0 +1,154 @@
1
+ """Commandes « / » : la liste intégrée, les commandes personnalisées et /init."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import NamedTuple
8
+
9
+ from .agents import agent_slug
10
+ from .config import SAVE_DIR
11
+ from .fichiers import read_text_file
12
+
13
+ # --------------------------------------------------------------------------- Commandes personnalisées (comme .claude/commands)
14
+ # Un fichier nom.md = une commande /nom : son texte est envoyé à Prométhée, $ARGUMENTS remplacé par ce qui suit /nom.
15
+ COMMANDS_DIR = SAVE_DIR / "commandes" # commandes globales
16
+ LOCAL_COMMANDS = Path(".promethee") / "commandes" # commandes d'un dossier
17
+ COMMAND_SCHEMA = {"type": "object", "properties": {
18
+ "nom": {"type": "string"},
19
+ "description": {"type": "string"},
20
+ "argument": {"type": "string"},
21
+ "consignes": {"type": "string"},
22
+ }, "required": ["nom", "description", "argument", "consignes"]}
23
+ COMMAND_GEN_SYSTEM = """Tu écris des commandes personnalisées pour Prométhée, un assistant de cybersécurité local sous Windows 11 (PowerShell 5.1). Une commande est un message type que l'utilisateur lance en tapant /nom suivi d'un argument. À partir de sa description, écris :
24
+ - nom : court, en minuscules, mots séparés par des tirets (par exemple « scan-reseau », « revue-script »).
25
+ - description : une phrase : ce que fait la commande.
26
+ - argument : ce que l'utilisateur tape après /nom (par exemple « <adresse ou plage IP> », « <fichier> »), ou "" s'il n'y en a pas.
27
+ - consignes : le message envoyé à Prométhée, à l'impératif, en le tutoyant, en 3 à 10 lignes : ce qu'il doit faire étape par étape et ce que doit contenir sa réponse. C'est Prométhée qui agit lui-même avec ses outils (executer_commande pour les commandes, lire_fichier, chercher_fichiers, chercher_texte) : écris « Lance … avec executer_commande », jamais des consignes pour un humain (« Exécutez … »). Écris $ARGUMENTS à l'endroit où va l'argument de l'utilisateur.
28
+ Cite seulement des commandes PowerShell qui existent vraiment, avec leurs vrais paramètres, entre backticks.
29
+ Réponds uniquement en JSON."""
30
+ COMMAND_TEMPLATE = {"name": "ma-commande", "description": "Ce que fait cette commande.", "argument": "<cible>",
31
+ "prompt": "Fais … sur $ARGUMENTS.\n\n1. …\n2. …\n\nTermine par un résumé : …"}
32
+
33
+
34
+ class Commande(NamedTuple):
35
+ """Une commande « / » intégrée. action : la méthode de PrometheeApp qui la traite (elle reçoit ce qui suit le nom)."""
36
+ nom: str
37
+ alias: tuple[str, ...]
38
+ description: str
39
+ action: str
40
+
41
+
42
+ # Le seul endroit où une commande intégrée est déclarée : le menu « / », l'aiguillage (app/commandes_slash.py) et les
43
+ # noms réservés (une commande personnalisée ne peut pas les prendre) partent tous de cette liste.
44
+ COMMANDS = [
45
+ Commande("/aide", ("help", "?", "h"), "afficher l'aide", "cmd_aide"),
46
+ Commande("/etat", ("état", "status", "statut"), "état de la plateforme : IA, moteur, périmètre, activité", "cmd_etat"),
47
+ Commande("/clear", ("nouveau", "reset", "new"), "nouvelle conversation", "clear_conversation"),
48
+ Commande("/compact", ("compacter", "resumer", "résumer"), "résumer la conversation pour libérer le contexte", "cmd_compact"),
49
+ Commande("/reprendre", ("resume", "continuer", "sessions", "historique"), "reprendre une conversation précédente", "cmd_resume"),
50
+ Commande("/memoire", ("memory", "mémoire"), "voir la mémoire PROMETHEE.md (/memoire ouvrir : la modifier)", "cmd_memoire"),
51
+ Commande("/plan", (), "activer ou couper le mode plan (aussi Shift+Tab)", "cmd_plan"),
52
+ Commande("/agents", ("agent",), "créer, voir et gérer les agents (sous-agents)", "cmd_agents"),
53
+ Commande("/code", (), "IA de code : bascule automatique (on / off) et modèle utilisé", "cmd_code"),
54
+ Commande("/commandes", ("commands",), "créer, voir et gérer tes commandes personnalisées", "cmd_commands"),
55
+ Commande("/init", (), "écrire la mémoire PROMETHEE.md de ce dossier (Prométhée l'explore)", "cmd_init"),
56
+ Commande("/contexte", ("context",), "ce qui remplit le contexte du modèle", "cmd_context"),
57
+ Commande("/modele", ("model", "modèle", "models", "modeles", "modèles"), "lister les modèles ou en changer", "cmd_modele"),
58
+ Commande("/copier", ("copy", "cp"), "copier la dernière réponse (/copier code : les blocs de code)", "cmd_copy"),
59
+ Commande("/sortie", ("output",), "sortie complète de la dernière commande", "cmd_sortie"),
60
+ Commande("/politique", ("policy",), "politique d'exécution : stricte ou souple, liste blanche", "cmd_politique"),
61
+ Commande("/permissions", ("perms", "autorisations"), "autorisations de la session, débloquer ou ajouter une commande", "cmd_permissions"),
62
+ Commande("/perimetre", ("périmètre", "scope", "cibles"), "cibles réseau que tu as le droit de tester", "cmd_perimetre"),
63
+ Commande("/journal", ("log", "logs"), "dernières actions : commandes exécutées, refusées, fichiers écrits", "cmd_journal"),
64
+ Commande("/recherche", ("web", "internet", "searxng"), "recherche web par SearXNG : état, on, off", "cmd_recherche"),
65
+ Commande("/theme", ("thème", "fond"), "fond de l'interface : promethee ou terminal", "cmd_theme"),
66
+ Commande("/sauver", ("save", "enregistrer"), "exporter la conversation en Markdown", "cmd_save"),
67
+ Commande("/quitter", ("exit", "quit", "bye", "q"), "quitter Prométhée", "cmd_quitter"),
68
+ ]
69
+ MENU_ROWS = 8
70
+
71
+
72
+ def find_command(name: str) -> Commande | None:
73
+ """La commande intégrée qui porte ce nom ou cet alias (sans la barre), ou None."""
74
+ return next((c for c in COMMANDS if name == c.nom[1:] or name in c.alias), None)
75
+
76
+
77
+ def builtin_command_names() -> set[str]:
78
+ return {c.nom[1:] for c in COMMANDS} | {a for c in COMMANDS for a in c.alias}
79
+
80
+
81
+ def parse_command_file(text: str, path: Path, scope: str = "global") -> dict | None:
82
+ """En-tête optionnel (description, argument) puis le texte envoyé à Prométhée."""
83
+ meta: dict[str, str] = {}
84
+ body = text.lstrip("\ufeff") # BOM éventuel
85
+ match = re.match(r"\s*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)(.*)$", body, re.S)
86
+ if match:
87
+ for line in match.group(1).splitlines():
88
+ key, sep, value = line.partition(":")
89
+ if sep:
90
+ meta[key.strip().lower()] = value.strip().strip("\"'")
91
+ body = match.group(2)
92
+ name = agent_slug(meta.get("nom") or meta.get("name") or path.stem)
93
+ prompt = body.strip()
94
+ if not name or not prompt:
95
+ return None
96
+ return {"name": name, "description": meta.get("description", ""),
97
+ "argument": meta.get("argument") or meta.get("argument-hint") or "", "prompt": prompt,
98
+ "scope": scope, "path": path}
99
+
100
+
101
+ def command_markdown(command: dict) -> str:
102
+ lines = ["---", f"description: {' '.join(command['description'].split())}"]
103
+ if command.get("argument"):
104
+ lines.append(f"argument: {command['argument']}")
105
+ return "\n".join(lines) + "\n---\n\n" + command["prompt"].strip() + "\n"
106
+
107
+
108
+ def list_custom_commands(cwd: Path) -> list[dict]:
109
+ """Commandes globales puis celles du dossier (même nom : celle du dossier gagne). Les noms des commandes
110
+ intégrées (/aide, /clear…) restent réservés."""
111
+ found: dict[str, dict] = {}
112
+ folders = [("global", COMMANDS_DIR)]
113
+ local = cwd / LOCAL_COMMANDS
114
+ if os.path.normcase(str(local)) != os.path.normcase(str(COMMANDS_DIR)):
115
+ folders.append(("dossier", local))
116
+ reserved = builtin_command_names()
117
+ for scope, folder in folders:
118
+ try:
119
+ files = sorted(folder.glob("*.md"))
120
+ except OSError:
121
+ continue
122
+ for path in files:
123
+ try:
124
+ command = parse_command_file(read_text_file(path)[0], path, scope)
125
+ except (OSError, ValueError):
126
+ continue
127
+ if command and command["name"] not in reserved:
128
+ found[command["name"]] = command
129
+ return list(found.values())
130
+
131
+
132
+ def find_custom_command(name: str, cwd: Path) -> dict | None:
133
+ slug = agent_slug(name)
134
+ return next((c for c in list_custom_commands(cwd) if c["name"] == slug), None)
135
+
136
+
137
+ def expand_command(command: dict, args: str) -> str:
138
+ """$ARGUMENTS = tout ce qui suit /nom ; $1, $2… = ses mots (les guillemets groupent)."""
139
+ text = command["prompt"]
140
+ words = [a or b for a, b in re.findall(r'"([^"]*)"|(\S+)', args)]
141
+ text = re.sub(r"\$([1-9])", lambda m: words[int(m.group(1)) - 1] if int(m.group(1)) <= len(words) else "", text)
142
+ if "$ARGUMENTS" in text:
143
+ return text.replace("$ARGUMENTS", args.strip())
144
+ return text + (f"\n\n{args.strip()}" if args.strip() else "")
145
+
146
+
147
+ # --------------------------------------------------------------------------- /init : mémoire du dossier
148
+ INIT_PROMPT = """Analyse le dossier courant ({cwd}) pour écrire sa mémoire : le fichier {path} (comme CLAUDE.md pour Claude Code), que tu reliras à chaque conversation dans ce dossier.
149
+ 1. Liste ses fichiers avec chercher_fichiers (motif *), seul, et attends son résultat avant de lire quoi que ce soit.
150
+ 2. Ensuite, lis avec lire_fichier les 5 fichiers les plus utiles DE CETTE LISTE (documentation, notes, scripts), en recopiant exactement leur chemin. Ne lis aucun fichier qui n'y figure pas.
151
+ 3. Écris {path} avec ecrire_fichier : un titre, puis des puces courtes : à quoi sert ce dossier, ses fichiers et sous-dossiers importants (seulement ceux de la liste), les commandes ou outils qui apparaissent dans les fichiers lus, et les précautions qu'ils mentionnent. Rien d'inventé : pas de fichier, de commande ni de conseil général qui ne viennent pas de ce que tu as lu.{existing}"""
152
+ INIT_EXISTING = ("\nCe fichier existe déjà : lis-le d'abord, puis complète-le avec modifier_fichier, sans supprimer ce que "
153
+ "l'utilisateur y a écrit.")
154
+
@@ -0,0 +1,140 @@
1
+ """Menus d'autocomplétion ( / et @ ) et fichiers joints avec @chemin."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .agents import list_agents
9
+ from .commandes import COMMANDS, list_custom_commands
10
+ from .fichiers import read_text_file, resolve_path, show_path
11
+
12
+
13
+ def command_completions(typed: str) -> list[tuple[str, str, str]]:
14
+ """(texte à insérer, libellé, description) des commandes qui commencent par ce qui est tapé."""
15
+ word = typed[1:].lower()
16
+ return [(c.nom, c.nom, c.description) for c in COMMANDS
17
+ if c.nom[1:].startswith(word) or any(a.startswith(word) for a in c.alias)]
18
+
19
+
20
+ def custom_command_completions(typed: str, cwd: Path) -> list[tuple[str, str, str]]:
21
+ word = typed[1:].lower()
22
+ items = []
23
+ for command in list_custom_commands(cwd):
24
+ if command["name"].startswith(word):
25
+ label = f"/{command['name']}" + (f" {command['argument']}" if command.get("argument") else "")
26
+ desc = " ".join(command["description"].split())
27
+ items.append((f"/{command['name']}", label, "perso · " + (desc[:70] + "…" if len(desc) > 70 else desc)))
28
+ return items
29
+
30
+
31
+ AT_TOKEN = re.compile(r'(?:^|(?<=\s))@("[^"]*|[^\s"]*)$') # « @chemin » en cours de frappe, juste avant le curseur
32
+
33
+
34
+ def human_size(n: int) -> str:
35
+ if n < 1024:
36
+ return f"{n} o"
37
+ if n < 1024 ** 2:
38
+ return f"{n / 1024:.0f} Ko"
39
+ return f"{n / 1024 ** 2:.1f} Mo"
40
+
41
+
42
+ def file_completions(token: str, cwd: Path, limit: int = 40) -> list[tuple[str, str, str]]:
43
+ raw = token.lstrip('"').replace("/", "\\")
44
+ folder, _, prefix = raw.rpartition("\\")
45
+ base = resolve_path(folder + "\\", cwd) if folder else cwd
46
+ entries = []
47
+ try:
48
+ with os.scandir(base) as it:
49
+ for i, entry in enumerate(it):
50
+ if i > 3000:
51
+ break
52
+ name = entry.name
53
+ if not name.lower().startswith(prefix.lower()) or (name.startswith(".") and not prefix.startswith(".")):
54
+ continue
55
+ try:
56
+ is_dir = entry.is_dir()
57
+ except OSError:
58
+ continue
59
+ entries.append((not is_dir, name.lower(), name, is_dir, entry))
60
+ except OSError:
61
+ return []
62
+ items = []
63
+ for _, _, name, is_dir, entry in sorted(entries)[:limit]:
64
+ value = (folder + "\\" if folder else "") + name + ("\\" if is_dir else "")
65
+ if is_dir:
66
+ desc = "dossier"
67
+ else:
68
+ try:
69
+ desc = human_size(entry.stat().st_size)
70
+ except OSError:
71
+ desc = ""
72
+ if " " in value: # chemin avec espaces : entre guillemets (laissés ouverts pour un dossier)
73
+ value = f'"{value}' if is_dir else f'"{value}"'
74
+ items.append((value, "@" + value.strip('"'), desc))
75
+ return items
76
+
77
+
78
+ def agent_completions(token: str, cwd: Path) -> list[tuple[str, str, str]]:
79
+ """Agents proposés en tête du menu « @ » (comme @agent-… dans Claude Code)."""
80
+ typed = token.lower()
81
+ if '"' in typed or "\\" in typed:
82
+ return []
83
+ items = []
84
+ for agent in list_agents(cwd):
85
+ value = f"agent-{agent['name']}"
86
+ if value.startswith(typed):
87
+ desc = " ".join(agent["description"].split())
88
+ items.append((value, "@" + value, "agent · " + (desc[:70] + "…" if len(desc) > 70 else desc)))
89
+ return items
90
+
91
+
92
+ # --------------------------------------------------------------------------- Fichiers joints avec @chemin
93
+ AT_REF = re.compile(r'(?:^|(?<=\s))@(?:"([^"]+)"?|([^\s"]+))')
94
+ MAX_ATTACH_CHARS = 8000
95
+
96
+
97
+ def expand_attachments(text: str, cwd: Path) -> tuple[str, list[tuple[str, str, bool]]]:
98
+ """Ajoute au message le contenu des fichiers cités avec @chemin.
99
+ Renvoie (texte pour le modèle, [(chemin affiché, résumé, réussi)])."""
100
+ blocks: list[str] = []
101
+ notes: list[tuple[str, str, bool]] = []
102
+ seen: set[str] = set()
103
+ for match in AT_REF.finditer(text):
104
+ raw = (match.group(1) or match.group(2) or "").strip()
105
+ path = resolve_path(raw, cwd)
106
+ if not path.exists(): # « regarde @log.txt, stp » : ponctuation collée
107
+ path = resolve_path(raw.rstrip(",.;:!?)»'"), cwd)
108
+ if not path.exists():
109
+ if re.search(r"[.\\/]", raw):
110
+ notes.append((raw, "introuvable, non joint", False))
111
+ continue
112
+ key = os.path.normcase(str(path))
113
+ if key in seen:
114
+ continue
115
+ seen.add(key)
116
+ shown = show_path(path, cwd)
117
+ if path.is_dir():
118
+ try:
119
+ names = sorted(e.name + ("\\" if e.is_dir() else "") for e in os.scandir(path))
120
+ except OSError as err:
121
+ notes.append((shown, f"illisible ({err})", False))
122
+ continue
123
+ blocks.append(f"[Contenu du dossier {path} : {len(names)} éléments]\n" + "\n".join(names[:200])
124
+ + ("\n[…]" if len(names) > 200 else ""))
125
+ notes.append((shown, f"{len(names)} éléments listés", True))
126
+ continue
127
+ try:
128
+ content = read_text_file(path)[0]
129
+ except (OSError, ValueError) as err:
130
+ notes.append((shown, f"non joint : {err}", False))
131
+ continue
132
+ n = content.count("\n") + (0 if content.endswith("\n") or not content else 1)
133
+ if len(content) > MAX_ATTACH_CHARS:
134
+ content = (content[:MAX_ATTACH_CHARS] + f"\n[… coupé : {n} lignes au total. Pour la suite, utilise "
135
+ "lire_fichier avec debut et fin …]")
136
+ blocks.append(f"[Fichier joint : {path}]\n```\n{content.rstrip()}\n```")
137
+ notes.append((shown, f"{n} ligne{'s' if n > 1 else ''}", True))
138
+ if not blocks:
139
+ return text, notes
140
+ return text + "\n\n" + "\n\n".join(blocks), notes
@@ -0,0 +1,12 @@
1
+ """Réglages fixes de la TUI : modèle par défaut, dossier de données, limites d'exécution."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ DEFAULT_MODEL = "promethee"
7
+ SAVE_DIR = Path.home() / "Documents" / "Promethee"
8
+
9
+ MAX_STEPS = 12 # nombre max d'allers-retours modèle -> commande par message (audit ≈ 6-8)
10
+ CMD_TIMEOUT = 300 # secondes avant d'arrêter une commande bloquée
11
+ MAX_TOOL_CHARS = 3500 # taille max d'une sortie renvoyée au modèle
12
+ CREATE_NO_WINDOW = 0x08000000