palabre 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -6,8 +6,11 @@ export const LEGACY_CONFIG_PATH = "chicane.config.json";
6
6
  export const CONFIG_DIR_NAME = ".palabre";
7
7
  export const GLOBAL_CONFIG_PATH = path.join(os.homedir(), CONFIG_DIR_NAME, DEFAULT_CONFIG_PATH);
8
8
  export const GLOBAL_LEGACY_CONFIG_PATH = path.join(os.homedir(), CONFIG_DIR_NAME, LEGACY_CONFIG_PATH);
9
+ export const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:4b";
10
+ export const DEFAULT_OUTPUT_DIR = ".palabre";
9
11
  export const exampleConfig = {
10
- outputDir: ".",
12
+ language: "fr",
13
+ outputDir: DEFAULT_OUTPUT_DIR,
11
14
  defaults: {
12
15
  agentA: "codex",
13
16
  agentB: "claude",
@@ -78,7 +81,7 @@ export const exampleConfig = {
78
81
  "ollama-local": {
79
82
  type: "ollama",
80
83
  baseUrl: "http://localhost:11434",
81
- model: "nemotron-3-nano:4b",
84
+ model: DEFAULT_OLLAMA_MODEL,
82
85
  role: "critic",
83
86
  tier: "local",
84
87
  temperature: 0.2,
@@ -87,6 +90,15 @@ export const exampleConfig = {
87
90
  }
88
91
  }
89
92
  };
93
+ /**
94
+ * Résout le dossier d'export effectif.
95
+ * `.` est traité comme l'ancien défaut historique afin de regrouper les exports
96
+ * dans un dossier dédié sans demander de migration manuelle aux utilisateurs.
97
+ */
98
+ export function resolveOutputDir(outputDir) {
99
+ const normalized = outputDir?.trim();
100
+ return !normalized || normalized === "." ? DEFAULT_OUTPUT_DIR : normalized;
101
+ }
90
102
  /** Charge et parse la config depuis `configPath`. Lance une erreur si le fichier est absent ou invalide. */
91
103
  export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
92
104
  const resolved = path.resolve(configPath);
@@ -126,7 +138,7 @@ export async function resolveDefaultConfigPath() {
126
138
  /**
127
139
  * Construit une `PalabreConfig` complète à partir des outils détectés localement.
128
140
  * Ajuste `defaults.agentA/agentB/summaryAgent` en fonction de la paire disponible.
129
- * Si aucune paire n'est détectée, `defaults` reste celui de `exampleConfig`.
141
+ * Si aucune paire n'est détectée, seuls les defaults sans agent sont conservés.
130
142
  */
131
143
  export function createConfigFromDiscovery(discovery) {
132
144
  const config = cloneConfig(exampleConfig);
@@ -147,10 +159,18 @@ export function createConfigFromDiscovery(discovery) {
147
159
  ...config.agents.opencode,
148
160
  ...(discovery.opencode.available ? { command: discovery.opencode.command } : {})
149
161
  };
150
- config.defaults = {
151
- ...config.defaults,
152
- ...(pair ? { agentA: pair[0], agentB: pair[1], summaryAgent: chooseDefaultSummaryAgent(pair) } : {})
153
- };
162
+ const ollamaAgent = config.agents["ollama-local"];
163
+ if (ollamaAgent?.type === "ollama") {
164
+ ollamaAgent.model = chooseDefaultOllamaModel(discovery);
165
+ }
166
+ config.defaults = pair
167
+ ? {
168
+ ...config.defaults,
169
+ agentA: pair[0],
170
+ agentB: pair[1],
171
+ summaryAgent: chooseDefaultSummaryAgent(pair)
172
+ }
173
+ : { turns: config.defaults?.turns };
154
174
  return config;
155
175
  }
156
176
  /** Écrit `config` sérialisé en JSON dans `configPath`. Crée le répertoire parent si nécessaire. */
@@ -159,6 +179,12 @@ export async function writeExampleConfig(configPath = DEFAULT_CONFIG_PATH, confi
159
179
  await mkdir(path.dirname(resolved), { recursive: true });
160
180
  await writeFile(resolved, `${JSON.stringify(config, null, 2)}\n`, "utf8");
161
181
  }
182
+ function chooseDefaultOllamaModel(discovery) {
183
+ if (discovery.ollama.models.includes(DEFAULT_OLLAMA_MODEL)) {
184
+ return DEFAULT_OLLAMA_MODEL;
185
+ }
186
+ return discovery.ollama.models[0] ?? DEFAULT_OLLAMA_MODEL;
187
+ }
162
188
  function chooseDefaultSummaryAgent(pair) {
163
189
  for (const preferred of ["claude", "codex", "gemini"]) {
164
190
  if (pair.includes(preferred)) {
@@ -7,47 +7,47 @@ import { DEFAULT_TURNS, MAX_TURNS, turnsOrDefault, validateTurns } from "./limit
7
7
  * Fonctionne en mode TTY (readline) et en mode piped (stdin lu en avance).
8
8
  * Écrit la config sur disque si l'utilisateur confirme ; sort sans modifier si l'utilisateur quitte.
9
9
  */
10
- export async function runConfigWizard(configPath, config) {
10
+ export async function runConfigWizard(configPath, config, messages) {
11
11
  const choices = Object.entries(config.agents).map(([name, agentConfig]) => ({ name, config: agentConfig }));
12
12
  if (choices.length < 2) {
13
- throw new Error("La config doit contenir au moins deux agents pour définir des paramètres par défaut.");
13
+ throw new Error(messages.config.wizardNeedsTwoAgents);
14
14
  }
15
15
  const rl = await createQuestioner();
16
16
  try {
17
- console.log("PALABRE - Configuration");
18
- console.log("À tout moment: Ctrl+C pour interrompre, ou tape q, quit ou exit dans un prompt pour quitter.");
17
+ console.log(messages.config.wizardTitle);
18
+ console.log(messages.config.wizardQuitHint);
19
19
  console.log("");
20
- console.log("Fichier de configuration :");
20
+ console.log(messages.config.wizardConfigFile);
21
21
  console.log(` ${configPath}`);
22
22
  console.log("");
23
- console.log("Paramètres par défaut actuels :");
24
- console.log(` ${config.defaults ? formatDefaults(config.defaults) : "Aucun"}`);
23
+ console.log(messages.config.wizardCurrentDefaults);
24
+ console.log(` ${config.defaults ? formatDefaults(config.defaults, messages) : messages.config.wizardNoDefaults}`);
25
25
  console.log("");
26
- console.log("Que veux-tu faire ?");
27
- console.log(" 1) Définir des paramètres par défaut");
28
- console.log(" 2) Supprimer les paramètres par défaut");
29
- console.log(" 3) Quitter sans modifier");
30
- const action = await askChoice(rl, "Tape le numéro de ton choix", "1", ["1", "2", "3"]);
26
+ console.log(messages.config.wizardActionQuestion);
27
+ console.log(` 1) ${messages.config.wizardActionSetDefaults}`);
28
+ 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
31
  if (!action || action === "3") {
32
- console.log("Config inchangée.");
32
+ console.log(messages.config.wizardUnchanged);
33
33
  return;
34
34
  }
35
35
  if (action === "2") {
36
36
  delete config.defaults;
37
37
  await writeExampleConfig(configPath, config);
38
- console.log(`Paramètres par défaut supprimés dans ${configPath}.`);
38
+ console.log(messages.config.wizardCleared(configPath));
39
39
  return;
40
40
  }
41
- const agentA = await askAgent(rl, choices, "Agent A", "Choisis l'agent A, celui qui répondra en premier.", config.defaults?.agentA);
41
+ const agentA = await askAgent(rl, choices, messages.config.wizardAgentADescription, config.defaults?.agentA, messages);
42
42
  if (!agentA)
43
43
  return;
44
- const agentB = await askAgent(rl, choices.filter((choice) => choice.name !== agentA), "Agent B", "Choisis l'agent B, celui qui répondra en second.", config.defaults?.agentB === agentA ? undefined : config.defaults?.agentB);
44
+ const agentB = await askAgent(rl, choices.filter((choice) => choice.name !== agentA), messages.config.wizardAgentBDescription, config.defaults?.agentB === agentA ? undefined : config.defaults?.agentB, messages);
45
45
  if (!agentB)
46
46
  return;
47
- const turns = await askNumber(rl, "Nombre de réponses par défaut", turnsOrDefault(config.defaults?.turns), Boolean(config.defaults?.turns));
47
+ const turns = await askNumber(rl, messages.config.wizardTurnsLabel, turnsOrDefault(config.defaults?.turns), Boolean(config.defaults?.turns), messages);
48
48
  if (turns === undefined)
49
49
  return;
50
- const summaryAgent = await askSummaryAgent(rl, choices, config.defaults?.summaryAgent ?? agentB, Boolean(config.defaults?.summaryAgent), agentB);
50
+ const summaryAgent = await askSummaryAgent(rl, choices, config.defaults?.summaryAgent ?? agentB, Boolean(config.defaults?.summaryAgent), agentB, messages);
51
51
  if (summaryAgent === undefined)
52
52
  return;
53
53
  config.defaults = {
@@ -57,7 +57,7 @@ export async function runConfigWizard(configPath, config) {
57
57
  turns
58
58
  };
59
59
  await writeExampleConfig(configPath, config);
60
- console.log(`Paramètres par défaut définis dans ${configPath}: ${formatDefaults(config.defaults)}.`);
60
+ console.log(messages.config.wizardDefaultsSet(configPath, formatDefaults(config.defaults, messages)));
61
61
  }
62
62
  finally {
63
63
  rl.close();
@@ -89,9 +89,9 @@ async function readPipedLines() {
89
89
  }
90
90
  return raw ? raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n") : [];
91
91
  }
92
- async function askAgent(rl, choices, _label, description, defaultName) {
92
+ async function askAgent(rl, choices, description, defaultName, messages) {
93
93
  const fallback = choices.find((choice) => choice.name === defaultName)?.name ?? choices[0]?.name;
94
- const fallbackLabel = defaultName ? "Actuel" : "Suggestion";
94
+ const fallbackLabel = defaultName ? messages.config.wizardCurrent : messages.config.wizardSuggestion;
95
95
  console.log("");
96
96
  console.log(description);
97
97
  console.log(`${fallbackLabel} : ${fallback}`);
@@ -100,7 +100,7 @@ async function askAgent(rl, choices, _label, description, defaultName) {
100
100
  console.log(` ${index + 1}) ${formatAgentLine(choice)}`);
101
101
  });
102
102
  while (true) {
103
- const answer = await rl.question(`Tape un numéro ou un nom d'agent (Entrée = ${fallback}) : `);
103
+ const answer = await rl.question(messages.config.wizardAgentPrompt(fallback));
104
104
  const value = answer.trim();
105
105
  if (isQuit(value))
106
106
  return undefined;
@@ -113,22 +113,22 @@ async function askAgent(rl, choices, _label, description, defaultName) {
113
113
  if (choices.some((choice) => choice.name === value)) {
114
114
  return value;
115
115
  }
116
- console.log("Choix invalide. Tape un numéro, un nom d'agent, Entrée ou q.");
116
+ console.log(messages.config.wizardInvalidAgentChoice);
117
117
  }
118
118
  }
119
- async function askSummaryAgent(rl, choices, defaultName, hasCurrentDefault, agentB) {
119
+ async function askSummaryAgent(rl, choices, defaultName, hasCurrentDefault, agentB, messages) {
120
120
  const fallback = choices.some((choice) => choice.name === defaultName) ? defaultName : choices[0]?.name;
121
- const fallbackLabel = hasCurrentDefault ? "Actuel" : "Suggestion";
121
+ const fallbackLabel = hasCurrentDefault ? messages.config.wizardCurrent : messages.config.wizardSuggestion;
122
122
  console.log("");
123
- console.log("Agent de synthèse par défaut");
124
- console.log(`${fallbackLabel} : ${fallback}${!hasCurrentDefault && fallback === agentB ? " (agent B)" : ""}`);
123
+ console.log(messages.config.wizardSummaryTitle);
124
+ console.log(`${fallbackLabel} : ${fallback}${!hasCurrentDefault && fallback === agentB ? ` (${messages.config.wizardAgentBHint})` : ""}`);
125
125
  console.log("");
126
- console.log(" 0) Aucun agent de synthèse par défaut");
126
+ console.log(` 0) ${messages.config.wizardNoSummary}`);
127
127
  choices.forEach((choice, index) => {
128
128
  console.log(` ${index + 1}) ${formatAgentLine(choice)}`);
129
129
  });
130
130
  while (true) {
131
- const answer = await rl.question(`Tape un numéro, un nom d'agent, ou 0 pour aucun (Entrée = ${fallback}) : `);
131
+ const answer = await rl.question(messages.config.wizardSummaryPrompt(fallback));
132
132
  const value = answer.trim();
133
133
  if (isQuit(value))
134
134
  return undefined;
@@ -143,12 +143,12 @@ async function askSummaryAgent(rl, choices, defaultName, hasCurrentDefault, agen
143
143
  if (choices.some((choice) => choice.name === value)) {
144
144
  return value;
145
145
  }
146
- console.log("Choix invalide. Tape un numéro, un nom d'agent, 0, Entrée ou q.");
146
+ console.log(messages.config.wizardInvalidSummaryChoice);
147
147
  }
148
148
  }
149
- async function askChoice(rl, label, defaultValue, allowed) {
149
+ async function askChoice(rl, label, defaultValue, allowed, messages) {
150
150
  while (true) {
151
- const answer = await rl.question(`${label} (Entrée = ${defaultValue}) : `);
151
+ const answer = await rl.question(messages.config.wizardChoiceQuestion(label, defaultValue));
152
152
  const value = answer.trim();
153
153
  if (isQuit(value))
154
154
  return undefined;
@@ -156,17 +156,17 @@ async function askChoice(rl, label, defaultValue, allowed) {
156
156
  return defaultValue;
157
157
  if (allowed.includes(value))
158
158
  return value;
159
- console.log(`Choix invalide. Valeurs: ${allowed.join(", ")}, Entrée ou q.`);
159
+ console.log(messages.config.wizardInvalidChoice(allowed.join(", ")));
160
160
  }
161
161
  }
162
- async function askNumber(rl, label, defaultValue, hasCurrentDefault) {
163
- const fallbackLabel = hasCurrentDefault ? "Actuel" : "Suggestion";
162
+ async function askNumber(rl, label, defaultValue, hasCurrentDefault, messages) {
163
+ const fallbackLabel = hasCurrentDefault ? messages.config.wizardCurrent : messages.config.wizardSuggestion;
164
164
  console.log("");
165
165
  console.log(label);
166
166
  console.log(`${fallbackLabel} : ${defaultValue}`);
167
167
  console.log("");
168
168
  while (true) {
169
- const answer = await rl.question(`Tape le nombre total de réponses du débat (Entrée = ${defaultValue}) : `);
169
+ const answer = await rl.question(messages.config.wizardTurnsPrompt(defaultValue));
170
170
  const value = answer.trim();
171
171
  if (isQuit(value))
172
172
  return undefined;
@@ -175,21 +175,26 @@ async function askNumber(rl, label, defaultValue, hasCurrentDefault) {
175
175
  const parsed = Number(value);
176
176
  if (Number.isInteger(parsed)) {
177
177
  try {
178
- validateTurns(parsed, "Le nombre de réponses");
178
+ validateTurns(parsed, messages.config.wizardTurnsLabel, messages);
179
179
  return parsed;
180
180
  }
181
181
  catch {
182
182
  // Show the user-facing wizard hint below.
183
183
  }
184
184
  }
185
- console.log(`Entre un nombre entier entre 1 et ${MAX_TURNS}, Entrée ou q.`);
185
+ console.log(messages.config.wizardTurnsInvalid(MAX_TURNS));
186
186
  }
187
187
  }
188
188
  function formatAgentLine(choice) {
189
189
  return `${choice.name.padEnd(12)} ${choice.config.type} / ${choice.config.role}`;
190
190
  }
191
- function formatDefaults(defaults) {
192
- return `${defaults.agentA ?? "?"} <-> ${defaults.agentB ?? "?"}, réponses: ${turnsOrDefault(defaults.turns ?? DEFAULT_TURNS)}${defaults.summaryAgent ? `, synthèse: ${defaults.summaryAgent}` : ""}`;
191
+ function formatDefaults(defaults, messages) {
192
+ return messages.config.wizardDefaults({
193
+ agentA: defaults.agentA,
194
+ agentB: defaults.agentB,
195
+ turns: turnsOrDefault(defaults.turns ?? DEFAULT_TURNS),
196
+ summaryAgent: defaults.summaryAgent
197
+ });
193
198
  }
194
199
  function isQuit(value) {
195
200
  return ["q", "quit", "exit"].includes(value.toLowerCase());
package/dist/context.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { createTranslator } from "./i18n.js";
3
4
  const MAX_FILE_BYTES = 64 * 1024;
4
5
  const MAX_TOTAL_BYTES = 192 * 1024;
5
6
  const DEFAULT_EXCLUDED_NAMES = new Set([
@@ -32,8 +33,8 @@ const TEXT_EXTENSIONS = new Set([
32
33
  * Mode strict (`--files`) : charge uniquement les fichiers explicitement listés.
33
34
  * Lève une erreur si un chemin est un dossier, un binaire, ou dépasse 64 KiB / 192 KiB au total.
34
35
  */
35
- export async function loadProjectFiles(paths, cwd = process.cwd()) {
36
- const result = await loadProjectInputs(paths, [], cwd);
36
+ export async function loadProjectFiles(paths, cwd = process.cwd(), messages = createTranslator("fr")) {
37
+ const result = await loadProjectInputs(paths, [], cwd, messages);
37
38
  return result.files;
38
39
  }
39
40
  /**
@@ -41,13 +42,14 @@ export async function loadProjectFiles(paths, cwd = process.cwd()) {
41
42
  * Les fichiers explicites sont chargés en premier et comptent dans le budget total.
42
43
  * Les chemins de contexte acceptent fichiers et dossiers ; les fichiers ignorés génèrent des warnings, pas des erreurs.
43
44
  */
44
- export async function loadProjectInputs(filePaths, contextPaths, cwd = process.cwd()) {
45
+ export async function loadProjectInputs(filePaths, contextPaths, cwd = process.cwd(), messages = createTranslator("fr")) {
45
46
  const state = {
46
47
  files: [],
47
48
  warnings: [],
48
49
  seen: new Set(),
49
50
  totalBytes: 0,
50
- gitignoreRules: await loadGitignoreRules(cwd)
51
+ gitignoreRules: await loadGitignoreRules(cwd),
52
+ messages
51
53
  };
52
54
  await addExplicitFiles(filePaths, cwd, state);
53
55
  await addContextPaths(contextPaths, cwd, state);
@@ -62,14 +64,14 @@ async function addExplicitFiles(paths, cwd, state) {
62
64
  const absolutePath = path.resolve(cwd, inputPath);
63
65
  const fileStat = await stat(absolutePath);
64
66
  if (!fileStat.isFile()) {
65
- throw new Error(`Le contexte fichier doit pointer vers un fichier: ${inputPath}`);
67
+ throw new Error(state.messages.context.explicitMustBeFile(inputPath));
66
68
  }
67
69
  if (fileStat.size > MAX_FILE_BYTES) {
68
- throw new Error(`Fichier trop gros pour le contexte: ${inputPath} (${fileStat.size} bytes, max ${MAX_FILE_BYTES})`);
70
+ throw new Error(state.messages.context.explicitTooLarge(inputPath, fileStat.size, MAX_FILE_BYTES));
69
71
  }
70
72
  const content = await readFile(absolutePath, "utf8");
71
73
  if (content.includes("\u0000")) {
72
- throw new Error(`Fichier binaire ou non texte refuse: ${inputPath}`);
74
+ throw new Error(state.messages.context.explicitBinary(inputPath));
73
75
  }
74
76
  addFileToState(cwd, state, absolutePath, content, fileStat.size, "explicit");
75
77
  }
@@ -84,7 +86,7 @@ async function addContextPaths(paths, cwd, state) {
84
86
  continue;
85
87
  }
86
88
  if (!fileStat.isDirectory()) {
87
- state.warnings.push(`Contexte ignore (ni fichier ni dossier): ${inputPath}`);
89
+ state.warnings.push(state.messages.context.ignoredNotFileOrDirectory(inputPath));
88
90
  continue;
89
91
  }
90
92
  await walkContextDirectory(absolutePath, cwd, state);
@@ -113,21 +115,21 @@ async function addContextFile(absolutePath, cwd, state) {
113
115
  return;
114
116
  }
115
117
  if (!isLikelyTextFile(absolutePath)) {
116
- state.warnings.push(`Contexte ignore (extension non texte): ${relativePath}`);
118
+ state.warnings.push(state.messages.context.ignoredNonTextExtension(relativePath));
117
119
  return;
118
120
  }
119
121
  const fileStat = await stat(absolutePath);
120
122
  if (fileStat.size > MAX_FILE_BYTES) {
121
- state.warnings.push(`Contexte ignore (fichier trop gros): ${relativePath} (${fileStat.size} bytes)`);
123
+ state.warnings.push(state.messages.context.ignoredTooLarge(relativePath, fileStat.size));
122
124
  return;
123
125
  }
124
126
  if (state.totalBytes + fileStat.size > MAX_TOTAL_BYTES) {
125
- state.warnings.push(`Contexte ignore (limite totale atteinte): ${relativePath}`);
127
+ state.warnings.push(state.messages.context.ignoredTotalLimit(relativePath));
126
128
  return;
127
129
  }
128
130
  const content = await readFile(absolutePath, "utf8");
129
131
  if (content.includes("\u0000")) {
130
- state.warnings.push(`Contexte ignore (binaire detecte): ${relativePath}`);
132
+ state.warnings.push(state.messages.context.ignoredBinary(relativePath));
131
133
  return;
132
134
  }
133
135
  addFileToState(cwd, state, absolutePath, content, fileStat.size, "context");
@@ -138,9 +140,9 @@ function addFileToState(cwd, state, absolutePath, content, sizeBytes, source) {
138
140
  }
139
141
  if (state.totalBytes + sizeBytes > MAX_TOTAL_BYTES) {
140
142
  if (source === "explicit") {
141
- throw new Error(`Contexte fichiers trop gros (${state.totalBytes + sizeBytes} bytes, max ${MAX_TOTAL_BYTES})`);
143
+ throw new Error(state.messages.context.explicitTotalTooLarge(state.totalBytes + sizeBytes, MAX_TOTAL_BYTES));
142
144
  }
143
- state.warnings.push(`Contexte ignore (limite totale atteinte): ${normalizePath(path.relative(cwd, absolutePath))}`);
145
+ state.warnings.push(state.messages.context.ignoredTotalLimit(normalizePath(path.relative(cwd, absolutePath))));
144
146
  return;
145
147
  }
146
148
  state.seen.add(absolutePath);