palabre 0.6.1 → 0.6.3

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 CHANGED
@@ -93,7 +93,9 @@ palabre --version
93
93
 
94
94
  Commandes utiles : `pnpm check`, `pnpm test`, `pnpm build`.
95
95
 
96
- Roadmap publique : [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Guide agents/contributeurs : [AGENTS.md](./AGENTS.md).
96
+ Avant une publication, `pnpm smoke:real-presets -- --keep-going` lance des débats réels sur les presets prioritaires disponibles afin de vérifier le flux complet agent → NDJSON → export. Ce smoke test appelle de vraies CLIs IA et peut consommer des quotas ; il n'est donc pas lancé par `pnpm test`.
97
+
98
+ Roadmap publique : [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Changements : [CHANGELOG.md](./CHANGELOG.md). Guide agents/contributeurs : [AGENTS.md](./AGENTS.md).
97
99
 
98
100
  ### Licence
99
101
 
@@ -181,7 +183,9 @@ palabre --version
181
183
 
182
184
  Useful commands: `pnpm check`, `pnpm test`, `pnpm build`.
183
185
 
184
- Public roadmap: [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Agent/contributor guide: [AGENTS.md](./AGENTS.md).
186
+ Before publishing, `pnpm smoke:real-presets -- --keep-going` runs real debates for the available priority presets to validate the full agent → NDJSON → export flow. This smoke test calls real AI CLIs and may consume quota, so it is not part of `pnpm test`.
187
+
188
+ Public roadmap: [docs/guide/fr/roadmap.md](./docs/guide/fr/roadmap.md). Changes: [CHANGELOG.md](./CHANGELOG.md). Agent/contributor guide: [AGENTS.md](./AGENTS.md).
185
189
 
186
190
  ### License
187
191
 
@@ -1,10 +1,10 @@
1
- import { spawn as spawnPty } from "node-pty";
2
1
  import { existsSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import { AdapterError } from "../errors.js";
4
+ import { executableExtensions } from "../exec.js";
5
5
  import { formatAgentPrompt } from "../prompt.js";
6
+ import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, withModelArgs } from "./cli-shared.js";
6
7
  import { cleanTerminalOutput } from "./terminal.js";
7
- const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
8
8
  /**
9
9
  * Adapter pour les CLIs qui exigent un vrai terminal.
10
10
  * Contrairement à `CliAdapter`, stdout/stderr sont fusionnés dans le flux PTY.
@@ -44,6 +44,7 @@ export class CliPtyAdapter {
44
44
  const args = promptMode === "argument"
45
45
  ? [...baseArgs, renderedPrompt]
46
46
  : baseArgs;
47
+ const { spawn: spawnPty } = await import("node-pty");
47
48
  return new Promise((resolve, reject) => {
48
49
  let output = "";
49
50
  let outputBytes = 0;
@@ -107,10 +108,10 @@ export class CliPtyAdapter {
107
108
  return;
108
109
  }
109
110
  hardTimer = setTimeout(() => {
110
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
111
- timeoutMs: this.config.timeoutMs ?? 180_000
111
+ finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
112
+ timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
112
113
  }));
113
- }, this.config.timeoutMs ?? 180_000);
114
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
114
115
  dataSubscription = term.onData((chunk) => {
115
116
  outputBytes += Buffer.byteLength(chunk, "utf8");
116
117
  if (outputBytes > maxOutputBytes) {
@@ -158,30 +159,6 @@ function cleanupPty(term) {
158
159
  // Best-effort cleanup for Windows ConPTY internals.
159
160
  }
160
161
  }
161
- function executableExtensions(command) {
162
- if (path.extname(command) || process.platform !== "win32") {
163
- return [""];
164
- }
165
- return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
166
- .split(";")
167
- .map((extension) => extension.toLowerCase())
168
- .concat(".ps1", "");
169
- }
170
- function withModelArgs(args, model, modelArg) {
171
- if (!model) {
172
- return [...args];
173
- }
174
- const promptStdinIndex = args.lastIndexOf("-");
175
- if (promptStdinIndex === args.length - 1) {
176
- return [
177
- ...args.slice(0, promptStdinIndex),
178
- modelArg,
179
- model,
180
- ...args.slice(promptStdinIndex)
181
- ];
182
- }
183
- return [...args, modelArg, model];
184
- }
185
162
  function createPtyExitError(adapterName, exitCode, raw) {
186
163
  return new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizePtyOutput(raw)}`, {
187
164
  exitCode,
@@ -0,0 +1,24 @@
1
+ /** Limite de sortie par défaut des adapters CLI/PTY : 50 Mio avant `output-too-large`. */
2
+ export const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
3
+ /** Timeout dur par défaut d'un appel d'agent CLI/PTY (3 minutes). */
4
+ export const DEFAULT_TIMEOUT_MS = 180_000;
5
+ /**
6
+ * Insère `modelArg model` dans la liste d'arguments d'une commande CLI.
7
+ * Si le dernier argument est `-` (marqueur stdin), insère avant lui pour
8
+ * préserver l'ordre attendu par les CLIs qui lisent le prompt sur stdin.
9
+ */
10
+ export function withModelArgs(args, model, modelArg) {
11
+ if (!model) {
12
+ return [...args];
13
+ }
14
+ const promptStdinIndex = args.lastIndexOf("-");
15
+ if (promptStdinIndex === args.length - 1) {
16
+ return [
17
+ ...args.slice(0, promptStdinIndex),
18
+ modelArg,
19
+ model,
20
+ ...args.slice(promptStdinIndex)
21
+ ];
22
+ }
23
+ return [...args, modelArg, model];
24
+ }
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { AdapterError } from "../errors.js";
3
3
  import { formatAgentPrompt } from "../prompt.js";
4
+ import { DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS, withModelArgs } from "./cli-shared.js";
4
5
  import { cleanTerminalOutput } from "./terminal.js";
5
- const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
6
6
  /**
7
7
  * Adapter pour les CLIs batch (Codex, Claude, Gemini…).
8
8
  * Lance un sous-processus, injecte le prompt via stdin ou argument, capture stdout.
@@ -81,10 +81,10 @@ export class CliAdapter {
81
81
  };
82
82
  hardTimer = setTimeout(() => {
83
83
  child.kill();
84
- finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? 180_000}ms`, {
85
- timeoutMs: this.config.timeoutMs ?? 180_000
84
+ finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
85
+ timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
86
86
  }));
87
- }, this.config.timeoutMs ?? 180_000);
87
+ }, this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
88
88
  const bumpIdleTimer = () => {
89
89
  if (!this.config.idleTimeoutMs)
90
90
  return;
@@ -144,28 +144,45 @@ export class CliAdapter {
144
144
  });
145
145
  }
146
146
  }
147
- /**
148
- * Insère `modelArg model` dans la liste d'arguments.
149
- * Si le dernier argument est `-` (stdin marker), insère avant lui pour préserver l'ordre attendu par les CLIs.
150
- */
151
- function withModelArgs(args, model, modelArg) {
152
- if (!model) {
153
- return [...args];
154
- }
155
- const promptStdinIndex = args.lastIndexOf("-");
156
- if (promptStdinIndex === args.length - 1) {
157
- return [
158
- ...args.slice(0, promptStdinIndex),
159
- modelArg,
160
- model,
161
- ...args.slice(promptStdinIndex)
162
- ];
163
- }
164
- return [...args, modelArg, model];
165
- }
166
147
  /** Retire les séquences ANSI et les espaces en tête/fin. */
167
148
  function cleanCliOutput(output) {
168
- return cleanTerminalOutput(output);
149
+ return stripWindowsTaskkillNoise(cleanTerminalOutput(output));
150
+ }
151
+ function stripWindowsTaskkillNoise(output) {
152
+ const lines = output.split("\n");
153
+ const kept = [];
154
+ let skipNextFrenchContinuation = false;
155
+ for (const line of lines) {
156
+ const trimmed = line.trim();
157
+ const normalized = normalizeForWindowsStatus(trimmed);
158
+ if (skipNextFrenchContinuation && /^arr.*t.*\.$/i.test(normalized)) {
159
+ skipNextFrenchContinuation = false;
160
+ continue;
161
+ }
162
+ skipNextFrenchContinuation = false;
163
+ if (isWindowsTaskkillStatusLine(trimmed)) {
164
+ skipNextFrenchContinuation = true;
165
+ continue;
166
+ }
167
+ kept.push(line);
168
+ }
169
+ return kept.join("\n").trim();
170
+ }
171
+ function isWindowsTaskkillStatusLine(line) {
172
+ const normalized = normalizeForWindowsStatus(line);
173
+ const lower = line.toLowerCase();
174
+ return (/^SUCCESS:\s+The process with PID \d+ .* has been terminated\.$/i.test(line) ||
175
+ /^operation reussie.*processus de pid \d+ .* a ete$/.test(normalized) ||
176
+ (lower.startsWith("op") &&
177
+ lower.includes("processus de pid ") &&
178
+ lower.includes("processus enfant de pid") &&
179
+ lower.includes(" a ")));
180
+ }
181
+ function normalizeForWindowsStatus(line) {
182
+ return line
183
+ .normalize("NFD")
184
+ .replace(/\p{Diacritic}/gu, "")
185
+ .toLowerCase();
169
186
  }
170
187
  /**
171
188
  * Construit une `AdapterError` typée depuis un exit code non nul.
@@ -1,4 +1,5 @@
1
1
  import { AdapterError } from "../errors.js";
2
+ import { createTranslator } from "../i18n.js";
2
3
  import { formatAgentPrompt } from "../prompt.js";
3
4
  /**
4
5
  * Adapter pour Ollama via l'API HTTP locale (`POST /api/chat`).
@@ -57,7 +58,7 @@ export class OllamaAdapter {
57
58
  {
58
59
  role: "system",
59
60
  content: this.config.systemPrompt ??
60
- "Tu participes a un debat technique orchestre. Reste precis, utile et honnete sur tes limites."
61
+ createTranslator(prompt.language ?? "fr").prompt.ollamaSystemPrompt
61
62
  },
62
63
  {
63
64
  role: "user",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Agents CLI connus, dans l'ordre d'affichage canonique.
3
+ * `ollama-local` n'est pas listé ici : ce n'est pas une commande CLI, il est
4
+ * géré séparément via `discovery.ollama`.
5
+ */
6
+ const KNOWN_CLI_AGENTS = [
7
+ { configKey: "codex", commandAliases: ["codex"], discoveryKey: "codex" },
8
+ { configKey: "claude", commandAliases: ["claude"], discoveryKey: "claude" },
9
+ { configKey: "gemini", commandAliases: ["gemini"], discoveryKey: "gemini" },
10
+ { configKey: "antigravity", commandAliases: ["agy", "antigravity"], discoveryKey: "antigravity" },
11
+ { configKey: "opencode", commandAliases: ["opencode"], discoveryKey: "opencode" }
12
+ ];
13
+ /** Clé de config de l'agent Ollama local par défaut. */
14
+ export const OLLAMA_AGENT_KEY = "ollama-local";
15
+ /**
16
+ * Extrait le nom de base d'une commande en supprimant le chemin et l'extension
17
+ * exécutable Windows éventuelle (ex. `C:\bin\claude.cmd` → `claude`).
18
+ */
19
+ export function normalizeCommandName(command) {
20
+ return command
21
+ .split(/[\\/]/)
22
+ .pop()
23
+ ?.toLowerCase()
24
+ .replace(/\.(exe|cmd|bat|ps1)$/i, "") ?? command.toLowerCase();
25
+ }
26
+ /**
27
+ * Résout l'entrée de découverte d'une commande d'agent CLI connue.
28
+ * Retourne `undefined` pour une commande custom non reconnue : Palabre ne peut
29
+ * pas connaître sa sémantique sans la lancer, donc l'appelant la considère
30
+ * généralement comme disponible.
31
+ */
32
+ export function detectionForCommand(command, discovery) {
33
+ const normalized = normalizeCommandName(command);
34
+ const known = KNOWN_CLI_AGENTS.find((agent) => agent.commandAliases.includes(normalized));
35
+ return known ? discovery[known.discoveryKey] : undefined;
36
+ }
37
+ /**
38
+ * Liste les clés d'agents connus effectivement détectés localement, dans
39
+ * l'ordre canonique (`codex`, `claude`, `gemini`, `antigravity`, `opencode`,
40
+ * puis `ollama-local`).
41
+ */
42
+ export function detectedAgentNames(discovery) {
43
+ const names = KNOWN_CLI_AGENTS
44
+ .filter((agent) => discovery[agent.discoveryKey].available)
45
+ .map((agent) => agent.configKey);
46
+ if (discovery.ollama.available) {
47
+ names.push(OLLAMA_AGENT_KEY);
48
+ }
49
+ return names;
50
+ }
51
+ /**
52
+ * Applique les chemins de commande résolus localement aux agents CLI connus
53
+ * d'une config. Mute `config` : l'appelant est responsable de la cloner au besoin.
54
+ * Sans détection disponible, l'agent garde la commande déjà déclarée.
55
+ */
56
+ export function applyDetectedCommands(config, discovery) {
57
+ for (const agent of KNOWN_CLI_AGENTS) {
58
+ const detection = discovery[agent.discoveryKey];
59
+ const cfg = config.agents[agent.configKey];
60
+ if (detection.available && cfg && (cfg.type === "cli" || cfg.type === "cli-pty")) {
61
+ cfg.command = detection.command;
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Indique si un agent de config est détecté localement.
67
+ * Pour Ollama, reflète l'accessibilité du serveur ; pour les CLIs connues, l'état
68
+ * de découverte ; pour une CLI custom inconnue, retourne `true` (faute de pouvoir vérifier).
69
+ */
70
+ export function isAgentDetected(name, config, discovery) {
71
+ if (config.type === "ollama") {
72
+ return discovery.ollama.available;
73
+ }
74
+ const detection = detectionForCommand(config.command || name, discovery);
75
+ return detection ? detection.available : true;
76
+ }
package/dist/args.js ADDED
@@ -0,0 +1,265 @@
1
+ import { listPresetNames } from "./presets.js";
2
+ /**
3
+ * Table centrale décrivant l'arité de chaque flag long canonique.
4
+ *
5
+ * C'est la source de vérité du parser : un flag `boolean` ne consomme jamais le
6
+ * token suivant (évite que `--plain codex-claude "x"` avale le preset), un flag
7
+ * `single` exige une valeur, un flag `multi` collecte plusieurs valeurs.
8
+ *
9
+ * Les noms sont les noms canoniques après `normalizeFlagName` (ex. `subject`
10
+ * est normalisé en `topic` avant la recherche dans cette table).
11
+ */
12
+ const FLAG_SPECS = {
13
+ // Booléens : présence = vrai, aucune valeur consommée.
14
+ help: { arity: "boolean" },
15
+ version: { arity: "boolean" },
16
+ plain: { arity: "boolean" },
17
+ json: { arity: "boolean" },
18
+ "no-summary": { arity: "boolean" },
19
+ "no-early-stop": { arity: "boolean" },
20
+ "show-prompt": { arity: "boolean" },
21
+ "pull-models": { arity: "boolean" },
22
+ local: { arity: "boolean" },
23
+ apply: { arity: "boolean" },
24
+ "clear-defaults": { arity: "boolean" },
25
+ "sync-agents": { arity: "boolean" },
26
+ // Valeur unique.
27
+ "agent-a": { arity: "single" },
28
+ "agent-b": { arity: "single" },
29
+ config: { arity: "single" },
30
+ language: { arity: "single" },
31
+ "model-a": { arity: "single" },
32
+ "model-b": { arity: "single" },
33
+ preset: { arity: "single" },
34
+ "summary-agent": { arity: "single" },
35
+ "summary-model": { arity: "single" },
36
+ topic: { arity: "single" },
37
+ turns: { arity: "single" },
38
+ renderer: { arity: "single" },
39
+ // Valeurs multiples.
40
+ "set-defaults": { arity: "multi", max: 2 },
41
+ files: { arity: "multi" },
42
+ context: { arity: "multi" }
43
+ };
44
+ /** Commandes acceptées comme premier argument positionnel. */
45
+ const COMMANDS = new Set([
46
+ "run",
47
+ "new",
48
+ "init",
49
+ "setup",
50
+ "help",
51
+ "version",
52
+ "update",
53
+ "doctor",
54
+ "config",
55
+ "agent",
56
+ "agents",
57
+ "preset",
58
+ "presets",
59
+ "context"
60
+ ]);
61
+ /**
62
+ * Parse `process.argv` en une structure typée `ParsedArgs`.
63
+ * Gère les flags courts (-h, -v, -s, -t, -a), les flags longs pilotés par
64
+ * `FLAG_SPECS`, les flags multi-valeurs (--files, --context, --set-defaults) et
65
+ * les positionnels.
66
+ * @param args - Tableau d'arguments (généralement `process.argv.slice(2)`).
67
+ * @returns Commande détectée, indicateur d'explicitation et map de flags.
68
+ */
69
+ export function parseArgs(args, messages) {
70
+ const flags = {};
71
+ let command = "run";
72
+ let commandExplicit = false;
73
+ const positionals = [];
74
+ const presets = new Set(listPresetNames());
75
+ for (let index = 0; index < args.length; index += 1) {
76
+ const value = args[index];
77
+ if (!value.startsWith("-") && !commandExplicit && positionals.length === 0 && COMMANDS.has(value)) {
78
+ command = value;
79
+ commandExplicit = true;
80
+ continue;
81
+ }
82
+ if (!value.startsWith("-") && index === 0) {
83
+ if (COMMANDS.has(value)) {
84
+ command = value;
85
+ commandExplicit = true;
86
+ }
87
+ else if (isLikelyCommandTypo(value, COMMANDS)) {
88
+ throw new Error(messages.common.unknownCommand(value, Array.from(COMMANDS).join(", ")));
89
+ }
90
+ else {
91
+ positionals.push(value);
92
+ }
93
+ continue;
94
+ }
95
+ if (!value.startsWith("-")) {
96
+ positionals.push(value);
97
+ continue;
98
+ }
99
+ if (value === "-h") {
100
+ flags.help = true;
101
+ continue;
102
+ }
103
+ if (value === "-v") {
104
+ flags.version = true;
105
+ continue;
106
+ }
107
+ if (value === "-a") {
108
+ command = "agents";
109
+ commandExplicit = true;
110
+ continue;
111
+ }
112
+ if (value === "-s") {
113
+ const next = args[index + 1];
114
+ if (!next || next.startsWith("-")) {
115
+ throw new Error(messages.common.optionRequiresValue("-s"));
116
+ }
117
+ flags.topic = next;
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (value === "-t") {
122
+ const next = args[index + 1];
123
+ if (!next || next.startsWith("-")) {
124
+ throw new Error(messages.common.optionRequiresValue("-t"));
125
+ }
126
+ flags.turns = next;
127
+ index += 1;
128
+ continue;
129
+ }
130
+ if (value.startsWith("--")) {
131
+ const rawKey = value.slice(2);
132
+ const key = normalizeFlagName(rawKey);
133
+ const spec = FLAG_SPECS[key];
134
+ if (spec?.arity === "multi") {
135
+ const values = [];
136
+ while (args[index + 1] && !args[index + 1].startsWith("-") && (spec.max === undefined || values.length < spec.max)) {
137
+ values.push(args[index + 1]);
138
+ index += 1;
139
+ }
140
+ if (key === "set-defaults" && values.length !== 2) {
141
+ throw new Error(messages.common.setDefaultsRequiresTwo);
142
+ }
143
+ flags[key] = key === "set-defaults"
144
+ ? values
145
+ : [...getStringListFlag(flags[key]), ...values];
146
+ continue;
147
+ }
148
+ if (spec?.arity === "boolean") {
149
+ flags[key] = true;
150
+ continue;
151
+ }
152
+ const next = args[index + 1];
153
+ const wantsValue = spec?.arity === "single";
154
+ if (!next || next.startsWith("-")) {
155
+ if (wantsValue) {
156
+ throw new Error(messages.common.optionRequiresValue(`--${rawKey}`));
157
+ }
158
+ flags[key] = true;
159
+ }
160
+ else if (wantsValue) {
161
+ flags[key] = next;
162
+ index += 1;
163
+ }
164
+ else {
165
+ // Flag inconnu : traité comme booléen pour ne jamais avaler un positionnel.
166
+ flags[key] = true;
167
+ }
168
+ }
169
+ }
170
+ if (command === "run") {
171
+ applyRunPositionals(positionals, flags, presets, commandExplicit, COMMANDS, messages);
172
+ }
173
+ return { command, commandExplicit, positionals, flags };
174
+ }
175
+ /**
176
+ * Détecte si une valeur ressemble à une faute de frappe d'une commande connue
177
+ * (même première lettre et distance de Levenshtein ≤ 2).
178
+ * @param value - Token saisi par l'utilisateur.
179
+ * @param commands - Ensemble des commandes valides.
180
+ */
181
+ export function isLikelyCommandTypo(value, commands) {
182
+ const normalized = value.toLowerCase();
183
+ for (const command of commands) {
184
+ if (normalized[0] === command[0] && levenshteinDistance(normalized, command) <= 2) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+ /**
191
+ * Calcule la distance de Levenshtein entre deux chaînes (insertions, suppressions, substitutions).
192
+ * @param left - Première chaîne.
193
+ * @param right - Deuxième chaîne.
194
+ * @returns Distance entière ≥ 0.
195
+ */
196
+ function levenshteinDistance(left, right) {
197
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
198
+ for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
199
+ let diagonal = previous[0];
200
+ previous[0] = leftIndex + 1;
201
+ for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
202
+ const insertCost = previous[rightIndex + 1] + 1;
203
+ const deleteCost = previous[rightIndex] + 1;
204
+ const replaceCost = diagonal + (left[leftIndex] === right[rightIndex] ? 0 : 1);
205
+ diagonal = previous[rightIndex + 1];
206
+ previous[rightIndex + 1] = Math.min(insertCost, deleteCost, replaceCost);
207
+ }
208
+ }
209
+ return previous[right.length] ?? 0;
210
+ }
211
+ /**
212
+ * Interprète les arguments positionnels pour la commande `run` :
213
+ * premier positionnel = preset si connu, sinon sujet complet concaténé.
214
+ * @param positionals - Arguments positionnels extraits du parseur.
215
+ * @param flags - Map de flags à muter si un preset ou un sujet est détecté.
216
+ * @param presets - Ensemble des noms de presets valides.
217
+ * @param commandExplicit - `true` si l'utilisateur a tapé `palabre run` explicitement.
218
+ */
219
+ function applyRunPositionals(positionals, flags, presets, commandExplicit, commands, messages) {
220
+ if (positionals.length === 0) {
221
+ return;
222
+ }
223
+ const [first, ...rest] = positionals;
224
+ if (presets.has(first)) {
225
+ flags.preset ??= first;
226
+ if (rest.length > 0) {
227
+ flags.topic ??= rest.join(" ");
228
+ }
229
+ return;
230
+ }
231
+ if (!commandExplicit && positionals.length === 1 && !positionals[0]?.includes(" ")) {
232
+ if (isLikelyCommandTypo(positionals[0], commands)) {
233
+ throw new Error(messages.common.unknownCommand(positionals[0], Array.from(commands).join(", ")));
234
+ }
235
+ throw new Error(messages.common.ambiguousSubject(positionals[0]));
236
+ }
237
+ flags.topic ??= positionals.join(" ");
238
+ }
239
+ /**
240
+ * Normalise un nom de flag long en son alias canonique (ex. `subject` → `topic`).
241
+ * @param value - Nom brut extrait après `--`.
242
+ */
243
+ export function normalizeFlagName(value) {
244
+ const aliases = {
245
+ lang: "language",
246
+ s: "topic",
247
+ subject: "topic",
248
+ t: "turns"
249
+ };
250
+ return aliases[value] ?? value;
251
+ }
252
+ /**
253
+ * Normalise une valeur de flag multi-valeur en tableau de chaînes.
254
+ * @param value - Valeur brute (tableau, chaîne unique ou absent).
255
+ * @returns Tableau de chaînes, vide si la valeur n'est pas applicable.
256
+ */
257
+ export function getStringListFlag(value) {
258
+ if (Array.isArray(value)) {
259
+ return value;
260
+ }
261
+ if (typeof value === "string") {
262
+ return [value];
263
+ }
264
+ return [];
265
+ }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { applyDetectedCommands } from "./agentRegistry.js";
4
5
  export const DEFAULT_CONFIG_PATH = "palabre.config.json";
5
6
  export const LEGACY_CONFIG_PATH = "chicane.config.json";
6
7
  export const CONFIG_DIR_NAME = ".palabre";
@@ -118,6 +119,30 @@ export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
118
119
  const raw = await readFile(resolved, "utf8");
119
120
  return JSON.parse(raw);
120
121
  }
122
+ /**
123
+ * Valide qu'une config chargée est exploitable pour lancer un débat.
124
+ *
125
+ * `loadConfig` se contente de parser le JSON ; cette garde attrape les configs
126
+ * structurellement cassées (racine non-objet, bloc `agents` absent ou vide)
127
+ * avant qu'elles ne provoquent un `TypeError` opaque dans l'orchestrateur.
128
+ * Volontairement minimale : la validation sémantique fine (agents par défaut
129
+ * inconnus, timeouts invalides, etc.) reste du ressort de `palabre doctor`.
130
+ *
131
+ * @throws {Error} message actionnable si la config ne peut pas faire tourner un débat.
132
+ */
133
+ export function assertRunnableConfig(config, messages, configPath = DEFAULT_CONFIG_PATH) {
134
+ const root = config;
135
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
136
+ throw new Error(messages.common.configInvalidShape(configPath));
137
+ }
138
+ const agents = root.agents;
139
+ if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
140
+ throw new Error(messages.common.configMissingAgents(configPath));
141
+ }
142
+ if (Object.keys(agents).length === 0) {
143
+ throw new Error(messages.common.configEmptyAgents(configPath));
144
+ }
145
+ }
121
146
  /** Retourne `true` si le fichier de config est accessible en lecture. Silencieux sur toute erreur filesystem. */
122
147
  export async function configExists(configPath = DEFAULT_CONFIG_PATH) {
123
148
  try {
@@ -156,26 +181,7 @@ export async function resolveDefaultConfigPath() {
156
181
  export function createConfigFromDiscovery(discovery) {
157
182
  const config = cloneConfig(exampleConfig);
158
183
  const pair = chooseDefaultPair(discovery);
159
- config.agents.codex = {
160
- ...config.agents.codex,
161
- ...(discovery.codex.available ? { command: discovery.codex.command } : {})
162
- };
163
- config.agents.claude = {
164
- ...config.agents.claude,
165
- ...(discovery.claude.available ? { command: discovery.claude.command } : {})
166
- };
167
- config.agents.gemini = {
168
- ...config.agents.gemini,
169
- ...(discovery.gemini.available ? { command: discovery.gemini.command } : {})
170
- };
171
- config.agents.antigravity = {
172
- ...config.agents.antigravity,
173
- ...(discovery.antigravity.available ? { command: discovery.antigravity.command } : {})
174
- };
175
- config.agents.opencode = {
176
- ...config.agents.opencode,
177
- ...(discovery.opencode.available ? { command: discovery.opencode.command } : {})
178
- };
184
+ applyDetectedCommands(config, discovery);
179
185
  const ollamaAgent = config.agents["ollama-local"];
180
186
  if (ollamaAgent?.type === "ollama") {
181
187
  ollamaAgent.model = chooseDefaultOllamaModel(discovery);
package/dist/discovery.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { executableExtensions } from "./exec.js";
3
4
  /**
4
5
  * Détecte en parallèle toutes les CLIs supportées et le serveur Ollama local.
5
6
  * Sur Windows, tente `claude.exe` avant `claude`.
@@ -100,18 +101,6 @@ async function findExecutable(command) {
100
101
  }
101
102
  return undefined;
102
103
  }
103
- function executableExtensions(command) {
104
- if (path.extname(command)) {
105
- return [""];
106
- }
107
- if (process.platform !== "win32") {
108
- return [""];
109
- }
110
- return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
111
- .split(";")
112
- .map((extension) => extension.toLowerCase())
113
- .concat(".ps1", "");
114
- }
115
104
  async function isAccessible(filePath) {
116
105
  try {
117
106
  await access(filePath);
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { configExists, loadConfig, resolveDefaultConfigPath, resolveOutputDir } from "./config.js";
4
+ import { detectedAgentNames, detectionForCommand } from "./agentRegistry.js";
4
5
  import { discoverLocalTools } from "./discovery.js";
5
6
  import { createTranslator, resolveLanguage } from "./i18n.js";
6
7
  import { DEFAULT_TURNS, MAX_TURNS } from "./limits.js";
@@ -194,7 +195,7 @@ function inspectAgentShape(name, agent, lines, t) {
194
195
  }
195
196
  }
196
197
  function inspectCliAgent(name, agent, discovery, lines, t) {
197
- const known = knownCliDetection(agent.command, discovery);
198
+ const known = detectionForCommand(agent.command, discovery);
198
199
  const prefix = `${name} [cli:${agent.role}] command=${agent.command}`;
199
200
  if (!known) {
200
201
  lines.push(info(t.doctor.customCommand(prefix)));
@@ -219,37 +220,11 @@ function inspectOllamaAgent(name, agent, discovery, lines, t) {
219
220
  ? ok(t.doctor.ollamaInstalled(prefix))
220
221
  : warn(t.doctor.ollamaMissing(prefix, agent.model)));
221
222
  }
222
- function detectedAgentNames(discovery) {
223
- return [
224
- discovery.codex.available ? "codex" : undefined,
225
- discovery.claude.available ? "claude" : undefined,
226
- discovery.gemini.available ? "gemini" : undefined,
227
- discovery.antigravity.available ? "antigravity" : undefined,
228
- discovery.opencode.available ? "opencode" : undefined,
229
- discovery.ollama.available ? "ollama-local" : undefined
230
- ].filter((name) => Boolean(name));
231
- }
232
223
  function formatCommand(label, available, command, resolvedPath, t) {
233
224
  return available
234
225
  ? ok(t.doctor.commandDetected(label, resolvedPath ?? command))
235
226
  : warn(t.doctor.commandMissing(label));
236
227
  }
237
- function knownCliDetection(command, discovery) {
238
- const normalized = path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/i, "");
239
- if (normalized === "codex")
240
- return discovery.codex;
241
- if (normalized === "claude")
242
- return discovery.claude;
243
- if (normalized === "gemini")
244
- return discovery.gemini;
245
- if (normalized === "agy")
246
- return discovery.antigravity;
247
- if (normalized === "antigravity")
248
- return discovery.antigravity;
249
- if (normalized === "opencode")
250
- return discovery.opencode;
251
- return undefined;
252
- }
253
228
  function render(lines, plain, t) {
254
229
  const hasErrors = lines.some((line) => line.level === "error");
255
230
  return {
package/dist/exec.js ADDED
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Extensions exécutables candidates pour résoudre une commande dans le PATH.
4
+ *
5
+ * Retourne `[""]` quand la commande porte déjà une extension ou hors Windows.
6
+ * Sur Windows sans extension, dérive la liste de `PATHEXT` et ajoute `.ps1`
7
+ * ainsi que la candidate vide (binaire sans extension).
8
+ */
9
+ export function executableExtensions(command) {
10
+ if (path.extname(command) || process.platform !== "win32") {
11
+ return [""];
12
+ }
13
+ return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
14
+ .split(";")
15
+ .map((extension) => extension.toLowerCase())
16
+ .concat(".ps1", "");
17
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { configExists, createConfigFromDiscovery, DEFAULT_CONFIG_PATH, GLOBAL_CONFIG_PATH, loadConfig, resolveDefaultConfigPath, resolveOutputDir, writeExampleConfig } from "./config.js";
5
+ import { assertRunnableConfig, configExists, createConfigFromDiscovery, DEFAULT_CONFIG_PATH, GLOBAL_CONFIG_PATH, loadConfig, resolveDefaultConfigPath, resolveOutputDir, writeExampleConfig } from "./config.js";
6
6
  import { loadProjectInputs } from "./context.js";
7
7
  import { buildContextScan } from "./contextScan.js";
8
8
  import { discoverLocalTools } from "./discovery.js";
@@ -20,6 +20,8 @@ import { runDebate } from "./orchestrator.js";
20
20
  import { writeDebateMarkdown } from "./output.js";
21
21
  import { applySourceUpdate, formatUpdateInstructions, getUpdateInfo } from "./update.js";
22
22
  import { createSessionContext } from "./session.js";
23
+ import { getStringListFlag, parseArgs } from "./args.js";
24
+ import { detectedAgentNames, detectionForCommand } from "./agentRegistry.js";
23
25
  /** Point d'entrée principal du CLI Palabre. Dispatche vers la commande appropriée selon les arguments. */
24
26
  async function main() {
25
27
  const rawArgs = process.argv.slice(2);
@@ -111,6 +113,7 @@ async function main() {
111
113
  configLanguage: config.language
112
114
  });
113
115
  const messages = createTranslator(language);
116
+ assertRunnableConfig(config, messages, configPath);
114
117
  if (parsed.command === "new") {
115
118
  const selection = await runNewWizard(config, messages);
116
119
  if (!selection) {
@@ -469,214 +472,6 @@ async function runContextCommand(flags, positionals) {
469
472
  console.error(`${messages.renderers.warningPrefix} ${warning}`);
470
473
  }
471
474
  }
472
- /**
473
- * Parse `process.argv` en une structure typée `ParsedArgs`.
474
- * Gère les flags courts (-h, -v, -s, -t, -a), les flags longs (--topic, --agent-a…),
475
- * les flags multi-valeurs (--files, --context, --set-defaults) et les positionnels.
476
- * @param args - Tableau d'arguments (généralement `process.argv.slice(2)`).
477
- * @returns Commande détectée, indicateur d'explicitation et map de flags.
478
- */
479
- function parseArgs(args, messages) {
480
- const flags = {};
481
- let command = "run";
482
- let commandExplicit = false;
483
- const positionals = [];
484
- const commands = new Set(["run", "new", "init", "setup", "help", "version", "update", "doctor", "config", "agent", "agents", "preset", "presets", "context"]);
485
- const presets = new Set(listPresetNames());
486
- for (let index = 0; index < args.length; index += 1) {
487
- const value = args[index];
488
- if (!value.startsWith("-") && !commandExplicit && positionals.length === 0 && commands.has(value)) {
489
- command = value;
490
- commandExplicit = true;
491
- continue;
492
- }
493
- if (!value.startsWith("-") && index === 0) {
494
- if (commands.has(value)) {
495
- command = value;
496
- commandExplicit = true;
497
- }
498
- else if (isLikelyCommandTypo(value, commands)) {
499
- throw new Error(messages.common.unknownCommand(value, Array.from(commands).join(", ")));
500
- }
501
- else {
502
- positionals.push(value);
503
- }
504
- continue;
505
- }
506
- if (!value.startsWith("-")) {
507
- positionals.push(value);
508
- continue;
509
- }
510
- if (value === "-h") {
511
- flags.help = true;
512
- continue;
513
- }
514
- if (value === "-v") {
515
- flags.version = true;
516
- continue;
517
- }
518
- if (value === "-a") {
519
- command = "agents";
520
- commandExplicit = true;
521
- continue;
522
- }
523
- if (value === "-s") {
524
- const next = args[index + 1];
525
- if (!next || next.startsWith("-")) {
526
- throw new Error(messages.common.optionRequiresValue("-s"));
527
- }
528
- flags.topic = next;
529
- index += 1;
530
- continue;
531
- }
532
- if (value === "-t") {
533
- const next = args[index + 1];
534
- if (!next || next.startsWith("-")) {
535
- throw new Error(messages.common.optionRequiresValue("-t"));
536
- }
537
- flags.turns = next;
538
- index += 1;
539
- continue;
540
- }
541
- if (value.startsWith("--")) {
542
- const rawKey = value.slice(2);
543
- const key = normalizeFlagName(rawKey);
544
- if (key === "set-defaults") {
545
- const values = [];
546
- while (args[index + 1] && !args[index + 1].startsWith("-") && values.length < 2) {
547
- values.push(args[index + 1]);
548
- index += 1;
549
- }
550
- if (values.length !== 2) {
551
- throw new Error(messages.common.setDefaultsRequiresTwo);
552
- }
553
- flags[key] = values;
554
- continue;
555
- }
556
- if (key === "files" || key === "context") {
557
- const values = [];
558
- while (args[index + 1] && !args[index + 1].startsWith("-")) {
559
- values.push(args[index + 1]);
560
- index += 1;
561
- }
562
- flags[key] = [...getStringListFlag(flags[key]), ...values];
563
- continue;
564
- }
565
- const next = args[index + 1];
566
- if (!next || next.startsWith("-")) {
567
- if (requiresFlagValue(key)) {
568
- throw new Error(messages.common.optionRequiresValue(`--${rawKey}`));
569
- }
570
- flags[key] = true;
571
- }
572
- else {
573
- flags[key] = next;
574
- index += 1;
575
- }
576
- }
577
- }
578
- if (command === "run") {
579
- applyRunPositionals(positionals, flags, presets, commandExplicit, commands, messages);
580
- }
581
- return { command, commandExplicit, positionals, flags };
582
- }
583
- /**
584
- * Détecte si une valeur ressemble à une faute de frappe d'une commande connue
585
- * (même première lettre et distance de Levenshtein ≤ 2).
586
- * @param value - Token saisi par l'utilisateur.
587
- * @param commands - Ensemble des commandes valides.
588
- */
589
- function isLikelyCommandTypo(value, commands) {
590
- const normalized = value.toLowerCase();
591
- for (const command of commands) {
592
- if (normalized[0] === command[0] && levenshteinDistance(normalized, command) <= 2) {
593
- return true;
594
- }
595
- }
596
- return false;
597
- }
598
- /**
599
- * Calcule la distance de Levenshtein entre deux chaînes (insertions, suppressions, substitutions).
600
- * @param left - Première chaîne.
601
- * @param right - Deuxième chaîne.
602
- * @returns Distance entière ≥ 0.
603
- */
604
- function levenshteinDistance(left, right) {
605
- const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
606
- for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
607
- let diagonal = previous[0];
608
- previous[0] = leftIndex + 1;
609
- for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
610
- const insertCost = previous[rightIndex + 1] + 1;
611
- const deleteCost = previous[rightIndex] + 1;
612
- const replaceCost = diagonal + (left[leftIndex] === right[rightIndex] ? 0 : 1);
613
- diagonal = previous[rightIndex + 1];
614
- previous[rightIndex + 1] = Math.min(insertCost, deleteCost, replaceCost);
615
- }
616
- }
617
- return previous[right.length] ?? 0;
618
- }
619
- /**
620
- * Interprète les arguments positionnels pour la commande `run` :
621
- * premier positionnel = preset si connu, sinon sujet complet concaténé.
622
- * @param positionals - Arguments positionnels extraits du parseur.
623
- * @param flags - Map de flags à muter si un preset ou un sujet est détecté.
624
- * @param presets - Ensemble des noms de presets valides.
625
- * @param commandExplicit - `true` si l'utilisateur a tapé `palabre run` explicitement.
626
- */
627
- function applyRunPositionals(positionals, flags, presets, commandExplicit, commands, messages) {
628
- if (positionals.length === 0) {
629
- return;
630
- }
631
- const [first, ...rest] = positionals;
632
- if (presets.has(first)) {
633
- flags.preset ??= first;
634
- if (rest.length > 0) {
635
- flags.topic ??= rest.join(" ");
636
- }
637
- return;
638
- }
639
- if (!commandExplicit && positionals.length === 1 && !positionals[0]?.includes(" ")) {
640
- if (isLikelyCommandTypo(positionals[0], commands)) {
641
- throw new Error(messages.common.unknownCommand(positionals[0], Array.from(commands).join(", ")));
642
- }
643
- throw new Error(messages.common.ambiguousSubject(positionals[0]));
644
- }
645
- flags.topic ??= positionals.join(" ");
646
- }
647
- /**
648
- * Normalise un nom de flag long en son alias canonique (ex. `subject` → `topic`).
649
- * @param value - Nom brut extrait après `--`.
650
- */
651
- function normalizeFlagName(value) {
652
- const aliases = {
653
- lang: "language",
654
- s: "topic",
655
- subject: "topic",
656
- t: "turns"
657
- };
658
- return aliases[value] ?? value;
659
- }
660
- /**
661
- * Indique si un flag long nécessite une valeur suivante (lève une erreur si absente).
662
- * @param value - Nom canonique du flag (sans `--`).
663
- */
664
- function requiresFlagValue(value) {
665
- return new Set([
666
- "agent-a",
667
- "agent-b",
668
- "config",
669
- "language",
670
- "model-a",
671
- "model-b",
672
- "preset",
673
- "summary-agent",
674
- "summary-model",
675
- "set-defaults",
676
- "topic",
677
- "turns"
678
- ]).has(value);
679
- }
680
475
  /** Lit la version depuis `package.json` adjacent au bundle compilé. */
681
476
  async function getPackageVersion() {
682
477
  const packageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
@@ -684,20 +479,6 @@ async function getPackageVersion() {
684
479
  const packageJson = JSON.parse(raw);
685
480
  return packageJson.version ?? "0.0.0";
686
481
  }
687
- /**
688
- * Normalise une valeur de flag multi-valeur en tableau de chaînes.
689
- * @param value - Valeur brute (tableau, chaîne unique ou absent).
690
- * @returns Tableau de chaînes, vide si la valeur n'est pas applicable.
691
- */
692
- function getStringListFlag(value) {
693
- if (Array.isArray(value)) {
694
- return value;
695
- }
696
- if (typeof value === "string") {
697
- return [value];
698
- }
699
- return [];
700
- }
701
482
  /**
702
483
  * Écrit les avertissements de contexte sur `stderr`.
703
484
  * @param warnings - Messages d'avertissement issus du chargement des fichiers de contexte.
@@ -728,15 +509,7 @@ function syncDetectedAgents(config, discovery) {
728
509
  * @param discovery - Résultat de la découverte locale des outils.
729
510
  */
730
511
  function findDetectedMissingAgents(config, discovery) {
731
- const detectedAgents = [
732
- discovery.codex.available ? "codex" : undefined,
733
- discovery.claude.available ? "claude" : undefined,
734
- discovery.gemini.available ? "gemini" : undefined,
735
- discovery.antigravity.available ? "antigravity" : undefined,
736
- discovery.opencode.available ? "opencode" : undefined,
737
- discovery.ollama.available ? "ollama-local" : undefined
738
- ].filter((agent) => Boolean(agent));
739
- return detectedAgents.filter((agentName) => !config.agents[agentName]);
512
+ return detectedAgentNames(discovery).filter((agentName) => !config.agents[agentName]);
740
513
  }
741
514
  /**
742
515
  * Affiche la liste des agents déclarés avec leur type, rôle, état de détection et défauts.
@@ -807,34 +580,15 @@ function formatAgentDetection(name, agentConfig, discovery, messages) {
807
580
  return detection.available ? messages.agents.detected(detection.command) : messages.agents.notDetected;
808
581
  }
809
582
  /**
810
- * Résout l'entrée de détection correspondant à un agent CLI dans le résultat de découverte.
583
+ * Résout l'entrée de détection correspondant à un agent CLI.
811
584
  * Renvoie un objet `{ available: true }` pour les agents CLI non reconnus (considérés disponibles).
812
585
  * @param name - Nom de l'agent dans la config.
813
586
  * @param agentConfig - Configuration de l'agent.
814
587
  * @param discovery - Résultat de la découverte locale des outils.
815
588
  */
816
589
  function cliDetectionForAgent(name, agentConfig, discovery) {
817
- const command = normalizeCommandName(agentConfig.type === "cli" || agentConfig.type === "cli-pty" ? agentConfig.command : name);
818
- if (command === "codex")
819
- return discovery.codex;
820
- if (command === "claude")
821
- return discovery.claude;
822
- if (command === "gemini")
823
- return discovery.gemini;
824
- if (command === "agy")
825
- return discovery.antigravity;
826
- if (command === "antigravity")
827
- return discovery.antigravity;
828
- if (command === "opencode")
829
- return discovery.opencode;
830
- return { available: true, command: agentConfig.type === "cli" || agentConfig.type === "cli-pty" ? agentConfig.command : name };
831
- }
832
- /**
833
- * Extrait le nom de base d'une commande en supprimant le chemin et l'extension Windows éventuelle.
834
- * @param command - Chemin ou nom de commande brut (ex. `C:\bin\claude.cmd`).
835
- */
836
- function normalizeCommandName(command) {
837
- return path.basename(command).replace(/\.(cmd|exe|ps1|bat)$/i, "").toLowerCase();
590
+ const command = agentConfig.type === "cli" || agentConfig.type === "cli-pty" ? agentConfig.command : name;
591
+ return detectionForCommand(command, discovery) ?? { available: true, command };
838
592
  }
839
593
  /**
840
594
  * Affiche le récapitulatif de détection locale après `palabre init`.
@@ -857,14 +611,7 @@ function printInitDiscovery(discovery, config, messages) {
857
611
  console.log(messages.init.languageHint(config.language ?? DEFAULT_LANGUAGE));
858
612
  }
859
613
  function formatDetectedAgentSummary(discovery, language) {
860
- const names = [
861
- discovery.codex.available ? "codex" : undefined,
862
- discovery.claude.available ? "claude" : undefined,
863
- discovery.gemini.available ? "gemini" : undefined,
864
- discovery.antigravity.available ? "antigravity" : undefined,
865
- discovery.opencode.available ? "opencode" : undefined,
866
- discovery.ollama.available ? "ollama-local" : undefined
867
- ].filter((name) => Boolean(name));
614
+ const names = detectedAgentNames(discovery);
868
615
  if (names.length === 0) {
869
616
  return language === "en" ? "no agent detected" : "aucun agent détecté";
870
617
  }
@@ -10,6 +10,9 @@ export const commonMessages = {
10
10
  unknownAgentForField: (field, agent, available) => `Agent inconnu pour ${field}: ${agent}. Agents disponibles: ${available}.`,
11
11
  unknownAgent: (agent) => `Agent inconnu: ${agent}`,
12
12
  unknownRenderer: (value, supported) => `Renderer inconnu: ${value}. Valeurs supportées: ${supported}.`,
13
+ configInvalidShape: (configPath) => `Config invalide: ${configPath} ne contient pas un objet JSON. Relance palabre init ou corrige le fichier.`,
14
+ configMissingAgents: (configPath) => `Config invalide: ${configPath} ne déclare pas de bloc "agents". Relance palabre init ou ajoute au moins un agent.`,
15
+ configEmptyAgents: (configPath) => `Config invalide: ${configPath} ne déclare aucun agent. Ajoute au moins un agent ou relance palabre init.`,
13
16
  errorPrefix: "Erreur"
14
17
  },
15
18
  en: {
@@ -23,6 +26,9 @@ export const commonMessages = {
23
26
  unknownAgentForField: (field, agent, available) => `Unknown agent for ${field}: ${agent}. Available agents: ${available}.`,
24
27
  unknownAgent: (agent) => `Unknown agent: ${agent}`,
25
28
  unknownRenderer: (value, supported) => `Unknown renderer: ${value}. Supported values: ${supported}.`,
29
+ configInvalidShape: (configPath) => `Invalid config: ${configPath} does not contain a JSON object. Run palabre init or fix the file.`,
30
+ configMissingAgents: (configPath) => `Invalid config: ${configPath} has no "agents" block. Run palabre init or add at least one agent.`,
31
+ configEmptyAgents: (configPath) => `Invalid config: ${configPath} declares no agent. Add at least one agent or run palabre init.`,
26
32
  errorPrefix: "Error"
27
33
  }
28
34
  };
@@ -2,12 +2,31 @@ export const orchestratorMessages = {
2
2
  fr: {
3
3
  agreementStopReason: "Accord clair detecte apres un tour complet.",
4
4
  earlyStop: (reason) => `Arret anticipe: ${reason}`,
5
+ agreementPatterns: [
6
+ "accord complet",
7
+ "accord total",
8
+ "aucun desaccord",
9
+ "aucune incertitude",
10
+ "rien a trancher",
11
+ "rien a ajouter",
12
+ "question factuelle resolue"
13
+ ],
5
14
  ollamaNoContext: (agentNames) => `${agentNames} ne lit pas le filesystem. Ajoute --files ou --context pour fournir un contexte projet.`,
6
15
  unknownSummaryAgent: (agentName) => `Agent de synthese inconnu: ${agentName}`
7
16
  },
8
17
  en: {
9
18
  agreementStopReason: "Clear agreement detected after a complete round.",
10
19
  earlyStop: (reason) => `Early stop: ${reason}`,
20
+ agreementPatterns: [
21
+ "full agreement",
22
+ "complete agreement",
23
+ "total agreement",
24
+ "no disagreement",
25
+ "no remaining uncertainty",
26
+ "nothing to settle",
27
+ "nothing to add",
28
+ "factual question resolved"
29
+ ],
11
30
  ollamaNoContext: (agentNames) => `${agentNames} cannot read the filesystem. Add --files or --context to provide project context.`,
12
31
  unknownSummaryAgent: (agentName) => `Unknown summary agent: ${agentName}`
13
32
  }
@@ -29,6 +29,7 @@ export const promptMessages = {
29
29
  cwd: (value) => `- Dossier courant: ${value}`,
30
30
  sessionStartedAt: (value) => `- Session demarree a: ${value}`,
31
31
  turnProgress: (turn, totalTurns) => `- Tour courant: ${turn}/${totalTurns}`,
32
+ responseLanguageInstruction: "Langue de reponse obligatoire: francais. Reponds uniquement en francais, meme si le sujet ou le transcript contient une autre langue.",
32
33
  objectiveTitle: "Objectif:",
33
34
  debateObjectives: [
34
35
  "- Apporte une reponse utile, concrete et courte.",
@@ -55,7 +56,8 @@ export const promptMessages = {
55
56
  actionsHeading: "### Actions proposees",
56
57
  conclusionHeading: "### Conclusion",
57
58
  finalProseInstruction: "Un court paragraphe de synthese en prose, sans liste, qui resume le sens general du debat et la decision ou direction la plus raisonnable.",
58
- summaryAnswerTitle: "Synthese:"
59
+ summaryAnswerTitle: "Synthese:",
60
+ ollamaSystemPrompt: "Tu participes a un debat technique orchestre. Reste precis, utile et honnete sur tes limites."
59
61
  },
60
62
  en: {
61
63
  subject: (topic) => `Subject: ${topic}`,
@@ -71,6 +73,7 @@ export const promptMessages = {
71
73
  cwd: (value) => `- Current directory: ${value}`,
72
74
  sessionStartedAt: (value) => `- Session started at: ${value}`,
73
75
  turnProgress: (turn, totalTurns) => `- Current turn: ${turn}/${totalTurns}`,
76
+ responseLanguageInstruction: "Required response language: English. Answer only in English, even if the subject or transcript contains another language.",
74
77
  objectiveTitle: "Objective:",
75
78
  debateObjectives: [
76
79
  "- Provide a useful, concrete, and concise answer.",
@@ -97,6 +100,7 @@ export const promptMessages = {
97
100
  actionsHeading: "### Proposed actions",
98
101
  conclusionHeading: "### Conclusion",
99
102
  finalProseInstruction: "A short prose summary paragraph, without a list, that captures the general meaning of the debate and the most reasonable decision or direction.",
100
- summaryAnswerTitle: "Summary:"
103
+ summaryAnswerTitle: "Summary:",
104
+ ollamaSystemPrompt: "You are taking part in an orchestrated technical debate. Stay precise, useful, and honest about your limits."
101
105
  }
102
106
  };
package/dist/new.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { isAgentDetected } from "./agentRegistry.js";
3
4
  import { discoverLocalTools } from "./discovery.js";
4
5
  import { findPresetNameForPair } from "./presets.js";
5
6
  import { MAX_TURNS, turnsOrDefault, validateTurns } from "./limits.js";
@@ -136,25 +137,6 @@ function buildAgentChoices(config, discovery, messages) {
136
137
  })
137
138
  .sort((left, right) => Number(right.detected) - Number(left.detected) || left.name.localeCompare(right.name));
138
139
  }
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 === "agy")
151
- return discovery.antigravity.available;
152
- if (normalized === "antigravity")
153
- return discovery.antigravity.available;
154
- if (normalized === "opencode")
155
- return discovery.opencode.available;
156
- return true;
157
- }
158
140
  function agentStatus(_name, config, discovery, detected, messages) {
159
141
  if (config.type === "ollama") {
160
142
  return detected
@@ -248,13 +230,6 @@ function splitPaths(value) {
248
230
  .map((entry) => entry.trim())
249
231
  .filter(Boolean) ?? [];
250
232
  }
251
- function normalizeCommandName(command) {
252
- return command
253
- .split(/[\\/]/)
254
- .pop()
255
- ?.toLowerCase()
256
- .replace(/\.(exe|cmd|bat|ps1)$/i, "") ?? command.toLowerCase();
257
- }
258
233
  function isQuit(value) {
259
234
  return ["q", "quit", "exit"].includes(value.toLowerCase());
260
235
  }
@@ -78,7 +78,7 @@ export async function runDebate(config, options, renderer, messages = createTran
78
78
  };
79
79
  transcript.push(message);
80
80
  renderer?.message(message.content);
81
- if (shouldStopOnAgreement(options, transcript)) {
81
+ if (shouldStopOnAgreement(options, transcript, messages)) {
82
82
  stopReason = messages.orchestrator.agreementStopReason;
83
83
  renderer?.notice(messages.orchestrator.earlyStop(stopReason));
84
84
  break;
@@ -110,28 +110,21 @@ export async function runDebate(config, options, renderer, messages = createTran
110
110
  /**
111
111
  * Heuristique d'arrêt sur accord explicite.
112
112
  * Ne s'active qu'après un tour complet (nombre pair de messages) pour éviter les faux positifs.
113
+ * Les phrases d'accord proviennent du dictionnaire i18n pour suivre la langue d'interface.
113
114
  * Intentionnellement prudente : ne remplace pas une évaluation sémantique réelle.
114
115
  */
115
- function shouldStopOnAgreement(options, messages) {
116
- if (!options.earlyStopOnAgreement || messages.length < 2 || messages.length % 2 !== 0) {
116
+ function shouldStopOnAgreement(options, transcript, messages) {
117
+ if (!options.earlyStopOnAgreement || transcript.length < 2 || transcript.length % 2 !== 0) {
117
118
  return false;
118
119
  }
119
- const latest = normalizeForAgreement(messages[messages.length - 1]?.content ?? "");
120
+ const latest = normalizeForAgreement(transcript[transcript.length - 1]?.content ?? "");
120
121
  if (!latest) {
121
122
  return false;
122
123
  }
123
- const positivePatterns = [
124
- "accord complet",
125
- "accord total",
126
- "aucun desaccord",
127
- "aucune incertitude",
128
- "rien a trancher",
129
- "rien a ajouter",
130
- "question factuelle resolue"
131
- ];
132
- if (positivePatterns.some((pattern) => latest.includes(pattern))) {
124
+ if (messages.orchestrator.agreementPatterns.some((pattern) => latest.includes(pattern))) {
133
125
  return true;
134
126
  }
127
+ // Combinaison française historique : confirmation explicite + absence de point ouvert.
135
128
  return (latest.includes("confirme") || latest.includes("acte")) &&
136
129
  (latest.includes("aucun") || latest.includes("rien a trancher") || latest.includes("rien a ajouter"));
137
130
  }
package/dist/presets.js CHANGED
@@ -1,4 +1,4 @@
1
- import path from "node:path";
1
+ import { detectionForCommand } from "./agentRegistry.js";
2
2
  const presets = {
3
3
  "codex-claude": {
4
4
  agentA: "codex",
@@ -181,7 +181,7 @@ function checkAgentAvailability(agentName, config, discovery, messages) {
181
181
  }
182
182
  return available(agentName);
183
183
  }
184
- const detection = knownCliDetection(agent, discovery);
184
+ const detection = detectionForCommand(agent.command, discovery);
185
185
  if (!detection) {
186
186
  // Les CLIs custom déclarées par l'utilisateur restent considérées utilisables :
187
187
  // Palabre ne peut pas connaître leur sémantique sans les lancer.
@@ -191,25 +191,6 @@ function checkAgentAvailability(agentName, config, discovery, messages) {
191
191
  ? available(agentName)
192
192
  : unavailable(agentName, messages?.presets.missingCommand(agentName, detection.command) ?? `commande non détectée pour ${agentName}: ${detection.command}`);
193
193
  }
194
- function knownCliDetection(agent, discovery) {
195
- const command = normalizeCommandName(agent.command);
196
- if (command === "codex")
197
- return discovery.codex;
198
- if (command === "claude")
199
- return discovery.claude;
200
- if (command === "gemini")
201
- return discovery.gemini;
202
- if (command === "agy")
203
- return discovery.antigravity;
204
- if (command === "antigravity")
205
- return discovery.antigravity;
206
- if (command === "opencode")
207
- return discovery.opencode;
208
- return undefined;
209
- }
210
- function normalizeCommandName(command) {
211
- return path.basename(command).toLowerCase().replace(/\.(exe|cmd|ps1|bat)$/i, "");
212
- }
213
194
  function available(agent) {
214
195
  return { agent, available: true, reason: "" };
215
196
  }
package/dist/prompt.js CHANGED
@@ -25,6 +25,8 @@ export function formatAgentPrompt(input) {
25
25
  messages.sessionStartedAt(input.session.startedAt),
26
26
  messages.turnProgress(input.turn, input.totalTurns),
27
27
  "",
28
+ messages.responseLanguageInstruction,
29
+ "",
28
30
  messages.objectiveTitle,
29
31
  ...messages.debateObjectives,
30
32
  "",
@@ -56,6 +58,8 @@ function formatSummaryPrompt(input, messages) {
56
58
  messages.cwd(input.session.cwd),
57
59
  messages.sessionStartedAt(input.session.startedAt),
58
60
  "",
61
+ messages.responseLanguageInstruction,
62
+ "",
59
63
  messages.objectiveTitle,
60
64
  ...messages.summaryObjectives,
61
65
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palabre",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Orchestrateur de debat entre agents IA locaux, CLIs et Ollama.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,8 @@
38
38
  "prepack": "pnpm build",
39
39
  "start": "node ./dist/index.js",
40
40
  "test": "pnpm build:test && node --test .tmp/test-dist/tests/*.test.js",
41
- "build:test": "node -e \"fs.rmSync('.tmp/test-dist',{recursive:true,force:true})\" && tsc -p tsconfig.test.json"
41
+ "build:test": "node -e \"fs.rmSync('.tmp/test-dist',{recursive:true,force:true})\" && tsc -p tsconfig.test.json",
42
+ "smoke:real-presets": "node -e \"fs.rmSync('.tmp/smoke-real',{recursive:true,force:true})\" && tsc --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --outDir .tmp/smoke-real scripts/smoke_real_presets.ts && node .tmp/smoke-real/smoke_real_presets.js"
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=20"