palabre 0.6.3 → 0.7.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.
@@ -38,6 +38,9 @@ export class CliPtyAdapter {
38
38
  };
39
39
  }
40
40
  async generate(prompt) {
41
+ if (prompt.signal?.aborted) {
42
+ throw cancelledError(this.name);
43
+ }
41
44
  const renderedPrompt = formatAgentPrompt(prompt);
42
45
  const promptMode = this.config.promptMode ?? "stdin";
43
46
  const baseArgs = withModelArgs(this.config.args ?? [], this.config.model, this.config.modelArg ?? "--model");
@@ -53,6 +56,7 @@ export class CliPtyAdapter {
53
56
  let term;
54
57
  let dataSubscription;
55
58
  let exitSubscription;
59
+ let abortListener;
56
60
  const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
57
61
  const finish = (error, exitCode, kill = true) => {
58
62
  if (settled)
@@ -61,6 +65,9 @@ export class CliPtyAdapter {
61
65
  clearTimeout(hardTimer);
62
66
  dataSubscription?.dispose();
63
67
  exitSubscription?.dispose();
68
+ if (abortListener) {
69
+ prompt.signal?.removeEventListener("abort", abortListener);
70
+ }
64
71
  if (kill) {
65
72
  try {
66
73
  term.kill();
@@ -107,6 +114,10 @@ export class CliPtyAdapter {
107
114
  }));
108
115
  return;
109
116
  }
117
+ abortListener = () => {
118
+ finish(cancelledError(this.name));
119
+ };
120
+ prompt.signal?.addEventListener("abort", abortListener, { once: true });
110
121
  hardTimer = setTimeout(() => {
111
122
  finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
112
123
  timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
@@ -165,6 +176,9 @@ function createPtyExitError(adapterName, exitCode, raw) {
165
176
  raw
166
177
  });
167
178
  }
179
+ function cancelledError(adapterName) {
180
+ return new AdapterError("cancelled", adapterName, `${adapterName} cancelled by user.`);
181
+ }
168
182
  function summarizePtyOutput(output) {
169
183
  const cleaned = cleanTerminalOutput(output);
170
184
  return cleaned ? cleaned.slice(-1_200) : "aucune sortie PTY capturee.";
@@ -37,6 +37,9 @@ export class CliAdapter {
37
37
  };
38
38
  }
39
39
  async generate(prompt) {
40
+ if (prompt.signal?.aborted) {
41
+ throw cancelledError(this.name);
42
+ }
40
43
  const renderedPrompt = formatAgentPrompt(prompt);
41
44
  const promptMode = this.config.promptMode ?? "stdin";
42
45
  const baseArgs = withModelArgs(this.config.args ?? [], this.config.model, this.config.modelArg ?? "--model");
@@ -54,6 +57,7 @@ export class CliAdapter {
54
57
  let outputBytes = 0;
55
58
  let hardTimer;
56
59
  let idleTimer;
60
+ let abortListener;
57
61
  const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
58
62
  const finish = (error) => {
59
63
  if (settled)
@@ -62,12 +66,20 @@ export class CliAdapter {
62
66
  clearTimeout(hardTimer);
63
67
  if (idleTimer)
64
68
  clearTimeout(idleTimer);
69
+ if (abortListener) {
70
+ prompt.signal?.removeEventListener("abort", abortListener);
71
+ }
65
72
  if (error) {
66
73
  reject(error);
67
74
  return;
68
75
  }
69
76
  const content = cleanCliOutput(stdout);
70
77
  if (!content && !this.config.allowEmptyOutput) {
78
+ const knownError = createKnownCliError(this.name, undefined, stderr);
79
+ if (knownError) {
80
+ reject(knownError);
81
+ return;
82
+ }
71
83
  const detail = stderr.trim() ? ` Stderr: ${stderr.trim()}` : "";
72
84
  reject(new AdapterError("empty-output", this.name, `${this.name} produced empty output.${detail}`, {
73
85
  stderr: stderr.trim()
@@ -79,8 +91,13 @@ export class CliAdapter {
79
91
  raw: stdout
80
92
  });
81
93
  };
94
+ abortListener = () => {
95
+ killChildProcess(child);
96
+ finish(cancelledError(this.name));
97
+ };
98
+ prompt.signal?.addEventListener("abort", abortListener, { once: true });
82
99
  hardTimer = setTimeout(() => {
83
- child.kill();
100
+ killChildProcess(child);
84
101
  finish(new AdapterError("timeout", this.name, `${this.name} timed out after ${this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, {
85
102
  timeoutMs: this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS
86
103
  }));
@@ -91,7 +108,7 @@ export class CliAdapter {
91
108
  if (idleTimer)
92
109
  clearTimeout(idleTimer);
93
110
  idleTimer = setTimeout(() => {
94
- child.kill();
111
+ killChildProcess(child);
95
112
  finish(new AdapterError("idle-timeout", this.name, `${this.name} stopped producing output for ${this.config.idleTimeoutMs}ms`, { idleTimeoutMs: this.config.idleTimeoutMs }));
96
113
  }, this.config.idleTimeoutMs);
97
114
  };
@@ -99,7 +116,7 @@ export class CliAdapter {
99
116
  child.stdout.on("data", (chunk) => {
100
117
  outputBytes += chunk.length;
101
118
  if (outputBytes > maxOutputBytes) {
102
- child.kill();
119
+ killChildProcess(child);
103
120
  finish(new AdapterError("output-too-large", this.name, `${this.name} produced more than ${maxOutputBytes} bytes of output`, {
104
121
  maxOutputBytes,
105
122
  outputBytes
@@ -112,7 +129,7 @@ export class CliAdapter {
112
129
  child.stderr.on("data", (chunk) => {
113
130
  outputBytes += chunk.length;
114
131
  if (outputBytes > maxOutputBytes) {
115
- child.kill();
132
+ killChildProcess(child);
116
133
  finish(new AdapterError("output-too-large", this.name, `${this.name} produced more than ${maxOutputBytes} bytes of output`, {
117
134
  maxOutputBytes,
118
135
  outputBytes
@@ -189,25 +206,29 @@ function normalizeForWindowsStatus(line) {
189
206
  * Élève en `usage-limit` si le stderr contient un signal de quota/rate-limit connu.
190
207
  */
191
208
  function createCliExitError(adapterName, exitCode, stderr) {
209
+ return createKnownCliError(adapterName, exitCode, stderr)
210
+ ?? new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizeCliError(cleanCliOutput(stderr))}`, {
211
+ exitCode,
212
+ stderr: cleanCliOutput(stderr)
213
+ });
214
+ }
215
+ function createKnownCliError(adapterName, exitCode, stderr) {
192
216
  const cleanedStderr = cleanCliOutput(stderr);
193
217
  const usageLimitMessage = extractUsageLimitMessage(cleanedStderr);
194
218
  const unsupportedModelMessage = extractUnsupportedModelMessage(cleanedStderr);
195
219
  if (usageLimitMessage) {
196
220
  return new AdapterError("usage-limit", adapterName, `${adapterName} a atteint une limite d'utilisation: ${usageLimitMessage}`, {
197
- exitCode,
221
+ ...(exitCode === undefined ? {} : { exitCode }),
198
222
  stderr: cleanedStderr
199
223
  });
200
224
  }
201
225
  if (unsupportedModelMessage) {
202
226
  return new AdapterError("unsupported-model", adapterName, `${adapterName} ne peut pas utiliser ce modèle: ${unsupportedModelMessage}`, {
203
- exitCode,
227
+ ...(exitCode === undefined ? {} : { exitCode }),
204
228
  stderr: cleanedStderr
205
229
  });
206
230
  }
207
- return new AdapterError("non-zero-exit", adapterName, `${adapterName} exited with code ${exitCode}: ${summarizeCliError(cleanedStderr)}`, {
208
- exitCode,
209
- stderr: cleanedStderr
210
- });
231
+ return undefined;
211
232
  }
212
233
  function extractUnsupportedModelMessage(stderr) {
213
234
  const lines = uniqueNonEmptyLines(stderr);
@@ -286,3 +307,19 @@ function clipLine(value, maxLength) {
286
307
  ? value
287
308
  : `${value.slice(0, maxLength - 1)}…`;
288
309
  }
310
+ function cancelledError(adapterName) {
311
+ return new AdapterError("cancelled", adapterName, `${adapterName} cancelled by user.`);
312
+ }
313
+ function killChildProcess(child) {
314
+ if (process.platform === "win32" && child.pid) {
315
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
316
+ windowsHide: true,
317
+ stdio: "ignore"
318
+ });
319
+ killer.on("error", () => {
320
+ child.kill();
321
+ });
322
+ return;
323
+ }
324
+ child.kill();
325
+ }
@@ -35,6 +35,9 @@ export class OllamaAdapter {
35
35
  };
36
36
  }
37
37
  async generate(prompt) {
38
+ if (prompt.signal?.aborted) {
39
+ throw cancelledError(this.name);
40
+ }
38
41
  const baseUrl = normalizeBaseUrl(this.config.baseUrl ?? "http://localhost:11434");
39
42
  if (this.config.validateModel !== false) {
40
43
  await this.ensureModelAvailable(baseUrl);
@@ -43,6 +46,8 @@ export class OllamaAdapter {
43
46
  await this.unloadOtherRunningModels(baseUrl);
44
47
  }
45
48
  const controller = new AbortController();
49
+ const abortListener = () => controller.abort();
50
+ prompt.signal?.addEventListener("abort", abortListener, { once: true });
46
51
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 120_000);
47
52
  try {
48
53
  const response = await fetch(`${baseUrl}/api/chat`, {
@@ -85,8 +90,15 @@ export class OllamaAdapter {
85
90
  content
86
91
  };
87
92
  }
93
+ catch (error) {
94
+ if (prompt.signal?.aborted) {
95
+ throw cancelledError(this.name);
96
+ }
97
+ throw error;
98
+ }
88
99
  finally {
89
100
  clearTimeout(timeout);
101
+ prompt.signal?.removeEventListener("abort", abortListener);
90
102
  }
91
103
  }
92
104
  /**
@@ -217,3 +229,6 @@ async function unloadModel(baseUrl, model, signal) {
217
229
  function normalizeBaseUrl(baseUrl) {
218
230
  return baseUrl.replace(/\/$/, "");
219
231
  }
232
+ function cancelledError(adapterName) {
233
+ return new AdapterError("cancelled", adapterName, `${adapterName} cancelled by user.`);
234
+ }
package/dist/args.js CHANGED
@@ -23,6 +23,8 @@ const FLAG_SPECS = {
23
23
  apply: { arity: "boolean" },
24
24
  "clear-defaults": { arity: "boolean" },
25
25
  "sync-agents": { arity: "boolean" },
26
+ "sync-ollama-model": { arity: "boolean" },
27
+ "ollama-models": { arity: "boolean" },
26
28
  // Valeur unique.
27
29
  "agent-a": { arity: "single" },
28
30
  "agent-b": { arity: "single" },
@@ -30,6 +32,7 @@ const FLAG_SPECS = {
30
32
  language: { arity: "single" },
31
33
  "model-a": { arity: "single" },
32
34
  "model-b": { arity: "single" },
35
+ "set-ollama-model": { arity: "single" },
33
36
  preset: { arity: "single" },
34
37
  "summary-agent": { arity: "single" },
35
38
  "summary-model": { arity: "single" },
package/dist/config.js CHANGED
@@ -1,7 +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
+ import { applyDetectedCommands, detectedAgentNames } from "./agentRegistry.js";
5
5
  export const DEFAULT_CONFIG_PATH = "palabre.config.json";
6
6
  export const LEGACY_CONFIG_PATH = "chicane.config.json";
7
7
  export const CONFIG_DIR_NAME = ".palabre";
@@ -196,6 +196,46 @@ export function createConfigFromDiscovery(discovery) {
196
196
  : { turns: config.defaults?.turns };
197
197
  return config;
198
198
  }
199
+ /**
200
+ * Ajoute dans `config.agents` les agents détectés localement mais absents de la config.
201
+ * Mute `config` directement ; l'appelant est responsable de persister la config.
202
+ */
203
+ export function syncDetectedAgents(config, discovery) {
204
+ const discoveredConfig = createConfigFromDiscovery(discovery);
205
+ const missingAgents = detectedAgentNames(discovery).filter((agentName) => !config.agents[agentName]);
206
+ applyDetectedCommands(config, discovery);
207
+ for (const agentName of missingAgents) {
208
+ config.agents[agentName] = discoveredConfig.agents[agentName];
209
+ }
210
+ return missingAgents;
211
+ }
212
+ export function syncOllamaModel(config, discovery) {
213
+ const agent = config.agents["ollama-local"];
214
+ if (agent?.type !== "ollama" || discovery.ollama.models.length === 0) {
215
+ return undefined;
216
+ }
217
+ if (discovery.ollama.models.includes(agent.model)) {
218
+ return undefined;
219
+ }
220
+ const previousModel = agent.model;
221
+ agent.model = chooseDefaultOllamaModel(discovery);
222
+ return {
223
+ previousModel,
224
+ nextModel: agent.model
225
+ };
226
+ }
227
+ export function setOllamaModel(config, model) {
228
+ const agent = config.agents["ollama-local"];
229
+ if (agent?.type !== "ollama") {
230
+ return undefined;
231
+ }
232
+ const previousModel = agent.model;
233
+ agent.model = model;
234
+ return {
235
+ previousModel,
236
+ nextModel: agent.model
237
+ };
238
+ }
199
239
  /** Écrit `config` sérialisé en JSON dans `configPath`. Crée le répertoire parent si nécessaire. */
200
240
  export async function writeExampleConfig(configPath = DEFAULT_CONFIG_PATH, config = exampleConfig) {
201
241
  const resolved = path.resolve(configPath);
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
- import { writeExampleConfig } from "./config.js";
3
+ import { syncDetectedAgents, writeExampleConfig } from "./config.js";
4
+ import { discoverLocalTools } from "./discovery.js";
4
5
  import { DEFAULT_TURNS, MAX_TURNS, turnsOrDefault, validateTurns } from "./limits.js";
5
6
  /**
6
7
  * Lance le wizard interactif de configuration des defaults.
@@ -26,9 +27,10 @@ export async function runConfigWizard(configPath, config, messages) {
26
27
  console.log(messages.config.wizardActionQuestion);
27
28
  console.log(` 1) ${messages.config.wizardActionSetDefaults}`);
28
29
  console.log(` 2) ${messages.config.wizardActionClearDefaults}`);
29
- console.log(` 3) ${messages.config.wizardActionExit}`);
30
- const action = await askChoice(rl, messages.config.wizardChoicePrompt, "1", ["1", "2", "3"], messages);
31
- if (!action || action === "3") {
30
+ console.log(` 3) ${messages.config.wizardActionSyncAgents}`);
31
+ console.log(` 4) ${messages.config.wizardActionExit}`);
32
+ const action = await askChoice(rl, messages.config.wizardChoicePrompt, "1", ["1", "2", "3", "4"], messages);
33
+ if (!action || action === "4") {
32
34
  console.log(messages.config.wizardUnchanged);
33
35
  return;
34
36
  }
@@ -38,6 +40,17 @@ export async function runConfigWizard(configPath, config, messages) {
38
40
  console.log(messages.config.wizardCleared(configPath));
39
41
  return;
40
42
  }
43
+ if (action === "3") {
44
+ const discovery = await discoverLocalTools();
45
+ const addedAgents = syncDetectedAgents(config, discovery);
46
+ if (addedAgents.length === 0) {
47
+ console.log(messages.config.syncNoMissing(configPath));
48
+ return;
49
+ }
50
+ await writeExampleConfig(configPath, config);
51
+ console.log(messages.config.syncAdded(configPath, addedAgents.join(", ")));
52
+ return;
53
+ }
41
54
  const agentA = await askAgent(rl, choices, messages.config.wizardAgentADescription, config.defaults?.agentA, messages);
42
55
  if (!agentA)
43
56
  return;
package/dist/discovery.js CHANGED
@@ -4,13 +4,14 @@ import { executableExtensions } from "./exec.js";
4
4
  /**
5
5
  * Détecte en parallèle toutes les CLIs supportées et le serveur Ollama local.
6
6
  * Sur Windows, tente `claude.exe` avant `claude`.
7
+ * Antigravity est exposé selon les installations sous `agy` ou `antigravity`.
7
8
  */
8
9
  export async function discoverLocalTools() {
9
10
  const [codex, claude, gemini, antigravity, opencode, ollamaCommand] = await Promise.all([
10
11
  detectCommand("codex"),
11
12
  detectFirstCommand(process.platform === "win32" ? ["claude.exe", "claude"] : ["claude"]),
12
13
  detectCommand("gemini"),
13
- detectCommand("agy"),
14
+ detectFirstCommand(["agy", "antigravity"]),
14
15
  detectCommand("opencode"),
15
16
  detectCommand("ollama")
16
17
  ]);
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { detectedAgentNames, detectionForCommand } from "./agentRegistry.js";
5
5
  import { discoverLocalTools } from "./discovery.js";
6
6
  import { createTranslator, resolveLanguage } from "./i18n.js";
7
7
  import { DEFAULT_TURNS, MAX_TURNS } from "./limits.js";
8
+ import { compareSemver, getLatestPackageVersion, getPackageVersion } from "./version.js";
8
9
  /**
9
10
  * Exécute le diagnostic complet : config, outils locaux et agents.
10
11
  * Retourne toujours un résultat (pas de throw) ; les erreurs de config sont reportées comme lignes `error`.
@@ -20,6 +21,7 @@ export async function runDoctor(explicitConfigPath, plain = false, explicitLangu
20
21
  });
21
22
  const t = createTranslator(language);
22
23
  lines.push(info(t.doctor.title, "title"));
24
+ await inspectCliVersion(lines, t);
23
25
  lines.push(info(t.doctor.currentDirectory(process.cwd()), "cwd"));
24
26
  lines.push(hasConfig
25
27
  ? ok(t.doctor.configFound(configPath))
@@ -53,6 +55,18 @@ export async function runDoctor(explicitConfigPath, plain = false, explicitLangu
53
55
  inspectAgents(config, discovery, lines, t);
54
56
  return render(lines, plain, t);
55
57
  }
58
+ async function inspectCliVersion(lines, t) {
59
+ const currentVersion = await getPackageVersion();
60
+ lines.push(info(t.doctor.cliVersion(currentVersion)));
61
+ const latestVersion = await getLatestPackageVersion();
62
+ if (!latestVersion) {
63
+ lines.push(info(t.doctor.updateUnknown));
64
+ return;
65
+ }
66
+ lines.push(compareSemver(currentVersion, latestVersion) < 0
67
+ ? warn(t.doctor.updateAvailable(currentVersion, latestVersion))
68
+ : ok(t.doctor.updateCurrent(latestVersion)));
69
+ }
56
70
  async function loadConfigSafely(configPath) {
57
71
  try {
58
72
  return await loadConfig(configPath);
package/dist/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { assertRunnableConfig, configExists, createConfigFromDiscovery, DEFAULT_CONFIG_PATH, GLOBAL_CONFIG_PATH, loadConfig, resolveDefaultConfigPath, resolveOutputDir, writeExampleConfig } from "./config.js";
2
+ import { assertRunnableConfig, configExists, createConfigFromDiscovery, DEFAULT_CONFIG_PATH, GLOBAL_CONFIG_PATH, loadConfig, resolveDefaultConfigPath, resolveOutputDir, setOllamaModel, syncDetectedAgents, syncOllamaModel, writeExampleConfig } from "./config.js";
6
3
  import { loadProjectInputs } from "./context.js";
7
4
  import { buildContextScan } from "./contextScan.js";
8
5
  import { discoverLocalTools } from "./discovery.js";
@@ -22,6 +19,7 @@ import { applySourceUpdate, formatUpdateInstructions, getUpdateInfo } from "./up
22
19
  import { createSessionContext } from "./session.js";
23
20
  import { getStringListFlag, parseArgs } from "./args.js";
24
21
  import { detectedAgentNames, detectionForCommand } from "./agentRegistry.js";
22
+ import { getPackageVersion } from "./version.js";
25
23
  /** Point d'entrée principal du CLI Palabre. Dispatche vers la commande appropriée selon les arguments. */
26
24
  async function main() {
27
25
  const rawArgs = process.argv.slice(2);
@@ -166,7 +164,8 @@ async function main() {
166
164
  summaryModel: optionalString(parsed.flags["summary-model"]),
167
165
  summaryEnabled: !parsed.flags["no-summary"],
168
166
  earlyStopOnAgreement: !parsed.flags["no-early-stop"],
169
- plainOutput: Boolean(parsed.flags.plain)
167
+ plainOutput: Boolean(parsed.flags.plain),
168
+ signal: debateAbortSignal()
170
169
  };
171
170
  if (parsed.flags["show-prompt"]) {
172
171
  printContextWarnings(context.warnings, messages);
@@ -179,9 +178,20 @@ async function main() {
179
178
  const outputPath = await writeDebateMarkdown(resolveOutputDir(config.outputDir), result.options, result.messages, result.summary, result.stopReason, messages, result.failure);
180
179
  renderer.done(outputPath);
181
180
  if (result.failure) {
182
- process.exitCode = 1;
181
+ process.exitCode = result.failure.kind === "cancelled" ? 130 : 1;
183
182
  }
184
183
  }
184
+ function debateAbortSignal() {
185
+ const controller = new AbortController();
186
+ const abort = () => {
187
+ if (!controller.signal.aborted) {
188
+ controller.abort();
189
+ }
190
+ };
191
+ process.once("SIGINT", abort);
192
+ process.once("SIGTERM", abort);
193
+ return controller.signal;
194
+ }
185
195
  /**
186
196
  * Exécute la commande `agents` : charge la config et affiche les agents déclarés avec leur état de détection.
187
197
  * @param flags - Flags parsés depuis la ligne de commande.
@@ -220,6 +230,19 @@ async function runConfigCommand(flags) {
220
230
  configLanguage: config.language
221
231
  });
222
232
  const messages = createTranslator(language);
233
+ if (flags["ollama-models"]) {
234
+ await runOllamaModelsCommand(config, Boolean(flags.json));
235
+ return;
236
+ }
237
+ const setOllamaModelValue = optionalString(flags["set-ollama-model"]);
238
+ if (setOllamaModelValue !== undefined) {
239
+ await runSetOllamaModelCommand(configPath, config, setOllamaModelValue, messages);
240
+ return;
241
+ }
242
+ if (flags["sync-ollama-model"]) {
243
+ await runSyncOllamaModelCommand(configPath, config, messages);
244
+ return;
245
+ }
223
246
  if (flags["sync-agents"]) {
224
247
  const discovery = await discoverLocalTools();
225
248
  const addedAgents = syncDetectedAgents(config, discovery);
@@ -278,6 +301,63 @@ async function runConfigCommand(flags) {
278
301
  }
279
302
  await runConfigWizard(configPath, config, messages);
280
303
  }
304
+ async function runOllamaModelsCommand(config, json) {
305
+ const discovery = await discoverLocalTools();
306
+ const agent = config.agents["ollama-local"];
307
+ const currentModel = agent?.type === "ollama" ? agent.model : null;
308
+ const payload = {
309
+ v: 1,
310
+ agent: "ollama-local",
311
+ available: discovery.ollama.available,
312
+ baseUrl: discovery.ollama.baseUrl,
313
+ currentModel,
314
+ currentModelInstalled: currentModel ? discovery.ollama.models.includes(currentModel) : false,
315
+ installedModels: discovery.ollama.models
316
+ };
317
+ if (json) {
318
+ console.log(JSON.stringify(payload, null, 2));
319
+ return;
320
+ }
321
+ console.log(`ollama-local: ${currentModel ?? "(non configuré)"}`);
322
+ console.log(`Ollama API: ${discovery.ollama.available ? "joignable" : "indisponible"} (${discovery.ollama.baseUrl})`);
323
+ console.log(`Modèles installés: ${discovery.ollama.models.length > 0 ? discovery.ollama.models.join(", ") : "(aucun)"}`);
324
+ }
325
+ async function runSetOllamaModelCommand(configPath, config, model, messages) {
326
+ const trimmed = model.trim();
327
+ if (!trimmed) {
328
+ throw new Error(messages.common.optionRequiresValue("--set-ollama-model"));
329
+ }
330
+ const discovery = await discoverLocalTools();
331
+ const agent = config.agents["ollama-local"];
332
+ if (agent?.type !== "ollama") {
333
+ throw new Error(messages.config.ollamaModelNoAgent);
334
+ }
335
+ if (!discovery.ollama.models.includes(trimmed)) {
336
+ throw new Error(messages.config.ollamaModelUnavailable(trimmed));
337
+ }
338
+ const result = setOllamaModel(config, trimmed);
339
+ await writeExampleConfig(configPath, config);
340
+ console.log(result
341
+ ? messages.config.ollamaModelUpdated(configPath, result.previousModel, result.nextModel)
342
+ : messages.config.ollamaModelNoChange(configPath, agent.model));
343
+ }
344
+ async function runSyncOllamaModelCommand(configPath, config, messages) {
345
+ const discovery = await discoverLocalTools();
346
+ const agent = config.agents["ollama-local"];
347
+ if (agent?.type !== "ollama") {
348
+ throw new Error(messages.config.ollamaModelNoAgent);
349
+ }
350
+ if (discovery.ollama.models.length === 0) {
351
+ throw new Error(messages.config.ollamaModelNoInstalledModels);
352
+ }
353
+ const result = syncOllamaModel(config, discovery);
354
+ if (!result) {
355
+ console.log(messages.config.ollamaModelNoChange(configPath, agent.model));
356
+ return;
357
+ }
358
+ await writeExampleConfig(configPath, config);
359
+ console.log(messages.config.ollamaModelUpdated(configPath, result.previousModel, result.nextModel));
360
+ }
281
361
  /**
282
362
  * Renvoie `true` si la valeur représente une désactivation explicite (ex. "none", "0", "disabled").
283
363
  * @param value - Chaîne saisie par l'utilisateur.
@@ -472,13 +552,6 @@ async function runContextCommand(flags, positionals) {
472
552
  console.error(`${messages.renderers.warningPrefix} ${warning}`);
473
553
  }
474
554
  }
475
- /** Lit la version depuis `package.json` adjacent au bundle compilé. */
476
- async function getPackageVersion() {
477
- const packageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
478
- const raw = await readFile(packageJsonPath, "utf8");
479
- const packageJson = JSON.parse(raw);
480
- return packageJson.version ?? "0.0.0";
481
- }
482
555
  /**
483
556
  * Écrit les avertissements de contexte sur `stderr`.
484
557
  * @param warnings - Messages d'avertissement issus du chargement des fichiers de contexte.
@@ -495,22 +568,6 @@ function printContextWarnings(warnings, messages) {
495
568
  * @param discovery - Résultat de la découverte locale des outils.
496
569
  * @returns Noms des agents nouvellement ajoutés.
497
570
  */
498
- function syncDetectedAgents(config, discovery) {
499
- const discoveredConfig = createConfigFromDiscovery(discovery);
500
- const missingAgents = findDetectedMissingAgents(config, discovery);
501
- for (const agentName of missingAgents) {
502
- config.agents[agentName] = discoveredConfig.agents[agentName];
503
- }
504
- return missingAgents;
505
- }
506
- /**
507
- * Renvoie les noms des agents détectés localement qui ne sont pas encore dans `config.agents`.
508
- * @param config - Config Palabre existante.
509
- * @param discovery - Résultat de la découverte locale des outils.
510
- */
511
- function findDetectedMissingAgents(config, discovery) {
512
- return detectedAgentNames(discovery).filter((agentName) => !config.agents[agentName]);
513
- }
514
571
  /**
515
572
  * Affiche la liste des agents déclarés avec leur type, rôle, état de détection et défauts.
516
573
  * @param configPath - Chemin du fichier de config (affiché en en-tête).
@@ -8,7 +8,7 @@ const frHints = {
8
8
  "usage-limit": "Attends la fenetre indiquee par la CLI, change de modele ou relance avec un autre agent/preset disponible.",
9
9
  "non-zero-exit": "Teste la commande directement, puis ajuste args, permissions, modele ou authentification de la CLI.",
10
10
  "model-unavailable": "Installe le modele Ollama ou relance avec --pull-models pour autoriser le telechargement.",
11
- "unsupported-model": "Verifie le nom du modele, ton abonnement, ou retire --model-a/--model-b/--summary-model pour laisser la CLI utiliser son modele par defaut.",
11
+ "unsupported-model": "Mets a jour la CLI de l'agent, verifie le nom du modele et ton abonnement, ou retire --model-a/--model-b/--summary-model pour laisser la CLI utiliser son modele par defaut.",
12
12
  "model-pull-failed": "Verifie le nom du modele, ta connexion et l'espace disque disponible.",
13
13
  "http-error": "Verifie que le service local est lance et que baseUrl est correct."
14
14
  };
@@ -22,7 +22,7 @@ const enHints = {
22
22
  "usage-limit": "Wait for the window indicated by the CLI, change model, or run again with another available agent/preset.",
23
23
  "non-zero-exit": "Test the command directly, then adjust args, permissions, model, or CLI authentication.",
24
24
  "model-unavailable": "Install the Ollama model or run again with --pull-models to allow downloading.",
25
- "unsupported-model": "Check the model name, your subscription, or remove --model-a/--model-b/--summary-model so the CLI can use its default model.",
25
+ "unsupported-model": "Update the agent CLI, check the model name and your subscription, or remove --model-a/--model-b/--summary-model so the CLI can use its default model.",
26
26
  "model-pull-failed": "Check the model name, your connection, and available disk space.",
27
27
  "http-error": "Check that the local service is running and baseUrl is correct."
28
28
  };
@@ -3,6 +3,11 @@ export const configMessages = {
3
3
  createdForConfig: (path) => `${path} créé. Édite la config puis relance palabre config.`,
4
4
  syncNoMissing: (path) => `Aucun agent détecté manquant dans ${path}.`,
5
5
  syncAdded: (path, agents) => `Agents ajoutés dans ${path}: ${agents}.`,
6
+ ollamaModelNoChange: (path, model) => `Modèle Ollama inchangé dans ${path}: ${model ?? "aucun"}.`,
7
+ ollamaModelUpdated: (path, previousModel, nextModel) => `Modèle Ollama mis à jour dans ${path}: ${previousModel} -> ${nextModel}.`,
8
+ ollamaModelUnavailable: (model) => `Modèle Ollama non installé: ${model}. Action: choisis un modèle installé ou lance \`ollama pull ${model}\`.`,
9
+ ollamaModelNoAgent: "Agent ollama-local absent ou invalide dans la config.",
10
+ ollamaModelNoInstalledModels: "Aucun modèle Ollama installé détecté. Action: lance `ollama pull <modèle>`.",
6
11
  updated: (path, defaults, language) => `Configuration mise à jour dans ${path}: ${defaults}, langue: ${language}.`,
7
12
  cleared: (path) => `Paramètres par défaut supprimés dans ${path}. Utilise maintenant un preset ou --agent-a/--agent-b pour lancer un débat.`,
8
13
  defaultsSummary: (agentA, agentB, turns, summaryAgent) => {
@@ -19,6 +24,7 @@ export const configMessages = {
19
24
  wizardActionQuestion: "Que veux-tu faire ?",
20
25
  wizardActionSetDefaults: "Définir des paramètres par défaut",
21
26
  wizardActionClearDefaults: "Supprimer les paramètres par défaut",
27
+ wizardActionSyncAgents: "Synchroniser les agents détectés",
22
28
  wizardActionExit: "Quitter sans modifier",
23
29
  wizardChoicePrompt: "Tape le numéro de ton choix",
24
30
  wizardChoiceQuestion: (label, defaultValue) => `${label} (Entrée = ${defaultValue}) : `,
@@ -46,6 +52,11 @@ export const configMessages = {
46
52
  createdForConfig: (path) => `${path} created. Edit the config, then run palabre config again.`,
47
53
  syncNoMissing: (path) => `No missing detected agent in ${path}.`,
48
54
  syncAdded: (path, agents) => `Agents added to ${path}: ${agents}.`,
55
+ ollamaModelNoChange: (path, model) => `Ollama model unchanged in ${path}: ${model ?? "none"}.`,
56
+ ollamaModelUpdated: (path, previousModel, nextModel) => `Ollama model updated in ${path}: ${previousModel} -> ${nextModel}.`,
57
+ ollamaModelUnavailable: (model) => `Ollama model is not installed: ${model}. Action: choose an installed model or run \`ollama pull ${model}\`.`,
58
+ ollamaModelNoAgent: "ollama-local agent is missing or invalid in the config.",
59
+ ollamaModelNoInstalledModels: "No installed Ollama model detected. Action: run `ollama pull <model>`.",
49
60
  updated: (path, defaults, language) => `Configuration updated in ${path}: ${defaults}, language: ${language}.`,
50
61
  cleared: (path) => `Default settings cleared in ${path}. Use a preset or --agent-a/--agent-b to start a debate now.`,
51
62
  defaultsSummary: (agentA, agentB, turns, summaryAgent) => {
@@ -62,6 +73,7 @@ export const configMessages = {
62
73
  wizardActionQuestion: "What do you want to do?",
63
74
  wizardActionSetDefaults: "Set default settings",
64
75
  wizardActionClearDefaults: "Clear default settings",
76
+ wizardActionSyncAgents: "Sync detected agents",
65
77
  wizardActionExit: "Exit without changes",
66
78
  wizardChoicePrompt: "Type the number of your choice",
67
79
  wizardChoiceQuestion: (label, defaultValue) => `${label} (Enter = ${defaultValue}): `,
@@ -1,6 +1,10 @@
1
1
  export const doctorMessages = {
2
2
  fr: {
3
3
  title: "PALABRE doctor",
4
+ cliVersion: (version) => `Version CLI: ${version}`,
5
+ updateCurrent: (version) => `Mise à jour: CLI à jour (${version}).`,
6
+ updateAvailable: (current, latest) => `Mise à jour disponible: ${current} -> ${latest}. Action: lance ` + "`palabre update`.",
7
+ updateUnknown: "Mise à jour: vérification npm indisponible. Action: lance `palabre update` pour les instructions.",
4
8
  currentDirectory: (cwd) => `Dossier courant: ${cwd}`,
5
9
  configFound: (path) => `Config trouvée: ${path}`,
6
10
  configMissing: (path) => `Config absente: ${path}`,
@@ -63,6 +67,10 @@ export const doctorMessages = {
63
67
  },
64
68
  en: {
65
69
  title: "PALABRE doctor",
70
+ cliVersion: (version) => `CLI version: ${version}`,
71
+ updateCurrent: (version) => `Update: CLI is up to date (${version}).`,
72
+ updateAvailable: (current, latest) => `Update available: ${current} -> ${latest}. Action: run ` + "`palabre update`.",
73
+ updateUnknown: "Update: npm check unavailable. Action: run `palabre update` for instructions.",
66
74
  currentDirectory: (cwd) => `Current directory: ${cwd}`,
67
75
  configFound: (path) => `Config found: ${path}`,
68
76
  configMissing: (path) => `Config missing: ${path}`,
@@ -11,6 +11,7 @@ export const orchestratorMessages = {
11
11
  "rien a ajouter",
12
12
  "question factuelle resolue"
13
13
  ],
14
+ cancelled: "Débat annulé par l'utilisateur.",
14
15
  ollamaNoContext: (agentNames) => `${agentNames} ne lit pas le filesystem. Ajoute --files ou --context pour fournir un contexte projet.`,
15
16
  unknownSummaryAgent: (agentName) => `Agent de synthese inconnu: ${agentName}`
16
17
  },
@@ -27,6 +28,7 @@ export const orchestratorMessages = {
27
28
  "nothing to add",
28
29
  "factual question resolved"
29
30
  ],
31
+ cancelled: "Debate cancelled by the user.",
30
32
  ollamaNoContext: (agentNames) => `${agentNames} cannot read the filesystem. Add --files or --context to provide project context.`,
31
33
  unknownSummaryAgent: (agentName) => `Unknown summary agent: ${agentName}`
32
34
  }
@@ -32,6 +32,19 @@ export async function runDebate(config, options, renderer, messages = createTran
32
32
  const transcript = [];
33
33
  let stopReason;
34
34
  for (let index = 0; index < options.turns; index += 1) {
35
+ const cancellation = cancellationFailureIfAborted(options, messages, {
36
+ phase: "debate",
37
+ turn: index + 1
38
+ });
39
+ if (cancellation) {
40
+ renderer?.error(cancellation);
41
+ return {
42
+ options,
43
+ messages: transcript,
44
+ stopReason,
45
+ failure: cancellation
46
+ };
47
+ }
35
48
  const current = agents[index % agents.length];
36
49
  const peer = agents[(index + 1) % agents.length];
37
50
  const turn = index + 1;
@@ -49,7 +62,8 @@ export async function runDebate(config, options, renderer, messages = createTran
49
62
  language: options.language,
50
63
  session: options.session,
51
64
  files: options.files,
52
- transcript
65
+ transcript,
66
+ signal: options.signal
53
67
  });
54
68
  }
55
69
  catch (error) {
@@ -88,6 +102,20 @@ export async function runDebate(config, options, renderer, messages = createTran
88
102
  let failure;
89
103
  if (options.summaryEnabled) {
90
104
  try {
105
+ const cancellation = cancellationFailureIfAborted(options, messages, {
106
+ phase: "summary",
107
+ agent: options.summaryAgent ?? options.agentB,
108
+ turn: transcript.length + 1
109
+ });
110
+ if (cancellation) {
111
+ renderer?.error(cancellation);
112
+ return {
113
+ options,
114
+ messages: transcript,
115
+ stopReason,
116
+ failure: cancellation
117
+ };
118
+ }
91
119
  summary = await generateSummary(config, options, transcript, renderer, messages);
92
120
  }
93
121
  catch (error) {
@@ -181,7 +209,8 @@ async function generateSummary(config, options, transcript, renderer, messages =
181
209
  language: options.language,
182
210
  session: options.session,
183
211
  files: options.files,
184
- transcript
212
+ transcript,
213
+ signal: options.signal
185
214
  }).finally(() => renderer?.thinkingEnd());
186
215
  const summary = {
187
216
  agent: summaryAgent.name,
@@ -192,6 +221,19 @@ async function generateSummary(config, options, transcript, renderer, messages =
192
221
  renderer?.message(summary.content);
193
222
  return summary;
194
223
  }
224
+ function cancellationFailureIfAborted(options, messages, context) {
225
+ if (!options.signal?.aborted) {
226
+ return undefined;
227
+ }
228
+ return {
229
+ phase: context.phase,
230
+ agent: context.agent,
231
+ role: context.role,
232
+ turn: context.turn,
233
+ kind: "cancelled",
234
+ message: messages.orchestrator.cancelled
235
+ };
236
+ }
195
237
  function toDebateFailure(error, context) {
196
238
  if (error instanceof AdapterError) {
197
239
  return {
@@ -0,0 +1,54 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Lit la version depuis `package.json` adjacent au bundle compilé. */
5
+ export async function getPackageVersion() {
6
+ const packageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
7
+ const raw = await readFile(packageJsonPath, "utf8");
8
+ const packageJson = JSON.parse(raw);
9
+ return packageJson.version ?? "0.0.0";
10
+ }
11
+ /** Compare deux versions semver simples `major.minor.patch`. */
12
+ export function compareSemver(left, right) {
13
+ const a = parseSemverParts(left);
14
+ const b = parseSemverParts(right);
15
+ for (let index = 0; index < 3; index += 1) {
16
+ const diff = a[index] - b[index];
17
+ if (diff !== 0) {
18
+ return diff;
19
+ }
20
+ }
21
+ return 0;
22
+ }
23
+ /** Lit la dernière version publiée sur npm. Retourne `undefined` hors ligne ou si le registre ne répond pas. */
24
+ export async function getLatestPackageVersion(timeoutMs = 1_500) {
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
27
+ try {
28
+ const response = await fetch("https://registry.npmjs.org/palabre/latest", {
29
+ signal: controller.signal,
30
+ headers: {
31
+ accept: "application/json"
32
+ }
33
+ });
34
+ if (!response.ok) {
35
+ return undefined;
36
+ }
37
+ const data = await response.json();
38
+ return typeof data.version === "string" ? data.version : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ finally {
44
+ clearTimeout(timeout);
45
+ }
46
+ }
47
+ function parseSemverParts(value) {
48
+ const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
49
+ return [
50
+ Number(match?.[1] ?? 0),
51
+ Number(match?.[2] ?? 0),
52
+ Number(match?.[3] ?? 0)
53
+ ];
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palabre",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Orchestrateur de debat entre agents IA locaux, CLIs et Ollama.",
5
5
  "license": "MIT",
6
6
  "type": "module",