@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,589 @@
1
+ """Appels d'outils du modèle et des agents : commandes, fichiers, recherche, délégation."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from rich.text import Text
12
+
13
+ from ..agents import (
14
+ AGENT_DONE, AGENT_MENTION, AGENT_REMINDER, AGENT_REPORT_NOW, AGENT_SYSTEM, AGENT_TOOL, MAX_AGENT_STEPS,
15
+ find_agent, list_agents,
16
+ )
17
+ from ..appels import prune_calls
18
+ from ..completion import human_size
19
+ from ..config import MAX_TOOL_CHARS
20
+ from ..consignes import (
21
+ ANNOUNCE, ASK_CONFIRM, CODE_EXTS, COMPILE_ONLY, CONTINUE, EDIT_TOOL, FAILED_CLAIM, FILE_TOOL_NAMES, INSPECT_CMD,
22
+ NO_CONFIRM, NUDGE, READ_TOOL, SEARCH_FILES_TOOL, SEARCH_TOOL_NAMES, SUCCESS_CLAIM, TOOLS, TOOL_NAME, WRITE_TOOL,
23
+ unverified_values, verify_nudge, writes_commands,
24
+ )
25
+ from ..fichiers import (
26
+ HOME, MAX_READ_CHARS, MAX_READ_LINES, SEARCH_LINES_TO_MODEL, apply_edit, change_summary,
27
+ missing_mentioned_files, new_file_format, numbered, read_text_file, render_diff, resolve_path, search_desc,
28
+ search_files, search_text, sensitive_path, show_path, write_text_file,
29
+ )
30
+ from ..memoire import GLOBAL_MEMORY
31
+ from ..securite import precheck
32
+ from ..shell import APP_CONTROL, for_model
33
+ from ..theme import DIM, GREEN, RED, YELLOW
34
+ from ..widgets import AgentView, ToolCallView, ToolState
35
+
36
+ # Résultat d'un outil fichier : (message pour le modèle, texte de référence pour contrôler la réponse, décision)
37
+ Resultat = tuple[str, str, str]
38
+
39
+
40
+ class _Rendu(Exception):
41
+ """L'outil fichier s'arrête là : son résultat est prêt (erreur à corriger par le modèle, ou rien à changer)."""
42
+
43
+ def __init__(self, resultat: Resultat) -> None:
44
+ super().__init__(resultat[0])
45
+ self.resultat = resultat
46
+
47
+
48
+ @dataclass
49
+ class EtatAgent:
50
+ """Ce qu'un agent retient d'une étape à l'autre de son travail."""
51
+ st: ToolState = field(default_factory=ToolState)
52
+ nudged: bool = False # relancé pour avoir écrit ses commandes au lieu de les exécuter
53
+ continues: int = 0 # relances « continue »
54
+ verifies: int = 0 # relances « vérifie ton code »
55
+ honest: bool = False # relancé pour un succès annoncé après un échec
56
+
57
+
58
+ class Outils:
59
+ """Partie de PrometheeApp : appels d'outils du modèle et des agents : commandes, fichiers, recherche, délégation."""
60
+
61
+ def _run_tool_calls(self, calls: list[dict], msgs: list[dict], st: ToolState,
62
+ allowed: list[str] | None = None) -> bool:
63
+ """Exécute les appels d'outils du modèle principal ou d'un agent (allowed = ses outils).
64
+ Ajoute les résultats à msgs. Renvoie True si le tour doit s'arrêter : tu as refusé une action,
65
+ ou la politique en a refusé deux."""
66
+ for call in calls:
67
+ if self.cancel_event.is_set():
68
+ return False
69
+ name = (call.get("function") or {}).get("name") or "?"
70
+ args = self._call_arguments(call)
71
+ st.uses += 1
72
+ known = allowed if allowed is not None else [t["function"]["name"] for t in self.request_tools()]
73
+ if name not in known:
74
+ listed = ", ".join(f"`{n}`" for n in known) or "aucun"
75
+ msgs.append({"role": "tool", "tool_name": name, "content": f"Outil inconnu ou non disponible : {name}. "
76
+ f"Outils disponibles : {listed}."})
77
+ elif name == AGENT_TOOL:
78
+ if self._agent_call(args, msgs, st):
79
+ return True
80
+ elif name in SEARCH_TOOL_NAMES:
81
+ self._search_call(name, args, msgs, st)
82
+ elif name in FILE_TOOL_NAMES:
83
+ if self._file_call(name, args, msgs, st):
84
+ return True
85
+ elif self._command_call(args, msgs, st):
86
+ return True
87
+ return False
88
+
89
+ @staticmethod
90
+ def _call_arguments(call: dict) -> dict:
91
+ """Arguments d'un appel d'outil. Un texte qui n'est pas du JSON est pris pour la commande elle-même."""
92
+ args = (call.get("function") or {}).get("arguments") or {}
93
+ if isinstance(args, str):
94
+ try:
95
+ args = json.loads(args)
96
+ except json.JSONDecodeError:
97
+ args = {"commande": args}
98
+ return args if isinstance(args, dict) else {}
99
+
100
+ def _agent_call(self, args: dict, msgs: list[dict], st: ToolState) -> bool:
101
+ """lancer_agent : confie la tâche à l'agent. Renvoie True si tu as refusé une de ses actions."""
102
+ agent = find_agent(str(args.get("agent") or ""), self.cwd)
103
+ task = str(args.get("tache") or "").strip()
104
+ if agent is None or not task:
105
+ names = ", ".join(a["name"] for a in list_agents(self.cwd))
106
+ msgs.append({"role": "tool", "tool_name": AGENT_TOOL, "content": (
107
+ f"Agent inconnu : {args.get('agent')}. Agents disponibles : {names}." if agent is None else
108
+ "Erreur : la tâche est vide. Écris dans `tache` la consigne complète pour l'agent.")})
109
+ return False
110
+ return self._delegate(agent, task, msgs, st)[1]
111
+
112
+ def _search_call(self, name: str, args: dict, msgs: list[dict], st: ToolState) -> None:
113
+ content = self._search_tool(name, args)
114
+ msgs.append({"role": "tool", "tool_name": name, "content": content})
115
+ st.ran += [f"{name} {json.dumps(args, ensure_ascii=False)}", content]
116
+ self.call_from_thread(self.task_progress, " ".join(str(v) for v in args.values()), True)
117
+
118
+ def _file_call(self, name: str, args: dict, msgs: list[dict], st: ToolState) -> bool:
119
+ """lire_fichier, ecrire_fichier, modifier_fichier. Renvoie True si tu as refusé l'écriture."""
120
+ content, reference, decision = self._file_tool(name, args)
121
+ msgs.append({"role": "tool", "tool_name": name, "content": content})
122
+ if reference:
123
+ st.ran += [f"{name} {args.get('chemin', '')}", reference]
124
+ if name != READ_TOOL and content.startswith("Fichier "):
125
+ self.call_from_thread(self.task_progress, str(args.get("chemin") or ""), True)
126
+ # Un fichier a changé : relancer la même commande (cargo check après une correction…) a de nouveau un sens
127
+ st.last_ok = None
128
+ st.failed.clear()
129
+ if Path(str(args.get("chemin") or "")).suffix.lower() in CODE_EXTS:
130
+ st.unverified = str(args.get("chemin"))
131
+ st.compiled_only = False
132
+ return decision == "no"
133
+
134
+ # ------------------------------------------------------------------ executer_commande (thread)
135
+ def _command_call(self, args: dict, msgs: list[dict], st: ToolState) -> bool:
136
+ """Une commande proposée par le modèle : contrôles, politique, ta validation, exécution.
137
+ Renvoie True si le tour doit s'arrêter."""
138
+
139
+ def reply(content: str) -> None:
140
+ msgs.append({"role": "tool", "tool_name": TOOL_NAME, "content": content})
141
+
142
+ # qwen3.5 envoie parfois « command » ou « cmd » au lieu de « commande » (vu : 12 étapes perdues en
143
+ # « commande vide » sans comprendre pourquoi)
144
+ command = str(args.get("commande") or args.get("command") or args.get("cmd") or "").strip()
145
+ if not command:
146
+ got = ", ".join(f"« {k} »" for k in args) or "aucun"
147
+ reply(f"Erreur : commande vide. {TOOL_NAME} attend la commande PowerShell complète dans le paramètre "
148
+ f"« commande » (paramètres reçus : {got}). Rappelle {TOOL_NAME} avec « commande » rempli.")
149
+ return False
150
+ view = self.call_from_thread(self._new_tool_view, command)
151
+ skipped = self._skip_command(command, st)
152
+ if skipped is not None:
153
+ shown, told = skipped
154
+ self.call_from_thread(view.set_skipped, shown)
155
+ reply(told)
156
+ return False
157
+ # 4) Politique : liste blanche, périmètre, arguments. Un refus n'est pas montré à l'utilisateur.
158
+ verdict = self._verdict(command)
159
+ if verdict is not None and verdict.refusee:
160
+ self.call_from_thread(view.set_refused, verdict.resume(), verdict.conseils())
161
+ self.noter_refus(command, verdict, "modèle")
162
+ reply(verdict.pour_modele())
163
+ st.failed.add(command)
164
+ st.refused += 1
165
+ # Deux refus dans la même réponse : le modèle cherche un détour, on s'arrête là
166
+ self._policy_stop = st.refused >= 2
167
+ return self._policy_stop
168
+ auto = self.is_allowed(command, verdict)
169
+ decision = "auto" if auto else self._ask_permission(command, str(args.get("explication") or ""), verdict)
170
+ if decision == "no":
171
+ self.call_from_thread(view.set_denied)
172
+ self.noter_commande(command, None, "", "refusée par toi", time.monotonic(), self.cwd, verdict, "refus", "modèle")
173
+ reply("L'utilisateur a refusé d'exécuter cette commande. Arrête-toi et attends ses instructions.")
174
+ return True
175
+ if decision == "always":
176
+ self.call_from_thread(self.allow, command)
177
+ code, output, note = self._run_command(command, verdict, auto, view)
178
+ reply(for_model(code, output, note))
179
+ self._after_command(command, code, output, st)
180
+ return False
181
+
182
+ def _skip_command(self, command: str, st: ToolState) -> tuple[str, str] | None:
183
+ """Commande à ne pas lancer telle quelle : (ce qui s'affiche, ce que reçoit le modèle), ou None.
184
+ Le modèle la corrige seul : elle ne t'est pas montrée."""
185
+ if command in st.failed:
186
+ # Le petit modèle a tendance à relancer à l'identique une commande fausse :
187
+ # inutile de la réexécuter (ni de la faire valider), on lui demande de la corriger.
188
+ return ("Non relancée : commande identique à celle qui vient d'échouer",
189
+ "Non exécutée : c'est exactement la même commande que celle qui vient d'échouer, elle échouerait "
190
+ "de la même façon. Corrige-la (autre syntaxe, autre option, autre commande) et rappelle l'outil, "
191
+ "ou explique le problème à l'utilisateur.")
192
+ if command == st.last_ok:
193
+ # Même commande que celle qui vient de réussir, sans rien entre les deux : même résultat
194
+ return ("Non relancée : identique à la commande précédente, déjà réussie",
195
+ "Non exécutée : tu viens déjà d'exécuter exactement cette commande avec succès, sa sortie est "
196
+ "juste au-dessus. Utilise-la et passe à l'étape suivante (autre commande) ou au bilan.")
197
+ # 3) Vérification automatique avant de montrer la commande à l'utilisateur
198
+ self.call_from_thread(self._spinner, True, "check", "Vérification de la commande")
199
+ problems = precheck(command, self.cwd)
200
+ if not problems:
201
+ return None
202
+ st.failed.add(command)
203
+ return ("Vérification : " + ", ".join(s for s, _ in problems) + " → "
204
+ + (f"l'agent {self._agent_name}" if self._agent_name else "Prométhée") + " corrige la commande",
205
+ "Non exécutée (vérification automatique, avant de la montrer à l'utilisateur) : "
206
+ + " ; ".join(d for _, d in problems) + ". Corrige la commande et rappelle l'outil.")
207
+
208
+ def _run_command(self, command: str, verdict, auto: bool, view: ToolCallView) -> tuple[int | None, str, str]:
209
+ """Exécute la commande validée, l'inscrit au journal et affiche son résultat."""
210
+ self.call_from_thread(view.set_running, auto)
211
+ self.call_from_thread(self._spinner, True, "command")
212
+ started, where = time.monotonic(), self.cwd
213
+ code, output, note = self._execute(command, verdict)
214
+ self.noter_commande(command, code, output, note, started, where, verdict, "session" if auto else "toi", "modèle")
215
+ self.last_output = output
216
+ self.call_from_thread(view.set_result, code, output, note)
217
+ self.call_from_thread(self.task_progress, command, code == 0)
218
+ return code, output, note
219
+
220
+ @staticmethod
221
+ def _after_command(command: str, code: int | None, output: str, st: ToolState) -> None:
222
+ """Ce que le tour retient d'une commande exécutée, pour ses contrôles et ses relances."""
223
+ st.ran += [command, output]
224
+ if code not in (0, None):
225
+ st.failed.add(command)
226
+ st.last_ok = command if code == 0 else None
227
+ st.unverified = None # une commande a tourné après la dernière modification du code
228
+ # (un programme compilé que Smart App Control empêche de lancer n'est pas un échec du code)
229
+ st.last_fail = (command, code) if code not in (0, None) and not APP_CONTROL.search(output) else None
230
+ if code == 0 and COMPILE_ONLY.search(command):
231
+ st.compiled_only = True
232
+ elif not INSPECT_CMD.match(command):
233
+ st.compiled_only = False # programme lancé (ou bloqué par Smart App Control), ou autre commande
234
+
235
+ # ------------------------------------------------------------------ agents (thread)
236
+ def _direct_agent(self, msgs: list[dict], name: str) -> None:
237
+ """« @agent-nom tâche » : l'agent travaille, son rapport est la réponse (sans tour du modèle principal)."""
238
+ agent = find_agent(name, self.cwd)
239
+ content = next(m["content"] for m in reversed(msgs) if m["role"] == "user")
240
+ task = AGENT_MENTION.sub("", content, count=1).strip()
241
+ if agent is None or not task:
242
+ names = ", ".join(f"@agent-{a['name']}" for a in list_agents(self.cwd))
243
+ error = (f"agent inconnu : « {name} ». Agents disponibles : {names} (/agents pour les gérer)."
244
+ if agent is None else f"écris la tâche à confier après @agent-{agent['name']}.")
245
+ self.call_from_thread(self.finish_agent, msgs, None, error, False, False, False)
246
+ return
247
+ call = {"function": {"name": AGENT_TOOL, "arguments": {"agent": agent["name"], "tache": task}}}
248
+ msgs.append({"role": "assistant", "content": "", "tool_calls": [call]})
249
+ report, refused = self._delegate(agent, task, msgs, ToolState(), direct=True)
250
+ cancelled = self.cancel_event.is_set()
251
+ if not refused and not cancelled:
252
+ self.call_from_thread(self._new_bubble, report)
253
+ self.call_from_thread(self.finish_agent, msgs, None, None, cancelled, refused, True)
254
+
255
+ def _delegate(self, agent: dict, task: str, msgs: list[dict], st: ToolState,
256
+ direct: bool = False) -> tuple[str, bool]:
257
+ """Confie une tâche à un agent : son cadre s'affiche, il travaille, son rapport rejoint la conversation."""
258
+ view = self.call_from_thread(self._new_agent_view, agent["name"], task)
259
+ report, refused, ran = self._run_subagent(agent, task, view)
260
+ content = f"Rapport de l'agent {agent['name']} :\n{report}"
261
+ message = {"role": "tool", "tool_name": AGENT_TOOL, "content": content if direct or refused else content + AGENT_DONE}
262
+ if direct:
263
+ message["direct"] = True # demandé avec @agent-nom : le rapport est la réponse affichée
264
+ msgs.append(message)
265
+ st.ran += ran # ses sorties de commandes servent aussi à contrôler la réponse finale
266
+ return report, refused
267
+
268
+ def _run_subagent(self, agent: dict, task: str, view: AgentView) -> tuple[str, bool, list[str]]:
269
+ """Fait travailler un agent dans son propre contexte. Renvoie (rapport, refusé, sorties lues)."""
270
+ name = agent["name"]
271
+ tools = [t for t in TOOLS if t["function"]["name"] in agent["tools"]]
272
+ allowed = [t["function"]["name"] for t in tools]
273
+ model = agent.get("model") or self._turn_model or self.model
274
+ system = AGENT_SYSTEM.format(name=name, prompt=agent["prompt"], cwd=self.cwd,
275
+ date=dt.date.today().strftime("%d/%m/%Y"),
276
+ tools=", ".join(allowed) or "aucun") + self.memory_text()
277
+ msgs: list[dict] = [{"role": "user", "content": task}]
278
+ e = EtatAgent()
279
+ start, tokens, prompt_tokens = time.monotonic(), 0, 0
280
+ report, refused, error = "", False, None
281
+ pending: str | None = None # relance, jamais gardée dans l'historique de l'agent
282
+ saved = (self._tool_parent, self._agent_name)
283
+ self._tool_parent, self._agent_name = self.call_from_thread(lambda: view.body), name
284
+ try:
285
+ for step in range(MAX_AGENT_STEPS + 1):
286
+ if self.cancel_event.is_set():
287
+ break
288
+ last = step == MAX_AGENT_STEPS # dernier tour : plus d'outils, il doit rendre son rapport
289
+ self.call_from_thread(self._spinner, True, "agent", f"Agent {name}")
290
+ request = [{"role": "system", "content": system}] + [dict(m) for m in msgs]
291
+ request[1]["content"] += AGENT_REMINDER
292
+ if last:
293
+ request.append({"role": "user", "content": AGENT_REPORT_NOW})
294
+ elif pending:
295
+ request.append({"role": "user", "content": pending})
296
+ text, calls, final, error = self._stream_turn(None, request, model, [] if last else tools)
297
+ if final:
298
+ tokens += final.get("eval_count") or 0
299
+ prompt_tokens = max(prompt_tokens, final.get("prompt_eval_count") or 0)
300
+ if error or self.cancel_event.is_set():
301
+ break
302
+ if not calls or last:
303
+ pending = self._agent_nudge(e, text, allowed, last)
304
+ if pending is None:
305
+ report = self._agent_report(text, e.st)
306
+ break
307
+ msgs.append({"role": "assistant", "content": text})
308
+ continue
309
+ pending = None
310
+ calls = prune_calls(calls, self.cwd)
311
+ msgs.append({"role": "assistant", "content": "", "tool_calls": calls})
312
+ refused = self._run_tool_calls(calls, msgs, e.st, allowed)
313
+ self.call_from_thread(view.set_progress, e.st.uses)
314
+ if refused:
315
+ break
316
+ finally:
317
+ self._tool_parent, self._agent_name = saved
318
+ seconds = time.monotonic() - start
319
+ report, note, color = self._agent_outcome(report, refused, error)
320
+ self.call_from_thread(view.set_done, e.st.uses, tokens + prompt_tokens, seconds, note, color, self.show_details)
321
+ if e.st.ran and not note:
322
+ self._check_agent_report(name, task, report, e.st, msgs)
323
+ return report, refused, e.st.ran
324
+
325
+ def _agent_nudge(self, e: EtatAgent, text: str, allowed: list[str], last: bool) -> str | None:
326
+ """L'agent a répondu sans appeler d'outil : faut-il le relancer plutôt que prendre ce texte pour son rapport ?
327
+ Renvoie la consigne de relance, ou None."""
328
+ st = e.st
329
+ if allowed and not st.uses and not e.nudged and writes_commands(text):
330
+ # Il a écrit les commandes au lieu de les exécuter : relancé une fois
331
+ e.nudged = True
332
+ return NUDGE
333
+ if st.uses and not last and e.continues < 3 and (ANNOUNCE.search(text) or ASK_CONFIRM.search(text)):
334
+ # Il annonce une étape sans la faire, ou demande une confirmation : on le relance
335
+ e.continues += 1
336
+ return NO_CONFIRM if ASK_CONFIRM.search(text) else CONTINUE
337
+ if st.unverified and TOOL_NAME in allowed and not last and e.verifies < 2:
338
+ # Code modifié puis rapport sans rien relancer : il doit d'abord vérifier
339
+ e.verifies += 1
340
+ return verify_nudge(st.unverified, self.cwd)
341
+ if st.last_fail and SUCCESS_CLAIM.search(text) and TOOL_NAME in allowed and not last and not e.honest:
342
+ # « Ça marche » alors que sa dernière commande a échoué
343
+ e.honest = True
344
+ return FAILED_CLAIM.format(cmd=st.last_fail[0], code=st.last_fail[1])
345
+ return None
346
+
347
+ @staticmethod
348
+ def _agent_report(text: str, st: ToolState) -> str:
349
+ """Le rapport de l'agent, avec un avertissement s'il annonce un succès après une commande en échec."""
350
+ report = text.strip()
351
+ if st.last_fail and SUCCESS_CLAIM.search(report):
352
+ report += (f"\n\n⚠ Contrôle automatique : la dernière commande de l'agent (`{st.last_fail[0]}`) a "
353
+ f"échoué (code {st.last_fail[1]}) : ne te fie pas à ce rapport, ça ne marche sans doute pas.")
354
+ return report
355
+
356
+ def _agent_outcome(self, report: str, refused: bool, error: str | None) -> tuple[str, str, str]:
357
+ """Comment le travail de l'agent s'est terminé : (rapport rendu, note affichée, couleur de la note)."""
358
+ note, color = "", GREEN
359
+ if refused:
360
+ note, color = "Arrêté : tu as refusé une de ses actions", YELLOW
361
+ report = "L'utilisateur a refusé une action de l'agent : il s'est arrêté avant la fin."
362
+ elif self.cancel_event.is_set():
363
+ note, color = "Interrompu", DIM
364
+ report = "(Agent interrompu par l'utilisateur.)"
365
+ elif error:
366
+ note, color = f"Erreur : {error}", RED
367
+ report = f"(L'agent n'a pas pu terminer : {error})"
368
+ elif not report:
369
+ note, color = "Terminé sans rapport", YELLOW
370
+ report = "(L'agent n'a rien rapporté.)"
371
+ if len(report) > MAX_TOOL_CHARS:
372
+ report = report[:MAX_TOOL_CHARS] + "\n[… rapport coupé …]"
373
+ return report, note, color
374
+
375
+ def _check_agent_report(self, name: str, task: str, report: str, st: ToolState, msgs: list[dict]) -> None:
376
+ """Contrôle du rapport : valeurs citées absentes de tout ce que l'agent a lu ?"""
377
+ refs = "\n".join(st.ran + [task, self.memory_text()] + [m["content"] for m in msgs if m["role"] == "tool"])
378
+ missing = unverified_values(report, refs)
379
+ if missing:
380
+ shown = ", ".join(missing[:8]) + (f"… (+{len(missing) - 8})" if len(missing) > 8 else "")
381
+ self.call_from_thread(self.add_notice, f"À vérifier (rapport de l'agent {name}) : ces valeurs "
382
+ f"n'apparaissent dans aucune sortie (il a pu les inventer) : {shown}", YELLOW)
383
+
384
+ # ------------------------------------------------------------------ outils fichiers (thread)
385
+ def _file_tool(self, name: str, args: dict) -> Resultat:
386
+ """lire / écrire / modifier un fichier. Renvoie (message pour le modèle, texte de référence, décision)."""
387
+ raw = str(args.get("chemin") or "").strip()
388
+ if not raw:
389
+ return "Erreur : le paramètre « chemin » est vide. Indique le chemin du fichier.", "", ""
390
+ path = resolve_path(raw, self.cwd)
391
+ label = {READ_TOOL: "Lire", WRITE_TOOL: "Écrire", EDIT_TOOL: "Modifier"}[name]
392
+ view = self.call_from_thread(self._new_tool_view, show_path(path, self.cwd), label,
393
+ "Lecture…" if name == READ_TOOL else "En attente de ta validation…")
394
+ try:
395
+ if path.is_dir():
396
+ self._fail(view, f"{path} est un dossier, pas un fichier. Pour lister son contenu, utilise executer_commande "
397
+ "avec Get-ChildItem.", "C'est un dossier")
398
+ if name == READ_TOOL:
399
+ return self._read_file(path, args, view)
400
+ # Écriture ou modification : on calcule le nouveau contenu, puis l'utilisateur valide le diff
401
+ old, new, encoding, newline = self._file_change(name, path, args, view)
402
+ return self._file_write(name, path, old, new, encoding, newline, str(args.get("explication") or ""), view)
403
+ except _Rendu as rendu:
404
+ return rendu.resultat
405
+
406
+ def _fail(self, view: ToolCallView, message: str, shown: str | None = None) -> None:
407
+ """Arrête l'outil fichier sur une erreur que le modèle doit corriger."""
408
+ self.call_from_thread(view.set_skipped, shown or message)
409
+ raise _Rendu((f"Erreur : {message}", "", ""))
410
+
411
+ def _read_file(self, path: Path, args: dict, view: ToolCallView) -> Resultat:
412
+ if not path.exists():
413
+ self._fail(view, f"fichier introuvable : {path}. Vérifie le chemin : lis seulement un fichier que tu as vu, "
414
+ "ou trouve-le avec chercher_fichiers.", "Fichier introuvable")
415
+ try:
416
+ text, encoding, _ = read_text_file(path)
417
+ except (OSError, ValueError) as err:
418
+ self._fail(view, f"lecture impossible de {path} : {err}", f"Lecture impossible : {err}")
419
+ return self._read_result(path, text, encoding, args, view)
420
+
421
+ def _file_change(self, name: str, path: Path, args: dict, view: ToolCallView) -> tuple[str | None, str, str, str]:
422
+ """Le changement demandé : (ancien contenu ou None, nouveau contenu, encodage, fin de ligne).
423
+ S'arrête (_Rendu) si le changement est impossible, vide, ou si une mémoire cite des fichiers inventés."""
424
+ old: str | None = None
425
+ encoding, newline = new_file_format(path)
426
+ if path.exists():
427
+ try:
428
+ old, encoding, newline = read_text_file(path)
429
+ except (OSError, ValueError) as err:
430
+ self._fail(view, f"lecture impossible de {path} : {err}", f"Lecture impossible : {err}")
431
+ if name == WRITE_TOOL:
432
+ content = args.get("contenu")
433
+ if not isinstance(content, str):
434
+ self._fail(view, "le paramètre « contenu » est manquant.", "Contenu manquant")
435
+ if "\n" not in content and content.count("\\n") >= 2:
436
+ content = content.replace("\\n", "\n").replace("\\t", "\t") # sauts de ligne échappés deux fois
437
+ new = content.replace("\r\n", "\n")
438
+ else:
439
+ if old is None:
440
+ self._fail(view, f"le fichier {path} n'existe pas. Pour le créer, utilise ecrire_fichier.", "Fichier introuvable")
441
+ new, error = apply_edit(old, str(args.get("ancien_texte") or ""), str(args.get("nouveau_texte") or ""))
442
+ if new is None:
443
+ self._fail(view, error, "Extrait introuvable ou ambigu → Prométhée relit le fichier" if "fois" in error
444
+ or "introuvable" in error else error)
445
+ if old is not None and new == old:
446
+ self.call_from_thread(view.set_text, "Aucun changement : contenu identique", None, DIM)
447
+ raise _Rendu(("Aucun changement : le nouveau contenu est identique à l'actuel.", "", ""))
448
+ if path.name.lower() == "promethee.md" and path != GLOBAL_MEMORY:
449
+ # Mémoire d'un dossier : pas de fichiers inventés (une seule fois par nom : s'il insiste, tu tranches)
450
+ missing = [m for m in missing_mentioned_files(new, old or "", path.parent)
451
+ if m.lower() not in self._memory_rejected]
452
+ if missing:
453
+ self._memory_rejected.update(m.lower() for m in missing)
454
+ existing = [rel for rel, _, _ in search_files("*", path.parent)[0]][:30]
455
+ self._fail(view, f"{path.name} cite des fichiers qui n'existent pas dans {path.parent} : {', '.join(missing)}. "
456
+ "La mémoire ne doit contenir que ce que tu as vu : rappelle "
457
+ f"{'ecrire_fichier' if name == WRITE_TOOL else 'modifier_fichier'} avec un contenu corrigé"
458
+ + (f" (fichiers qui existent : {', '.join(existing)})." if existing else "."),
459
+ f"Fichiers inexistants cités ({', '.join(missing)}) → Prométhée corrige")
460
+ return old, new, encoding, newline
461
+
462
+ def _file_write(self, name: str, path: Path, old: str | None, new: str, encoding: str, newline: str,
463
+ explanation: str, view: ToolCallView) -> Resultat:
464
+ """Te montre le diff (sauf écriture autorisée pour la session), écrit le fichier, l'inscrit au journal."""
465
+ home = os.path.normcase(str(HOME)) + os.sep
466
+ outside = not os.path.normcase(str(path)).startswith(home)
467
+ # Profil PowerShell, dossier Démarrage, clés, mémoire, agents : toujours montrés, même après « oui pour toutes »
468
+ sensitive = "" if outside else sensitive_path(path)
469
+ if self.allow_edits and not outside and not sensitive:
470
+ decision = "auto"
471
+ else:
472
+ decision = self._wait_choice(
473
+ lambda done: self.show_edit_permission(name, path, old, new, explanation, outside, done, sensitive))
474
+ if decision == "no":
475
+ self.call_from_thread(view.set_denied)
476
+ self.noter_fichier(name, path, "cancelled", "Écriture refusée par toi.")
477
+ return ("L'utilisateur a refusé cette écriture dans le fichier. Arrête-toi et attends ses instructions.",
478
+ "", "no")
479
+ if decision == "always":
480
+ self.allow_edits = True
481
+ try:
482
+ write_text_file(path, new, encoding, newline)
483
+ except OSError as err:
484
+ self.noter_fichier(name, path, "error", f"Écriture impossible : {err}")
485
+ self._fail(view, f"écriture impossible dans {path} : {err}", f"Écriture impossible : {err}")
486
+ diff, added, removed = render_diff(old, new, max_lines=10)
487
+ self.noter_fichier(name, path, "ok", change_summary(added, removed, old is None), added=added, removed=removed,
488
+ confirmation="session" if decision == "auto" else "toi", outside=outside or None)
489
+ summary = change_summary(added, removed, old is None) + (" (autorisé pour la session)" if decision == "auto" else "")
490
+ self.call_from_thread(view.set_text, summary, diff)
491
+ what = "créé" if old is None else "modifié"
492
+ return f"Fichier {what} : {path} (+{added} −{removed} lignes).", new, decision
493
+
494
+ def _read_result(self, path: Path, text: str, encoding: str, args: dict, view: ToolCallView) -> Resultat:
495
+ lines = text.split("\n")
496
+ if lines and lines[-1] == "":
497
+ lines.pop() # saut de ligne final
498
+ total = len(lines)
499
+ if not total:
500
+ self.call_from_thread(view.set_text, "Fichier vide", None, DIM)
501
+ return f"Le fichier {path} est vide.", "", "auto"
502
+
503
+ def number(value, default: int) -> int:
504
+ try:
505
+ return int(value)
506
+ except (TypeError, ValueError):
507
+ return default
508
+
509
+ start = min(max(1, number(args.get("debut"), 1)), total)
510
+ end = max(start, min(total, number(args.get("fin"), total), start + MAX_READ_LINES - 1))
511
+ body = numbered("\n".join(lines[start - 1:end]), start)
512
+ if len(body) > MAX_READ_CHARS:
513
+ cut = body.rfind("\n", 0, MAX_READ_CHARS)
514
+ body = body[:cut] if cut > 0 else body[:MAX_READ_CHARS] + " […ligne coupée…]"
515
+ end = start + body.count("\n")
516
+ partial = start > 1 or end < total
517
+ note = (f"\n[Lignes {start} à {end} sur {total}. Pour la suite : lire_fichier avec debut={end + 1}.]"
518
+ if end < total else "")
519
+ count = end - start + 1
520
+ self.call_from_thread(view.set_text, f"{count} ligne{'s' if count > 1 else ''} lue{'s' if count > 1 else ''}"
521
+ + (f" (sur {total})" if partial else ""))
522
+ return f"Contenu de {path} ({total} lignes) :\n{body}{note}", "\n".join(lines[start - 1:end]), "auto"
523
+
524
+ # ------------------------------------------------------------------ outils de recherche (thread)
525
+ def _search_tool(self, name: str, args: dict) -> str:
526
+ """chercher_fichiers / chercher_texte : lecture seule, donc sans validation (comme Glob / Grep)."""
527
+ raw = str(args.get("dossier") or "").strip()
528
+ root = resolve_path(raw, self.cwd) if raw and raw != "." else self.cwd
529
+ view = self.call_from_thread(self._new_tool_view, search_desc(name, args, show_path(root, self.cwd)
530
+ if root != self.cwd else ""), "Chercher", "Recherche…")
531
+ if not root.is_dir():
532
+ self.call_from_thread(view.set_skipped, "Dossier introuvable")
533
+ return f"Erreur : dossier introuvable : {root}. Vérifie le chemin."
534
+ self.call_from_thread(self._spinner, True, "check", "Recherche")
535
+ found = (self._find_files if name == SEARCH_FILES_TOOL else self._find_text)(args, root, view)
536
+ if isinstance(found, str):
537
+ return found # rien trouvé, ou demande invalide : le message est prêt et la vue est à jour
538
+ summary, content, shown = found
539
+ detail = Text("\n".join(" " + line[:160] for line in shown[:8]), style=DIM)
540
+ if len(shown) > 8:
541
+ detail.append(f"\n … +{len(shown) - 8}", style=f"italic {DIM}")
542
+ self.call_from_thread(view.set_text, summary, detail)
543
+ return content
544
+
545
+ def _find_files(self, args: dict, root: Path, view: ToolCallView) -> str | tuple[str, str, list[str]]:
546
+ """chercher_fichiers : (résumé affiché, résultat pour le modèle, lignes montrées), ou le message s'il n'y a rien."""
547
+ pattern = str(args.get("motif") or "").strip() or "*"
548
+ found, note = search_files(pattern, root, self.cancel_event)
549
+ extra = f" ; {note}" if note else ""
550
+ if not found:
551
+ self.call_from_thread(view.set_text, "Aucun fichier trouvé" + extra, None, DIM)
552
+ return f"Aucun fichier « {pattern} » dans {root} ni ses sous-dossiers{extra}."
553
+ lines = [f"{rel} ({human_size(size)}, modifié le {dt.datetime.fromtimestamp(mtime):%d/%m/%Y %H:%M})"
554
+ for rel, size, mtime in found]
555
+ summary = f"{len(found)} fichier{'s' if len(found) > 1 else ''} trouvé{'s' if len(found) > 1 else ''}{extra}"
556
+ content = f"Fichiers « {pattern} » dans {root} ({len(found)}{extra}) :\n" + "\n".join(lines)
557
+ return summary, content, [rel for rel, _, _ in found]
558
+
559
+ def _find_text(self, args: dict, root: Path, view: ToolCallView) -> str | tuple[str, str, list[str]]:
560
+ """chercher_texte : (résumé affiché, résultat pour le modèle, lignes montrées), ou le message s'il n'y a rien."""
561
+ text = str(args.get("texte") or "").strip()
562
+ if not text:
563
+ self.call_from_thread(view.set_skipped, "Texte à chercher vide")
564
+ return "Erreur : le paramètre « texte » est vide."
565
+ files = str(args.get("fichiers") or "").strip()
566
+ found, scanned, note = search_text(text, root, files, self.cancel_event)
567
+ extra = f" ; {note}" if note else ""
568
+ if not found:
569
+ self.call_from_thread(view.set_text, f"Aucune ligne trouvée ({scanned} fichiers lus{extra})", None, DIM)
570
+ return f"Aucune ligne ne contient « {text} » dans {root} ({scanned} fichiers lus{extra})."
571
+ n_files = len({rel for rel, _, _ in found})
572
+ shown = [f"{rel}:{number}: {line}" for rel, number, line in found]
573
+ summary = (f"{len(found)} ligne{'s' if len(found) > 1 else ''} dans {n_files} fichier"
574
+ f"{'s' if n_files > 1 else ''}{extra}")
575
+ content = (f"Lignes contenant « {text} » dans {root} ({len(found)} lignes dans {n_files} fichiers, "
576
+ f"{scanned} fichiers lus{extra}) :\n")
577
+ if len(found) <= SEARCH_LINES_TO_MODEL:
578
+ return summary, content + "\n".join(shown), shown
579
+ # Beaucoup de lignes (jusqu'à 100 × 200 caractères) : elles rempliraient le contexte de 8 192 jetons
580
+ # avant toute lecture. Le modèle reçoit le compte par fichier et les premières lignes.
581
+ counts: dict[str, int] = {}
582
+ for rel, _, _ in found:
583
+ counts[rel] = counts.get(rel, 0) + 1
584
+ ranked = sorted(counts.items(), key=lambda kv: -kv[1])
585
+ content += ("Par fichier : " + ", ".join(f"{rel} ({n})" for rel, n in ranked[:30])
586
+ + (" …" if len(ranked) > 30 else "") + f"\nLes {SEARCH_LINES_TO_MODEL} premières lignes :\n"
587
+ + "\n".join(line[:160] for line in shown[:SEARCH_LINES_TO_MODEL])
588
+ + "\n(Lis le fichier le plus utile avec lire_fichier, ou précise la recherche.)")
589
+ return summary, content, shown