@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,354 @@
1
+ """Outils fichiers : lecture, écriture, recherche, modification d'un extrait, diff."""
2
+ from __future__ import annotations
3
+
4
+ import codecs
5
+ import difflib
6
+ import fnmatch
7
+ import os
8
+ import re
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from rich.text import Text
14
+
15
+ from .consignes import SEARCH_FILES_TOOL
16
+ from .shell import ANSI_CP
17
+ from .theme import DIFF_ADD, DIFF_DEL, DIM
18
+
19
+ # --------------------------------------------------------------------------- Fichiers : lecture, écriture, diff
20
+ MAX_FILE_BYTES = 2_000_000
21
+ MAX_READ_LINES = 400
22
+ MAX_READ_CHARS = 16_000
23
+ HOME = Path.home()
24
+
25
+
26
+ def resolve_path(raw: str, cwd: Path) -> Path:
27
+ cleaned = os.path.expandvars(os.path.expanduser(str(raw).strip().strip('"').strip("'")))
28
+ path = Path(cleaned)
29
+ if not path.is_absolute():
30
+ path = cwd / path
31
+ return Path(os.path.normpath(path))
32
+
33
+
34
+ def show_path(path: Path, cwd: Path) -> str:
35
+ try:
36
+ return str(path.relative_to(cwd))
37
+ except ValueError:
38
+ return str(path)
39
+
40
+
41
+ def sensitive_path(path: Path, home: Path = HOME) -> str:
42
+ """Pourquoi ce fichier demande toujours ta validation, même après « oui pour toutes les modifications » ;
43
+ "" s'il n'a rien de particulier. Ce sont des fichiers relus ou exécutés plus tard, sans que tu les rouvres."""
44
+ try:
45
+ parts = [p.lower() for p in path.relative_to(home).parts]
46
+ except ValueError:
47
+ parts = [p.lower() for p in path.parts]
48
+ name = parts[-1] if parts else ""
49
+ if name == "promethee.md":
50
+ return "mémoire de Prométhée, relue à chaque conversation"
51
+ if ".promethee" in parts or parts[:2] == ["documents", "promethee"]:
52
+ return "données de Prométhée (agents, commandes, préférences, journal)"
53
+ if parts[:2] in (["documents", "windowspowershell"], ["documents", "powershell"]):
54
+ return "profil PowerShell, exécuté à chaque ouverture d'un terminal"
55
+ if parts[:1] == ["appdata"]:
56
+ return "dossier AppData (démarrage automatique, réglages des logiciels)"
57
+ if any(p.startswith(".") and p not in (".", "..") for p in parts):
58
+ return "fichier ou dossier caché (.ssh, .git, .env…)"
59
+ return ""
60
+
61
+
62
+ def read_text_file(path: Path) -> tuple[str, str, str]:
63
+ """(texte avec \\n, encodage, fin de ligne d'origine). ValueError si binaire ou trop gros."""
64
+ data = path.read_bytes()
65
+ if len(data) > MAX_FILE_BYTES:
66
+ raise ValueError(f"fichier trop gros ({len(data) // 1024} Ko, maximum {MAX_FILE_BYTES // 1024} Ko)")
67
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
68
+ encoding = "utf-16" # fréquent sous Windows (Out-File de PowerShell 5.1)
69
+ elif b"\x00" in data[:8192]:
70
+ raise ValueError("fichier binaire : ce n'est pas du texte")
71
+ elif data.startswith(codecs.BOM_UTF8):
72
+ encoding = "utf-8-sig"
73
+ else:
74
+ try:
75
+ data.decode("utf-8")
76
+ encoding = "utf-8"
77
+ except UnicodeDecodeError:
78
+ encoding = ANSI_CP
79
+ text = data.decode(encoding, "replace")
80
+ newline = "\r\n" if "\r\n" in text else "\n"
81
+ return text.replace("\r\n", "\n"), encoding, newline
82
+
83
+
84
+ def new_file_format(path: Path) -> tuple[str, str]:
85
+ """Encodage et fin de ligne d'un nouveau fichier selon son type."""
86
+ ext = path.suffix.lower()
87
+ encoding = "utf-8-sig" if ext in (".ps1", ".psm1", ".psd1") else "utf-8" # PowerShell 5.1 veut le BOM
88
+ newline = "\r\n" if ext in (".ps1", ".psm1", ".psd1", ".bat", ".cmd") else "\n"
89
+ return encoding, newline
90
+
91
+
92
+ def write_text_file(path: Path, text: str, encoding: str, newline: str) -> None:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ data = text.replace("\r\n", "\n").replace("\n", newline).encode(encoding)
95
+ tmp = path.with_name(f".{path.name}.prmt-tmp")
96
+ tmp.write_bytes(data)
97
+ os.replace(tmp, path)
98
+
99
+
100
+ SEARCH_SKIP_DIRS = {".git", ".svn", ".hg", "node_modules", "__pycache__", ".venv", "venv", "$recycle.bin",
101
+ "system volume information", "appdata", ".cache", ".npm", ".gradle", ".m2"}
102
+ MAX_SEARCH_ENTRIES = 40_000 # éléments parcourus au maximum
103
+ MAX_SEARCH_SECONDS = 10
104
+ MAX_FOUND_FILES = 200
105
+ MAX_FOUND_LINES = 100
106
+ SEARCH_LINES_TO_MODEL = 25 # au-delà, le modèle reçoit le compte par fichier et seulement ces premières lignes
107
+ MAX_SEARCH_FILE = 5_000_000 # fichiers plus gros ignorés par chercher_texte
108
+
109
+
110
+ def search_desc(name: str, args: dict, where: str = "") -> str:
111
+ """Libellé affiché : « fichiers *.log dans logs » ou « "password" dans logs (*.txt) »."""
112
+ place = f" dans {where}" if where else ""
113
+ if name == SEARCH_FILES_TOOL:
114
+ return f"fichiers {str(args.get('motif') or '*').strip()}{place}"
115
+ files = str(args.get("fichiers") or "").strip()
116
+ return f"« {str(args.get('texte') or '').strip()} »{place}" + (f" ({files})" if files else "")
117
+
118
+
119
+ def walk_files(root: Path, cancel: threading.Event | None = None):
120
+ """Fichiers sous root (sans les dossiers lourds ou cachés) : génère (entrée, ""), puis (None, raison) si la
121
+ recherche s'arrête avant la fin (trop d'éléments, délai dépassé, interruption)."""
122
+ start, seen = time.monotonic(), 0
123
+ stack = [root]
124
+ def stop() -> bool:
125
+ return time.monotonic() - start > MAX_SEARCH_SECONDS or bool(cancel and cancel.is_set())
126
+
127
+ while stack:
128
+ folder = stack.pop()
129
+ # À chaque dossier, et tous les 200 éléments d'un gros dossier (avant : tous les 500 éléments seulement,
130
+ # donc jamais dans une arborescence de petits dossiers)
131
+ if stop():
132
+ yield None, "recherche interrompue" if cancel and cancel.is_set() else f"recherche arrêtée après {MAX_SEARCH_SECONDS} s"
133
+ return
134
+ try:
135
+ entries = sorted(os.scandir(folder), key=lambda e: e.name.lower())
136
+ except OSError:
137
+ continue
138
+ subdirs = []
139
+ for entry in entries:
140
+ seen += 1
141
+ if seen > MAX_SEARCH_ENTRIES:
142
+ yield None, f"recherche arrêtée après {MAX_SEARCH_ENTRIES} éléments parcourus"
143
+ return
144
+ if seen % 200 == 0 and stop():
145
+ yield None, "recherche interrompue" if cancel and cancel.is_set() else f"recherche arrêtée après {MAX_SEARCH_SECONDS} s"
146
+ return
147
+ try:
148
+ if entry.is_dir(follow_symlinks=False):
149
+ if entry.name.lower() not in SEARCH_SKIP_DIRS and not entry.name.startswith("."):
150
+ subdirs.append(Path(entry.path))
151
+ elif entry.is_file(follow_symlinks=False):
152
+ yield entry, ""
153
+ except OSError:
154
+ continue
155
+ stack.extend(reversed(subdirs))
156
+
157
+
158
+ def name_matcher(pattern: str):
159
+ """« *.log », « rapport*.txt », « nginx » (= contient nginx), « logs\\*.log » (chemin relatif)."""
160
+ pat = pattern.strip().strip("\"'").replace("/", "\\") or "*"
161
+ while pat.startswith("**\\"): # « **/*.conf » : aussi les fichiers du dossier lui-même
162
+ pat = pat[3:]
163
+ if not any(c in pat for c in "*?["):
164
+ pat = f"*{pat}*"
165
+ with_path = "\\" in pat
166
+ pat = pat.lower()
167
+ return lambda rel: fnmatch.fnmatchcase(rel.lower() if with_path else rel.lower().rsplit("\\", 1)[-1], pat)
168
+
169
+
170
+ def search_files(pattern: str, root: Path, cancel: threading.Event | None = None) -> tuple[list[tuple[str, int, float]], str]:
171
+ match = name_matcher(pattern)
172
+ found: list[tuple[str, int, float]] = []
173
+ note = ""
174
+ for entry, stop in walk_files(root, cancel):
175
+ if entry is None:
176
+ note = stop
177
+ break
178
+ rel = show_path(Path(entry.path), root)
179
+ if match(rel):
180
+ try:
181
+ st = entry.stat()
182
+ found.append((rel, st.st_size, st.st_mtime))
183
+ except OSError:
184
+ found.append((rel, 0, 0.0))
185
+ if len(found) >= MAX_FOUND_FILES:
186
+ note = f"seulement les {MAX_FOUND_FILES} premiers"
187
+ break
188
+ return found, note
189
+
190
+
191
+ def search_text(text: str, root: Path, files: str = "", cancel: threading.Event | None = None
192
+ ) -> tuple[list[tuple[str, int, str]], int, str]:
193
+ """Lignes qui contiennent text (expression régulière, sinon texte exact ; sans tenir compte des majuscules)."""
194
+ try:
195
+ regex = re.compile(text, re.I)
196
+ except re.error:
197
+ regex = re.compile(re.escape(text), re.I)
198
+ match = name_matcher(files) if files.strip() else None
199
+ found: list[tuple[str, int, str]] = []
200
+ scanned, note = 0, ""
201
+ for entry, stop in walk_files(root, cancel):
202
+ if entry is None:
203
+ note = stop
204
+ break
205
+ rel = show_path(Path(entry.path), root)
206
+ if match and not match(rel):
207
+ continue
208
+ try:
209
+ if entry.stat().st_size > MAX_SEARCH_FILE:
210
+ continue
211
+ content = read_text_file(Path(entry.path))[0]
212
+ except (OSError, ValueError):
213
+ continue # binaire, illisible ou trop gros
214
+ scanned += 1
215
+ for number, line in enumerate(content.split("\n"), 1):
216
+ if regex.search(line):
217
+ found.append((rel, number, line.strip()[:200]))
218
+ if len(found) >= MAX_FOUND_LINES:
219
+ return found, scanned, f"seulement les {MAX_FOUND_LINES} premières lignes"
220
+ return found, scanned, note
221
+
222
+
223
+ # Mémoire d'un dossier (PROMETHEE.md, /init) : le modèle 8B y cite des fichiers qu'il n'a pas vus
224
+ # (« scripts\init.ps1 », « configurations\settings.json »). Les chemins cités sont vérifiés avant ta validation.
225
+ FILE_EXTS = ("md|txt|ps1|psm1|psd1|py|sh|bat|cmd|vbs|json|ya?ml|xml|ini|conf|cfg|log|csv|pcap|pcapng|cap|html?|js|ts|"
226
+ "css|php|sql|db|sqlite|toml|env|docx?|xlsx?|pptx?|pdf|zip|7z|rar|tar|gz|ova|ovf|vmdk|vdi|iso|kdbx|pem|crt|"
227
+ "key|rules|nse|lua|reg|jar|c|h|cpp|java|go|rs|rb|png|jpe?g|ipynb")
228
+ MENTIONED_FILE = re.compile(rf"(?<![\w.\-\\/:])((?:[\w.\-]+[\\/])*[\w.\-]*\w\.(?:{FILE_EXTS}))(?![\w\\/-])", re.I)
229
+ ABSOLUTE_FILE = re.compile(rf"(?<!\w)([A-Za-z]:[\\/](?:[^\\/:*?\"<>|\r\n`]+[\\/])*[^\\/:*?\"<>|\r\n`\s]*\w\.(?:{FILE_EXTS}))"
230
+ rf"(?![\w\\/-])", re.I)
231
+
232
+
233
+ def missing_mentioned_files(new: str, old: str, folder: Path) -> list[str]:
234
+ """Fichiers cités dans new (et pas déjà dans old) qui n'existent pas : chemins complets, ou relatifs à folder."""
235
+ text = re.sub(r"\w+://\S+", " ", new)
236
+ absolute = [m for m in dict.fromkeys(ABSOLUTE_FILE.findall(text)) if m not in old]
237
+ missing_abs = [m for m in absolute if not Path(m).exists()]
238
+ names = [m for m in dict.fromkeys(MENTIONED_FILE.findall(text))
239
+ if m.lower() != "promethee.md" and m not in old and not any(m in a for a in absolute)]
240
+ if not names:
241
+ return missing_abs
242
+ known: list[str] = []
243
+ for entry, _ in walk_files(folder):
244
+ if entry is None:
245
+ break
246
+ known.append(show_path(Path(entry.path), folder).replace("/", "\\").lower())
247
+ missing = []
248
+ for name in names:
249
+ rel = name.replace("/", "\\").lower().strip("\\")
250
+ if (folder / name).exists() or Path(name).is_absolute() and Path(name).exists():
251
+ continue
252
+ if any(k == rel or k.endswith("\\" + rel) or rel.endswith("\\" + k) for k in known):
253
+ continue # même fichier cité sans son sous-dossier, ou depuis un dossier parent
254
+ missing.append(name)
255
+ return missing_abs + missing
256
+
257
+
258
+ def numbered(text: str, start: int = 1) -> str:
259
+ return "\n".join(f"{start + i:>5}\t{line}" for i, line in enumerate(text.split("\n")))
260
+
261
+
262
+ LINE_NUMBER = re.compile(r"^\s*\d+(?:\t|[|│:] ?)")
263
+
264
+
265
+ def strip_line_numbers(text: str) -> str:
266
+ """Le modèle recopie parfois les numéros de ligne de lire_fichier : on les enlève."""
267
+ lines = text.split("\n")
268
+ if lines and all(LINE_NUMBER.match(l) for l in lines if l.strip()):
269
+ return "\n".join(LINE_NUMBER.sub("", l, count=1) for l in lines)
270
+ return text
271
+
272
+
273
+ def apply_edit(text: str, old: str, new: str) -> tuple[str | None, str]:
274
+ """Remplace l'extrait `old` (unique) par `new`. Renvoie (nouveau texte, erreur)."""
275
+ old = old.replace("\r\n", "\n")
276
+ new = new.replace("\r\n", "\n")
277
+ if not old.strip():
278
+ return None, "ancien_texte est vide : pour créer ou remplacer tout un fichier, utilise ecrire_fichier."
279
+ for candidate in dict.fromkeys((old, strip_line_numbers(old))):
280
+ count = text.count(candidate)
281
+ if count == 1:
282
+ return text.replace(candidate, new, 1), ""
283
+ if count > 1:
284
+ return None, (f"ancien_texte apparaît {count} fois dans le fichier : ajoute des lignes autour "
285
+ "pour qu'il n'apparaisse qu'une fois.")
286
+ # Tolérance : on compare ligne à ligne en ignorant les espaces en fin (puis en début) de ligne
287
+ lines = text.split("\n")
288
+ wanted = strip_line_numbers(old).strip("\n").split("\n")
289
+ for norm in (str.rstrip, str.strip):
290
+ hits = [i for i in range(len(lines) - len(wanted) + 1)
291
+ if all(norm(lines[i + j]) == norm(wanted[j]) for j in range(len(wanted)))]
292
+ if len(hits) == 1:
293
+ i = hits[0]
294
+ return "\n".join(lines[:i] + new.strip("\n").split("\n") + lines[i + len(wanted):]), ""
295
+ if len(hits) > 1:
296
+ return None, (f"ancien_texte apparaît {len(hits)} fois dans le fichier : ajoute des lignes autour "
297
+ "pour qu'il n'apparaisse qu'une fois.")
298
+ return None, ("ancien_texte est introuvable dans le fichier : relis-le avec lire_fichier et copie l'extrait "
299
+ "exact (sans les numéros de ligne).")
300
+
301
+
302
+ def render_diff(old: str | None, new: str, max_lines: int = 30) -> tuple[Text, int, int]:
303
+ """Diff coloré façon Claude Code. Renvoie (texte, lignes ajoutées, lignes supprimées)."""
304
+ out = Text()
305
+ shown = 0
306
+ added = removed = 0
307
+
308
+ def line(num: int, sign: str, content: str, style: str) -> None:
309
+ nonlocal shown
310
+ if shown < max_lines:
311
+ out.append(f"{num:>5} {sign} ", style=DIM if not sign.strip() else style)
312
+ out.append(content[:300] + "\n", style=style if sign.strip() else "")
313
+ shown += 1
314
+
315
+ def split(text: str) -> list[str]:
316
+ lines = text.split("\n")
317
+ return lines[:-1] if len(lines) > 1 and lines[-1] == "" else lines # saut de ligne final ≠ ligne
318
+
319
+ if old is None: # nouveau fichier
320
+ for i, content in enumerate(split(new)):
321
+ added += 1
322
+ line(i + 1, "+", content, DIFF_ADD)
323
+ else:
324
+ a, b = split(old), split(new)
325
+ for gi, group in enumerate(difflib.SequenceMatcher(a=a, b=b, autojunk=False).get_grouped_opcodes(2)):
326
+ if gi and shown < max_lines:
327
+ out.append(" ⋮\n", style=DIM)
328
+ for tag, i1, i2, j1, j2 in group:
329
+ if tag == "equal":
330
+ for k in range(i1, i2):
331
+ line(k + 1, " ", a[k], "")
332
+ if tag in ("replace", "delete"):
333
+ for k in range(i1, i2):
334
+ removed += 1
335
+ line(k + 1, "-", a[k], DIFF_DEL)
336
+ if tag in ("replace", "insert"):
337
+ for k in range(j1, j2):
338
+ added += 1
339
+ line(k + 1, "+", b[k], DIFF_ADD)
340
+ if shown > max_lines:
341
+ out.append(f" … +{shown - max_lines} lignes\n", style=f"italic {DIM}")
342
+ out.rstrip()
343
+ return out, added, removed
344
+
345
+
346
+ def change_summary(added: int, removed: int, new_file: bool) -> str:
347
+ if new_file:
348
+ return f"Fichier créé ({added} ligne{'s' if added > 1 else ''})"
349
+ parts = []
350
+ if added:
351
+ parts.append(f"{added} ajout{'s' if added > 1 else ''}")
352
+ if removed:
353
+ parts.append(f"{removed} suppression{'s' if removed > 1 else ''}")
354
+ return "Fichier modifié : " + (" et ".join(parts) if parts else "aucun changement")
@@ -0,0 +1,116 @@
1
+ """Journal des actions de la TUI : un fichier JSONL en ajout seul, au même format que celui de la console.
2
+
3
+ Chaque commande exécutée, refusée ou annulée, chaque fichier écrit, chaque recherche web et chaque changement de
4
+ politique y laisse une ligne. La console web lit ce fichier (sans y écrire) pour son tableau de bord ; la TUI lit
5
+ aussi celui de la console pour /journal et /etat. Écrire dans le journal ne doit jamais faire échouer
6
+ une action : toute erreur d'écriture est ignorée.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import json
12
+ import os
13
+ import secrets
14
+ import threading
15
+ from pathlib import Path
16
+
17
+ from .config import SAVE_DIR
18
+
19
+ FICHIER = SAVE_DIR / "journal.jsonl"
20
+ ARCHIVE = SAVE_DIR / "journal-archive.jsonl"
21
+ TAILLE_MAX = 4_000_000 # au-delà, le fichier devient l'archive et un nouveau commence
22
+ QUEUE_SORTIE = 800 # fin de la sortie d'une commande gardée dans le journal
23
+ TITRE_MAX = 120
24
+ TYPES = ("command", "refusal", "audit", "ai", "search", "scope", "settings", "security", "system")
25
+ ISSUES = ("ok", "error", "refused", "cancelled", "info")
26
+ _verrou = threading.Lock()
27
+
28
+
29
+ def noter(kind: str, outcome: str, title: str, detail: str = "", fichier: Path | None = None,
30
+ donnees: dict | None = None, **data) -> dict | None:
31
+ """Ajoute une entrée. Renvoie l'entrée écrite, ou None si l'écriture a échoué.
32
+ data : les valeurs None sont omises. donnees : gardées telles quelles, None compris (la console écrit « place: null »)."""
33
+ if kind not in TYPES or outcome not in ISSUES:
34
+ raise ValueError(f"entrée de journal inconnue : {kind} / {outcome}")
35
+ title = " ".join(title.split())
36
+ entry: dict = {
37
+ "id": secrets.token_hex(8),
38
+ "at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
39
+ "kind": kind,
40
+ "outcome": outcome,
41
+ "title": title if len(title) <= TITRE_MAX else title[:TITRE_MAX] + "…",
42
+ }
43
+ if detail:
44
+ entry["detail"] = detail
45
+ entry["data"] = {**(donnees or {}), "origin": "tui", **{k: v for k, v in data.items() if v is not None}}
46
+ cible = fichier or FICHIER
47
+ try:
48
+ with _verrou:
49
+ cible.parent.mkdir(parents=True, exist_ok=True)
50
+ try:
51
+ if cible.stat().st_size > TAILLE_MAX:
52
+ os.replace(cible, cible.with_name(ARCHIVE.name))
53
+ except OSError:
54
+ pass
55
+ with open(cible, "a", encoding="utf-8", newline="\n") as f:
56
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
57
+ except OSError:
58
+ return None
59
+ return entry
60
+
61
+
62
+ def _lignes(fichier: Path, maximum: int) -> list[dict]:
63
+ try:
64
+ with open(fichier, encoding="utf-8") as f:
65
+ brut = f.readlines()[-maximum:]
66
+ except OSError:
67
+ return []
68
+ entries = []
69
+ for ligne in brut:
70
+ try:
71
+ entry = json.loads(ligne)
72
+ except ValueError:
73
+ continue # ligne abîmée (arrêt brutal pendant une écriture)
74
+ if (isinstance(entry, dict) and isinstance(entry.get("at"), str) and isinstance(entry.get("title"), str)
75
+ and entry.get("kind") in TYPES and entry.get("outcome") in ISSUES):
76
+ entries.append(entry)
77
+ return entries
78
+
79
+
80
+ def lire(fichiers: list[Path] | None = None, maximum: int = 5000) -> list[dict]:
81
+ """Entrées des journaux donnés (par défaut celui de la TUI), de la plus ancienne à la plus récente."""
82
+ entries: list[dict] = []
83
+ for fichier in fichiers or [FICHIER]:
84
+ entries += _lignes(fichier, maximum)
85
+ entries.sort(key=lambda e: e["at"])
86
+ return entries[-maximum:]
87
+
88
+
89
+ def _moment(entry: dict) -> dt.datetime | None:
90
+ try:
91
+ return dt.datetime.fromisoformat(entry["at"].replace("Z", "+00:00")).astimezone()
92
+ except ValueError:
93
+ return None
94
+
95
+
96
+ def bilan(entries: list[dict], maintenant: dt.datetime | None = None, jours: int = 7) -> dict:
97
+ """Commandes exécutées, en échec et refusées : aujourd'hui et sur les derniers jours, par interface."""
98
+ now = (maintenant or dt.datetime.now()).astimezone()
99
+ debut = (now - dt.timedelta(days=jours - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
100
+ vide = {"executees": 0, "echecs": 0, "refusees": 0, "annulees": 0}
101
+ compte = {"jour": dict(vide), "periode": dict(vide), "jours": jours, "tui": 0, "console": 0, "derniere": None}
102
+ cles = {"ok": "executees", "error": "echecs", "cancelled": "annulees"}
103
+ for entry in entries:
104
+ if entry["kind"] not in ("command", "refusal"):
105
+ continue
106
+ quand = _moment(entry)
107
+ if quand is None or quand < debut:
108
+ continue
109
+ # Une commande que tu as refusée ou arrêtée n'est pas un échec : elle est comptée à part
110
+ cle = "refusees" if entry["kind"] == "refusal" else cles.get(entry["outcome"], "echecs")
111
+ compte["periode"][cle] += 1
112
+ if quand.date() == now.date():
113
+ compte["jour"][cle] += 1
114
+ compte["tui" if (entry.get("data") or {}).get("origin") == "tui" else "console"] += 1
115
+ compte["derniere"] = entry
116
+ return compte
@@ -0,0 +1,132 @@
1
+ """Logo en pixels : la flamme sur son pupitre, le nom en capitales antiques, la frise grecque."""
2
+ from __future__ import annotations
3
+
4
+ from rich.text import Text
5
+ from textual.widget import Widget
6
+
7
+ from .theme import DIM, ORANGE
8
+
9
+ # --------------------------------------------------------------------------- Logo
10
+ # Nom en capitales antiques à empattements (comme gravées sur un temple), en or, en pixels de 10 de haut.
11
+ ANTIQUE = {
12
+ "P": ["######.", ".##..##", ".##..##", ".##..##", ".#####.", ".##....", ".##....", ".##....", ".##....", "####..."],
13
+ "R": ["######..", ".##..##.", ".##..##.", ".##..##.", ".#####..", ".##.##..", ".##..##.", ".##..##.", ".##...##", "####..##"],
14
+ "O": ["..####..", ".##..##.", "##....##", "##....##", "##....##", "##....##", "##....##", "##....##", ".##..##.", "..####.."],
15
+ "M": ["##.....##", "###...###", "####.####", "##.###.##", "##..#..##", "##.....##", "##.....##", "##.....##", "##.....##", "###...###"],
16
+ "E": ["########", ".##...##", ".##....#", ".##..#..", ".#####..", ".##..#..", ".##.....", ".##....#", ".##...##", "########"],
17
+ "T": ["########", "#..##..#", "...##...", "...##...", "...##...", "...##...", "...##...", "...##...", "...##...", "..####.."],
18
+ "H": ["###...###", ".##...##.", ".##...##.", ".##...##.", ".#######.", ".##...##.", ".##...##.", ".##...##.", ".##...##.", "###...###"],
19
+ }
20
+ NAME = "PROMÉTHÉE"
21
+ GREEK_NAME = "Π Ρ Ο Μ Η Θ Ε Υ Σ" # Προμηθεύς : « celui qui pense avant »
22
+ # Dégradé or, du haut (accents) vers le bas des lettres : une couleur par ligne de pixels
23
+ NAME_GOLD = ["#ffe9a0", "#ffdf87", "#ffd75f", "#f5c542", "#e6b432", "#d7a32a", "#c79325",
24
+ "#b8841f", "#a8751a", "#99681a", "#99681a", "#8a5c16"]
25
+ BRONZE = "#9a6b1c"
26
+ # Frise grecque (méandre) sous le nom, motif de 8 pixels
27
+ MEANDER = ["#######.", "......#.", "####..#.", "#..#..#.", "#.....#.", "#######."]
28
+
29
+
30
+ def name_pixels(gap: int = 2) -> list[str]:
31
+ """Lignes de pixels du nom : 2 lignes pour les accents + 10 pour les lettres."""
32
+ rows = [""] * 12
33
+ for i, ch in enumerate(NAME):
34
+ glyph = ANTIQUE["E" if ch == "É" else ch]
35
+ w = len(glyph[0])
36
+ accent = ["." * w, "." * w]
37
+ if ch == "É":
38
+ accent = ["." * (w // 2) + "##" + "." * (w - w // 2 - 2), "." * (w // 2 - 1) + "##" + "." * (w - w // 2 - 1)]
39
+ sep = "." * gap if i < len(NAME) - 1 else ""
40
+ rows = [r + c + sep for r, c in zip(rows, accent + glyph)]
41
+ return rows
42
+
43
+
44
+ # Icône : la flamme (le feu de Prométhée) posée sur un pupitre. Pixel art : 1 caractère = 2 pixels
45
+ # superposés (demi-blocs ▀ ▄), pour des pixels presque carrés dans le terminal.
46
+ FLAME_ART = [
47
+ ".........R........",
48
+ ".........RR.......",
49
+ "........ROR.......",
50
+ ".......ROOR...R...",
51
+ "....R..ROGOR.RR...",
52
+ "....RR.ROGGORROR..",
53
+ "...ROOROGYGOROOR..",
54
+ "..ROOOGGYYGGOOOR..",
55
+ "..ROOGGYYYYGGOOR..",
56
+ "..ROGGYYYYYYGGOR..",
57
+ "..DROGGYYYYGGORD..",
58
+ "...DROOGGGGOORD...",
59
+ "....DDRROORRDD....",
60
+ ".LLLLLLLLLLLLLLLL.",
61
+ ".bBBBBBBBBBBBBBBb.",
62
+ "..bbbbbbbbbbbbbb..",
63
+ "...bBBBBBBBBBBb...",
64
+ "...bBMMMMMMMMBb...",
65
+ "...bBMBBBBBBMBb...",
66
+ "...bBMMMMMMMMBb...",
67
+ "...bBBBBBBBBBBb...",
68
+ "..bbbbbbbbbbbbbb..",
69
+ ]
70
+ FLAME_COLORS = {
71
+ "Y": "#ffffaf", "G": "#ffd700", "O": "#ff8700", "R": "#ff5f00", "D": "#d70000", # feu
72
+ "L": "#d7875f", "M": "#d78700", "B": "#af5f00", "b": "#5f3700", # bois du pupitre
73
+ }
74
+
75
+
76
+ def pixel_art(rows: list[str], color) -> list[Text]:
77
+ """color(caractère, n° de ligne de pixels) → couleur du pixel, ou None (transparent)."""
78
+ if len(rows) % 2:
79
+ rows = rows + ["." * len(rows[0])]
80
+ lines = []
81
+ for y in range(0, len(rows), 2):
82
+ line = Text()
83
+ for a, b in zip(rows[y], rows[y + 1]):
84
+ ca, cb = color(a, y), color(b, y + 1)
85
+ if ca and cb:
86
+ line.append("█", style=ca) if ca == cb else line.append("▀", style=f"{ca} on {cb}")
87
+ elif ca:
88
+ line.append("▀", style=ca)
89
+ elif cb:
90
+ line.append("▄", style=cb)
91
+ else:
92
+ line.append(" ")
93
+ lines.append(line)
94
+ return lines
95
+
96
+
97
+ def build_logo(width: int) -> Text:
98
+ if width < 30:
99
+ return Text.assemble(("✻ ", ORANGE), (" ".join(NAME), f"bold {NAME_GOLD[2]}"))
100
+ icon = pixel_art(FLAME_ART, lambda ch, _: FLAME_COLORS.get(ch))
101
+ side: dict[int, Text] = {}
102
+ if width >= 115:
103
+ # Inscription antique en or, frise grecque dessous, puis le nom grec
104
+ name = name_pixels()
105
+ n = len(name[0])
106
+ meander_width = (n // len(MEANDER[0])) * len(MEANDER[0]) - 1 # se termine sur un motif complet
107
+ indent = (n - meander_width) // 2
108
+ frieze = ["." * indent + (row * (n // len(row) + 1))[:meander_width] for row in MEANDER]
109
+ pixels = name + ["." * n] + frieze # 1 pixel d'espace entre le nom et la frise
110
+ block = (pixel_art(pixels, lambda ch, y: (NAME_GOLD[y] if y < len(name) else BRONZE) if ch == "#" else None)
111
+ + [Text(GREEK_NAME.center(n).rstrip(), style=NAME_GOLD[5])])
112
+ top = max(0, len(icon) - len(block)) # aligné en bas, sur le pied du pupitre
113
+ side = {top + i: line for i, line in enumerate(block)}
114
+ else:
115
+ mid = len(icon) // 2
116
+ side = {mid - 2: Text(" ".join(NAME), style=f"bold {NAME_GOLD[2]}"),
117
+ mid - 1: Text(GREEK_NAME, style=NAME_GOLD[6]),
118
+ mid + 1: Text("IA locale de cybersécurité", style=DIM)}
119
+ text = Text()
120
+ for i, line in enumerate(icon):
121
+ text.append_text(line)
122
+ if i in side:
123
+ text.append(" ")
124
+ text.append_text(side[i])
125
+ if i < len(icon) - 1:
126
+ text.append("\n")
127
+ return text
128
+
129
+
130
+ class Logo(Widget):
131
+ def render(self) -> Text:
132
+ return build_logo(self.app.size.width)
@@ -0,0 +1,50 @@
1
+ """Mémoire PROMETHEE.md (globale et par dossier)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .config import SAVE_DIR
8
+ from .fichiers import new_file_format, read_text_file, write_text_file
9
+
10
+ # --------------------------------------------------------------------------- Mémoire (PROMETHEE.md, comme CLAUDE.md)
11
+ MEMORY_NAME = "PROMETHEE.md"
12
+ GLOBAL_MEMORY = SAVE_DIR / MEMORY_NAME
13
+ MAX_MEMORY_CHARS = 6000
14
+ MEMORY_HEADER = "# Mémoire de Prométhée\n\n"
15
+ MEMORY_INTRO = ("\n\n## Mémoire (fichiers PROMETHEE.md écrits par l'utilisateur)\n"
16
+ "Informations et consignes données par l'utilisateur lui-même : elles sont vraies. Applique-les et "
17
+ "sers-t'en sans les redemander.")
18
+
19
+
20
+ def memory_files(cwd: Path) -> list[tuple[str, Path]]:
21
+ """Mémoire globale (Documents\\Promethee) puis celle du dossier courant, si elles existent."""
22
+ files = [("globale", GLOBAL_MEMORY)]
23
+ local = cwd / MEMORY_NAME
24
+ if os.path.normcase(str(local)) != os.path.normcase(str(GLOBAL_MEMORY)):
25
+ files.append(("du dossier", local))
26
+ return [(label, path) for label, path in files if path.is_file()]
27
+
28
+
29
+ def read_memory(cwd: Path) -> list[tuple[str, Path, str]]:
30
+ out = []
31
+ for label, path in memory_files(cwd):
32
+ try:
33
+ text = read_text_file(path)[0].strip()
34
+ except (OSError, ValueError):
35
+ continue
36
+ if text and text != MEMORY_HEADER.strip():
37
+ if len(text) > MAX_MEMORY_CHARS:
38
+ text = text[:MAX_MEMORY_CHARS] + "\n[… coupé …]"
39
+ out.append((label, path, text))
40
+ return out
41
+
42
+
43
+ def add_memory_note(path: Path, note: str) -> None:
44
+ if path.is_file():
45
+ text, encoding, newline = read_text_file(path)
46
+ if text and not text.endswith("\n"):
47
+ text += "\n"
48
+ else:
49
+ text, (encoding, newline) = MEMORY_HEADER, new_file_format(path)
50
+ write_text_file(path, text + f"- {note}\n", encoding, newline)