palabre 0.1.4
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/README.md +377 -0
- package/dist/adapters/cli.js +250 -0
- package/dist/adapters/index.js +11 -0
- package/dist/adapters/ollama.js +218 -0
- package/dist/config.js +199 -0
- package/dist/configWizard.js +196 -0
- package/dist/context.js +198 -0
- package/dist/discovery.js +121 -0
- package/dist/doctor.js +341 -0
- package/dist/errors.js +49 -0
- package/dist/index.js +635 -0
- package/dist/limits.js +41 -0
- package/dist/new.js +312 -0
- package/dist/orchestrator.js +195 -0
- package/dist/output.js +100 -0
- package/dist/presets.js +98 -0
- package/dist/prompt.js +122 -0
- package/dist/renderers/console.js +171 -0
- package/dist/session.js +35 -0
- package/dist/types.js +1 -0
- package/dist/update.js +68 -0
- package/package.json +46 -0
- package/palabre.config.example.json +81 -0
package/dist/limits.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const DEFAULT_TURNS = 4;
|
|
2
|
+
export const MAX_TURNS = 20;
|
|
3
|
+
/** Convertit `value` en nombre et valide la plage [1, `MAX_TURNS`]. Lève une erreur si invalide. */
|
|
4
|
+
export function parseTurns(value, label = "--turns") {
|
|
5
|
+
const parsed = typeof value === "number" ? value : Number(value ?? DEFAULT_TURNS);
|
|
6
|
+
validateTurns(parsed, label);
|
|
7
|
+
return parsed;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Variante pour les flags CLI : gère les types polymorphes retournés par les parsers d'arguments
|
|
11
|
+
* (`boolean` pour un flag sans valeur, `string[]` si fourni plusieurs fois).
|
|
12
|
+
* Lève une erreur si `value` est un booléen ou un tableau de longueur ≠ 1.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTurnsFlag(value, fallback = DEFAULT_TURNS, label = "--turns") {
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
return parseTurns(fallback, label);
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "boolean") {
|
|
19
|
+
throw new Error(`${label} attend un nombre entier entre 1 et ${MAX_TURNS}.`);
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
if (value.length !== 1) {
|
|
23
|
+
throw new Error(`${label} doit être fourni une seule fois.`);
|
|
24
|
+
}
|
|
25
|
+
return parseTurns(value[0], label);
|
|
26
|
+
}
|
|
27
|
+
return parseTurns(value, label);
|
|
28
|
+
}
|
|
29
|
+
/** Valide que `value` est un entier dans [1, `MAX_TURNS`]. Lève une erreur descriptive sinon. */
|
|
30
|
+
export function validateTurns(value, label = "--turns") {
|
|
31
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_TURNS) {
|
|
32
|
+
throw new Error(`${label} doit être un nombre entier entre 1 et ${MAX_TURNS}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Retourne `value` s'il est valide, sinon `DEFAULT_TURNS`. Silencieux : ne lève pas d'erreur. */
|
|
36
|
+
export function turnsOrDefault(value) {
|
|
37
|
+
if (value !== undefined && Number.isInteger(value) && value >= 1 && value <= MAX_TURNS) {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_TURNS;
|
|
41
|
+
}
|
package/dist/new.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { discoverLocalTools } from "./discovery.js";
|
|
4
|
+
import { findPresetNameForPair } from "./presets.js";
|
|
5
|
+
import { MAX_TURNS, turnsOrDefault, validateTurns } from "./limits.js";
|
|
6
|
+
/**
|
|
7
|
+
* Lance le wizard interactif `palabre new`.
|
|
8
|
+
* Détecte les outils locaux, liste les agents de la config et guide la composition du débat.
|
|
9
|
+
* Retourne `undefined` si l'utilisateur annule (q/quit/exit ou Ctrl+C).
|
|
10
|
+
*/
|
|
11
|
+
export async function runNewWizard(config) {
|
|
12
|
+
const discovery = await discoverLocalTools();
|
|
13
|
+
const choices = buildAgentChoices(config, discovery);
|
|
14
|
+
if (choices.length < 2) {
|
|
15
|
+
throw new Error("palabre new a besoin d'au moins deux agents dans la config. Lance `palabre init` ou edite ta config.");
|
|
16
|
+
}
|
|
17
|
+
const rl = await createQuestioner();
|
|
18
|
+
try {
|
|
19
|
+
console.log("PALABRE - ASSISTANT DE CONFIGURATION");
|
|
20
|
+
console.log("À tout moment: Ctrl+C pour interrompre, ou tape q, quit ou exit dans un prompt pour quitter.");
|
|
21
|
+
console.log("Appuie sur Entrée pour accepter un choix par défaut (*).");
|
|
22
|
+
console.log("");
|
|
23
|
+
const agentA = await askAgent(rl, choices, "Agent A", config.defaults?.agentA);
|
|
24
|
+
if (!agentA)
|
|
25
|
+
return undefined;
|
|
26
|
+
const agentB = await askAgent(rl, choices.filter((choice) => choice.name !== agentA), "Agent B", config.defaults?.agentB === agentA ? undefined : config.defaults?.agentB);
|
|
27
|
+
if (!agentB)
|
|
28
|
+
return undefined;
|
|
29
|
+
const topic = await askRequiredText(rl, "Sujet");
|
|
30
|
+
if (!topic)
|
|
31
|
+
return undefined;
|
|
32
|
+
printCommandPreview({ agentA, agentB, topic, turns: turnsOrDefault(config.defaults?.turns) });
|
|
33
|
+
console.log("Réponds non pour choisir le nombre de réponses, les modèles, la synthèse et le contexte.");
|
|
34
|
+
const launchMinimal = await askYesNo(rl, "Lancer maintenant avec les options par défaut ?", true);
|
|
35
|
+
if (launchMinimal === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (launchMinimal) {
|
|
38
|
+
return {
|
|
39
|
+
agentA,
|
|
40
|
+
agentB,
|
|
41
|
+
topic,
|
|
42
|
+
files: [],
|
|
43
|
+
context: [],
|
|
44
|
+
showPrompt: false,
|
|
45
|
+
plainOutput: false
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const turns = await askNumber(rl, "Nombre de réponses", turnsOrDefault(config.defaults?.turns));
|
|
49
|
+
if (turns === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const modelA = await askOptionalText(rl, `Modèle pour ${agentA} (optionnel)`);
|
|
52
|
+
if (modelA === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const modelB = await askOptionalText(rl, `Modèle pour ${agentB} (optionnel)`);
|
|
55
|
+
if (modelB === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const summaryEnabled = await askYesNo(rl, "Synthèse finale ?", true);
|
|
58
|
+
if (summaryEnabled === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
let summaryAgent;
|
|
61
|
+
let summaryModel;
|
|
62
|
+
if (summaryEnabled) {
|
|
63
|
+
summaryAgent = await askAgent(rl, choices, "Agent de synthèse", config.defaults?.summaryAgent ?? agentB);
|
|
64
|
+
if (!summaryAgent)
|
|
65
|
+
return undefined;
|
|
66
|
+
summaryModel = await askOptionalText(rl, `Modèle de synthèse pour ${summaryAgent} (optionnel)`);
|
|
67
|
+
if (summaryModel === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const context = splitPaths(await askOptionalText(rl, "Contexte dossier/fichier via --context (optionnel)"));
|
|
71
|
+
const files = splitPaths(await askOptionalText(rl, "Fichiers stricts via --files (optionnel)"));
|
|
72
|
+
const showPrompt = await askYesNo(rl, "Afficher seulement le prompt ?", false);
|
|
73
|
+
if (showPrompt === undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
const plainOutput = await askYesNo(rl, "Rendu plain ?", false);
|
|
76
|
+
if (plainOutput === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
const selection = {
|
|
79
|
+
agentA,
|
|
80
|
+
agentB,
|
|
81
|
+
topic,
|
|
82
|
+
modelA,
|
|
83
|
+
modelB,
|
|
84
|
+
turns,
|
|
85
|
+
summaryAgent,
|
|
86
|
+
summaryModel,
|
|
87
|
+
summaryEnabled,
|
|
88
|
+
files,
|
|
89
|
+
context,
|
|
90
|
+
showPrompt,
|
|
91
|
+
plainOutput
|
|
92
|
+
};
|
|
93
|
+
printCommandPreview(selection);
|
|
94
|
+
return selection;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
rl.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function createQuestioner() {
|
|
101
|
+
if (input.isTTY) {
|
|
102
|
+
return createInterface({ input, output });
|
|
103
|
+
}
|
|
104
|
+
const lines = await readPipedLines();
|
|
105
|
+
let index = 0;
|
|
106
|
+
return {
|
|
107
|
+
async question(prompt) {
|
|
108
|
+
output.write(prompt);
|
|
109
|
+
const value = lines[index];
|
|
110
|
+
index += 1;
|
|
111
|
+
output.write(`${value ?? "q"}\n`);
|
|
112
|
+
return value ?? "q";
|
|
113
|
+
},
|
|
114
|
+
close() {
|
|
115
|
+
// Nothing to close for scripted stdin.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function readPipedLines() {
|
|
120
|
+
let raw = "";
|
|
121
|
+
for await (const chunk of input) {
|
|
122
|
+
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
123
|
+
}
|
|
124
|
+
return raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
125
|
+
}
|
|
126
|
+
function buildAgentChoices(config, discovery) {
|
|
127
|
+
return Object.entries(config.agents)
|
|
128
|
+
.map(([name, agentConfig]) => {
|
|
129
|
+
const detected = isAgentDetected(name, agentConfig, discovery);
|
|
130
|
+
return {
|
|
131
|
+
name,
|
|
132
|
+
config: agentConfig,
|
|
133
|
+
detected,
|
|
134
|
+
status: agentStatus(name, agentConfig, discovery, detected)
|
|
135
|
+
};
|
|
136
|
+
})
|
|
137
|
+
.sort((left, right) => Number(right.detected) - Number(left.detected) || left.name.localeCompare(right.name));
|
|
138
|
+
}
|
|
139
|
+
function isAgentDetected(name, config, discovery) {
|
|
140
|
+
if (config.type === "ollama") {
|
|
141
|
+
return discovery.ollama.available;
|
|
142
|
+
}
|
|
143
|
+
const normalized = normalizeCommandName(config.command || name);
|
|
144
|
+
if (normalized === "codex")
|
|
145
|
+
return discovery.codex.available;
|
|
146
|
+
if (normalized === "claude")
|
|
147
|
+
return discovery.claude.available;
|
|
148
|
+
if (normalized === "gemini")
|
|
149
|
+
return discovery.gemini.available;
|
|
150
|
+
if (normalized === "opencode")
|
|
151
|
+
return discovery.opencode.available;
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
function agentStatus(_name, config, discovery, detected) {
|
|
155
|
+
if (config.type === "ollama") {
|
|
156
|
+
return detected
|
|
157
|
+
? `ollama/${config.role} détecté (${discovery.ollama.models.length} modèle(s))`
|
|
158
|
+
: `ollama/${config.role} non joignable`;
|
|
159
|
+
}
|
|
160
|
+
return detected
|
|
161
|
+
? `cli/${config.role} détecté`
|
|
162
|
+
: `cli/${config.role} non détecté`;
|
|
163
|
+
}
|
|
164
|
+
async function askAgent(rl, choices, label, defaultName) {
|
|
165
|
+
const fallback = choices.find((choice) => choice.name === defaultName)?.name ?? choices[0]?.name;
|
|
166
|
+
console.log(label);
|
|
167
|
+
choices.forEach((choice, index) => {
|
|
168
|
+
const marker = choice.name === fallback ? "(*)" : " ";
|
|
169
|
+
console.log(` ${index + 1}) ${marker} ${choice.name} - ${choice.status}`);
|
|
170
|
+
});
|
|
171
|
+
while (true) {
|
|
172
|
+
const answer = await rl.question(`${label} [${fallback}]: `);
|
|
173
|
+
const value = answer.trim();
|
|
174
|
+
if (isQuit(value))
|
|
175
|
+
return undefined;
|
|
176
|
+
if (!value)
|
|
177
|
+
return fallback;
|
|
178
|
+
const number = Number(value);
|
|
179
|
+
if (Number.isInteger(number) && number >= 1 && number <= choices.length) {
|
|
180
|
+
return choices[number - 1]?.name;
|
|
181
|
+
}
|
|
182
|
+
if (choices.some((choice) => choice.name === value)) {
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
console.log("Choix invalide. Tape un numéro, un nom d'agent, Entrée ou q.");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function askRequiredText(rl, label) {
|
|
189
|
+
while (true) {
|
|
190
|
+
const answer = await rl.question(`${label}: `);
|
|
191
|
+
const value = answer.trim();
|
|
192
|
+
if (isQuit(value))
|
|
193
|
+
return undefined;
|
|
194
|
+
if (value)
|
|
195
|
+
return value;
|
|
196
|
+
console.log("Ce champ est requis pour lancer un débat.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function askOptionalText(rl, label) {
|
|
200
|
+
const answer = await rl.question(`${label}: `);
|
|
201
|
+
const value = answer.trim();
|
|
202
|
+
return isQuit(value) ? undefined : value;
|
|
203
|
+
}
|
|
204
|
+
async function askNumber(rl, label, defaultValue) {
|
|
205
|
+
while (true) {
|
|
206
|
+
const answer = await rl.question(`${label} [${defaultValue}]: `);
|
|
207
|
+
const value = answer.trim();
|
|
208
|
+
if (isQuit(value))
|
|
209
|
+
return undefined;
|
|
210
|
+
if (!value)
|
|
211
|
+
return defaultValue;
|
|
212
|
+
const parsed = Number(value);
|
|
213
|
+
if (Number.isInteger(parsed)) {
|
|
214
|
+
try {
|
|
215
|
+
validateTurns(parsed, "Le nombre de réponses");
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Show the user-facing wizard hint below.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
console.log(`Entre un nombre entier entre 1 et ${MAX_TURNS}, Entrée ou q.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function askYesNo(rl, label, defaultValue) {
|
|
226
|
+
const suffix = defaultValue ? "Y/n" : "y/N";
|
|
227
|
+
while (true) {
|
|
228
|
+
const answer = await rl.question(`${label} [${suffix}]: `);
|
|
229
|
+
const value = answer.trim().toLowerCase();
|
|
230
|
+
if (isQuit(value))
|
|
231
|
+
return undefined;
|
|
232
|
+
if (!value)
|
|
233
|
+
return defaultValue;
|
|
234
|
+
if (["y", "yes", "o", "oui"].includes(value))
|
|
235
|
+
return true;
|
|
236
|
+
if (["n", "no", "non"].includes(value))
|
|
237
|
+
return false;
|
|
238
|
+
console.log("Réponds par oui, non, Entrée ou q.");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function splitPaths(value) {
|
|
242
|
+
return value
|
|
243
|
+
?.split(/\s+/)
|
|
244
|
+
.map((entry) => entry.trim())
|
|
245
|
+
.filter(Boolean) ?? [];
|
|
246
|
+
}
|
|
247
|
+
function normalizeCommandName(command) {
|
|
248
|
+
return command
|
|
249
|
+
.split(/[\\/]/)
|
|
250
|
+
.pop()
|
|
251
|
+
?.toLowerCase()
|
|
252
|
+
.replace(/\.(exe|cmd|bat|ps1)$/i, "") ?? command.toLowerCase();
|
|
253
|
+
}
|
|
254
|
+
function isQuit(value) {
|
|
255
|
+
return ["q", "quit", "exit"].includes(value.toLowerCase());
|
|
256
|
+
}
|
|
257
|
+
function printCommandPreview(selection) {
|
|
258
|
+
const explicitCommand = buildExplicitCommand(selection);
|
|
259
|
+
const shortCommand = buildShortCommand(selection);
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log("Commandes équivalentes:");
|
|
262
|
+
console.log(` ${explicitCommand}`);
|
|
263
|
+
if (shortCommand) {
|
|
264
|
+
console.log(` ${shortCommand}`);
|
|
265
|
+
}
|
|
266
|
+
console.log("");
|
|
267
|
+
}
|
|
268
|
+
function buildExplicitCommand(selection) {
|
|
269
|
+
const args = ["palabre"];
|
|
270
|
+
args.push("--agent-a", selection.agentA);
|
|
271
|
+
args.push("--agent-b", selection.agentB);
|
|
272
|
+
args.push(quoteShellArg(selection.topic));
|
|
273
|
+
appendOptionalArgs(args, selection);
|
|
274
|
+
return args.join(" ");
|
|
275
|
+
}
|
|
276
|
+
function buildShortCommand(selection) {
|
|
277
|
+
const presetName = findPresetNameForPair(selection.agentA, selection.agentB);
|
|
278
|
+
if (!presetName) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const args = ["palabre", presetName, quoteShellArg(selection.topic)];
|
|
282
|
+
appendOptionalArgs(args, selection);
|
|
283
|
+
return args.join(" ");
|
|
284
|
+
}
|
|
285
|
+
function appendOptionalArgs(args, selection) {
|
|
286
|
+
if (selection.turns)
|
|
287
|
+
args.push("-t", String(selection.turns));
|
|
288
|
+
if (selection.modelA)
|
|
289
|
+
args.push("--model-a", quoteShellArg(selection.modelA));
|
|
290
|
+
if (selection.modelB)
|
|
291
|
+
args.push("--model-b", quoteShellArg(selection.modelB));
|
|
292
|
+
if (selection.summaryEnabled === false)
|
|
293
|
+
args.push("--no-summary");
|
|
294
|
+
if (selection.summaryAgent)
|
|
295
|
+
args.push("--summary-agent", selection.summaryAgent);
|
|
296
|
+
if (selection.summaryModel)
|
|
297
|
+
args.push("--summary-model", quoteShellArg(selection.summaryModel));
|
|
298
|
+
if (selection.context && selection.context.length > 0)
|
|
299
|
+
args.push("--context", ...selection.context.map(quoteShellArg));
|
|
300
|
+
if (selection.files && selection.files.length > 0)
|
|
301
|
+
args.push("--files", ...selection.files.map(quoteShellArg));
|
|
302
|
+
if (selection.showPrompt)
|
|
303
|
+
args.push("--show-prompt");
|
|
304
|
+
if (selection.plainOutput)
|
|
305
|
+
args.push("--plain");
|
|
306
|
+
}
|
|
307
|
+
function quoteShellArg(value) {
|
|
308
|
+
if (/^[A-Za-z0-9._/:\\-]+$/.test(value)) {
|
|
309
|
+
return value;
|
|
310
|
+
}
|
|
311
|
+
return `"${value.replace(/(["`$\\])/g, "\\$1")}"`;
|
|
312
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createAgent } from "./adapters/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Point d'entrée de l'orchestration.
|
|
4
|
+
* Lance le ping-pong entre `agentA` et `agentB` pendant `options.turns` tours,
|
|
5
|
+
* applique l'arrêt anticipé si activé, puis génère la synthèse si `summaryEnabled` est vrai.
|
|
6
|
+
*
|
|
7
|
+
* @throws {Error} si un agent référencé dans `options` est absent de `config.agents`.
|
|
8
|
+
*/
|
|
9
|
+
export async function runDebate(config, options, renderer) {
|
|
10
|
+
const agentAConfig = withRuntimeOverrides(config.agents[options.agentA], options.modelA, options.pullModels);
|
|
11
|
+
const agentBConfig = withRuntimeOverrides(config.agents[options.agentB], options.modelB, options.pullModels);
|
|
12
|
+
if (!agentAConfig) {
|
|
13
|
+
throw new Error(`Agent inconnu: ${options.agentA}`);
|
|
14
|
+
}
|
|
15
|
+
if (!agentBConfig) {
|
|
16
|
+
throw new Error(`Agent inconnu: ${options.agentB}`);
|
|
17
|
+
}
|
|
18
|
+
warnIfOllamaHasNoContext(options, [
|
|
19
|
+
[options.agentA, agentAConfig],
|
|
20
|
+
[options.agentB, agentBConfig]
|
|
21
|
+
], renderer);
|
|
22
|
+
renderer?.start(options, [
|
|
23
|
+
{ name: options.agentA, role: agentAConfig.role, type: agentAConfig.type },
|
|
24
|
+
{ name: options.agentB, role: agentBConfig.role, type: agentBConfig.type }
|
|
25
|
+
]);
|
|
26
|
+
const agents = [
|
|
27
|
+
createAgent(options.agentA, agentAConfig),
|
|
28
|
+
createAgent(options.agentB, agentBConfig)
|
|
29
|
+
];
|
|
30
|
+
const messages = [];
|
|
31
|
+
let stopReason;
|
|
32
|
+
for (let index = 0; index < options.turns; index += 1) {
|
|
33
|
+
const current = agents[index % agents.length];
|
|
34
|
+
const peer = agents[(index + 1) % agents.length];
|
|
35
|
+
const turn = index + 1;
|
|
36
|
+
renderer?.turnStart(turn, options.turns, current.name, current.role);
|
|
37
|
+
renderer?.thinkingStart(current.name, current.role);
|
|
38
|
+
const response = await current.generate({
|
|
39
|
+
topic: options.topic,
|
|
40
|
+
turn,
|
|
41
|
+
selfName: current.name,
|
|
42
|
+
peerName: peer.name,
|
|
43
|
+
selfRole: current.role,
|
|
44
|
+
session: options.session,
|
|
45
|
+
files: options.files,
|
|
46
|
+
transcript: messages
|
|
47
|
+
}).finally(() => renderer?.thinkingEnd());
|
|
48
|
+
const message = {
|
|
49
|
+
agent: current.name,
|
|
50
|
+
role: current.role,
|
|
51
|
+
content: response.content,
|
|
52
|
+
createdAt: new Date().toISOString()
|
|
53
|
+
};
|
|
54
|
+
messages.push(message);
|
|
55
|
+
renderer?.message(message.content);
|
|
56
|
+
if (shouldStopOnAgreement(options, messages)) {
|
|
57
|
+
stopReason = "Accord clair detecte apres un tour complet.";
|
|
58
|
+
renderer?.notice(`Arret anticipe: ${stopReason}`);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const summary = options.summaryEnabled
|
|
63
|
+
? await generateSummary(config, options, messages, renderer)
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
options,
|
|
67
|
+
messages,
|
|
68
|
+
summary,
|
|
69
|
+
stopReason
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Heuristique d'arrêt sur accord explicite.
|
|
74
|
+
* Ne s'active qu'après un tour complet (nombre pair de messages) pour éviter les faux positifs.
|
|
75
|
+
* Intentionnellement prudente : ne remplace pas une évaluation sémantique réelle.
|
|
76
|
+
*/
|
|
77
|
+
function shouldStopOnAgreement(options, messages) {
|
|
78
|
+
if (!options.earlyStopOnAgreement || messages.length < 2 || messages.length % 2 !== 0) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const latest = normalizeForAgreement(messages[messages.length - 1]?.content ?? "");
|
|
82
|
+
if (!latest) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const positivePatterns = [
|
|
86
|
+
"accord complet",
|
|
87
|
+
"accord total",
|
|
88
|
+
"aucun desaccord",
|
|
89
|
+
"aucune incertitude",
|
|
90
|
+
"rien a trancher",
|
|
91
|
+
"rien a ajouter",
|
|
92
|
+
"question factuelle resolue"
|
|
93
|
+
];
|
|
94
|
+
if (positivePatterns.some((pattern) => latest.includes(pattern))) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return (latest.includes("confirme") || latest.includes("acte")) &&
|
|
98
|
+
(latest.includes("aucun") || latest.includes("rien a trancher") || latest.includes("rien a ajouter"));
|
|
99
|
+
}
|
|
100
|
+
/** Normalise le texte pour la détection d'accord : minuscules, sans diacritiques, espaces unifiés. */
|
|
101
|
+
function normalizeForAgreement(value) {
|
|
102
|
+
return value
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.normalize("NFD")
|
|
105
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
106
|
+
.replace(/[’']/g, " ")
|
|
107
|
+
.replace(/\s+/g, " ")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Émet un avertissement si un agent Ollama participe sans contexte fichier.
|
|
112
|
+
* L'adapter Ollama ne lit pas le filesystem : sans `--files` ou `--context`, il ne voit pas le projet.
|
|
113
|
+
*/
|
|
114
|
+
function warnIfOllamaHasNoContext(options, agents, renderer) {
|
|
115
|
+
if (options.files.length > 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ollamaAgents = agents
|
|
119
|
+
.filter(([, config]) => config.type === "ollama")
|
|
120
|
+
.map(([name]) => name)
|
|
121
|
+
.filter((name, index, names) => names.indexOf(name) === index);
|
|
122
|
+
if (ollamaAgents.length === 0) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
renderer?.warning(`${ollamaAgents.join(", ")} ne lit pas le filesystem. Ajoute --files ou --context pour fournir un contexte projet.`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Phase de synthèse post-débat. Utilise `options.summaryAgent` quand il est défini, sinon `agentB`.
|
|
129
|
+
*
|
|
130
|
+
* @throws {Error} si l'agent de synthèse est absent de `config.agents`.
|
|
131
|
+
*/
|
|
132
|
+
async function generateSummary(config, options, messages, renderer) {
|
|
133
|
+
const summaryAgentName = options.summaryAgent ?? options.agentB;
|
|
134
|
+
const summaryModel = options.summaryModel ?? modelForAgent(options, summaryAgentName);
|
|
135
|
+
const summaryConfig = withRuntimeOverrides(config.agents[summaryAgentName], summaryModel, options.pullModels);
|
|
136
|
+
if (!summaryConfig) {
|
|
137
|
+
throw new Error(`Agent de synthese inconnu: ${summaryAgentName}`);
|
|
138
|
+
}
|
|
139
|
+
const summaryAgent = createAgent(summaryAgentName, summaryConfig);
|
|
140
|
+
renderer?.summaryStart(summaryAgent.name, summaryAgent.role);
|
|
141
|
+
renderer?.thinkingStart(summaryAgent.name, summaryAgent.role);
|
|
142
|
+
const response = await summaryAgent.generate({
|
|
143
|
+
topic: options.topic,
|
|
144
|
+
turn: messages.length + 1,
|
|
145
|
+
selfName: summaryAgent.name,
|
|
146
|
+
peerName: "transcript",
|
|
147
|
+
selfRole: summaryAgent.role,
|
|
148
|
+
mode: "summary",
|
|
149
|
+
session: options.session,
|
|
150
|
+
files: options.files,
|
|
151
|
+
transcript: messages
|
|
152
|
+
}).finally(() => renderer?.thinkingEnd());
|
|
153
|
+
const summary = {
|
|
154
|
+
agent: summaryAgent.name,
|
|
155
|
+
role: summaryAgent.role,
|
|
156
|
+
content: response.content,
|
|
157
|
+
createdAt: new Date().toISOString()
|
|
158
|
+
};
|
|
159
|
+
renderer?.message(summary.content);
|
|
160
|
+
return summary;
|
|
161
|
+
}
|
|
162
|
+
/** Résout le model override pour un agent donné. Retourne `undefined` si l'agent n'est ni A ni B. */
|
|
163
|
+
function modelForAgent(options, agent) {
|
|
164
|
+
if (agent === options.agentA) {
|
|
165
|
+
return options.modelA;
|
|
166
|
+
}
|
|
167
|
+
if (agent === options.agentB) {
|
|
168
|
+
return options.modelB;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Fusionne les overrides runtime dans la config agent.
|
|
174
|
+
* Pour l'adapter `ollama`, applique aussi `autoPullModel` si `pullModels` est vrai.
|
|
175
|
+
* Retourne `undefined` si `config` est `undefined`.
|
|
176
|
+
*/
|
|
177
|
+
function withRuntimeOverrides(config, model, pullModels) {
|
|
178
|
+
if (!config) {
|
|
179
|
+
return config;
|
|
180
|
+
}
|
|
181
|
+
if (config.type === "ollama") {
|
|
182
|
+
return {
|
|
183
|
+
...config,
|
|
184
|
+
...(model ? { model } : {}),
|
|
185
|
+
...(pullModels ? { autoPullModel: true } : {})
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (!model) {
|
|
189
|
+
return config;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
...config,
|
|
193
|
+
model
|
|
194
|
+
};
|
|
195
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Écrit le débat au format Markdown dans `outputDir`.
|
|
5
|
+
* Crée le répertoire si absent. Retourne le chemin absolu du fichier créé.
|
|
6
|
+
*/
|
|
7
|
+
export async function writeDebateMarkdown(outputDir, options, messages, summary, stopReason) {
|
|
8
|
+
const safeDate = new Date().toISOString().replace(/[:.]/g, "-");
|
|
9
|
+
const fileName = `palabre-${slugifyTopic(options.topic)}-${safeDate}.debate.md`;
|
|
10
|
+
const filePath = path.resolve(outputDir, fileName);
|
|
11
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
12
|
+
await writeFile(filePath, renderDebateMarkdown(options, messages, summary, stopReason), "utf8");
|
|
13
|
+
return filePath;
|
|
14
|
+
}
|
|
15
|
+
function slugifyTopic(topic) {
|
|
16
|
+
const slug = topic
|
|
17
|
+
.normalize("NFD")
|
|
18
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "")
|
|
22
|
+
.slice(0, 64)
|
|
23
|
+
.replace(/-+$/g, "");
|
|
24
|
+
return slug || "debat";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Produit la représentation Markdown complète du débat.
|
|
28
|
+
* Fonction pure : aucun effet de bord sur le filesystem.
|
|
29
|
+
*/
|
|
30
|
+
export function renderDebateMarkdown(options, messages, summary, stopReason) {
|
|
31
|
+
const lines = [
|
|
32
|
+
"# PALABRE Debate",
|
|
33
|
+
"",
|
|
34
|
+
...renderSessionHeader(options, messages, stopReason),
|
|
35
|
+
"",
|
|
36
|
+
"## Contexte",
|
|
37
|
+
"",
|
|
38
|
+
...renderFileList(options.files),
|
|
39
|
+
"",
|
|
40
|
+
"## Echanges",
|
|
41
|
+
""
|
|
42
|
+
];
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
lines.push(`### ${message.agent} (${message.role})`, "", normalizeMarkdownForWindowsPreview(message.content.trim()), "");
|
|
45
|
+
}
|
|
46
|
+
lines.push("---", "", "## Synthese finale", "", ...renderSummaryBlock(options, summary));
|
|
47
|
+
return `${lines.join("\n")}\n`;
|
|
48
|
+
}
|
|
49
|
+
function renderSummaryBlock(options, summary) {
|
|
50
|
+
if (summary) {
|
|
51
|
+
return [
|
|
52
|
+
"| Champ | Valeur |",
|
|
53
|
+
"| --- | --- |",
|
|
54
|
+
`| Agent | ${escapeTableCell(summary.agent)} |`,
|
|
55
|
+
`| Role | ${escapeTableCell(summary.role)} |`,
|
|
56
|
+
`| Date | ${escapeTableCell(summary.createdAt)} |`,
|
|
57
|
+
"",
|
|
58
|
+
normalizeMarkdownForWindowsPreview(summary.content.trim()),
|
|
59
|
+
""
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
options.summaryEnabled
|
|
64
|
+
? "_Synthese finale demandee mais non disponible._"
|
|
65
|
+
: "_Synthese desactivee._",
|
|
66
|
+
""
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
function normalizeMarkdownForWindowsPreview(content) {
|
|
70
|
+
return content.replace(/:\*\*/g, ":**");
|
|
71
|
+
}
|
|
72
|
+
function renderSessionHeader(options, messages, stopReason) {
|
|
73
|
+
const rows = [
|
|
74
|
+
["Sujet", options.topic],
|
|
75
|
+
["Agents", `${options.agentA} <-> ${options.agentB}`],
|
|
76
|
+
["Auto-pull Ollama", options.pullModels ? "yes" : "no"],
|
|
77
|
+
["Synthese", options.summaryEnabled ? options.summaryAgent ?? options.agentB : "disabled"],
|
|
78
|
+
["Tours demandes", String(options.turns)],
|
|
79
|
+
["Tours joues", String(messages.length)],
|
|
80
|
+
["Arret anticipe", stopReason ?? "no"],
|
|
81
|
+
["Date locale", options.session.localDate],
|
|
82
|
+
["Fuseau horaire", options.session.timeZone],
|
|
83
|
+
["Dossier courant", options.session.cwd],
|
|
84
|
+
["Session demarree a", options.session.startedAt]
|
|
85
|
+
];
|
|
86
|
+
return [
|
|
87
|
+
"| Champ | Valeur |",
|
|
88
|
+
"| --- | --- |",
|
|
89
|
+
...rows.map(([label, value]) => `| ${escapeTableCell(label)} | ${escapeTableCell(value)} |`)
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
function escapeTableCell(value) {
|
|
93
|
+
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
94
|
+
}
|
|
95
|
+
function renderFileList(files) {
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
return ["Aucun contexte fichier injecte."];
|
|
98
|
+
}
|
|
99
|
+
return files.map((file) => `- \`${file.path}\` (${file.sizeBytes} bytes)`);
|
|
100
|
+
}
|