@nosserb/prmt 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/prmt.mjs +174 -0
- package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
- package/dist/assets/Audits-yUBm0EpW.js +1 -0
- package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
- package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
- package/dist/assets/Carte-CPuiR1Ha.css +1 -0
- package/dist/assets/Carte-xycu1mQn.js +1 -0
- package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
- package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
- package/dist/assets/Field-C7c2mCbb.js +2 -0
- package/dist/assets/Journal-BLIIlh80.css +1 -0
- package/dist/assets/Journal-BaelFjwV.js +1 -0
- package/dist/assets/Landing-B2jxV3zs.css +1 -0
- package/dist/assets/Landing-Dr612Zdu.js +26 -0
- package/dist/assets/Register-DA_Aq5f8.js +1 -0
- package/dist/assets/Reports-8wfDNSxH.js +1 -0
- package/dist/assets/Reports-B8GXN95N.css +1 -0
- package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
- package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
- package/dist/assets/SettingsPage-BElIP46c.css +1 -0
- package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
- package/dist/assets/Shell-BAyHVljp.css +1 -0
- package/dist/assets/Shell-CQRlx9-V.js +2 -0
- package/dist/assets/Table-DUmGel5e.js +1 -0
- package/dist/assets/Tabs-CfhbELZk.js +1 -0
- package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
- package/dist/assets/Terminal-CEfj38NV.css +1 -0
- package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
- package/dist/assets/auditViews-BMza2Sdz.js +1 -0
- package/dist/assets/auditViews-BePWj26h.css +1 -0
- package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
- package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
- package/dist/assets/charts-BcijSJ4J.js +1 -0
- package/dist/assets/circle-stop-C33BAcKW.js +1 -0
- package/dist/assets/command-ChA49Xk7.js +1 -0
- package/dist/assets/command-Ck4pdQCv.css +1 -0
- package/dist/assets/download-CeuKOenD.js +1 -0
- package/dist/assets/eye-CXXHBBpr.js +1 -0
- package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/assets/habillage-B5mMBadn.js +1 -0
- package/dist/assets/index-BH1etemR.css +1 -0
- package/dist/assets/index-COSiM-R9.js +14 -0
- package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
- package/dist/assets/layers-BWBAp2CZ.png +0 -0
- package/dist/assets/markdown-DF87LQh1.css +1 -0
- package/dist/assets/markdown-o5sMhPHN.js +4 -0
- package/dist/assets/marker-icon-hN30_KVU.png +0 -0
- package/dist/assets/play-Daw4K_73.js +1 -0
- package/dist/assets/plus-D_QHm8ud.js +1 -0
- package/dist/assets/trash-DLpHM-s8.js +1 -0
- package/dist/assets/web-6j1-aY67.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +18 -0
- package/docs/ANALYSE.md +87 -0
- package/docs/SECURITE.md +188 -0
- package/lib/scripts/lier.js +15 -0
- package/lib/scripts/searxng.js +91 -0
- package/lib/server/app.js +444 -0
- package/lib/server/audits/modules/local.js +429 -0
- package/lib/server/audits/modules/network.js +331 -0
- package/lib/server/audits/modules/web.js +302 -0
- package/lib/server/audits/net.js +141 -0
- package/lib/server/audits/registry.js +202 -0
- package/lib/server/audits/types.js +58 -0
- package/lib/server/brain/brain.js +624 -0
- package/lib/server/brain/conversations.js +82 -0
- package/lib/server/brain/ollama.js +197 -0
- package/lib/server/brain/prompt.js +188 -0
- package/lib/server/brain/provider.js +1 -0
- package/lib/server/cli/bridge.js +292 -0
- package/lib/server/cli/pont.js +62 -0
- package/lib/server/config.js +68 -0
- package/lib/server/engine/analyzer.js +205 -0
- package/lib/server/engine/catalog.js +229 -0
- package/lib/server/engine/decoder.js +91 -0
- package/lib/server/engine/engine.js +194 -0
- package/lib/server/engine/paths.js +129 -0
- package/lib/server/engine/policy.js +397 -0
- package/lib/server/engine/relax.js +10 -0
- package/lib/server/engine/runner.js +193 -0
- package/lib/server/errors.js +23 -0
- package/lib/server/http/guard.js +75 -0
- package/lib/server/http/router.js +154 -0
- package/lib/server/http/static.js +76 -0
- package/lib/server/index.js +40 -0
- package/lib/server/journal.js +139 -0
- package/lib/server/lien.js +33 -0
- package/lib/server/savoir/base.js +284 -0
- package/lib/server/savoir/lecture.js +406 -0
- package/lib/server/savoir/memoire.js +115 -0
- package/lib/server/search/categories.js +179 -0
- package/lib/server/search/fiche.js +326 -0
- package/lib/server/search/lieux.js +134 -0
- package/lib/server/search/overpass.js +216 -0
- package/lib/server/search/searxng.js +223 -0
- package/lib/server/search/tiles.js +103 -0
- package/lib/server/status.js +157 -0
- package/lib/server/stores.js +141 -0
- package/lib/server/telephone/acces.js +457 -0
- package/lib/server/telephone/certificat.js +79 -0
- package/lib/server/telephone/qr.js +308 -0
- package/lib/shared/analysis.js +40 -0
- package/lib/shared/schemas.js +105 -0
- package/lib/shared/types.js +10 -0
- package/package.json +81 -4
- package/searxng/settings.yml +35 -0
- package/tui/Modelfile +23 -0
- package/tui/README.md +103 -0
- package/tui/prmt_tui.py +65 -0
- package/tui/promethee/__init__.py +1 -0
- package/tui/promethee/__main__.py +4 -0
- package/tui/promethee/accueil.py +66 -0
- package/tui/promethee/agents.py +220 -0
- package/tui/promethee/app/__init__.py +4 -0
- package/tui/promethee/app/application.py +299 -0
- package/tui/promethee/app/clavier.py +99 -0
- package/tui/promethee/app/commandes_slash.py +164 -0
- package/tui/promethee/app/contexte.py +188 -0
- package/tui/promethee/app/definitions.py +518 -0
- package/tui/promethee/app/ecran.py +177 -0
- package/tui/promethee/app/envoi.py +141 -0
- package/tui/promethee/app/historique.py +373 -0
- package/tui/promethee/app/modele.py +108 -0
- package/tui/promethee/app/notes.py +63 -0
- package/tui/promethee/app/outils.py +589 -0
- package/tui/promethee/app/plateforme.py +409 -0
- package/tui/promethee/app/saisie.py +100 -0
- package/tui/promethee/app/taches.py +92 -0
- package/tui/promethee/app/tour.py +539 -0
- package/tui/promethee/app/validation.py +210 -0
- package/tui/promethee/appels.py +35 -0
- package/tui/promethee/commandes.py +154 -0
- package/tui/promethee/completion.py +140 -0
- package/tui/promethee/config.py +12 -0
- package/tui/promethee/consignes.py +278 -0
- package/tui/promethee/conversation.py +94 -0
- package/tui/promethee/etat.py +253 -0
- package/tui/promethee/fichiers.py +354 -0
- package/tui/promethee/journal.py +116 -0
- package/tui/promethee/logo.py +132 -0
- package/tui/promethee/memoire.py +50 -0
- package/tui/promethee/ollama.py +52 -0
- package/tui/promethee/plan.py +91 -0
- package/tui/promethee/politique.py +429 -0
- package/tui/promethee/preferences.py +65 -0
- package/tui/promethee/presse_papiers.py +44 -0
- package/tui/promethee/recherche.py +581 -0
- package/tui/promethee/securite.py +204 -0
- package/tui/promethee/shell.py +190 -0
- package/tui/promethee/textes.py +190 -0
- package/tui/promethee/theme.py +92 -0
- package/tui/promethee/widgets.py +493 -0
- package/tui/requirements.txt +1 -0
|
@@ -0,0 +1,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
|