@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,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Le cerveau de Prométhée : conversation avec le modèle local, propositions de commandes,
|
|
3
|
+
* et reprise de la réponse après la décision de l'utilisateur.
|
|
4
|
+
*
|
|
5
|
+
* Le modèle ne peut rien exécuter. Son seul outil crée une proposition ; c'est l'appel à
|
|
6
|
+
* decide(), déclenché par un clic de l'utilisateur, qui lance la commande via le moteur.
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { AppError, badRequest, describeError } from '../errors.js';
|
|
10
|
+
import { READ_BUDGET_MS, pagesToRead, recallExtracts, searchExtracts } from '../savoir/memoire.js';
|
|
11
|
+
import { anchorAccepted, anchorCandidates, detectMany, zoneOf } from '../search/categories.js';
|
|
12
|
+
import { cut, placeCandidates } from '../search/searxng.js';
|
|
13
|
+
import { COMMAND_TOOL, RECALL_TOOL_NAME, ROUTER_SCHEMA, ROUTER_SYSTEM, SEARCH_ROUTER_SCHEMA, SEARCH_ROUTER_SYSTEM, SEARCH_TOOL_NAME, TOOL_NAME, autoRunRules, extractsText, recallResultText, recallRules, refusalText, searchAnswerRules, searchResultText, searchRouterInput, systemPrompt, toolResultText, unverifiedValues, } from './prompt.js';
|
|
14
|
+
/** Commandes au plus qu'un seul tour auto enchaîne tout seul, avant de s'arrêter par sécurité. */
|
|
15
|
+
const MAX_AUTO_STEPS = 20;
|
|
16
|
+
const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`;
|
|
17
|
+
const MAX_REFUSALS = 3;
|
|
18
|
+
const id16 = () => randomBytes(8).toString('hex');
|
|
19
|
+
function proposalResult(p) {
|
|
20
|
+
switch (p.status) {
|
|
21
|
+
case 'refused':
|
|
22
|
+
return refusalText(p.verdict);
|
|
23
|
+
case 'denied':
|
|
24
|
+
return "L'utilisateur a refusé cette commande. Ne la repropose pas telle quelle : demande-lui ce qu'il préfère ou propose autre chose.";
|
|
25
|
+
case 'done':
|
|
26
|
+
case 'failed':
|
|
27
|
+
return p.result ? toolResultText(p.result) : 'Commande terminée sans résultat enregistré.';
|
|
28
|
+
default:
|
|
29
|
+
return "L'utilisateur n'a pas répondu à cette proposition.";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Le message de l'utilisateur tel que le modèle le lit : le texte tapé, puis chaque pièce jointe sous un en-tête qui la nomme. */
|
|
33
|
+
export function userText(m) {
|
|
34
|
+
const parts = m.content.trim() ? [m.content] : [];
|
|
35
|
+
for (const a of m.attachments ?? [])
|
|
36
|
+
parts.push(`[Fichier joint : ${a.name}]\n${a.text}`);
|
|
37
|
+
return parts.join('\n\n');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reconstruit l'historique au format du modèle à partir des messages enregistrés.
|
|
41
|
+
*
|
|
42
|
+
* Les passages lus dans les pages ne sont donnés qu'à la réponse qui suit leur recherche ou leur rappel (le message est
|
|
43
|
+
* alors le dernier de l'historique), toujours produite sans l'outil de commande. Plus loin dans la conversation, il ne
|
|
44
|
+
* reste que la liste des résultats : un long texte venu d'internet ne reste pas sous les yeux du modèle quand il peut
|
|
45
|
+
* proposer des commandes, et la fenêtre de contexte n'en est pas encombrée.
|
|
46
|
+
*/
|
|
47
|
+
export function toTurns(conversation) {
|
|
48
|
+
const turns = [];
|
|
49
|
+
const lastIndex = conversation.messages.length - 1;
|
|
50
|
+
conversation.messages.forEach((m, i) => {
|
|
51
|
+
if (m.role === 'user')
|
|
52
|
+
turns.push({ role: 'user', content: userText(m) });
|
|
53
|
+
else if (m.search) {
|
|
54
|
+
// La recherche se présente au modèle comme un appel d'outil suivi de son résultat
|
|
55
|
+
const args = { requete: m.search.query, ...(m.search.placeQuery ? { lieu: m.search.placeQuery } : {}) };
|
|
56
|
+
turns.push({ role: 'assistant', content: '', toolCalls: [{ name: SEARCH_TOOL_NAME, arguments: args }] });
|
|
57
|
+
turns.push({ role: 'tool', toolName: SEARCH_TOOL_NAME, content: searchResultText(m.search, i === lastIndex) });
|
|
58
|
+
}
|
|
59
|
+
else if (m.recall) {
|
|
60
|
+
if (i !== lastIndex)
|
|
61
|
+
return;
|
|
62
|
+
turns.push({ role: 'assistant', content: '', toolCalls: [{ name: RECALL_TOOL_NAME, arguments: { question: m.recall.query } }] });
|
|
63
|
+
turns.push({ role: 'tool', toolName: RECALL_TOOL_NAME, content: recallResultText(m.recall) });
|
|
64
|
+
}
|
|
65
|
+
else if (m.proposal) {
|
|
66
|
+
turns.push({ role: 'assistant', content: m.content, toolCalls: [{ name: TOOL_NAME, arguments: { commande: m.proposal.command, explication: m.proposal.explanation } }] });
|
|
67
|
+
turns.push({ role: 'tool', toolName: TOOL_NAME, content: proposalResult(m.proposal) });
|
|
68
|
+
}
|
|
69
|
+
else if (m.content)
|
|
70
|
+
turns.push({ role: 'assistant', content: m.content });
|
|
71
|
+
});
|
|
72
|
+
return turns;
|
|
73
|
+
}
|
|
74
|
+
export class Brain {
|
|
75
|
+
generating = new Map();
|
|
76
|
+
deps;
|
|
77
|
+
constructor(deps) {
|
|
78
|
+
this.deps = deps;
|
|
79
|
+
}
|
|
80
|
+
/** Modèle utilisé : celui des réglages s'il est installé, sinon « promethee », sinon le premier. */
|
|
81
|
+
pickModel(installed) {
|
|
82
|
+
const wanted = this.deps.settings().model;
|
|
83
|
+
return installed.find((m) => m.name === wanted) ?? installed.find((m) => m.name.startsWith('promethee')) ?? installed[0] ?? null;
|
|
84
|
+
}
|
|
85
|
+
async models() {
|
|
86
|
+
const provider = this.deps.provider();
|
|
87
|
+
const [installed, loaded] = await Promise.all([provider.listModels(), provider.loadedModels().catch(() => [])]);
|
|
88
|
+
return { installed, loaded, selected: this.pickModel(installed)?.name ?? null };
|
|
89
|
+
}
|
|
90
|
+
stop(conversationId) {
|
|
91
|
+
const controller = this.generating.get(conversationId);
|
|
92
|
+
controller?.abort();
|
|
93
|
+
return controller !== undefined;
|
|
94
|
+
}
|
|
95
|
+
begin(conversationId) {
|
|
96
|
+
if (this.generating.has(conversationId))
|
|
97
|
+
throw new AppError('busy', 409, 'Une réponse est déjà en cours dans cette conversation.');
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
this.generating.set(conversationId, controller);
|
|
100
|
+
return controller;
|
|
101
|
+
}
|
|
102
|
+
async classify(conversation) {
|
|
103
|
+
const recent = conversation.messages.slice(-4).map((m) => ({ role: m.role, content: m.content.slice(0, 600) || (m.proposal ? `(commande proposée : ${m.proposal.command})` : '') }));
|
|
104
|
+
try {
|
|
105
|
+
const out = await this.deps.provider().structured({ model: conversation.model, messages: [{ role: 'system', content: ROUTER_SYSTEM }, ...recent], schema: ROUTER_SCHEMA, timeoutMs: 20_000 });
|
|
106
|
+
return out.type === 'explication' ? 'explication' : 'action';
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 'action';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Faut-il chercher sur internet avant de répondre ? Un appel à part, mesuré seul (.equipe/recherche/mesure-routeur.md).
|
|
114
|
+
* En cas de doute ou d'erreur : pas de recherche, le tour se passe comme avant.
|
|
115
|
+
*/
|
|
116
|
+
async searchPlan(conversation) {
|
|
117
|
+
const messages = conversation.messages;
|
|
118
|
+
let last = '';
|
|
119
|
+
let previousAnswer = null;
|
|
120
|
+
let previousRequest = null;
|
|
121
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
122
|
+
const m = messages[i];
|
|
123
|
+
if (!m)
|
|
124
|
+
continue;
|
|
125
|
+
if (m.role === 'user' && !last)
|
|
126
|
+
last = m.content;
|
|
127
|
+
else if (last && m.role === 'assistant' && m.content && previousAnswer === null)
|
|
128
|
+
previousAnswer = m.content;
|
|
129
|
+
else if (previousAnswer !== null && m.role === 'user') {
|
|
130
|
+
previousRequest = m.content;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!last)
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
const out = (await this.deps.provider().structured({
|
|
138
|
+
model: conversation.model,
|
|
139
|
+
messages: [
|
|
140
|
+
{ role: 'system', content: SEARCH_ROUTER_SYSTEM },
|
|
141
|
+
{ role: 'user', content: searchRouterInput(last, previousAnswer, previousRequest) },
|
|
142
|
+
],
|
|
143
|
+
schema: SEARCH_ROUTER_SCHEMA,
|
|
144
|
+
timeoutMs: 20_000,
|
|
145
|
+
maxTokens: 120,
|
|
146
|
+
}));
|
|
147
|
+
// Sans requête, le classement n'est pas sûr (« affiche la date du jour » sortait ainsi) : pas de recherche
|
|
148
|
+
const query = typeof out?.requete === 'string' ? out.requete.trim() : '';
|
|
149
|
+
if (out?.chercher !== true || !query)
|
|
150
|
+
return null;
|
|
151
|
+
const place = typeof out.lieu === 'string' && out.lieu.trim() ? cut(out.lieu.trim(), 120) : null;
|
|
152
|
+
return { query: cut(query.replace(/\s+/g, ' '), 200), place };
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** La recherche elle-même : un message sans texte qui porte la requête, les sources et le lieu. */
|
|
159
|
+
async *search(conversation, model, plan, signal) {
|
|
160
|
+
const { journal, store } = this.deps;
|
|
161
|
+
const running = { query: plan.query, state: 'running', sources: [], placeQuery: plan.place, place: null, error: null, durationMs: null };
|
|
162
|
+
const message = { id: id16(), role: 'assistant', content: '', at: new Date().toISOString(), model: model.name, search: running };
|
|
163
|
+
conversation.messages.push(message);
|
|
164
|
+
store.save(conversation);
|
|
165
|
+
yield { type: 'message', message: { ...message } };
|
|
166
|
+
const started = performance.now();
|
|
167
|
+
const backend = this.deps.search();
|
|
168
|
+
const asked = [...conversation.messages].reverse().find((m) => m.role === 'user')?.content ?? '';
|
|
169
|
+
const many = detectMany(asked, plan.query);
|
|
170
|
+
const findMap = async () => {
|
|
171
|
+
if (many) {
|
|
172
|
+
// Plusieurs lieux (« tous les hôtels de Paris ») : la zone par Photon, puis les points à part, sans les attendre
|
|
173
|
+
// (Overpass met de 2 à 40 s selon sa charge). Les notes ne s'affichent que si le classement voulait une carte.
|
|
174
|
+
for (const candidate of anchorCandidates(plan.query, plan.place, many)) {
|
|
175
|
+
const anchor = await backend.place(candidate, signal);
|
|
176
|
+
if (!anchor || !anchorAccepted(candidate, anchor))
|
|
177
|
+
continue;
|
|
178
|
+
const where = zoneOf(anchor);
|
|
179
|
+
if (!where)
|
|
180
|
+
return { place: null, places: null, note: plan.place ? `La zone « ${anchor.name} » est trop grande pour y lister des ${many.label}. Précise une ville ou un quartier.` : null };
|
|
181
|
+
this.deps.prefetchPlaces?.(many, where);
|
|
182
|
+
const around = where.kind === 'around';
|
|
183
|
+
return { place: null, places: { label: many.label, zone: anchor.name, mode: around ? 'around' : 'area', radius: around ? where.radius : null, request: { category: many.label, where } }, note: null };
|
|
184
|
+
}
|
|
185
|
+
return { place: null, places: null, note: plan.place ? `Zone introuvable sur la carte : « ${plan.place} ».` : null };
|
|
186
|
+
}
|
|
187
|
+
// Le lieu : la requête d'abord, plus précise que « lieu » qui se réduit souvent à la ville (mesure-lieux.ts : 8 lieux
|
|
188
|
+
// justes sur 12 au lieu de 6), puis « lieu ». L'un après l'autre, en même temps que la recherche générale.
|
|
189
|
+
for (const candidate of plan.place ? placeCandidates(plan.query, plan.place) : []) {
|
|
190
|
+
const found = await backend.place(candidate, signal);
|
|
191
|
+
if (found)
|
|
192
|
+
return { place: found, places: null, note: null };
|
|
193
|
+
}
|
|
194
|
+
return { place: null, places: null, note: null };
|
|
195
|
+
};
|
|
196
|
+
const [web, map] = await Promise.allSettled([backend.web(plan.query, signal), findMap()]);
|
|
197
|
+
const durationMs = Math.round(performance.now() - started);
|
|
198
|
+
const found = map.status === 'fulfilled' ? map.value : { place: null, places: null, note: null };
|
|
199
|
+
// Plusieurs lieux : pas de « lieu introuvable », la carte (ou sa note) le remplace
|
|
200
|
+
const shown = { place: found.place, placeQuery: many ? null : plan.place, places: found.places, placesNote: found.note };
|
|
201
|
+
const search = web.status === 'fulfilled'
|
|
202
|
+
? { ...running, ...shown, state: 'done', sources: web.value, durationMs }
|
|
203
|
+
: { ...running, ...shown, state: 'failed', error: signal.aborted ? 'Recherche interrompue.' : describeError(web.reason).message, durationMs };
|
|
204
|
+
message.search = search;
|
|
205
|
+
store.save(conversation);
|
|
206
|
+
const onMap = search.place ? `, lieu : ${search.place.name}` : search.places ? `, carte : ${search.places.label} (${search.places.zone})` : '';
|
|
207
|
+
const detail = search.state === 'done' ? `${search.sources.length} source${search.sources.length > 1 ? 's' : ''}${onMap}` : (search.error ?? undefined);
|
|
208
|
+
journal.add({
|
|
209
|
+
kind: 'search',
|
|
210
|
+
outcome: search.state === 'done' ? 'ok' : signal.aborted ? 'cancelled' : 'error',
|
|
211
|
+
title: `Recherche web : ${cut(plan.query, 100)}`,
|
|
212
|
+
detail,
|
|
213
|
+
data: {
|
|
214
|
+
query: plan.query,
|
|
215
|
+
place: search.place?.name ?? (search.places ? null : plan.place),
|
|
216
|
+
...(search.places ? { places: { label: search.places.label, zone: search.places.zone } } : {}),
|
|
217
|
+
sources: search.sources.map((s) => s.url),
|
|
218
|
+
conversation: conversation.id,
|
|
219
|
+
origin: 'console',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
yield { type: 'search', messageId: message.id, search };
|
|
223
|
+
if (search.state !== 'done' || signal.aborted || !this.deps.settings().search.learn)
|
|
224
|
+
return search;
|
|
225
|
+
return yield* this.learn(conversation, message, search, asked, signal);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Après la recherche : lit en entier les meilleures pages, les retient dans le savoir local, puis choisit les passages que
|
|
229
|
+
* le modèle relira, de ces pages et des pages retenues lors de recherches précédentes. Les pages se lisent ensemble, 15 s
|
|
230
|
+
* au plus. Une page lue il y a moins de 7 jours n'est pas relue.
|
|
231
|
+
*/
|
|
232
|
+
async *learn(conversation, message, search, asked, signal) {
|
|
233
|
+
const { savoir, readPage, journal, store } = this.deps;
|
|
234
|
+
if (!savoir || !readPage)
|
|
235
|
+
return search;
|
|
236
|
+
const ids = new Map();
|
|
237
|
+
const pages = pagesToRead(search.sources).map((s) => {
|
|
238
|
+
const known = savoir.fresh(s.url);
|
|
239
|
+
if (!known)
|
|
240
|
+
return { n: s.n, url: s.url, state: 'reading', title: null, chars: 0, error: null };
|
|
241
|
+
ids.set(s.n, known.id);
|
|
242
|
+
return { n: s.n, url: s.url, state: 'known', title: known.title, chars: known.chars, error: null };
|
|
243
|
+
});
|
|
244
|
+
const show = () => ({ ...search, pages: pages.map((p) => ({ ...p })) });
|
|
245
|
+
const budget = AbortSignal.any([signal, AbortSignal.timeout(READ_BUDGET_MS)]);
|
|
246
|
+
const readOne = async (i) => {
|
|
247
|
+
const page = pages[i];
|
|
248
|
+
try {
|
|
249
|
+
const text = await readPage(page.url, budget);
|
|
250
|
+
const kept = savoir.learn({ url: page.url, title: text.title, lang: text.lang, text: text.text, query: search.query });
|
|
251
|
+
ids.set(page.n, kept.id);
|
|
252
|
+
pages[i] = { ...page, state: 'learned', title: kept.title, chars: kept.chars };
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const why = signal.aborted ? 'Lecture interrompue.' : budget.aborted ? `Pas lue dans les ${READ_BUDGET_MS / 1000} s.` : err instanceof Error ? err.message : String(err);
|
|
256
|
+
pages[i] = { ...page, state: 'failed', error: why };
|
|
257
|
+
}
|
|
258
|
+
return i;
|
|
259
|
+
};
|
|
260
|
+
const pending = new Map();
|
|
261
|
+
pages.forEach((p, i) => {
|
|
262
|
+
if (p.state === 'reading')
|
|
263
|
+
pending.set(i, readOne(i));
|
|
264
|
+
});
|
|
265
|
+
if (pending.size > 0) {
|
|
266
|
+
message.search = show();
|
|
267
|
+
yield { type: 'search', messageId: message.id, search: message.search };
|
|
268
|
+
}
|
|
269
|
+
while (pending.size > 0) {
|
|
270
|
+
pending.delete(await Promise.race(pending.values()));
|
|
271
|
+
message.search = show();
|
|
272
|
+
yield { type: 'search', messageId: message.id, search: message.search };
|
|
273
|
+
}
|
|
274
|
+
const read = pages.flatMap((p) => {
|
|
275
|
+
const pageId = ids.get(p.n);
|
|
276
|
+
return pageId && (p.state === 'learned' || p.state === 'known') ? [{ n: p.n, pageId, fresh: p.state === 'learned' }] : [];
|
|
277
|
+
});
|
|
278
|
+
const next = Math.max(0, ...search.sources.map((s) => s.n)) + 1;
|
|
279
|
+
let extracts = [];
|
|
280
|
+
let failure = null;
|
|
281
|
+
try {
|
|
282
|
+
extracts = signal.aborted ? [] : searchExtracts(savoir, read, `${search.query} ${asked}`, search.query, next);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
// Base illisible : la réponse se fait avec la liste des résultats seule, et le journal le dit
|
|
286
|
+
failure = describeError(err).message;
|
|
287
|
+
}
|
|
288
|
+
const done = { ...search, pages: pages.map((p) => ({ ...p })), extracts };
|
|
289
|
+
message.search = done;
|
|
290
|
+
store.save(conversation);
|
|
291
|
+
if (pages.length > 0 || (extracts?.length ?? 0) > 0 || failure) {
|
|
292
|
+
const count = (state) => pages.filter((p) => p.state === state).length;
|
|
293
|
+
const [learned, known, failed] = [count('learned'), count('known'), count('failed')];
|
|
294
|
+
const parts = [
|
|
295
|
+
learned ? plural(learned, 'page retenue', 'pages retenues') : '',
|
|
296
|
+
known ? `${known} déjà dans le savoir` : '',
|
|
297
|
+
failed ? plural(failed, 'non lue', 'non lues') : '',
|
|
298
|
+
].filter(Boolean);
|
|
299
|
+
journal.add({
|
|
300
|
+
kind: 'search',
|
|
301
|
+
outcome: failure ? 'error' : signal.aborted ? 'cancelled' : learned + known > 0 || pages.length === 0 ? 'ok' : 'error',
|
|
302
|
+
title: `Pages lues : ${cut(search.query, 100)}`,
|
|
303
|
+
detail: failure ?? `${parts.join(', ') || 'aucune page lisible parmi les résultats'}. ${plural(extracts?.length ?? 0, 'passage donné', 'passages donnés')} au modèle.`,
|
|
304
|
+
data: {
|
|
305
|
+
query: search.query,
|
|
306
|
+
// sources : les adresses des pages, que le journal affiche comme pour une recherche ; pages : le détail
|
|
307
|
+
sources: pages.map((p) => p.url),
|
|
308
|
+
pages: pages.map((p) => ({ url: p.url, state: p.state, chars: p.chars, ...(p.error ? { error: p.error } : {}) })),
|
|
309
|
+
extracts: (extracts ?? []).map((e) => ({ n: e.n, url: e.url, fresh: e.fresh })),
|
|
310
|
+
conversation: conversation.id,
|
|
311
|
+
origin: 'console',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
yield { type: 'search', messageId: message.id, search: done };
|
|
316
|
+
return done;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Sans recherche web, pour une explication : les passages du savoir local qui collent à la question, s'il y en a. Ils
|
|
320
|
+
* forment un message sans texte, comme une recherche, et la réponse qui suit se fait sans l'outil de commande.
|
|
321
|
+
*/
|
|
322
|
+
*recall(conversation, model, question) {
|
|
323
|
+
const { savoir, settings, store, journal } = this.deps;
|
|
324
|
+
if (!savoir || !settings().search.learn)
|
|
325
|
+
return null;
|
|
326
|
+
let recall;
|
|
327
|
+
try {
|
|
328
|
+
recall = { query: question.slice(0, 500), extracts: recallExtracts(savoir, question) };
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (recall.extracts.length === 0)
|
|
334
|
+
return null;
|
|
335
|
+
const message = { id: id16(), role: 'assistant', content: '', at: new Date().toISOString(), model: model.name, recall };
|
|
336
|
+
conversation.messages.push(message);
|
|
337
|
+
store.save(conversation);
|
|
338
|
+
const urls = [...new Set(recall.extracts.map((e) => e.url))];
|
|
339
|
+
journal.add({
|
|
340
|
+
kind: 'ai',
|
|
341
|
+
outcome: 'ok',
|
|
342
|
+
title: `Savoir local relu : ${plural(urls.length, 'page', 'pages')}`,
|
|
343
|
+
detail: [...new Set(recall.extracts.map((e) => e.title))].map((t) => cut(t, 80)).join(' · '),
|
|
344
|
+
data: { conversation: conversation.id, pages: urls, extracts: recall.extracts.length },
|
|
345
|
+
});
|
|
346
|
+
yield { type: 'message', message: { ...message } };
|
|
347
|
+
yield { type: 'recall', messageId: message.id, recall };
|
|
348
|
+
return recall;
|
|
349
|
+
}
|
|
350
|
+
/** Envoie un message de l'utilisateur. attachments : de longs textes collés, rangés à part (comme des fichiers joints). */
|
|
351
|
+
async send(conversationId, text, attachments = []) {
|
|
352
|
+
const { store, provider } = this.deps;
|
|
353
|
+
const conversation = store.get(conversationId);
|
|
354
|
+
const installed = await provider().listModels();
|
|
355
|
+
const model = this.pickModel(installed);
|
|
356
|
+
if (!model)
|
|
357
|
+
throw new AppError('model_missing', 404, 'Aucun modèle n’est installé dans Ollama. Installe-en un avec « ollama pull » ou « ollama create ».');
|
|
358
|
+
const controller = this.begin(conversationId);
|
|
359
|
+
for (const m of conversation.messages)
|
|
360
|
+
if (m.proposal?.status === 'pending' || m.proposal?.status === 'running')
|
|
361
|
+
m.proposal = { ...m.proposal, status: 'expired', ticket: null };
|
|
362
|
+
conversation.model = model.name;
|
|
363
|
+
const user = { id: id16(), role: 'user', content: text, at: new Date().toISOString(), ...(attachments.length > 0 ? { attachments } : {}) };
|
|
364
|
+
conversation.messages.push(user);
|
|
365
|
+
// Titre : le texte tapé, sinon le nom de la première pièce jointe, sinon le début de son contenu
|
|
366
|
+
if (conversation.messages.length === 1) {
|
|
367
|
+
const titre = text.trim() || attachments[0]?.name || attachments[0]?.text || '';
|
|
368
|
+
conversation.title = titre.replace(/\s+/g, ' ').slice(0, 64);
|
|
369
|
+
}
|
|
370
|
+
store.save(conversation);
|
|
371
|
+
const self = this;
|
|
372
|
+
return (async function* () {
|
|
373
|
+
try {
|
|
374
|
+
yield { type: 'message', message: user };
|
|
375
|
+
// L'un après l'autre, comme pendant la mesure : lancés ensemble, Ollama les calcule en lot et le classement de la
|
|
376
|
+
// recherche change (65/68 au lieu de 66/68, lieux moins précis) pour presque rien de gagné (0,68 s contre 0,76 s).
|
|
377
|
+
const plan = self.deps.settings().search.enabled ? await self.searchPlan(conversation) : null;
|
|
378
|
+
if (plan) {
|
|
379
|
+
const search = yield* self.search(conversation, model, plan, controller.signal);
|
|
380
|
+
if (!controller.signal.aborted)
|
|
381
|
+
yield* self.generate(conversation, model, 'search', controller.signal, { search });
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const mode = model.capabilities.includes('tools') ? await self.classify(conversation) : 'explication';
|
|
385
|
+
const reglages = self.deps.settings();
|
|
386
|
+
const auto = mode === 'action' && reglages.fullCyber && reglages.autoRun;
|
|
387
|
+
if (auto)
|
|
388
|
+
self.deps.journal.add({ kind: 'ai', outcome: 'info', title: `Mode auto lancé : ${text.replace(/\s+/g, ' ').slice(0, 100)}`, detail: `Jusqu'à ${MAX_AUTO_STEPS} commandes autorisées enchaînées. Les garde-fous (destructrices, interdites, hors périmètre) tiennent.`, data: { conversation: conversation.id, origin: 'console' } });
|
|
389
|
+
// Pour une explication seulement : une demande d'action garde son outil, sans texte venu d'internet à côté
|
|
390
|
+
const recall = mode === 'explication' ? yield* self.recall(conversation, model, text) : null;
|
|
391
|
+
yield* self.generate(conversation, model, mode, controller.signal, recall ? { recall } : { auto });
|
|
392
|
+
}
|
|
393
|
+
yield { type: 'done' };
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
self.generating.delete(conversationId);
|
|
397
|
+
}
|
|
398
|
+
})();
|
|
399
|
+
}
|
|
400
|
+
/** Décision de l'utilisateur sur une proposition : exécuter ou refuser, puis laisser le modèle conclure. */
|
|
401
|
+
async decide(conversationId, proposalId, decision, acknowledge) {
|
|
402
|
+
const { store, engine, journal, provider } = this.deps;
|
|
403
|
+
const conversation = store.get(conversationId);
|
|
404
|
+
const message = conversation.messages.find((m) => m.proposal?.id === proposalId);
|
|
405
|
+
const proposal = message?.proposal;
|
|
406
|
+
if (!message || !proposal || proposal.status !== 'pending')
|
|
407
|
+
throw badRequest('Cette proposition n’attend plus de décision.', 'not_pending');
|
|
408
|
+
const installed = await provider().listModels();
|
|
409
|
+
const model = installed.find((m) => m.name === conversation.model) ?? this.pickModel(installed);
|
|
410
|
+
if (!model)
|
|
411
|
+
throw new AppError('model_missing', 404, 'Le modèle de cette conversation n’est plus installé.');
|
|
412
|
+
let execution = null;
|
|
413
|
+
if (decision === 'approve') {
|
|
414
|
+
try {
|
|
415
|
+
execution = engine.start(proposal.ticket ?? '', { acknowledge, confirmation: 'human' });
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
if (err instanceof AppError && err.code === 'ticket_expired') {
|
|
419
|
+
message.proposal = { ...proposal, status: 'expired', ticket: null };
|
|
420
|
+
store.save(conversation);
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let controller;
|
|
426
|
+
try {
|
|
427
|
+
controller = this.begin(conversationId);
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (execution)
|
|
431
|
+
engine.cancel(execution.execId);
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
const self = this;
|
|
435
|
+
return (async function* () {
|
|
436
|
+
try {
|
|
437
|
+
if (!execution) {
|
|
438
|
+
message.proposal = { ...proposal, status: 'denied', ticket: null };
|
|
439
|
+
journal.add({ kind: 'ai', outcome: 'refused', title: `Proposition écartée par toi : ${proposal.command.slice(0, 100)}`, data: { conversation: conversationId, command: proposal.command } });
|
|
440
|
+
store.save(conversation);
|
|
441
|
+
yield { type: 'proposal', messageId: message.id, proposal: message.proposal };
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
yield* self.runExecution(conversation, message, proposal, execution, controller.signal);
|
|
445
|
+
}
|
|
446
|
+
if (!controller.signal.aborted)
|
|
447
|
+
yield* self.generate(conversation, model, 'action', controller.signal);
|
|
448
|
+
yield { type: 'done' };
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
self.generating.delete(conversationId);
|
|
452
|
+
}
|
|
453
|
+
})();
|
|
454
|
+
}
|
|
455
|
+
/** Lance une commande déjà démarrée, en relaie la sortie, et rend la proposition terminée (done ou failed). Partagé par la décision humaine et le mode auto. */
|
|
456
|
+
async *runExecution(conversation, message, proposal, execution, signal) {
|
|
457
|
+
const { engine, store } = this.deps;
|
|
458
|
+
const onAbort = () => void engine.cancel(execution.execId);
|
|
459
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
460
|
+
const running = { ...proposal, status: 'running', ticket: null };
|
|
461
|
+
message.proposal = running;
|
|
462
|
+
yield { type: 'proposal', messageId: message.id, proposal: running };
|
|
463
|
+
let output = '';
|
|
464
|
+
let done = { ...running, status: 'failed', result: { exitCode: null, durationMs: 0, cancelled: true, timedOut: false, truncated: false, output: '' } };
|
|
465
|
+
try {
|
|
466
|
+
for await (const event of execution.events) {
|
|
467
|
+
yield { type: 'exec', proposalId: proposal.id, event };
|
|
468
|
+
if (event.type === 'out')
|
|
469
|
+
output = (output + event.text).slice(-60_000);
|
|
470
|
+
else if (event.type === 'end')
|
|
471
|
+
done = {
|
|
472
|
+
...running,
|
|
473
|
+
status: event.exitCode === 0 ? 'done' : 'failed',
|
|
474
|
+
result: { exitCode: event.exitCode, durationMs: event.durationMs, cancelled: event.cancelled, timedOut: event.timedOut, truncated: event.truncated, output },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
signal.removeEventListener('abort', onAbort);
|
|
480
|
+
message.proposal = done;
|
|
481
|
+
store.save(conversation);
|
|
482
|
+
}
|
|
483
|
+
yield { type: 'proposal', messageId: message.id, proposal: done };
|
|
484
|
+
return done;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Réponse du modèle. Après une recherche ou un rappel du savoir, l'outil de commande n'est pas proposé : un texte venu
|
|
488
|
+
* d'internet ne doit pas pouvoir pousser le modèle à proposer une commande dans ce tour.
|
|
489
|
+
*/
|
|
490
|
+
async *generate(conversation, model, mode, signal, context = {}) {
|
|
491
|
+
const { provider, engine, journal, settings, store } = this.deps;
|
|
492
|
+
const withTools = mode === 'action' && !context.search && !context.recall && model.capabilities.includes('tools') && engine.available;
|
|
493
|
+
const cwd = engine.defaultCwd();
|
|
494
|
+
const fullCyber = settings().fullCyber;
|
|
495
|
+
const auto = context.auto === true && withTools;
|
|
496
|
+
const system = context.search
|
|
497
|
+
? `${systemPrompt(cwd, false, fullCyber)}\n\n${searchAnswerRules(context.search)}`
|
|
498
|
+
: context.recall
|
|
499
|
+
? `${systemPrompt(cwd, false, fullCyber)}\n\n${recallRules()}`
|
|
500
|
+
: `${systemPrompt(cwd, withTools, fullCyber)}${auto ? `\n\n${autoRunRules(MAX_AUTO_STEPS)}` : ''}`;
|
|
501
|
+
let refusals = 0;
|
|
502
|
+
let autoSteps = 0;
|
|
503
|
+
for (;;) {
|
|
504
|
+
// Mode auto : au bout de la limite, on s'arrête net et on le dit, sans laisser le modèle enchaîner sans fin.
|
|
505
|
+
if (auto && autoSteps >= MAX_AUTO_STEPS) {
|
|
506
|
+
const note = { id: id16(), role: 'assistant', content: `J'ai exécuté ${autoSteps} commandes sans conclure. Je m'arrête par sécurité. Relance-moi si tu veux que je continue.`, at: new Date().toISOString(), model: model.name };
|
|
507
|
+
conversation.messages.push(note);
|
|
508
|
+
store.save(conversation);
|
|
509
|
+
yield { type: 'message', message: { ...note } };
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const message = { id: id16(), role: 'assistant', content: '', at: new Date().toISOString(), model: model.name };
|
|
513
|
+
conversation.messages.push(message);
|
|
514
|
+
yield { type: 'message', message: { ...message } };
|
|
515
|
+
let call = null;
|
|
516
|
+
try {
|
|
517
|
+
const turns = toTurns({ ...conversation, messages: conversation.messages.slice(0, -1) });
|
|
518
|
+
const chunks = provider().chat({
|
|
519
|
+
model: model.name,
|
|
520
|
+
messages: [{ role: 'system', content: system }, ...turns],
|
|
521
|
+
...(withTools ? { tools: [COMMAND_TOOL] } : {}),
|
|
522
|
+
signal,
|
|
523
|
+
firstTokenTimeoutMs: settings().ollama.replyTimeoutSec * 1000,
|
|
524
|
+
idleTimeoutMs: 60_000,
|
|
525
|
+
disableThinking: model.capabilities.includes('thinking'),
|
|
526
|
+
});
|
|
527
|
+
for await (const chunk of chunks) {
|
|
528
|
+
if (chunk.type === 'token') {
|
|
529
|
+
message.content += chunk.text;
|
|
530
|
+
yield { type: 'token', messageId: message.id, text: chunk.text };
|
|
531
|
+
}
|
|
532
|
+
else if (chunk.type === 'tool') {
|
|
533
|
+
if (!call && chunk.call.name === TOOL_NAME)
|
|
534
|
+
call = chunk.call;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
message.stats = chunk.stats;
|
|
538
|
+
yield { type: 'stats', messageId: message.id, stats: chunk.stats };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
const { code, message: text } = describeError(err);
|
|
544
|
+
message.error = text;
|
|
545
|
+
store.save(conversation);
|
|
546
|
+
journal.add({ kind: 'ai', outcome: code === 'aborted' ? 'cancelled' : 'error', title: `Réponse de ${model.name} interrompue`, detail: text, data: { conversation: conversation.id, model: model.name, code } });
|
|
547
|
+
yield { type: 'message', message: { ...message } };
|
|
548
|
+
if (code !== 'aborted')
|
|
549
|
+
yield { type: 'error', code, message: text };
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
journal.add({
|
|
553
|
+
kind: 'ai',
|
|
554
|
+
outcome: 'ok',
|
|
555
|
+
title: `Réponse de ${model.name}`,
|
|
556
|
+
detail: message.stats ? `${message.stats.tokens} tokens à ${message.stats.tokensPerSec} tokens/s.` : undefined,
|
|
557
|
+
data: { conversation: conversation.id, model: model.name, tokens: message.stats?.tokens ?? 0, tokensPerSec: message.stats?.tokensPerSec ?? 0, totalMs: message.stats?.totalMs ?? 0, proposed: call !== null },
|
|
558
|
+
});
|
|
559
|
+
if (!call) {
|
|
560
|
+
const outputs = conversation.messages.flatMap((m) => (m.proposal?.result ? [m.proposal.result.output, m.proposal.command] : []));
|
|
561
|
+
if (outputs.length > 0) {
|
|
562
|
+
// Les résultats de recherche et les passages lus comptent aussi comme source : un prix lu sur le web n'est pas inventé
|
|
563
|
+
const found = conversation.messages.flatMap((m) => [...(m.search ? [searchResultText(m.search, true)] : []), ...(m.recall ? [extractsText(m.recall.extracts, 0)] : [])]);
|
|
564
|
+
const references = [...outputs, ...found, ...conversation.messages.filter((m) => m.role === 'user').map((m) => userText(m))].join('\n');
|
|
565
|
+
const missing = unverifiedValues(message.content, references);
|
|
566
|
+
if (missing.length > 0)
|
|
567
|
+
message.unverified = missing.slice(0, 12);
|
|
568
|
+
}
|
|
569
|
+
store.save(conversation);
|
|
570
|
+
yield { type: 'message', message: { ...message } };
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const command = typeof call.arguments.commande === 'string' ? call.arguments.commande.trim() : '';
|
|
574
|
+
const explanation = typeof call.arguments.explication === 'string' ? call.arguments.explication.trim() : '';
|
|
575
|
+
if (!command) {
|
|
576
|
+
message.error = 'Le modèle a appelé l’outil sans donner de commande.';
|
|
577
|
+
store.save(conversation);
|
|
578
|
+
yield { type: 'message', message: { ...message } };
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
let proposal;
|
|
582
|
+
try {
|
|
583
|
+
const preview = await engine.preview(command, cwd, 'ai');
|
|
584
|
+
proposal = { id: id16(), command: preview.command, explanation, verdict: preview.verdict, status: preview.ticket ? 'pending' : 'refused', ticket: preview.ticket };
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const { code, message: text } = describeError(err);
|
|
588
|
+
const verdict = { level: 'denied', reasons: [{ code, message: text, severity: 'block' }], commands: [], writes: [], network: [] };
|
|
589
|
+
proposal = { id: id16(), command, explanation, verdict, status: 'refused', ticket: null };
|
|
590
|
+
}
|
|
591
|
+
message.proposal = proposal;
|
|
592
|
+
store.save(conversation);
|
|
593
|
+
// En mode auto, on n'affiche pas l'état « en attente » (aucun bouton à présenter) : l'exécution part tout de suite.
|
|
594
|
+
if (!(auto && proposal.status === 'pending'))
|
|
595
|
+
yield { type: 'proposal', messageId: message.id, proposal };
|
|
596
|
+
if (proposal.status === 'pending') {
|
|
597
|
+
// Hors auto : on s'arrête et on attend la décision humaine.
|
|
598
|
+
if (!auto)
|
|
599
|
+
return;
|
|
600
|
+
// Mode auto : la politique a délivré un ticket (lecture ou sensible), on exécute nous-mêmes, comme un « oui ».
|
|
601
|
+
let execution;
|
|
602
|
+
try {
|
|
603
|
+
execution = engine.start(proposal.ticket ?? '', { acknowledge: true, confirmation: 'human' });
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
const { code, message: text } = describeError(err);
|
|
607
|
+
message.proposal = { ...proposal, status: 'refused', ticket: null, verdict: { ...proposal.verdict, reasons: [{ code, message: text, severity: 'block' }] } };
|
|
608
|
+
store.save(conversation);
|
|
609
|
+
yield { type: 'proposal', messageId: message.id, proposal: message.proposal };
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
yield* this.runExecution(conversation, message, proposal, execution, signal);
|
|
613
|
+
autoSteps++;
|
|
614
|
+
if (signal.aborted)
|
|
615
|
+
return;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
// Refusée par la politique : le modèle reçoit la raison et peut corriger, trois fois au plus.
|
|
619
|
+
refusals++;
|
|
620
|
+
if (refusals >= MAX_REFUSALS || signal.aborted)
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|