@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,373 @@
1
+ """Fin d'un tour, compactage et sessions."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import re
8
+ import urllib.error
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ from rich.text import Text
13
+ from textual import work
14
+ from textual.widget import Widget
15
+ from textual.widgets import Markdown, Static
16
+
17
+ from ..agents import AGENT_TOOL
18
+ from ..consignes import (
19
+ EDIT_TOOL, FILE_TOOL_NAMES, READ_TOOL, REPORT_RE, SEARCH_TOOL_NAMES, WRITE_TOOL, unverified_values,
20
+ )
21
+ from ..conversation import (
22
+ AUTO_COMPACT_PCT, COMPACT_SYSTEM, SESSIONS_DIR, conversation_transcript, describe_when, list_sessions,
23
+ new_session_id, parse_tool_result,
24
+ )
25
+ from ..fichiers import change_summary, resolve_path, search_desc, show_path
26
+ from ..ollama import OLLAMA, http_error_text
27
+ from ..recherche import OUTIL as WEB_TOOL
28
+ from ..shell import SHELL_TAG
29
+ from ..theme import DIM, GREEN, RED, YELLOW
30
+ from ..widgets import AgentView, AssistantMessage, SearchView, ToolCallView
31
+
32
+
33
+ class Historique:
34
+ """Partie de PrometheeApp : fin d'un tour, compactage et sessions."""
35
+
36
+ # ------------------------------------------------------------------ fin d'un tour
37
+ def finish_agent(self, msgs: list[dict], final: dict | None, error: str | None,
38
+ interrupted: bool, denied: bool, produced: bool) -> None:
39
+ self.generating = False
40
+ self._spinner(False)
41
+ self._turn_model = None
42
+ self.update_status()
43
+ if self._perm is not None:
44
+ self.hide_permission()
45
+ stopped, self._policy_stop = self._policy_stop, False
46
+ if error and not produced and msgs and msgs[-1]["role"] == "user":
47
+ msgs = msgs[:-1] # on retire la question restée sans réponse
48
+ self.messages = msgs
49
+ for m in reversed(msgs):
50
+ if m.get("direct"):
51
+ self.last_answer = m["content"].split("\n", 1)[-1] # sans « Rapport de l'agent … : »
52
+ break
53
+ if m["role"] == "assistant" and m.get("content", "").strip():
54
+ self.last_answer = m["content"]
55
+ break
56
+ if interrupted:
57
+ self.add_notice("Interrompu", DIM)
58
+ elif denied and stopped:
59
+ self.add_notice("Arrêt : la politique a refusé deux commandes dans cette réponse. /politique pour voir les "
60
+ "règles, ou reformule ta demande.", YELLOW)
61
+ elif denied:
62
+ self.add_notice("Refusé. Dis à Prométhée quoi faire autrement.", YELLOW)
63
+ elif error:
64
+ self.add_notice(f"Erreur : {error}", f"bold {RED}")
65
+ elif not produced:
66
+ self.add_notice("(réponse vide)")
67
+ if final:
68
+ reported = (final.get("prompt_eval_count") or 0) + (final.get("eval_count") or 0)
69
+ self.ctx_used = max(reported, self.estimate_tokens())
70
+ if final.get("eval_duration") and final.get("eval_count"):
71
+ self.last_tps = final["eval_count"] / (final["eval_duration"] / 1e9)
72
+ else:
73
+ self.ctx_used = max(self.ctx_used, self.estimate_tokens())
74
+ if self.tasks and produced and not interrupted and not error:
75
+ if self.task_kind == "audit" and REPORT_RE.search(self.last_answer):
76
+ for t in self.tasks:
77
+ if t["pattern"] is None:
78
+ t["state"] = "done" # « Rapport final »
79
+ elif self.task_kind == "plan" and self.plan_steps_left() == 0 and self.last_answer.strip():
80
+ for t in self.tasks:
81
+ t["state"] = "done" # il ne restait que le bilan, et la réponse finale le donne
82
+ self.render_tasks()
83
+ self.update_status()
84
+ self.save_session()
85
+ self.call_after_refresh(self._follow)
86
+ offer = self._run_plan and produced and not (interrupted or error or denied) and self.plan_mode
87
+ # Un plan vient d'être proposé : on te demande d'abord si tu l'exécutes. Le compactage automatique attend
88
+ # la réponse suivante, sinon les deux se lançaient ensemble sur la même conversation.
89
+ if not interrupted and not error and not offer:
90
+ self._maybe_auto_compact()
91
+ if offer:
92
+ self.call_after_refresh(self.offer_plan)
93
+
94
+ def _maybe_auto_compact(self) -> None:
95
+ """Comme Claude Code : quand le contexte est presque plein, la conversation est résumée automatiquement."""
96
+ pct = self.context_pct(self.ctx_used)
97
+ if pct is not None and pct >= AUTO_COMPACT_PCT and len(self.messages) >= 3:
98
+ self.add_notice(f"Contexte rempli à {pct} % : compactage automatique de la conversation…", YELLOW)
99
+ self.call_after_refresh(self.start_compact, "", True)
100
+
101
+ def context_pct(self, tokens: int) -> int | None:
102
+ return min(100, round(100 * tokens / self.num_ctx)) if self.num_ctx else None
103
+
104
+ # ------------------------------------------------------------------ compactage
105
+ def start_compact(self, instructions: str = "", auto: bool = False) -> None:
106
+ if self.generating:
107
+ self.add_notice("Attends la fin de la réponse avant de compacter (ou Esc pour l'interrompre).", YELLOW)
108
+ return
109
+ if not self.messages:
110
+ self.add_notice("Rien à compacter : la conversation est vide." if not self.summary
111
+ else "Rien de nouveau à compacter depuis le dernier résumé.", YELLOW)
112
+ return
113
+ self.generating = True
114
+ self.cancel_event.clear()
115
+ self._spinner(True, "check", "Compactage de la conversation")
116
+ before = max(self.ctx_used, self.estimate_tokens())
117
+ self.compact_conversation(list(self.messages), self.summary, instructions, auto, before)
118
+
119
+ @work(thread=True, exclusive=True, group="generate")
120
+ def compact_conversation(self, messages: list[dict], summary: str, instructions: str, auto: bool, before: int) -> None:
121
+ transcript = conversation_transcript(messages, summary)
122
+ system = COMPACT_SYSTEM + (f"\nConsigne de l'utilisateur pour ce résumé : {instructions}" if instructions else "")
123
+ payload: dict = {"model": self.model, "stream": True, "options": {"temperature": 0.2, "num_predict": 700},
124
+ "messages": [{"role": "system", "content": system},
125
+ {"role": "user", "content": f"Conversation à résumer :\n\n{transcript}"}]}
126
+ if self.supports_thinking:
127
+ payload["think"] = False
128
+ text, error = "", None
129
+ try:
130
+ text = self._summarize(payload)
131
+ except urllib.error.HTTPError as err:
132
+ error = http_error_text(err)
133
+ except Exception as err:
134
+ if not self.cancel_event.is_set():
135
+ error = f"Impossible de joindre Ollama sur {OLLAMA} ({err})."
136
+ self.call_from_thread(self.finish_compact, messages, text, error, auto, transcript, before)
137
+
138
+ def _summarize(self, payload: dict) -> str:
139
+ req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(payload).encode("utf-8"),
140
+ headers={"Content-Type": "application/json"})
141
+ parts: list[str] = []
142
+ with urllib.request.urlopen(req, timeout=600) as resp:
143
+ self._resp = resp
144
+ try:
145
+ for raw in resp:
146
+ if self.cancel_event.is_set():
147
+ break
148
+ if not raw.strip():
149
+ continue
150
+ chunk = json.loads(raw)
151
+ if "error" in chunk:
152
+ raise RuntimeError(chunk["error"])
153
+ piece = (chunk.get("message") or {}).get("content") or ""
154
+ if piece:
155
+ parts.append(piece)
156
+ if chunk.get("done"):
157
+ break
158
+ finally:
159
+ self._resp = None
160
+ return re.sub(r"<think>.*?</think>", "", "".join(parts), flags=re.S).strip()
161
+
162
+ def finish_compact(self, messages: list[dict], text: str, error: str | None, auto: bool,
163
+ transcript: str, before: int) -> None:
164
+ self.generating = False
165
+ self._spinner(False)
166
+ if self.cancel_event.is_set():
167
+ self.add_notice("Compactage annulé : la conversation est intacte.", DIM)
168
+ return
169
+ if error or not text:
170
+ self.add_notice(f"Compactage impossible : {error or 'résumé vide'}. La conversation est intacte.", f"bold {RED}")
171
+ return
172
+ self.summary = text
173
+ self.messages = self.messages[len(messages):] # (rien de neuf normalement : la saisie est bloquée)
174
+ after = self.estimate_tokens()
175
+ self.ctx_used = after
176
+ pb, pa = self.context_pct(before), self.context_pct(after)
177
+ sizes = f"contexte {pb} % → {pa} %" if pb is not None else f"≈{before} → {after} jetons"
178
+ self.add_notice(f"Conversation compactée{' automatiquement' if auto else ''} ({sizes}). "
179
+ "Prométhée continue avec ce résumé :", GREEN)
180
+ self.add_notice_md(text)
181
+ missing = unverified_values(text, transcript)
182
+ if missing:
183
+ self.add_notice("À vérifier : ces valeurs du résumé n'apparaissent pas dans la conversation : "
184
+ + ", ".join(missing[:8]), YELLOW)
185
+ self.update_status()
186
+ self.save_session()
187
+ self.call_after_refresh(self._follow)
188
+
189
+ # ------------------------------------------------------------------ sessions (reprise)
190
+ def save_session(self) -> None:
191
+ """Enregistre la conversation après chaque réponse (Documents\\Promethee\\sessions), pour /reprendre."""
192
+ if not self.messages and not self.summary:
193
+ return
194
+ data = {"version": 1, "id": self.session_id, "model": self.model, "cwd": str(self.cwd),
195
+ "created": self.session_created, "updated": dt.datetime.now().isoformat(timespec="seconds"),
196
+ "title": self.session_title, "summary": self.summary, "messages": self.messages}
197
+ try:
198
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
199
+ path = SESSIONS_DIR / f"{self.session_id}.json"
200
+ tmp = path.with_suffix(".tmp")
201
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
202
+ os.replace(tmp, path)
203
+ except (OSError, TypeError, ValueError) as err:
204
+ self.add_notice(f"Sauvegarde automatique impossible : {err}", YELLOW)
205
+
206
+ def cmd_resume(self, arg: str = "") -> None:
207
+ if self.generating:
208
+ self.add_notice("Attends la fin de la réponse avant de changer de conversation.", YELLOW)
209
+ return
210
+ sessions = [s for s in list_sessions(9) if s.get("id") != self.session_id]
211
+ if not sessions:
212
+ self.add_notice("Aucune autre conversation enregistrée à reprendre.", YELLOW)
213
+ return
214
+ options = []
215
+ for s in sessions:
216
+ n = sum(1 for m in s.get("messages") or [] if m.get("role") == "user")
217
+ title = (s.get("title") or "(sans titre)")[:60]
218
+ label = f"{describe_when(s.get('updated', ''))} · {title}"
219
+ if n:
220
+ label += f" · {n} message{'s' if n > 1 else ''}"
221
+ if s.get("summary"):
222
+ label += " · compactée"
223
+ if s.get("model") and s.get("model") != self.model:
224
+ label += f" · {s['model']}"
225
+ options.append((s.get("id", ""), label))
226
+ options.append(("no", "Annuler (Esc)"))
227
+ by_id = {s.get("id"): s for s in sessions}
228
+
229
+ def chosen(decision: str) -> None:
230
+ if decision in by_id:
231
+ self.call_later(self.load_session, by_id[decision])
232
+
233
+ self.ask("Reprendre une conversation", None, "Quelle conversation reprendre ?", options, chosen, enter="reprendre")
234
+
235
+ async def load_session(self, data: dict) -> None:
236
+ if self.generating:
237
+ self.add_notice("Attends la fin de la réponse avant de changer de conversation.", YELLOW)
238
+ return
239
+ self.messages = [m for m in data.get("messages") or [] if isinstance(m, dict) and m.get("role")]
240
+ self.summary = data.get("summary") or ""
241
+ self.session_id = data.get("id") or new_session_id()
242
+ self.session_created = data.get("created") or dt.datetime.now().isoformat(timespec="seconds")
243
+ self.session_title = data.get("title") or ""
244
+ self.allow_edits = False
245
+ self.last_output = ""
246
+ self.tasks = []
247
+ self.render_tasks()
248
+ self.last_answer = next((m["content"] for m in reversed(self.messages)
249
+ if m["role"] == "assistant" and (m.get("content") or "").strip()), "")
250
+ cwd = Path(data.get("cwd") or "")
251
+ if data.get("cwd") and cwd.is_dir():
252
+ self.cwd = cwd
253
+ await self.chat.remove_children()
254
+ await self.chat.mount_all(list(self._welcome_widgets()))
255
+ if self.summary:
256
+ await self.chat.mount(Static(Text.assemble(("⎿ ", DIM), ("Début de la conversation compacté. Résumé :", DIM)),
257
+ classes="notice"))
258
+ await self.chat.mount(Markdown(self.summary, classes="notice-md"))
259
+ await self.chat.mount_all(self._replay_widgets())
260
+ when = describe_when(data.get("updated", ""))
261
+ self.add_notice(f"Conversation reprise ({when}) : la suite sera enregistrée avec elle.", GREEN)
262
+ if data.get("model") and data["model"] != self.model:
263
+ self.add_notice(f"Elle avait été tenue avec le modèle {data['model']} (tu utilises {self.model} ; "
264
+ f"/modele {data['model']} pour changer).", YELLOW)
265
+ self.ctx_used = self.estimate_tokens()
266
+ self.update_status()
267
+ self.chat.scroll_end(animate=False)
268
+ self.call_after_refresh(self._follow)
269
+
270
+ def _replay_widgets(self) -> list[Widget]:
271
+ """Réaffiche une conversation enregistrée : messages, commandes et leurs résultats."""
272
+ widgets: list[Widget] = []
273
+ waiting: list[tuple[str, ToolCallView]] = [] # appels d'outils en attente de leur résultat
274
+ for m in self.messages:
275
+ role, content = m.get("role"), m.get("content") or ""
276
+ if role != "tool":
277
+ waiting.clear() # les appels restés sans résultat gardent « Non exécutée »
278
+ if role == "user":
279
+ widgets.append(self._replay_shell(content) if content.startswith(SHELL_TAG)
280
+ else self.user_widget(m.get("display") or content))
281
+ elif role == "assistant":
282
+ if content.strip():
283
+ widgets.append(AssistantMessage(content))
284
+ for call in m.get("tool_calls") or []:
285
+ fn = call.get("function") or {}
286
+ name = fn.get("name") or "?"
287
+ args = fn.get("arguments") or {}
288
+ if not isinstance(args, dict):
289
+ args = {"commande": str(args)}
290
+ if name == AGENT_TOOL:
291
+ view = AgentView(str(args.get("agent") or "?"), str(args.get("tache") or ""))
292
+ view._initial = lambda v: v.set_done(-1, 0, 0, "Non terminé", DIM)
293
+ waiting.append((name, view))
294
+ widgets.append(view)
295
+ continue
296
+ if name == WEB_TOOL:
297
+ # Recherche web : requête, sources et lieu viennent du message lui-même (clé locale « search »)
298
+ search = m.get("search") if isinstance(m.get("search"), dict) else None
299
+ view = SearchView(str(args.get("requete") or ""), search, replay=True)
300
+ waiting.append((name, view))
301
+ widgets.append(view)
302
+ continue
303
+ if name in SEARCH_TOOL_NAMES:
304
+ view = ToolCallView(search_desc(name, args, str(args.get("dossier") or "")), "Chercher", "")
305
+ elif name in FILE_TOOL_NAMES:
306
+ label = {READ_TOOL: "Lire", WRITE_TOOL: "Écrire", EDIT_TOOL: "Modifier"}[name]
307
+ shown = show_path(resolve_path(str(args.get("chemin") or "?"), self.cwd), self.cwd)
308
+ view = ToolCallView(shown, label, "")
309
+ else:
310
+ view = ToolCallView(str(args.get("commande") or ""), "PowerShell", "")
311
+ view._initial = lambda v: v.set_skipped("Non exécutée")
312
+ waiting.append((name, view))
313
+ widgets.append(view)
314
+ elif role == "tool" and waiting:
315
+ name, view = waiting.pop(0)
316
+ if name == WEB_TOOL:
317
+ continue # son résultat est déjà affiché par la vue de la recherche
318
+ view._initial = self._replayed_result(name, content)
319
+ report = content.split("\n", 1)[-1]
320
+ if m.get("direct") and not report.startswith(("L'utilisateur a refusé", "(Agent interrompu")):
321
+ widgets.append(AssistantMessage(report)) # rapport d'un agent lancé avec @agent-nom
322
+ return widgets
323
+
324
+ @staticmethod
325
+ def _replayed_result(name: str, content: str):
326
+ if name == AGENT_TOOL:
327
+ report = content.split("\n", 1)[-1]
328
+ if report.startswith("L'utilisateur a refusé"):
329
+ return lambda v: v.set_done(-1, 0, 0, "Arrêté : tu as refusé une de ses actions", YELLOW)
330
+ if report.startswith("(Agent interrompu"):
331
+ return lambda v: v.set_done(-1, 0, 0, "Interrompu", DIM)
332
+ return lambda v: v.set_done(-1, 0, 0)
333
+ first = content.split("\n", 1)[0]
334
+ if name in SEARCH_TOOL_NAMES:
335
+ found = re.search(r"\((\d+)", first)
336
+ if content.startswith(("Fichiers ", "Lignes ")) and found:
337
+ what = "fichier" if content.startswith("Fichiers ") else "ligne"
338
+ n = int(found.group(1))
339
+ return lambda v: v.set_text(f"{n} {what}{'s' if n > 1 else ''} trouvé{'e' if what == 'ligne' else ''}"
340
+ f"{'s' if n > 1 else ''}")
341
+ return lambda v: v.set_text(first[:140], None, DIM)
342
+ if content.startswith("L'utilisateur a refusé"):
343
+ return lambda v: v.set_denied()
344
+ if content.startswith("Non exécutée : la politique"):
345
+ reason = re.search(r"Raisons : (.*?)(?: L'utilisateur peut| Ne contourne pas|$)", content, re.S)
346
+ return lambda v: v.set_refused(reason.group(1).strip() if reason else "")
347
+ if name in FILE_TOOL_NAMES:
348
+ if content.startswith("Contenu de "):
349
+ n = re.search(r"\((\d+) lignes\)", first)
350
+ return lambda v: v.set_text(f"Lu ({n.group(1)} lignes)" if n else "Lu")
351
+ if content.startswith("Fichier "):
352
+ n = re.search(r"\(\+(\d+) −(\d+) lignes\)", first)
353
+ summary = (change_summary(int(n.group(1)), int(n.group(2)), content.startswith("Fichier créé"))
354
+ if n else first.rstrip("."))
355
+ return lambda v: v.set_text(summary)
356
+ if content.startswith("Aucun changement"):
357
+ return lambda v: v.set_text("Aucun changement", None, DIM)
358
+ return lambda v: v.set_skipped(first[:140] + ("…" if len(first) > 140 else ""))
359
+ if content.startswith(("Code de sortie", "Commande non terminée")):
360
+ code, body = parse_tool_result(content)
361
+ note = re.search(r"\((.+)\)$", first)
362
+ body = "" if body.strip() == "(aucune sortie)" else body
363
+ return lambda v: v.set_result(code, body, note.group(1) if note and code is None else "")
364
+ return lambda v: v.set_skipped(first[:200])
365
+
366
+ def _replay_shell(self, content: str) -> Widget:
367
+ match = re.match(re.escape(SHELL_TAG) + r" `(?P<cmd>.*?)`\n(?:Code de sortie : (?P<code>-?\d+)|Commande non terminée)"
368
+ r"(?: \((?P<note>[^\n]*)\))?\n(?P<out>.*)$", content, re.S)
369
+ if not match:
370
+ return self.user_widget(content)
371
+ code = int(match["code"]) if match["code"] is not None else None
372
+ out = "" if match["out"].strip() == "(aucune sortie)" else match["out"]
373
+ return ToolCallView(match["cmd"], "!", "", initial=lambda v: v.set_result(code, out, match["note"] or ""))
@@ -0,0 +1,108 @@
1
+ """Chargement, liste et changement de modèle."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import urllib.error
6
+
7
+ from textual import work
8
+
9
+ from ..ollama import OLLAMA, api, context_from_env, http_error_text
10
+ from ..theme import RED
11
+
12
+
13
+ class Modele:
14
+ """Partie de PrometheeApp : chargement, liste et changement de modèle."""
15
+
16
+ # ------------------------------------------------------------------ modèle
17
+ def _set_model_state(self, model: str, state: str) -> None:
18
+ """État affiché du modèle. Ignoré si tu as changé de modèle entre-temps (deux /modele rapprochés)."""
19
+ if model == self.model:
20
+ self.model_state = state
21
+ self.update_status()
22
+
23
+ def read_model(self, model: str) -> tuple[str, str]:
24
+ """Lit les caractéristiques du modèle auprès d'Ollama (fil de travail).
25
+ Renvoie ("", "") si tout va bien, sinon (état à afficher, message d'erreur)."""
26
+ try:
27
+ info = api("/api/show", {"model": model}, timeout=15)
28
+ except urllib.error.HTTPError as err:
29
+ return "introuvable", f"Modèle « {model} » : {http_error_text(err)}. Tape /modele pour voir la liste."
30
+ except Exception as err:
31
+ return "Ollama injoignable", (f"Ollama ne répond pas sur {OLLAMA} ({err}). Lance l'application Ollama : "
32
+ "Prométhée réessaiera à ton prochain message.")
33
+ if model != self.model:
34
+ return "", ""
35
+ match = re.search(r"num_ctx\s+(\d+)", info.get("parameters") or "")
36
+ self.num_ctx = int(match.group(1)) if match else context_from_env()
37
+ details = info.get("details") or {}
38
+ self.model_desc = " ".join(x for x in (details.get("parameter_size"), details.get("quantization_level")) if x)
39
+ self.model_system = info.get("system") or ""
40
+ self.supports_tools = "tools" in (info.get("capabilities") or [])
41
+ self.supports_thinking = "thinking" in (info.get("capabilities") or [])
42
+ self._caps.pop(model, None)
43
+ return "", ""
44
+
45
+ @work(thread=True, exclusive=True, group="model")
46
+ def load_model(self) -> None:
47
+ model = self.model
48
+ state, problem = self.read_model(model)
49
+ if problem:
50
+ self.call_from_thread(self._set_model_state, model, state)
51
+ if model == self.model:
52
+ self.call_from_thread(self.add_notice, problem, f"bold {RED}")
53
+ return
54
+ self.call_from_thread(self._set_model_state, model, "chargement…")
55
+ try:
56
+ api("/api/chat", {"model": model, "messages": []}, timeout=300) # précharge en mémoire GPU
57
+ self._loaded_model = model
58
+ self.call_from_thread(self._set_model_state, model, "")
59
+ self._check_gpu(model)
60
+ except Exception as err:
61
+ self.call_from_thread(self._set_model_state, model, "erreur de chargement")
62
+ if model == self.model:
63
+ self.call_from_thread(self.add_notice, f"Chargement impossible : {err}", f"bold {RED}")
64
+
65
+ @work(thread=True, exclusive=True, group="model")
66
+ def list_models(self, switch_to: str | None) -> None:
67
+ try:
68
+ models = sorted((m for m in api("/api/tags", timeout=10).get("models", []) if m.get("name")),
69
+ key=lambda m: m["name"])
70
+ except Exception as err:
71
+ self.call_from_thread(self.add_notice, f"Ollama ne répond pas sur {OLLAMA} ({err}).", f"bold {RED}")
72
+ return
73
+ names = [m["name"] for m in models]
74
+ short = [n[:-7] if n.endswith(":latest") else n for n in names]
75
+ if switch_to:
76
+ wanted = switch_to[:-7] if switch_to.endswith(":latest") else switch_to
77
+ if wanted not in short:
78
+ self.call_from_thread(self.add_notice, f"Modèle « {switch_to} » introuvable. Tape /modele pour voir la liste.", f"bold {RED}")
79
+ return
80
+ self.call_from_thread(self._switch_model, wanted)
81
+ return
82
+ try:
83
+ loaded = {m.get("name") for m in api("/api/ps", timeout=6).get("models") or []}
84
+ except Exception:
85
+ loaded = set() # la liste reste utile sans savoir lesquels sont en mémoire
86
+
87
+ def detail(m: dict) -> str:
88
+ d = m.get("details") or {}
89
+ parts = [x for x in (d.get("parameter_size"), d.get("quantization_level")) if x]
90
+ if m.get("size"):
91
+ parts.append(f"{m['size'] / 1e9:.1f} Go".replace(".", ","))
92
+ if m["name"] in loaded:
93
+ parts.append("en mémoire")
94
+ return " · " + " · ".join(parts) if parts else ""
95
+
96
+ lines = "\n".join(f"- `{n}`{detail(m)}" + (" ← actuel" if n == self.model else "")
97
+ + (" ← IA de code" if n == self.code_model and n != self.model else "")
98
+ for n, m in zip(short, models))
99
+ self.call_from_thread(self.add_notice_md, f"**Modèles installés** (change avec `/modele <nom>`)\n\n{lines}")
100
+
101
+ def _switch_model(self, name: str) -> None:
102
+ self.model = name
103
+ self.num_ctx = None
104
+ self.last_tps = None
105
+ self.model_state = "chargement…"
106
+ self.update_status()
107
+ self.add_notice(f"Modèle changé : {name} (la conversation continue, /clear pour repartir de zéro)")
108
+ self.load_model()
@@ -0,0 +1,63 @@
1
+ """Mémoire « # » : enregistrer, afficher, ouvrir."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+
7
+ from rich.text import Text
8
+
9
+ from ..config import CREATE_NO_WINDOW
10
+ from ..fichiers import HOME, new_file_format, show_path, write_text_file
11
+ from ..memoire import GLOBAL_MEMORY, MEMORY_HEADER, MEMORY_NAME, add_memory_note, read_memory
12
+ from ..theme import DIM, GREEN, MEMORY_COLOR, RED
13
+
14
+
15
+ class Notes:
16
+ """Partie de PrometheeApp : mémoire « # » : enregistrer, afficher, ouvrir."""
17
+
18
+ # ------------------------------------------------------------------ mémoire « # »
19
+ def ask_memory_target(self, note: str) -> None:
20
+ local = self.cwd / MEMORY_NAME
21
+ targets = {"global": ("globale", GLOBAL_MEMORY)}
22
+ options = [("global", "Mémoire globale : pour toutes les conversations")]
23
+ body = Text.assemble((f" {note}\n\n", "bold"), (f" globale : {GLOBAL_MEMORY}", DIM))
24
+ if os.path.normcase(str(local)) != os.path.normcase(str(GLOBAL_MEMORY)):
25
+ targets["local"] = ("du dossier", local)
26
+ options.append(("local", f"Mémoire de ce dossier : seulement quand tu travailles dans {self.cwd.name or self.cwd}"))
27
+ body.append(f"\n dossier : {local}", style=DIM)
28
+ options.append(("no", "Annuler (Esc)"))
29
+
30
+ def chosen(decision: str) -> None:
31
+ if decision not in targets:
32
+ return
33
+ label, path = targets[decision]
34
+ try:
35
+ add_memory_note(path, note)
36
+ except (OSError, ValueError) as err:
37
+ self.add_notice(f"Impossible d'écrire dans {path} : {err}", f"bold {RED}")
38
+ return
39
+ self.add_notice(f"# {note} → noté dans la mémoire {label} ({show_path(path, HOME)})", MEMORY_COLOR)
40
+ self.ctx_used = max(self.ctx_used, self.estimate_tokens())
41
+ self.update_status()
42
+
43
+ self.ask("Ajouter à la mémoire", body, "Où enregistrer cette information ?", options, chosen)
44
+
45
+ def show_memory(self) -> None:
46
+ memory = read_memory(self.cwd)
47
+ if not memory:
48
+ self.add_notice(f"Mémoire vide. Ajoute une information en commençant ton message par # (par exemple "
49
+ f"« # je suis étudiant en BTS SIO »). Fichier : {GLOBAL_MEMORY}")
50
+ return
51
+ parts = [f"**Mémoire {label}** (`{path}`)\n\n{text}" for label, path, text in memory]
52
+ self.add_notice_md("\n\n".join(parts) + "\n\n*`/memoire ouvrir` pour la modifier · `# …` pour ajouter une ligne*")
53
+
54
+ def open_memory(self) -> None:
55
+ try:
56
+ if not GLOBAL_MEMORY.is_file():
57
+ write_text_file(GLOBAL_MEMORY, MEMORY_HEADER, *new_file_format(GLOBAL_MEMORY))
58
+ subprocess.Popen(["notepad.exe", str(GLOBAL_MEMORY)], creationflags=CREATE_NO_WINDOW)
59
+ except OSError as err:
60
+ self.add_notice(f"Impossible d'ouvrir {GLOBAL_MEMORY} : {err}", f"bold {RED}")
61
+ return
62
+ self.add_notice(f"Mémoire ouverte dans le Bloc-notes ({GLOBAL_MEMORY}). Les changements comptent dès ton "
63
+ "prochain message.", GREEN)