palabre 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +377 -0
- package/dist/adapters/cli.js +250 -0
- package/dist/adapters/index.js +11 -0
- package/dist/adapters/ollama.js +218 -0
- package/dist/config.js +199 -0
- package/dist/configWizard.js +196 -0
- package/dist/context.js +198 -0
- package/dist/discovery.js +121 -0
- package/dist/doctor.js +341 -0
- package/dist/errors.js +49 -0
- package/dist/index.js +635 -0
- package/dist/limits.js +41 -0
- package/dist/new.js +312 -0
- package/dist/orchestrator.js +195 -0
- package/dist/output.js +100 -0
- package/dist/presets.js +98 -0
- package/dist/prompt.js +122 -0
- package/dist/renderers/console.js +171 -0
- package/dist/session.js +35 -0
- package/dist/types.js +1 -0
- package/dist/update.js +68 -0
- package/package.json +46 -0
- package/palabre.config.example.json +81 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { AdapterError } from "../errors.js";
|
|
2
|
+
import { formatAgentPrompt } from "../prompt.js";
|
|
3
|
+
/**
|
|
4
|
+
* Adapter pour Ollama via l'API HTTP locale (`POST /api/chat`).
|
|
5
|
+
* N'accède jamais au filesystem : ne voit que le prompt et le transcript fournis par l'orchestrateur.
|
|
6
|
+
* Garantit : rejection des sorties vides et des timeouts.
|
|
7
|
+
*/
|
|
8
|
+
export class OllamaAdapter {
|
|
9
|
+
name;
|
|
10
|
+
config;
|
|
11
|
+
role;
|
|
12
|
+
contract;
|
|
13
|
+
constructor(name, config) {
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.role = config.role;
|
|
17
|
+
this.contract = {
|
|
18
|
+
name,
|
|
19
|
+
kind: "ollama",
|
|
20
|
+
capabilities: {
|
|
21
|
+
mode: "http",
|
|
22
|
+
supportsModelOverride: true,
|
|
23
|
+
supportsFilesystemAccess: false,
|
|
24
|
+
supportsStreaming: false,
|
|
25
|
+
supportsProcessExitCode: false,
|
|
26
|
+
supportsStderr: false
|
|
27
|
+
},
|
|
28
|
+
guarantees: {
|
|
29
|
+
rejectsEmptyOutput: true,
|
|
30
|
+
rejectsNonZeroExit: false,
|
|
31
|
+
rejectsTimeout: true,
|
|
32
|
+
returnsRawOutput: false
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async generate(prompt) {
|
|
37
|
+
const baseUrl = normalizeBaseUrl(this.config.baseUrl ?? "http://localhost:11434");
|
|
38
|
+
if (this.config.validateModel !== false) {
|
|
39
|
+
await this.ensureModelAvailable(baseUrl);
|
|
40
|
+
}
|
|
41
|
+
if (this.config.unloadOtherModels !== false) {
|
|
42
|
+
await this.unloadOtherRunningModels(baseUrl);
|
|
43
|
+
}
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 120_000);
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"content-type": "application/json"
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
model: this.config.model,
|
|
54
|
+
stream: false,
|
|
55
|
+
...(this.config.keepAlive !== undefined ? { keep_alive: this.config.keepAlive } : {}),
|
|
56
|
+
messages: [
|
|
57
|
+
{
|
|
58
|
+
role: "system",
|
|
59
|
+
content: this.config.systemPrompt ??
|
|
60
|
+
"Tu participes a un debat technique orchestre. Reste precis, utile et honnete sur tes limites."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
role: "user",
|
|
64
|
+
content: formatAgentPrompt(prompt)
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
options: {
|
|
68
|
+
temperature: this.config.temperature ?? 0.2
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
signal: controller.signal
|
|
72
|
+
});
|
|
73
|
+
const data = (await response.json());
|
|
74
|
+
if (!response.ok || data.error) {
|
|
75
|
+
throw new AdapterError("http-error", this.name, data.error ?? `Ollama HTTP ${response.status}`, {
|
|
76
|
+
status: response.status
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const content = data.message?.content?.trim() ?? "";
|
|
80
|
+
if (!content) {
|
|
81
|
+
throw new AdapterError("empty-output", this.name, `${this.name} produced empty output.`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
content
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Vérifie que le modèle est disponible avant de générer.
|
|
93
|
+
* Si absent et `autoPullModel` est faux, lève `model-unavailable` avec la liste des modèles détectés.
|
|
94
|
+
* Si absent et `autoPullModel` est vrai, déclenche le pull puis re-vérifie.
|
|
95
|
+
*/
|
|
96
|
+
async ensureModelAvailable(baseUrl) {
|
|
97
|
+
const available = await this.isModelAvailable(baseUrl);
|
|
98
|
+
if (available) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!this.config.autoPullModel) {
|
|
102
|
+
const models = await this.listAvailableModels(baseUrl);
|
|
103
|
+
throw new AdapterError("model-unavailable", this.name, `Modele Ollama indisponible: ${this.config.model}. Modeles detectes: ${models.join(", ") || "aucun"}. ` +
|
|
104
|
+
"Utilise --pull-models ou autoPullModel: true pour autoriser le telechargement.", { model: this.config.model, availableModels: models });
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write(`\n[ollama] Modele absent, telechargement: ${this.config.model}\n`);
|
|
107
|
+
await this.pullModel(baseUrl);
|
|
108
|
+
if (!(await this.isModelAvailable(baseUrl))) {
|
|
109
|
+
throw new AdapterError("model-pull-failed", this.name, `Le modele Ollama ${this.config.model} reste indisponible apres telechargement.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async isModelAvailable(baseUrl) {
|
|
113
|
+
const models = await this.listAvailableModels(baseUrl);
|
|
114
|
+
return models.includes(this.config.model);
|
|
115
|
+
}
|
|
116
|
+
async listAvailableModels(baseUrl) {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 120_000);
|
|
119
|
+
try {
|
|
120
|
+
return await this.fetchAvailableModels(baseUrl, controller.signal);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async fetchAvailableModels(baseUrl, signal) {
|
|
127
|
+
const response = await fetch(`${baseUrl}/api/tags`, { signal });
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new AdapterError("http-error", this.name, `Ollama HTTP ${response.status} pendant la detection des modeles`, {
|
|
130
|
+
status: response.status
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const data = (await response.json());
|
|
134
|
+
return data.models
|
|
135
|
+
?.map((model) => model.name ?? model.model)
|
|
136
|
+
.filter((modelName) => Boolean(modelName)) ?? [];
|
|
137
|
+
}
|
|
138
|
+
async pullModel(baseUrl) {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeout = setTimeout(() => controller.abort(), this.config.pullTimeoutMs ?? 1_800_000);
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(`${baseUrl}/api/pull`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"content-type": "application/json"
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
model: this.config.model,
|
|
149
|
+
stream: false
|
|
150
|
+
}),
|
|
151
|
+
signal: controller.signal
|
|
152
|
+
});
|
|
153
|
+
const data = (await response.json());
|
|
154
|
+
if (!response.ok || data.error) {
|
|
155
|
+
throw new AdapterError("model-pull-failed", this.name, data.error ?? `Ollama HTTP ${response.status}`, {
|
|
156
|
+
status: response.status
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
throw new AdapterError("model-pull-failed", this.name, `Echec du telechargement Ollama ${this.config.model}: ${message}`);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async unloadOtherRunningModels(baseUrl) {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 120_000);
|
|
171
|
+
try {
|
|
172
|
+
await this.unloadOtherRunningModelsWithSignal(baseUrl, controller.signal);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async unloadOtherRunningModelsWithSignal(baseUrl, signal) {
|
|
179
|
+
const response = await fetch(`${baseUrl}/api/ps`, { signal });
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw new AdapterError("http-error", this.name, `Ollama HTTP ${response.status} pendant la detection des modeles charges`, {
|
|
182
|
+
status: response.status
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const data = (await response.json());
|
|
186
|
+
const runningModels = data.models
|
|
187
|
+
?.map((model) => model.name ?? model.model)
|
|
188
|
+
.filter((modelName) => Boolean(modelName))
|
|
189
|
+
.filter((modelName) => modelName !== this.config.model) ?? [];
|
|
190
|
+
for (const model of runningModels) {
|
|
191
|
+
await unloadModel(baseUrl, model, signal);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Décharge un modèle Ollama en mémoire GPU/CPU via `POST /api/generate` avec `keep_alive: 0`. */
|
|
196
|
+
async function unloadModel(baseUrl, model, signal) {
|
|
197
|
+
const response = await fetch(`${baseUrl}/api/generate`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: {
|
|
200
|
+
"content-type": "application/json"
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
model,
|
|
204
|
+
keep_alive: 0
|
|
205
|
+
}),
|
|
206
|
+
signal
|
|
207
|
+
});
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new AdapterError("http-error", "ollama", `Impossible de decharger le modele Ollama ${model}: HTTP ${response.status}`, {
|
|
210
|
+
status: response.status,
|
|
211
|
+
model
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Supprime le slash final de `baseUrl` pour éviter les doubles slashs dans les URLs construites. */
|
|
216
|
+
function normalizeBaseUrl(baseUrl) {
|
|
217
|
+
return baseUrl.replace(/\/$/, "");
|
|
218
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const DEFAULT_CONFIG_PATH = "palabre.config.json";
|
|
5
|
+
export const LEGACY_CONFIG_PATH = "chicane.config.json";
|
|
6
|
+
export const CONFIG_DIR_NAME = ".palabre";
|
|
7
|
+
export const GLOBAL_CONFIG_PATH = path.join(os.homedir(), CONFIG_DIR_NAME, DEFAULT_CONFIG_PATH);
|
|
8
|
+
export const GLOBAL_LEGACY_CONFIG_PATH = path.join(os.homedir(), CONFIG_DIR_NAME, LEGACY_CONFIG_PATH);
|
|
9
|
+
export const exampleConfig = {
|
|
10
|
+
outputDir: ".",
|
|
11
|
+
defaults: {
|
|
12
|
+
agentA: "codex",
|
|
13
|
+
agentB: "claude",
|
|
14
|
+
summaryAgent: "claude",
|
|
15
|
+
turns: 4
|
|
16
|
+
},
|
|
17
|
+
agents: {
|
|
18
|
+
codex: {
|
|
19
|
+
type: "cli",
|
|
20
|
+
command: "codex",
|
|
21
|
+
args: [
|
|
22
|
+
"exec",
|
|
23
|
+
"--skip-git-repo-check",
|
|
24
|
+
"--color",
|
|
25
|
+
"never",
|
|
26
|
+
"--sandbox",
|
|
27
|
+
"read-only",
|
|
28
|
+
"-"
|
|
29
|
+
],
|
|
30
|
+
promptMode: "stdin",
|
|
31
|
+
shell: process.platform === "win32",
|
|
32
|
+
role: "implementer",
|
|
33
|
+
tier: "primary"
|
|
34
|
+
},
|
|
35
|
+
claude: {
|
|
36
|
+
type: "cli",
|
|
37
|
+
command: process.platform === "win32" ? "claude.exe" : "claude",
|
|
38
|
+
args: [
|
|
39
|
+
"--print",
|
|
40
|
+
"--output-format",
|
|
41
|
+
"text",
|
|
42
|
+
"--no-session-persistence"
|
|
43
|
+
],
|
|
44
|
+
promptMode: "stdin",
|
|
45
|
+
shell: false,
|
|
46
|
+
role: "reviewer",
|
|
47
|
+
tier: "primary"
|
|
48
|
+
},
|
|
49
|
+
gemini: {
|
|
50
|
+
type: "cli",
|
|
51
|
+
command: "gemini",
|
|
52
|
+
args: [
|
|
53
|
+
"--output-format",
|
|
54
|
+
"text",
|
|
55
|
+
"--approval-mode",
|
|
56
|
+
"plan",
|
|
57
|
+
"--skip-trust",
|
|
58
|
+
"--prompt",
|
|
59
|
+
"-"
|
|
60
|
+
],
|
|
61
|
+
promptMode: "stdin",
|
|
62
|
+
shell: process.platform === "win32",
|
|
63
|
+
role: "reviewer",
|
|
64
|
+
tier: "primary"
|
|
65
|
+
},
|
|
66
|
+
opencode: {
|
|
67
|
+
type: "cli",
|
|
68
|
+
command: "opencode",
|
|
69
|
+
args: [
|
|
70
|
+
"run"
|
|
71
|
+
],
|
|
72
|
+
promptMode: "stdin",
|
|
73
|
+
modelArg: "--model",
|
|
74
|
+
shell: process.platform === "win32",
|
|
75
|
+
role: "reviewer",
|
|
76
|
+
tier: "primary"
|
|
77
|
+
},
|
|
78
|
+
"ollama-local": {
|
|
79
|
+
type: "ollama",
|
|
80
|
+
baseUrl: "http://localhost:11434",
|
|
81
|
+
model: "nemotron-3-nano:4b",
|
|
82
|
+
role: "critic",
|
|
83
|
+
tier: "local",
|
|
84
|
+
temperature: 0.2,
|
|
85
|
+
validateModel: true,
|
|
86
|
+
unloadOtherModels: true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** Charge et parse la config depuis `configPath`. Lance une erreur si le fichier est absent ou invalide. */
|
|
91
|
+
export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
92
|
+
const resolved = path.resolve(configPath);
|
|
93
|
+
const raw = await readFile(resolved, "utf8");
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
}
|
|
96
|
+
/** Retourne `true` si le fichier de config est accessible en lecture. Silencieux sur toute erreur filesystem. */
|
|
97
|
+
export async function configExists(configPath = DEFAULT_CONFIG_PATH) {
|
|
98
|
+
try {
|
|
99
|
+
await access(path.resolve(configPath));
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Résout le chemin de config à utiliser selon l'ordre de priorité :
|
|
108
|
+
* local (`palabre.config.json`) → legacy local → global → legacy global.
|
|
109
|
+
* Retourne le chemin global même s'il n'existe pas encore (cas d'un premier `init`).
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveDefaultConfigPath() {
|
|
112
|
+
if (await configExists(DEFAULT_CONFIG_PATH)) {
|
|
113
|
+
return DEFAULT_CONFIG_PATH;
|
|
114
|
+
}
|
|
115
|
+
if (await configExists(LEGACY_CONFIG_PATH)) {
|
|
116
|
+
return LEGACY_CONFIG_PATH;
|
|
117
|
+
}
|
|
118
|
+
if (await configExists(GLOBAL_CONFIG_PATH)) {
|
|
119
|
+
return GLOBAL_CONFIG_PATH;
|
|
120
|
+
}
|
|
121
|
+
if (await configExists(GLOBAL_LEGACY_CONFIG_PATH)) {
|
|
122
|
+
return GLOBAL_LEGACY_CONFIG_PATH;
|
|
123
|
+
}
|
|
124
|
+
return GLOBAL_CONFIG_PATH;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Construit une `PalabreConfig` complète à partir des outils détectés localement.
|
|
128
|
+
* 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`.
|
|
130
|
+
*/
|
|
131
|
+
export function createConfigFromDiscovery(discovery) {
|
|
132
|
+
const config = cloneConfig(exampleConfig);
|
|
133
|
+
const pair = chooseDefaultPair(discovery);
|
|
134
|
+
config.agents.codex = {
|
|
135
|
+
...config.agents.codex,
|
|
136
|
+
...(discovery.codex.available ? { command: discovery.codex.command } : {})
|
|
137
|
+
};
|
|
138
|
+
config.agents.claude = {
|
|
139
|
+
...config.agents.claude,
|
|
140
|
+
...(discovery.claude.available ? { command: discovery.claude.command } : {})
|
|
141
|
+
};
|
|
142
|
+
config.agents.gemini = {
|
|
143
|
+
...config.agents.gemini,
|
|
144
|
+
...(discovery.gemini.available ? { command: discovery.gemini.command } : {})
|
|
145
|
+
};
|
|
146
|
+
config.agents.opencode = {
|
|
147
|
+
...config.agents.opencode,
|
|
148
|
+
...(discovery.opencode.available ? { command: discovery.opencode.command } : {})
|
|
149
|
+
};
|
|
150
|
+
config.defaults = {
|
|
151
|
+
...config.defaults,
|
|
152
|
+
...(pair ? { agentA: pair[0], agentB: pair[1], summaryAgent: chooseDefaultSummaryAgent(pair) } : {})
|
|
153
|
+
};
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
/** Écrit `config` sérialisé en JSON dans `configPath`. Crée le répertoire parent si nécessaire. */
|
|
157
|
+
export async function writeExampleConfig(configPath = DEFAULT_CONFIG_PATH, config = exampleConfig) {
|
|
158
|
+
const resolved = path.resolve(configPath);
|
|
159
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
160
|
+
await writeFile(resolved, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
161
|
+
}
|
|
162
|
+
function chooseDefaultSummaryAgent(pair) {
|
|
163
|
+
for (const preferred of ["claude", "codex", "gemini"]) {
|
|
164
|
+
if (pair.includes(preferred)) {
|
|
165
|
+
return preferred;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return pair[1];
|
|
169
|
+
}
|
|
170
|
+
function chooseDefaultPair(discovery) {
|
|
171
|
+
if (discovery.codex.available && discovery.claude.available) {
|
|
172
|
+
return ["codex", "claude"];
|
|
173
|
+
}
|
|
174
|
+
if (discovery.codex.available && discovery.ollama.available) {
|
|
175
|
+
return ["codex", "ollama-local"];
|
|
176
|
+
}
|
|
177
|
+
if (discovery.claude.available && discovery.ollama.available) {
|
|
178
|
+
return ["claude", "ollama-local"];
|
|
179
|
+
}
|
|
180
|
+
if (discovery.opencode.available && discovery.ollama.available) {
|
|
181
|
+
return ["opencode", "ollama-local"];
|
|
182
|
+
}
|
|
183
|
+
if (discovery.gemini.available && discovery.ollama.available) {
|
|
184
|
+
return ["gemini", "ollama-local"];
|
|
185
|
+
}
|
|
186
|
+
const cliAgents = [
|
|
187
|
+
discovery.codex.available ? "codex" : undefined,
|
|
188
|
+
discovery.claude.available ? "claude" : undefined,
|
|
189
|
+
discovery.opencode.available ? "opencode" : undefined,
|
|
190
|
+
discovery.gemini.available ? "gemini" : undefined
|
|
191
|
+
].filter((agent) => Boolean(agent));
|
|
192
|
+
if (cliAgents.length >= 2) {
|
|
193
|
+
return [cliAgents[0], cliAgents[1]];
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
function cloneConfig(config) {
|
|
198
|
+
return JSON.parse(JSON.stringify(config));
|
|
199
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { writeExampleConfig } from "./config.js";
|
|
4
|
+
import { DEFAULT_TURNS, MAX_TURNS, turnsOrDefault, validateTurns } from "./limits.js";
|
|
5
|
+
/**
|
|
6
|
+
* Lance le wizard interactif de configuration des defaults.
|
|
7
|
+
* Fonctionne en mode TTY (readline) et en mode piped (stdin lu en avance).
|
|
8
|
+
* Écrit la config sur disque si l'utilisateur confirme ; sort sans modifier si l'utilisateur quitte.
|
|
9
|
+
*/
|
|
10
|
+
export async function runConfigWizard(configPath, config) {
|
|
11
|
+
const choices = Object.entries(config.agents).map(([name, agentConfig]) => ({ name, config: agentConfig }));
|
|
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.");
|
|
14
|
+
}
|
|
15
|
+
const rl = await createQuestioner();
|
|
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.");
|
|
19
|
+
console.log("");
|
|
20
|
+
console.log("Fichier de configuration :");
|
|
21
|
+
console.log(` ${configPath}`);
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log("Paramètres par défaut actuels :");
|
|
24
|
+
console.log(` ${config.defaults ? formatDefaults(config.defaults) : "Aucun"}`);
|
|
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"]);
|
|
31
|
+
if (!action || action === "3") {
|
|
32
|
+
console.log("Config inchangée.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (action === "2") {
|
|
36
|
+
delete config.defaults;
|
|
37
|
+
await writeExampleConfig(configPath, config);
|
|
38
|
+
console.log(`Paramètres par défaut supprimés dans ${configPath}.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const agentA = await askAgent(rl, choices, "Agent A", "Choisis l'agent A, celui qui répondra en premier.", config.defaults?.agentA);
|
|
42
|
+
if (!agentA)
|
|
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);
|
|
45
|
+
if (!agentB)
|
|
46
|
+
return;
|
|
47
|
+
const turns = await askNumber(rl, "Nombre de réponses par défaut", turnsOrDefault(config.defaults?.turns), Boolean(config.defaults?.turns));
|
|
48
|
+
if (turns === undefined)
|
|
49
|
+
return;
|
|
50
|
+
const summaryAgent = await askSummaryAgent(rl, choices, config.defaults?.summaryAgent ?? agentB, Boolean(config.defaults?.summaryAgent), agentB);
|
|
51
|
+
if (summaryAgent === undefined)
|
|
52
|
+
return;
|
|
53
|
+
config.defaults = {
|
|
54
|
+
agentA,
|
|
55
|
+
agentB,
|
|
56
|
+
...(summaryAgent ? { summaryAgent } : {}),
|
|
57
|
+
turns
|
|
58
|
+
};
|
|
59
|
+
await writeExampleConfig(configPath, config);
|
|
60
|
+
console.log(`Paramètres par défaut définis dans ${configPath}: ${formatDefaults(config.defaults)}.`);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function createQuestioner() {
|
|
67
|
+
if (input.isTTY) {
|
|
68
|
+
return createInterface({ input, output });
|
|
69
|
+
}
|
|
70
|
+
const lines = await readPipedLines();
|
|
71
|
+
let index = 0;
|
|
72
|
+
return {
|
|
73
|
+
async question(prompt) {
|
|
74
|
+
output.write(prompt);
|
|
75
|
+
const value = lines[index];
|
|
76
|
+
index += 1;
|
|
77
|
+
output.write(`${value ?? "3"}\n`);
|
|
78
|
+
return value ?? "3";
|
|
79
|
+
},
|
|
80
|
+
close() {
|
|
81
|
+
// Nothing to close for scripted stdin.
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function readPipedLines() {
|
|
86
|
+
let raw = "";
|
|
87
|
+
for await (const chunk of input) {
|
|
88
|
+
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
89
|
+
}
|
|
90
|
+
return raw ? raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n") : [];
|
|
91
|
+
}
|
|
92
|
+
async function askAgent(rl, choices, _label, description, defaultName) {
|
|
93
|
+
const fallback = choices.find((choice) => choice.name === defaultName)?.name ?? choices[0]?.name;
|
|
94
|
+
const fallbackLabel = defaultName ? "Actuel" : "Suggestion";
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(description);
|
|
97
|
+
console.log(`${fallbackLabel} : ${fallback}`);
|
|
98
|
+
console.log("");
|
|
99
|
+
choices.forEach((choice, index) => {
|
|
100
|
+
console.log(` ${index + 1}) ${formatAgentLine(choice)}`);
|
|
101
|
+
});
|
|
102
|
+
while (true) {
|
|
103
|
+
const answer = await rl.question(`Tape un numéro ou un nom d'agent (Entrée = ${fallback}) : `);
|
|
104
|
+
const value = answer.trim();
|
|
105
|
+
if (isQuit(value))
|
|
106
|
+
return undefined;
|
|
107
|
+
if (!value)
|
|
108
|
+
return fallback;
|
|
109
|
+
const number = Number(value);
|
|
110
|
+
if (Number.isInteger(number) && number >= 1 && number <= choices.length) {
|
|
111
|
+
return choices[number - 1]?.name;
|
|
112
|
+
}
|
|
113
|
+
if (choices.some((choice) => choice.name === value)) {
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
console.log("Choix invalide. Tape un numéro, un nom d'agent, Entrée ou q.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function askSummaryAgent(rl, choices, defaultName, hasCurrentDefault, agentB) {
|
|
120
|
+
const fallback = choices.some((choice) => choice.name === defaultName) ? defaultName : choices[0]?.name;
|
|
121
|
+
const fallbackLabel = hasCurrentDefault ? "Actuel" : "Suggestion";
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log("Agent de synthèse par défaut");
|
|
124
|
+
console.log(`${fallbackLabel} : ${fallback}${!hasCurrentDefault && fallback === agentB ? " (agent B)" : ""}`);
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(" 0) Aucun agent de synthèse par défaut");
|
|
127
|
+
choices.forEach((choice, index) => {
|
|
128
|
+
console.log(` ${index + 1}) ${formatAgentLine(choice)}`);
|
|
129
|
+
});
|
|
130
|
+
while (true) {
|
|
131
|
+
const answer = await rl.question(`Tape un numéro, un nom d'agent, ou 0 pour aucun (Entrée = ${fallback}) : `);
|
|
132
|
+
const value = answer.trim();
|
|
133
|
+
if (isQuit(value))
|
|
134
|
+
return undefined;
|
|
135
|
+
if (!value)
|
|
136
|
+
return fallback;
|
|
137
|
+
if (value === "0" || value.toLowerCase() === "none" || value.toLowerCase() === "aucun")
|
|
138
|
+
return "";
|
|
139
|
+
const number = Number(value);
|
|
140
|
+
if (Number.isInteger(number) && number >= 1 && number <= choices.length) {
|
|
141
|
+
return choices[number - 1]?.name;
|
|
142
|
+
}
|
|
143
|
+
if (choices.some((choice) => choice.name === value)) {
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
console.log("Choix invalide. Tape un numéro, un nom d'agent, 0, Entrée ou q.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function askChoice(rl, label, defaultValue, allowed) {
|
|
150
|
+
while (true) {
|
|
151
|
+
const answer = await rl.question(`${label} (Entrée = ${defaultValue}) : `);
|
|
152
|
+
const value = answer.trim();
|
|
153
|
+
if (isQuit(value))
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!value)
|
|
156
|
+
return defaultValue;
|
|
157
|
+
if (allowed.includes(value))
|
|
158
|
+
return value;
|
|
159
|
+
console.log(`Choix invalide. Valeurs: ${allowed.join(", ")}, Entrée ou q.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function askNumber(rl, label, defaultValue, hasCurrentDefault) {
|
|
163
|
+
const fallbackLabel = hasCurrentDefault ? "Actuel" : "Suggestion";
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(label);
|
|
166
|
+
console.log(`${fallbackLabel} : ${defaultValue}`);
|
|
167
|
+
console.log("");
|
|
168
|
+
while (true) {
|
|
169
|
+
const answer = await rl.question(`Tape le nombre total de réponses du débat (Entrée = ${defaultValue}) : `);
|
|
170
|
+
const value = answer.trim();
|
|
171
|
+
if (isQuit(value))
|
|
172
|
+
return undefined;
|
|
173
|
+
if (!value)
|
|
174
|
+
return defaultValue;
|
|
175
|
+
const parsed = Number(value);
|
|
176
|
+
if (Number.isInteger(parsed)) {
|
|
177
|
+
try {
|
|
178
|
+
validateTurns(parsed, "Le nombre de réponses");
|
|
179
|
+
return parsed;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Show the user-facing wizard hint below.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
console.log(`Entre un nombre entier entre 1 et ${MAX_TURNS}, Entrée ou q.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function formatAgentLine(choice) {
|
|
189
|
+
return `${choice.name.padEnd(12)} ${choice.config.type} / ${choice.config.role}`;
|
|
190
|
+
}
|
|
191
|
+
function formatDefaults(defaults) {
|
|
192
|
+
return `${defaults.agentA ?? "?"} <-> ${defaults.agentB ?? "?"}, réponses: ${turnsOrDefault(defaults.turns ?? DEFAULT_TURNS)}${defaults.summaryAgent ? `, synthèse: ${defaults.summaryAgent}` : ""}`;
|
|
193
|
+
}
|
|
194
|
+
function isQuit(value) {
|
|
195
|
+
return ["q", "quit", "exit"].includes(value.toLowerCase());
|
|
196
|
+
}
|