@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,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)
|