@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,539 @@
1
+ """Un tour avec le modèle : classification de la demande, recherche web, flux de la réponse, relances."""
2
+ from __future__ import annotations
3
+
4
+ import http.client
5
+ import json
6
+ import shutil
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+
11
+ from textual import work
12
+ from textual.widgets import Markdown
13
+
14
+ from ..agents import AGENT_HINT, AGENT_MENTION, list_agents
15
+ from ..appels import prune_calls
16
+ from ..config import MAX_STEPS
17
+ from ..consignes import (
18
+ ANNOUNCE, ASK_CONFIRM, AUDIT_PLAYBOOK, AUDIT_RE, AUDIT_STEPS, CODE_START, CODE_SYSTEM, CONTINUE, EMPTY_REPORT,
19
+ EXPLAIN_REMINDER, FAILED_CLAIM, MAX_CONTINUES, NO_CONFIRM, NUDGE, REPORT_RE, RETURN_REMINDER, ROUTER_SCHEMA,
20
+ ROUTER_SYSTEM, RUN_CHECK, SUCCESS_CLAIM, TOOL_REMINDER, TOOL_TEXT, unverified_values, verify_nudge,
21
+ writes_commands,
22
+ )
23
+ from ..memoire import read_memory
24
+ from ..ollama import OLLAMA, api, http_error_text
25
+ from ..plan import AUDIT_TASKS, MEMORY_HINT, PLAN_FIX, PLAN_REMINDER, missing_cmdlets
26
+ from ..recherche import (
27
+ OUTIL, SEARCH_ROUTER_SCHEMA, SEARCH_ROUTER_SYSTEM, Classement, chercher, dernier_echange, plan_from,
28
+ search_result_text, search_router_input,
29
+ )
30
+ from ..theme import CODE_COLOR, DIM, YELLOW
31
+ from ..widgets import AssistantMessage, LOCAL_KEYS, ToolState
32
+
33
+
34
+ @dataclass
35
+ class EtatTour:
36
+ """Ce qu'un tour retient d'une étape à l'autre."""
37
+ msgs: list[dict]
38
+ model: str # modèle qui répond : Prométhée, ou l'IA de code
39
+ use_tools: bool
40
+ mode: str = "" # « action », « explication », « plan », ou "" (modèle sans outils)
41
+ reminder: str = "" # rappel ajouté au dernier message de l'utilisateur, dans la requête seulement
42
+ audit: bool = False
43
+ code_turn: bool = False
44
+ st: ToolState = field(default_factory=ToolState)
45
+ final: dict | None = None # dernier message de fin d'Ollama (compte des jetons, durée)
46
+ error: str | None = None
47
+ denied: bool = False # l'utilisateur a refusé une action, ou la politique en a refusé deux
48
+ produced: bool = False # quelque chose est entré dans l'historique
49
+ executed: bool = False # le modèle a déjà appelé un outil pendant cette réponse
50
+ nudged: bool = False # relancé pour avoir écrit des commandes au lieu de les exécuter
51
+ verifies: int = 0 # relances « vérifie ton code »
52
+ honest: bool = False # relancé pour un succès annoncé après un échec
53
+ run_checked: bool = False # relancé pour un programme compilé mais jamais lancé
54
+ empty_asked: bool = False # relancé pour une réponse vide
55
+ plan_checked: bool = False # les cmdlets du plan ont été vérifiées
56
+ continues: int = 0 # relances « continue »
57
+ pending: dict | None = None # relance en attente (voir _next_nudge)
58
+ search: dict | None = None # recherche web décidée par son classement : {query, place} (voir _search_turn)
59
+
60
+
61
+ class Tour:
62
+ """Partie de PrometheeApp : un tour avec le modèle : classification de la demande, recherche web, flux de la réponse,
63
+ relances."""
64
+
65
+ # ------------------------------------------------------------------ boucle d'agent (thread)
66
+ @work(thread=True, exclusive=True, group="generate")
67
+ def run_agent(self, history: list[dict], model: str, plan: bool = False) -> None:
68
+ """Un tour complet : préparer, enchaîner les étapes avec le modèle, contrôler la réponse, rendre la main."""
69
+ msgs = list(history)
70
+ if not self._model_ready(msgs, model):
71
+ return
72
+ # Mode plan : le modèle ne reçoit aucun outil, il ne peut donc rien exécuter
73
+ use_tools = self.supports_tools and not plan
74
+ last_user = next((m.get("intent") or m.get("display") or m["content"] for m in reversed(msgs)
75
+ if m["role"] == "user"), "")
76
+ # « @agent-nom tâche » : la tâche part directement à l'agent, sans passer par le modèle principal
77
+ mention = AGENT_MENTION.search(last_user) if use_tools else None
78
+ if mention:
79
+ self._direct_agent(msgs, mention.group(1))
80
+ return
81
+ # Recherche web : son classement passe d'abord, puis l'interpréteur s'il n'y a pas de recherche. L'un après
82
+ # l'autre, comme pendant la mesure : lancés ensemble, Ollama les calcule en lot et le classement change (65/68 au
83
+ # lieu de 66/68, lieux moins précis) pour presque rien de gagné.
84
+ web = self._start_search_plan(msgs, model, plan)
85
+ found = None
86
+ if web is not None:
87
+ self.call_from_thread(self._spinner, True, "check", "Interprétation de la demande")
88
+ found = web.attendre()
89
+ if found is not None:
90
+ self._search_turn(EtatTour(msgs=msgs, model=model, use_tools=False, mode="recherche", search=found))
91
+ return
92
+ if self.cancel_event.is_set():
93
+ # Esc pendant ce classement : le tour s'arrête là, l'interpréteur n'est pas interrogé
94
+ self.call_from_thread(self.finish_agent, msgs, None, None, True, False, False)
95
+ return
96
+ t = self._prepare_turn(msgs, model, plan, use_tools, last_user)
97
+ for _ in range(MAX_STEPS):
98
+ if self.cancel_event.is_set() or not self._step(t):
99
+ break
100
+ else:
101
+ self.call_from_thread(self.add_notice, f"Arrêt après {MAX_STEPS} étapes. Dis à Prométhée s'il doit continuer.", YELLOW)
102
+ self._final_checks(t, len(history))
103
+ self.call_from_thread(self.finish_agent, t.msgs, t.final, t.error, self.cancel_event.is_set(), t.denied, t.produced)
104
+
105
+ def _model_ready(self, msgs: list[dict], model: str) -> bool:
106
+ """Ollama ne répondait pas au démarrage : on réessaie maintenant, plutôt que de répondre sans outils."""
107
+ if self.num_ctx is not None or model != self.model:
108
+ return True
109
+ self.call_from_thread(self._spinner, True, "check", "Connexion à Ollama")
110
+ state, problem = self.read_model(model)
111
+ self.call_from_thread(self._set_model_state, model, state)
112
+ if problem:
113
+ self.call_from_thread(self.finish_agent, msgs, None, problem, False, False, False)
114
+ return not problem
115
+
116
+ def _prepare_turn(self, msgs: list[dict], model: str, plan: bool, use_tools: bool, last_user: str) -> EtatTour:
117
+ """1) Interpréteur : action (exécuter) ou explication (répondre directement) ? programmation ?
118
+ Choisit le rappel ajouté à la demande et le modèle qui répond (Prométhée, ou l'IA de code)."""
119
+ forced = next((m.get("mode") for m in reversed(msgs) if m["role"] == "user"), None)
120
+ mode, reminder, audit, code_turn = "", "", False, False
121
+ if plan:
122
+ mode, reminder = "plan", PLAN_REMINDER + (AUDIT_PLAYBOOK if AUDIT_RE.search(last_user) else "")
123
+ elif use_tools:
124
+ self.call_from_thread(self._spinner, True, "check", "Interprétation de la demande")
125
+ self._is_code = False
126
+ # Interpréteur lancé sur le modèle déjà en mémoire : pas de rechargement juste pour classer
127
+ judge = self._loaded_model if self._loaded_model in (model, self.code_model) else model
128
+ mode = forced or self._classify(msgs, judge)
129
+ if not forced and judge != model and not self._is_code:
130
+ # L'IA de code (encore chargée) dit « pas du code » : Prométhée va répondre, il doit donc être chargé de
131
+ # toute façon ; c'est lui qui tranche (qwen3.5 classait « merci ! » en « action » 2 fois sur 3).
132
+ mode = self._classify(msgs, model)
133
+ if self._is_code and self.code_auto and self.code_model != model:
134
+ caps = self.model_caps(self.code_model)
135
+ if caps is not None and "tools" in caps:
136
+ code_turn = True
137
+ elif not self._code_warned:
138
+ self._code_warned = True
139
+ self.call_from_thread(self.add_notice, f"IA de code « {self.code_model} » indisponible "
140
+ f"({'non installée' if caps is None else 'sans outils'}) : Prométhée répond. "
141
+ "Change-la avec /code <modèle>.", YELLOW)
142
+ if mode == "explication":
143
+ reminder = EXPLAIN_REMINDER
144
+ else:
145
+ audit = bool(AUDIT_RE.search(last_user))
146
+ memory_hint = MEMORY_HINT if read_memory(self.cwd) else ""
147
+ agent_hint = AGENT_HINT if list_agents(self.cwd) else ""
148
+ reminder = TOOL_REMINDER[:-1] + agent_hint + memory_hint + ")" + (AUDIT_PLAYBOOK if audit else "")
149
+ if audit:
150
+ self.call_from_thread(self.start_tasks, "audit",
151
+ [{"title": t, "pattern": p} for t, p in AUDIT_TASKS])
152
+ if code_turn:
153
+ model = self.code_model
154
+ self.call_from_thread(self.add_notice, f"⇄ IA de code : {model} prend la main pour cette demande "
155
+ "(Prométhée reprend ensuite).", CODE_COLOR)
156
+ elif self._last_code and mode != "plan":
157
+ self.call_from_thread(self.add_notice, "⇄ Retour à Prométhée.", DIM)
158
+ if mode == "explication":
159
+ reminder = RETURN_REMINDER
160
+ self._last_code = code_turn
161
+ self._turn_model = model
162
+ self.call_from_thread(self.update_status)
163
+ return EtatTour(msgs=msgs, model=model, use_tools=use_tools, mode=mode, reminder=reminder, audit=audit, code_turn=code_turn)
164
+
165
+ def _step(self, t: EtatTour) -> bool:
166
+ """Une étape du tour : interroger le modèle, régler la relance en attente, en décider une nouvelle au besoin,
167
+ sinon garder sa réponse et exécuter ses outils. Renvoie False quand le tour est fini."""
168
+ bubble, text, calls = self._ask_model(t)
169
+ # IA de code relancée (« vérifie ») qui répond encore sans rien lancer : on refait les contrôles de relance
170
+ had_pending = t.pending is not None
171
+ recheck = bool(t.code_turn and t.pending and t.pending["keep"] and not calls)
172
+ text, over = self._settle_pending(t, bubble, text, calls)
173
+ if over:
174
+ return False
175
+ nudge = self._next_nudge(t, bubble, text, calls, had_pending, recheck)
176
+ if nudge is not None:
177
+ t.pending = nudge
178
+ return True
179
+ if not text.strip():
180
+ self.call_from_thread(bubble.remove)
181
+ if calls:
182
+ calls = prune_calls(calls, self.cwd) # délégation ou lectures lancées à l'aveugle
183
+ if text or calls:
184
+ t.produced = True
185
+ # Le template du modèle n'affiche QUE le texte OU l'appel d'outil d'un message.
186
+ # Si on gardait la phrase d'intro (« vous pouvez utiliser la commande… »), le modèle
187
+ # croirait avoir seulement expliqué la commande et arrêterait d'utiliser l'outil.
188
+ entry: dict = {"role": "assistant", "content": "" if calls else text}
189
+ if calls:
190
+ entry["tool_calls"] = calls
191
+ t.msgs.append(entry)
192
+ if t.error or self.cancel_event.is_set() or not calls:
193
+ return False
194
+ t.executed = True
195
+ t.denied = self._run_tool_calls(calls, t.msgs, t.st)
196
+ return not (t.denied or self.cancel_event.is_set())
197
+
198
+ def _ask_model(self, t: EtatTour) -> tuple[AssistantMessage, str, list[dict]]:
199
+ """Envoie la conversation au modèle et diffuse sa réponse. Le rappel et la relance en attente ne sont ajoutés
200
+ qu'à la requête, jamais à l'historique."""
201
+ self.call_from_thread(self._spinner, True, "model")
202
+ bubble = self.call_from_thread(self._new_bubble)
203
+ system = self.system_message()
204
+ if t.code_turn and system:
205
+ system = {"role": "system", "content": system["content"] + CODE_SYSTEM.format(model=t.model)}
206
+ request = [{k: v for k, v in m.items() if k not in LOCAL_KEYS} for m in t.msgs]
207
+ if t.reminder:
208
+ # Rappel (+ méthode d'audit) sur le dernier message de l'utilisateur, à chaque étape
209
+ idx = max((i for i, m in enumerate(request) if m["role"] == "user"), default=None)
210
+ if idx is not None:
211
+ request[idx]["content"] += t.reminder
212
+ if t.pending:
213
+ if not t.pending["keep"]:
214
+ request.append({"role": "assistant", "content": t.pending["text"]})
215
+ request.append({"role": "user", "content": t.pending["nudge"]})
216
+ text, calls, turn_final, t.error = self._stream_turn(bubble, ([system] if system else []) + request, t.model, t.use_tools)
217
+ t.final = turn_final or t.final
218
+ return bubble, text, calls
219
+
220
+ def _settle_pending(self, t: EtatTour, bubble: AssistantMessage, text: str, calls: list[dict]) -> tuple[str, bool]:
221
+ """Le modèle vient de répondre à une relance : que garde-t-on, à l'écran et dans l'historique ?
222
+ Renvoie (texte retenu, tour terminé)."""
223
+ first, t.pending = t.pending, None
224
+ if first is None:
225
+ return text, False
226
+ if first.get("replace"):
227
+ # Plan corrigé : il remplace le premier à l'écran et dans l'historique (sinon on garde le premier)
228
+ if text.strip() and not t.error and not self.cancel_event.is_set():
229
+ self.call_from_thread(first["bubble"].remove)
230
+ self.call_from_thread(self.add_notice, f"Plan corrigé automatiquement : {first['names']} n'existe pas sur ce PC.")
231
+ still = missing_cmdlets(text, self.cwd)
232
+ if still:
233
+ self.call_from_thread(self.add_notice, "À vérifier : " + ", ".join(f"`{n}`" for n in still)
234
+ + " n'existe pas non plus sur ce PC.", YELLOW)
235
+ return text, False
236
+ self.call_from_thread(bubble.remove)
237
+ return first["text"], False
238
+ if calls:
239
+ if not first["keep"]:
240
+ # 2) Relance réussie : la réponse écrite disparaît, place à l'exécution
241
+ self.call_from_thread(first["bubble"].remove)
242
+ self.call_from_thread(self.add_notice, "Commandes écrites au lieu d'être exécutées : relancé automatiquement.")
243
+ return text, False
244
+ if first["keep"]:
245
+ return text, False # continuation sans appel d'outil : ce texte est sans doute le rapport final, on le garde
246
+ # Relance sans effet : on garde la première réponse… sauf si elle contient de faux appels d'outils
247
+ # (« executer_commande("…") » suivi de « [La sortie de …] ») et pas la seconde
248
+ fake = TOOL_TEXT.search(first["text"])
249
+ if fake and text.strip() and not t.error and not TOOL_TEXT.search(text):
250
+ self.call_from_thread(first["bubble"].remove)
251
+ kept = text
252
+ else:
253
+ self.call_from_thread(bubble.remove)
254
+ kept = first["text"]
255
+ t.msgs.append({"role": "assistant", "content": kept})
256
+ if TOOL_TEXT.search(kept):
257
+ self.call_from_thread(self.add_notice, "Prométhée a écrit ses appels d'outils au lieu de les "
258
+ "lancer : rien n'a été exécuté. Relance ta demande ou reformule-la.", YELLOW)
259
+ t.produced = True
260
+ return kept, True
261
+
262
+ def _next_nudge(self, t: EtatTour, bubble: AssistantMessage, text: str, calls: list[dict],
263
+ had_pending: bool, recheck: bool) -> dict | None:
264
+ """Faut-il relancer le modèle au lieu d'accepter sa réponse ? Renvoie la relance, ou None.
265
+ Une relance : {"bubble", "text", "nudge", "keep"} ; keep = le texte reste affiché et dans l'historique.
266
+ Chaque cas vient d'un comportement observé sur le modèle ; leur ordre compte."""
267
+ idle = not calls and not t.error and not self.cancel_event.is_set() # il a répondu sans appeler d'outil
268
+ st = t.st
269
+
270
+ def kept(nudge: str) -> dict:
271
+ """Relance qui garde la réponse : elle rejoint l'historique, la consigne s'y ajoute pour le tour suivant."""
272
+ t.produced = True
273
+ t.msgs.append({"role": "assistant", "content": text})
274
+ return {"bubble": bubble, "text": text, "nudge": nudge, "keep": True}
275
+
276
+ if t.code_turn and t.executed and idle and not text.strip() and not t.empty_asked:
277
+ # IA de code qui s'arrête sur une réponse vide (vu après un compactage) : on lui demande son rapport
278
+ t.empty_asked = True
279
+ self.call_from_thread(bubble.remove)
280
+ return {"bubble": bubble, "text": "", "nudge": EMPTY_REPORT, "keep": True}
281
+ if (recheck or not had_pending) and t.mode == "action" and idle and text.strip():
282
+ if not t.executed and not t.nudged and writes_commands(text) and not t.code_turn:
283
+ # Demande d'action mais le modèle a écrit les commandes : on le relance une fois
284
+ t.nudged = True
285
+ return {"bubble": bubble, "text": text, "nudge": NUDGE, "keep": False}
286
+ if t.code_turn and st.unverified and t.verifies < 2:
287
+ # IA de code : code modifié puis « c'est fait » sans rien relancer → vérifier d'abord
288
+ t.verifies += 1
289
+ return kept(verify_nudge(st.unverified, self.cwd))
290
+ if t.code_turn and st.last_fail and SUCCESS_CLAIM.search(text) and not t.honest:
291
+ t.honest = True
292
+ return kept(FAILED_CLAIM.format(cmd=st.last_fail[0], code=st.last_fail[1]))
293
+ if t.code_turn and st.compiled_only and not t.run_checked:
294
+ # Compilé mais jamais lancé : une fois, on lui demande de l'essayer sur un exemple
295
+ t.run_checked = True
296
+ return kept(RUN_CHECK)
297
+ if (t.code_turn and not t.executed and t.continues < 2 and len(text) < 800 and "```" not in text
298
+ and (ANNOUNCE.search(text) or CODE_START.search(text))):
299
+ # IA de code qui annonce ce qu'elle va faire mais n'a encore rien lancé : on la relance
300
+ t.continues += 1
301
+ return kept(CONTINUE)
302
+ n_ran = len(st.ran) // 2
303
+ if t.executed and t.continues < MAX_CONTINUES and (
304
+ ANNOUNCE.search(text) or (t.audit and n_ran < AUDIT_STEPS and not REPORT_RE.search(text))
305
+ or (self.plan_steps_left() > 0 and not REPORT_RE.search(text) and "bilan" not in text.lower())):
306
+ # Il annonce l'étape suivante (« Passons au scan… »), ou un audit / un plan accepté s'arrête
307
+ # avant la fin sans rapport : on le relance
308
+ t.continues += 1
309
+ return kept(NO_CONFIRM if ASK_CONFIRM.search(text) else CONTINUE)
310
+ if (t.mode == "action" and t.executed and idle and t.continues < MAX_CONTINUES and ASK_CONFIRM.search(text)
311
+ and (self.plan_steps_left() > 0 or (t.audit and len(st.ran) // 2 < AUDIT_STEPS))):
312
+ # Plan ou audit en cours et il demande la permission par écrit : on le relance
313
+ t.continues += 1
314
+ return kept(NO_CONFIRM)
315
+ if t.mode == "plan" and not t.plan_checked and text.strip() and not t.error and not self.cancel_event.is_set():
316
+ # Mode plan : les cmdlets citées existent-elles ? Sinon, une seule demande de correction.
317
+ t.plan_checked = True
318
+ self.call_from_thread(self._spinner, True, "check", "Vérification du plan")
319
+ bad = missing_cmdlets(text, self.cwd)
320
+ if bad:
321
+ names = ", ".join(f"`{n}`" for n in bad)
322
+ return {"bubble": bubble, "text": text, "nudge": PLAN_FIX.format(names=names), "keep": False,
323
+ "replace": True, "names": names}
324
+ return None
325
+
326
+ def _final_checks(self, t: EtatTour, start: int) -> None:
327
+ """Après la dernière étape : succès annoncé après un échec, valeurs citées absentes des sorties."""
328
+ st = t.st
329
+ last_text = next((m.get("content") or "" for m in reversed(t.msgs) if m["role"] == "assistant"), "")
330
+ if t.code_turn and st.last_fail and SUCCESS_CLAIM.search(last_text):
331
+ self.call_from_thread(self.add_notice, f"⚠ L'IA de code dit que ça marche, mais sa dernière commande "
332
+ f"(`{st.last_fail[0]}`) a échoué (code {st.last_fail[1]}) : vérifie avant de t'y fier.",
333
+ f"bold {YELLOW}")
334
+ if st.ran:
335
+ # 4) Contrôle de la réponse : valeurs citées absentes de toutes les sorties de commandes ?
336
+ answer = "\n".join(m.get("content") or "" for m in t.msgs[start:] if m["role"] == "assistant")
337
+ refs = "\n".join(st.ran + [m.get("content") or "" for m in t.msgs if m["role"] in ("user", "tool")]
338
+ + [self.memory_text(), self.summary])
339
+ missing = unverified_values(answer, refs)
340
+ if missing:
341
+ shown = ", ".join(missing[:8]) + (f"… (+{len(missing) - 8})" if len(missing) > 8 else "")
342
+ self.call_from_thread(self.add_notice, "À vérifier : ces valeurs n'apparaissent dans aucune sortie de "
343
+ f"commande (le modèle a pu les inventer) : {shown}", YELLOW)
344
+
345
+ def _classify(self, msgs: list[dict], model: str) -> str:
346
+ """Interpréteur : « action » ou « explication ». En cas de doute ou d'erreur : action."""
347
+ last_user, prev, prev_user = "", "", ""
348
+ for m in reversed(msgs):
349
+ if m["role"] == "user" and not last_user:
350
+ last_user = m.get("intent") or m.get("display") or m["content"]
351
+ elif last_user and m["role"] == "assistant" and m.get("content") and not prev:
352
+ prev = m["content"]
353
+ elif prev and m["role"] == "user":
354
+ # La demande d'avant : sans elle, « et en Rust ? » ou « explique la ligne 3 » n'étaient pas vus comme du code
355
+ prev_user = m.get("intent") or m.get("display") or m["content"]
356
+ break
357
+ content = (f"Demande précédente de l'utilisateur : « {prev_user[:200]} »\n" if prev_user else "") \
358
+ + (f"Réponse précédente de l'assistant (extrait) : « {prev[-300:]} »\n\n" if prev else "") \
359
+ + f"Dernière demande : « {last_user} »"
360
+ payload: dict = {"model": model, "stream": False, "format": ROUTER_SCHEMA,
361
+ "options": {"temperature": 0, "num_predict": 30},
362
+ "messages": [{"role": "system", "content": ROUTER_SYSTEM}, {"role": "user", "content": content}]}
363
+ if "thinking" in (self.model_caps(model) or []):
364
+ payload["think"] = False
365
+ try:
366
+ # La réponse est gardée le temps de l'appel : Esc la ferme, la classification s'arrête tout de suite
367
+ data = json.loads(api("/api/chat", payload, timeout=60, on_open=self._hold_response)["message"]["content"])
368
+ except Exception:
369
+ return "action"
370
+ finally:
371
+ self._resp = None
372
+ self._is_code = data.get("code") is True
373
+ kind = data.get("type")
374
+ return kind if kind in ("action", "explication") else "action"
375
+
376
+ def _hold_response(self, resp) -> None:
377
+ self._resp = resp
378
+
379
+ # ------------------------------------------------------------------ recherche web
380
+ def _start_search_plan(self, msgs: list[dict], model: str, plan: bool) -> Classement | None:
381
+ """Lance le classement de la recherche web dans son propre fil, s'il a lieu d'être : recherche active dans les
382
+ réglages, hors mode plan, et demande qui n'impose pas l'action (/init). Sinon None : le tour se passe comme avant."""
383
+ forced = next((m.get("mode") for m in reversed(msgs) if m["role"] == "user"), None)
384
+ if plan or forced or not self.reglage_recherche()["enabled"]:
385
+ return None
386
+ snapshot = list(msgs)
387
+ return Classement(lambda hold: self._search_plan(snapshot, model, hold), self.cancel_event)
388
+
389
+ def _search_plan(self, msgs: list[dict], model: str, hold=None) -> dict | None:
390
+ """Faut-il chercher sur internet avant de répondre ? Un second classement, à part de l'interpréteur ci-dessus qui
391
+ reste tel quel. Même appel que lui, avec le texte et la mise en forme mesurés dans la console
392
+ (.equipe/recherche/mesure-routeur.md). Renvoie {query, place}, ou None : pas de recherche (en cas d'erreur aussi)."""
393
+ last_user, prev, prev_user = dernier_echange(msgs)
394
+ if not last_user:
395
+ return None
396
+ payload: dict = {"model": model, "stream": False, "format": SEARCH_ROUTER_SCHEMA,
397
+ "options": {"temperature": 0, "num_predict": 120},
398
+ "messages": [{"role": "system", "content": SEARCH_ROUTER_SYSTEM},
399
+ {"role": "user", "content": search_router_input(last_user, prev, prev_user)}]}
400
+ if "thinking" in (self.model_caps(model) or []):
401
+ payload["think"] = False
402
+ try:
403
+ # La réponse est confiée à hold : Esc la ferme, le classement s'arrête tout de suite
404
+ data = json.loads(api("/api/chat", payload, timeout=60, on_open=hold)["message"]["content"])
405
+ except Exception:
406
+ return None
407
+ return plan_from(data, last_user)
408
+
409
+ def _search_turn(self, t: EtatTour) -> None:
410
+ """Tour avec recherche web, comme dans la console : la recherche, visible (requête, sources, lieu) et notée au
411
+ journal, puis la réponse du modèle SANS outil. Un texte venu d'internet ne doit pas pouvoir lui faire proposer une
412
+ commande dans ce tour. Dans l'historique, la recherche est un appel d'outil « recherche_web » suivi de son
413
+ résultat. Le détail (sources, lieu) reste dans le message, sous la clé locale « search », pour le réafficher."""
414
+ plan, msgs, model = t.search, t.msgs, t.model
415
+ if self._last_code:
416
+ self.call_from_thread(self.add_notice, "⇄ Retour à Prométhée.", DIM)
417
+ self._last_code = False
418
+ self._turn_model = model
419
+ self.call_from_thread(self.update_status)
420
+ self.call_from_thread(self._spinner, True, "check", "Recherche web")
421
+ view = self.call_from_thread(self._new_search_view, plan["query"])
422
+ search = chercher(self.client_recherche(), plan, self.cancel_event)
423
+ self.call_from_thread(view.set_result, search)
424
+ self.noter_recherche(plan, search)
425
+ args = {"requete": plan["query"], **({"lieu": plan["place"]} if plan["place"] else {})}
426
+ msgs.append({"role": "assistant", "content": "", "tool_calls": [{"function": {"name": OUTIL, "arguments": args}}],
427
+ "search": search})
428
+ msgs.append({"role": "tool", "tool_name": OUTIL, "content": search_result_text(search)})
429
+ text, final, error = "", None, None
430
+ if not self.cancel_event.is_set():
431
+ self.call_from_thread(self._spinner, True, "model")
432
+ bubble = self.call_from_thread(self._new_bubble)
433
+ request = [{k: v for k, v in m.items() if k not in LOCAL_KEYS} for m in msgs]
434
+ # use_tools=False : aucun outil dans la requête, et un appel d'outil inventé n'est jamais exécuté
435
+ text, _, final, error = self._stream_turn(bubble, [self.search_system(search)] + request, model, False)
436
+ if not text.strip():
437
+ self.call_from_thread(bubble.remove)
438
+ if text:
439
+ msgs.append({"role": "assistant", "content": text})
440
+ self.call_from_thread(self.finish_agent, msgs, final, error, self.cancel_event.is_set(), False, bool(text))
441
+
442
+ def model_caps(self, model: str) -> list[str] | None:
443
+ """Capacités d'un modèle (tools, thinking…), None s'il n'est pas installé. Gardées en cache."""
444
+ if model not in self._caps:
445
+ if model == self.model and self.num_ctx is not None:
446
+ self._caps[model] = (["tools"] if self.supports_tools else []) + \
447
+ (["thinking"] if self.supports_thinking else [])
448
+ else:
449
+ try:
450
+ self._caps[model] = api("/api/show", {"model": model}, timeout=15).get("capabilities") or []
451
+ except urllib.error.HTTPError:
452
+ self._caps[model] = None # Ollama répond : ce modèle n'est pas installé
453
+ except Exception:
454
+ return None # Ollama ne répond pas : rien n'est retenu, la prochaine demande réessaiera
455
+ return self._caps[model]
456
+
457
+ def _check_gpu(self, model: str) -> None:
458
+ """Ollama ne détecte parfois pas la carte graphique au démarrage (après une mise en veille) et tourne alors
459
+ entièrement sur le processeur : 5 à 10 fois plus lent. On le signale."""
460
+ if not shutil.which("nvidia-smi"):
461
+ return
462
+ try:
463
+ loaded = api("/api/ps", timeout=10).get("models") or []
464
+ except Exception:
465
+ return
466
+ name = model if ":" in model else f"{model}:latest"
467
+ entry = next((m for m in loaded if m.get("name") in (model, name)), None)
468
+ if entry is not None and not entry.get("size_vram"):
469
+ self.call_from_thread(self.add_notice, "Ollama tourne sur le processeur : ta carte graphique n'a pas été "
470
+ "détectée (souvent après une mise en veille). Les réponses sont 5 à 10 fois plus lentes. "
471
+ "Quitte Ollama (icône près de l'horloge → Quit Ollama), relance-le, puis relance prmt.",
472
+ f"bold {YELLOW}")
473
+
474
+ def _stream_turn(self, bubble: AssistantMessage | None, messages: list[dict], model: str, use_tools: bool | list):
475
+ # bubble = None : rien ne s'affiche (travail d'un agent : seul son rapport compte)
476
+ # (la recherche du widget Markdown se fait dans le fil de l'interface, comme tout accès aux widgets)
477
+ stream = self.call_from_thread(lambda: Markdown.get_stream(bubble.markdown)) if bubble is not None else None
478
+ parts: list[str] = []
479
+ calls: list[dict] = []
480
+ final: dict | None = None
481
+ error: str | None = None
482
+ payload: dict = {"model": model, "messages": messages, "stream": True}
483
+ if model != self.model and "thinking" in (self.model_caps(model) or []):
484
+ payload["think"] = False # IA de code / modèle d'un agent : réponse directe, bien plus rapide
485
+ self._loaded_model = model
486
+ # use_tools : True (outils du modèle principal) ou la liste des outils d'un agent
487
+ tools = use_tools if isinstance(use_tools, list) else self.request_tools() if use_tools else None
488
+ if tools:
489
+ payload["tools"] = tools
490
+ req = urllib.request.Request(
491
+ OLLAMA + "/api/chat",
492
+ data=json.dumps(payload).encode("utf-8"),
493
+ headers={"Content-Type": "application/json"},
494
+ )
495
+ try:
496
+ with urllib.request.urlopen(req, timeout=600) as resp:
497
+ self._resp = resp
498
+ for raw in resp:
499
+ if self.cancel_event.is_set():
500
+ break
501
+ if not raw.strip():
502
+ continue
503
+ chunk = json.loads(raw)
504
+ if "error" in chunk:
505
+ error = chunk["error"]
506
+ break
507
+ msg = chunk.get("message") or {}
508
+ if msg.get("thinking"):
509
+ self.spin_thinking = True
510
+ if msg.get("tool_calls"):
511
+ calls.extend(msg["tool_calls"])
512
+ piece = msg.get("content") or ""
513
+ if piece:
514
+ parts.append(piece)
515
+ self.spin_tokens += 1
516
+ if stream is not None:
517
+ self.call_from_thread(stream.write, piece)
518
+ if chunk.get("done"):
519
+ final = chunk
520
+ break
521
+ except urllib.error.HTTPError as err:
522
+ error = http_error_text(err)
523
+ except (OSError, http.client.HTTPException) as err: # connexion refusée, coupure volontaire (Esc), délai dépassé
524
+ if not self.cancel_event.is_set():
525
+ error = f"Impossible de joindre Ollama sur {OLLAMA} ({err}). Lance l'application Ollama puis réessaie."
526
+ except ValueError as err: # une ligne de la réponse n'est pas du JSON
527
+ if not self.cancel_event.is_set():
528
+ error = f"Réponse illisible d'Ollama ({err}). Réessaie ; si cela continue, relance Ollama."
529
+ except Exception as err:
530
+ if not self.cancel_event.is_set():
531
+ error = f"Erreur inattendue pendant la réponse ({type(err).__name__} : {err})."
532
+ finally:
533
+ self._resp = None
534
+ try:
535
+ if stream is not None:
536
+ self.call_from_thread(stream.stop)
537
+ except Exception:
538
+ pass
539
+ return "".join(parts), calls, final, error