@phuetz/code-buddy 1.5.0 → 1.6.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/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  <p align="center">
18
18
  <a href="https://github.com/phuetz/code-buddy/stargazers"><img src="https://img.shields.io/github/stars/phuetz/code-buddy?style=flat-square&logo=github&color=feca57&label=Star" alt="GitHub stars"/></a>
19
19
  <img src="https://img.shields.io/badge/Tests-27K%2B-00d26a?style=flat-square&logo=jest" alt="Tests"/>
20
- <img src="https://img.shields.io/badge/v1.5.0-GA-blueviolet?style=flat-square" alt="Version 1.5.0 GA"/>
20
+ <img src="https://img.shields.io/badge/v1.6.0-GA-blueviolet?style=flat-square" alt="Version 1.6.0 GA"/>
21
21
  </p>
22
22
 
23
23
  <br/>
@@ -128,7 +128,7 @@ More desktop demos (Fleet, Autonomy, Companion, …) and captures: [`cowork/read
128
128
 
129
129
  ## What's shipped
130
130
 
131
- **1.5.0 GA — these aren't roadmap items.** The captures above are unedited, and the core runs today:
131
+ **1.6.0 GA — these aren't roadmap items.** The captures above are unedited, and the core runs today:
132
132
 
133
133
  - ✅ **`$0` local coding agent** — a local Ollama model reasons on screen, then calls tools to do real work. *(the demos above)*
134
134
  - ✅ **ChatGPT Plus/Pro → `gpt-5.5` at `$0`** — `buddy login`, flat-fee, no API key, no per-token metering.
@@ -201,9 +201,13 @@ buddy --yolo # full autonomy
201
201
  ```bash
202
202
  buddy llm # list the LLMs you're logged into + the failover order
203
203
  buddy llm ensemble "is this approach sound?" # ask ChatGPT + Grok + Ollama together, then synthesize
204
+ buddy council "compare REST vs GraphQL" # route by capability, judge the best answer, LEARN which AI wins per task type
205
+ buddy council --scoreboard # the learned ranking (which model is best for code / reasoning / …)
204
206
  CODEBUDDY_LLM_FAILOVER=1 buddy -p "…" # if the primary errors, auto-continue on the next active LLM
205
207
  ```
206
208
 
209
+ **`buddy council`** takes the ensemble further: it routes a task to the *best-suited* models by capability (and by their past win rate), an impartial judge keeps the single best answer, and a scoreboard **learns which AI is best for which kind of task** over time — so it gets smarter the more you use it. Works in Telegram too (`council <task>`).
210
+
207
211
  <p align="center">
208
212
  <img src="docs/assets/llm-demo.gif" alt="buddy llm lists your active LLMs, then auto-fails over from Grok to ChatGPT when the primary errors" width="760"/>
209
213
  <br/>
@@ -334,7 +338,7 @@ The agent has **~110 tools** — file edit, shell, web search (5-provider fallba
334
338
  Two systems: **Extended Thinking** (provider budget tokens — off/minimal/low/medium/high/xhigh) and Code Buddy's own **Tree-of-Thought + MCTS** with four depths (shallow CoT → beam search → MCTS → exhaustive). A reasoning middleware auto-detects complex queries and injects guidance; `/think`, `/megathink`, and `/ultrathink` set the depth, and the `reason` tool streams its search. (MCTSr Q-value `Q(a) = 0.5·(min(R) + mean(R))`.)
335
339
 
336
340
  ### Goal loops & autonomy
337
- A **goal loop** is autonomy with a referee: the agent acts, an LLM **judge** checks the goal after each turn, and it self-corrects until done or the turn budget runs out — no hand-written retry logic. Drive it with `/goal "<objective>"` + `/subgoal` (numbered criteria), or headless `buddy goal`. **`buddy --yolo`** grants 400 tool rounds under a `$100` cap with guardrails, and the **24/7 autonomous daemon** (`buddy autonomy install`) claims tasks from a shared queue and runs them free-first (local → Tailscale → paid).
341
+ A **goal loop** is autonomy with a referee: the agent acts, an LLM **judge** checks the goal after each turn, and it self-corrects until done or the turn budget runs out — no hand-written retry logic. Drive it with `/goal "<objective>"` + `/subgoal` (numbered criteria), or headless `buddy goal`. **`buddy --yolo`** grants 400 tool rounds under a `$100` cap with guardrails, and the **24/7 autonomous daemon** (`buddy autonomy install`) claims tasks from a shared queue and runs them free-first (local → Tailscale → paid). That queue is a **unified kanban board**: the agent's `kanban_*` tools and the daemon drive one shared board with a claim **lease + heartbeat**, **zombie reclaim** of a crashed peer's work, a **retry budget** that dead-letters a hopeless task to a review column, and a dependency DAG — view it as Hermes-style columns with `buddy autonomy tasks board`.
338
342
 
339
343
  ### Multi-AI Fleet
340
344
  Run several Code Buddy instances as **peers on a WebSocket mesh** that observe each other's events live and call each other's models + read-only tools: `peer.chat` (one-shot), `peer.chat-session.*` (multi-turn, persisted), and `peer.tool.invoke` (remote read-only tools, behind three security gates that fail closed). **`/fleet route "<prompt>"`** classifies a task, gathers peer capabilities, runs a privacy lint (SSN/IBAN/card detection), and recommends a delegation; `/fleet listen|send|status|history` manage the mesh. It interops over A2A + ACP + MCP.
@@ -357,6 +361,8 @@ Layered, fail-closed safety: the **Guardian Agent** scores each operation 0–10
357
361
  ### Channels
358
362
  Code Buddy runs on **20+ messaging platforms** — Telegram, Discord, Slack, WhatsApp, Signal, Matrix, IRC, Nostr, Mattermost, Nextcloud Talk, iMessage (real persistent transports with auto-reconnect) plus REST/webhook adapters (Teams, Google Chat, Feishu, LINE, ntfy, DingTalk, WeCom, …). **DM pairing** prevents unauthorized credit burn: an unknown user gets a 6-char code (15-min TTL) you approve via `buddy pairing approve`. (A few niche adapters — Twitch/Tlon/Gmail — are in-process stubs, and Feishu real-time *inbound* needs the Lark SDK installed.)
359
363
 
364
+ **Multiple bots, each with its own memory.** Run several bots from one instance (e.g. several Telegram tokens). Each gets its own **persona** — name, model, and system prompt via `channels.json` — and its own **isolated persistent memory** under `~/.codebuddy/bots/<id>/`, so two bots never see each other's remembered facts. Every conversation is **session-isolated** per `(channel, user)` (no cross-user context bleed) and **persists across daemon restarts** (history is replayed from disk onto a cold agent). Cross-channel **identity links** can still collapse the same person's Telegram + Discord into one canonical thread.
365
+
360
366
  ### Git & code intelligence
361
367
  `buddy dev run` plans + implements + tests + **auto-commits** with a Conventional-Commit message; **`/pr`** opens a summarized PR; `lsp_rename`/`lsp_code_action` drive language servers for safe refactors; the **bug finder** flags 25+ patterns across 6 languages. For whole-repo understanding, the optional **[Code Explorer](https://github.com/phuetz/code-explorer)** (the `gitnexus` MCP server — a standalone Rust code-intelligence engine) pre-indexes the repo into a knowledge graph with 31 tools for impact/blast-radius, coupling, hotspots, and execution traces, answering structural questions with ~40× less context. Code Buddy also runs as an **ACP** agent (`buddy acp`) so editors like Zed can drive it natively.
362
368
 
@@ -70,6 +70,12 @@ export declare class CodeBuddyAgent extends BaseAgent {
70
70
  * Called by dev workflows to associate tool calls with a RunStore run.
71
71
  */
72
72
  setRunId(runId: string | undefined): void;
73
+ /**
74
+ * Multi-bot channels: scope this agent's per-bot state (persistent memory,
75
+ * lessons) to a bot id. Tool-execution contexts get tagged with it so
76
+ * `remember`/`lessons` write under `~/.codebuddy/bots/<botId>/`.
77
+ */
78
+ setChannelBotId(botId: string | undefined): void;
73
79
  private initializeAgentSystemPrompt;
74
80
  private applyRuntimeSystemPromptAppend;
75
81
  private isGrokModel;
@@ -518,6 +518,14 @@ Look at the screenshot and find the element matching the user's intent. Output o
518
518
  setRunId(runId) {
519
519
  this.toolHandler.setRunId(runId);
520
520
  }
521
+ /**
522
+ * Multi-bot channels: scope this agent's per-bot state (persistent memory,
523
+ * lessons) to a bot id. Tool-execution contexts get tagged with it so
524
+ * `remember`/`lessons` write under `~/.codebuddy/bots/<botId>/`.
525
+ */
526
+ setChannelBotId(botId) {
527
+ this.toolHandler.setBotId(botId);
528
+ }
521
529
  async initializeAgentSystemPrompt(systemPromptId, modelName, customInstructions) {
522
530
  try {
523
531
  const hasInitialOverride = !!this.customSystemPromptOverride;
@@ -75,6 +75,7 @@ export declare class ToolHandler {
75
75
  * process directory. `undefined` falls back to `process.cwd()` (CLI default).
76
76
  */
77
77
  private currentWorkingDirectory;
78
+ private currentBotId;
78
79
  constructor(deps: ToolHandlerDependencies);
79
80
  /**
80
81
  * Initialize tool hooks system with default hooks
@@ -99,6 +100,11 @@ export declare class ToolHandler {
99
100
  * `.codebuddy/` rather than the Electron process directory.
100
101
  */
101
102
  setWorkingDirectory(dir: string | undefined): void;
103
+ /**
104
+ * Multi-bot channels: tag tool-execution contexts with the bot id so per-bot
105
+ * state (persistent memory, lessons) is scoped to that bot. Undefined = global.
106
+ */
107
+ setBotId(botId: string | undefined): void;
102
108
  /**
103
109
  * Set callback for user confirmation
104
110
  * Called when policy requires confirmation before tool execution
@@ -142,6 +142,7 @@ export class ToolHandler {
142
142
  * process directory. `undefined` falls back to `process.cwd()` (CLI default).
143
143
  */
144
144
  currentWorkingDirectory;
145
+ currentBotId;
145
146
  constructor(deps) {
146
147
  this.deps = deps;
147
148
  this.registry = getFormalToolRegistry();
@@ -182,6 +183,13 @@ export class ToolHandler {
182
183
  setWorkingDirectory(dir) {
183
184
  this.currentWorkingDirectory = dir;
184
185
  }
186
+ /**
187
+ * Multi-bot channels: tag tool-execution contexts with the bot id so per-bot
188
+ * state (persistent memory, lessons) is scoped to that bot. Undefined = global.
189
+ */
190
+ setBotId(botId) {
191
+ this.currentBotId = botId;
192
+ }
185
193
  /**
186
194
  * Set callback for user confirmation
187
195
  * Called when policy requires confirmation before tool execution
@@ -736,6 +744,7 @@ export class ToolHandler {
736
744
  const metadata = registeredTool.metadata;
737
745
  const context = {
738
746
  cwd: this.currentWorkingDirectory ?? process.cwd(),
747
+ botId: this.currentBotId,
739
748
  };
740
749
  // Handle bash tool with hooks and auto-repair
741
750
  if (toolName === 'bash') {
@@ -49,6 +49,9 @@ export interface ChannelInfo {
49
49
  id: string;
50
50
  /** Channel type */
51
51
  type: ChannelType;
52
+ /** For multi-bot channels (e.g. Telegram), the id of the bot that received
53
+ * this message — used to isolate sessions and select a per-bot persona. */
54
+ botId?: string;
52
55
  /** Channel name */
53
56
  name?: string;
54
57
  /** Is this a direct message */
@@ -22,6 +22,15 @@ export declare class TelegramChannel extends BaseChannel {
22
22
  constructor(config: TelegramConfig);
23
23
  private get telegramConfig();
24
24
  private get apiUrl();
25
+ /**
26
+ * Stable per-bot id, derived synchronously from the token (the part before
27
+ * the colon in `<botId>:<secret>` IS the bot's numeric id — no need to wait
28
+ * for getMe). Used to isolate sessions per bot when several bots share one
29
+ * process, and to look up per-bot persona config.
30
+ */
31
+ get botId(): string;
32
+ /** Prefix a session key with this bot's id so two bots never share a chat session. */
33
+ private scopeSessionKey;
25
34
  /** Lazy getter for ProFeatures bundle with TelegramProFormatter */
26
35
  get pro(): ProFeatures;
27
36
  get scopedAuth(): import("./scoped-auth.js").ScopedAuthManager;
@@ -152,6 +161,11 @@ export declare class TelegramChannel extends BaseChannel {
152
161
  * Throws on failure so the caller keeps the text reply as the fallback.
153
162
  */
154
163
  sendVoiceReply(channelId: string, text: string): Promise<void>;
164
+ /**
165
+ * Send a local image file (e.g. a chart the agent generated) to the chat as a
166
+ * photo, via multipart sendPhoto. Lets the bot deliver visual artifacts.
167
+ */
168
+ sendImageFile(channelId: string, imagePath: string, caption?: string): Promise<void>;
155
169
  /**
156
170
  * Get file download URL
157
171
  */
@@ -37,6 +37,19 @@ export class TelegramChannel extends BaseChannel {
37
37
  get apiUrl() {
38
38
  return `${TELEGRAM_API_BASE}/bot${this.telegramConfig.token}`;
39
39
  }
40
+ /**
41
+ * Stable per-bot id, derived synchronously from the token (the part before
42
+ * the colon in `<botId>:<secret>` IS the bot's numeric id — no need to wait
43
+ * for getMe). Used to isolate sessions per bot when several bots share one
44
+ * process, and to look up per-bot persona config.
45
+ */
46
+ get botId() {
47
+ return (this.telegramConfig.token || '').split(':')[0] || 'unknown';
48
+ }
49
+ /** Prefix a session key with this bot's id so two bots never share a chat session. */
50
+ scopeSessionKey(base) {
51
+ return `${this.botId}:${base ?? 'global'}`;
52
+ }
40
53
  /** Lazy getter for ProFeatures bundle with TelegramProFormatter */
41
54
  get pro() {
42
55
  if (!this._pro) {
@@ -375,15 +388,24 @@ export class TelegramChannel extends BaseChannel {
375
388
  * Handle an update
376
389
  */
377
390
  async handleUpdate(update) {
378
- if (update.message) {
379
- await this.handleMessage(update.message);
380
- }
381
- else if (update.edited_message) {
382
- // Could emit 'message-edited' event
383
- await this.handleMessage(update.edited_message);
391
+ // Guard the whole dispatch: a throw here used to be swallowed by the poll
392
+ // loop's catch, silently dropping the message with no trace.
393
+ try {
394
+ if (update.message) {
395
+ await this.handleMessage(update.message);
396
+ }
397
+ else if (update.edited_message) {
398
+ // Could emit 'message-edited' event
399
+ await this.handleMessage(update.edited_message);
400
+ }
401
+ else if (update.callback_query) {
402
+ await this.handleCallbackQuery(update.callback_query);
403
+ }
384
404
  }
385
- else if (update.callback_query) {
386
- await this.handleCallbackQuery(update.callback_query);
405
+ catch (e) {
406
+ logger.error('Telegram update handler error', {
407
+ err: e instanceof Error ? (e.stack || e.message) : String(e),
408
+ });
387
409
  }
388
410
  }
389
411
  /**
@@ -406,7 +428,7 @@ export class TelegramChannel extends BaseChannel {
406
428
  await this.maybeTranscribeVoice(message);
407
429
  const parsed = this.parseCommand(message);
408
430
  // Attach session key for session isolation
409
- parsed.sessionKey = getSessionKey(parsed);
431
+ parsed.sessionKey = this.scopeSessionKey(getSessionKey(parsed));
410
432
  // DM pairing check: gate unapproved DM senders
411
433
  const pairingStatus = await checkDMPairing(parsed);
412
434
  if (!pairingStatus.approved) {
@@ -432,8 +454,13 @@ export class TelegramChannel extends BaseChannel {
432
454
  return;
433
455
  }
434
456
  }
435
- // Route enhanced commands
436
- if (parsed.isCommand && this.telegramConfig.enhancedCommands !== false) {
457
+ // Route enhanced commands — but let `council`/`conseil` fall through to the
458
+ // normal message path so the channel AI handler can convene the multi-LLM
459
+ // council (the pro command router doesn't own these).
460
+ const passthroughCommands = new Set(['council', 'conseil']);
461
+ if (parsed.isCommand &&
462
+ this.telegramConfig.enhancedCommands !== false &&
463
+ !passthroughCommands.has((parsed.commandName ?? '').toLowerCase())) {
437
464
  const handled = await this.routeEnhancedCommand(parsed, chatId, userId);
438
465
  if (handled)
439
466
  return;
@@ -477,7 +504,7 @@ export class TelegramChannel extends BaseChannel {
477
504
  raw: query,
478
505
  };
479
506
  // Attach session key for session isolation
480
- message.sessionKey = getSessionKey(message);
507
+ message.sessionKey = this.scopeSessionKey(getSessionKey(message));
481
508
  this.emit('command', message);
482
509
  }
483
510
  }
@@ -586,6 +613,7 @@ export class TelegramChannel extends BaseChannel {
586
613
  return {
587
614
  id: String(chat.id),
588
615
  type: 'telegram',
616
+ botId: this.botId, // which bot received this — for per-bot routing/persona
589
617
  name: chat.title || chat.username || chat.first_name,
590
618
  isDM: chat.type === 'private',
591
619
  isGroup: chat.type === 'group' || chat.type === 'supergroup',
@@ -829,6 +857,26 @@ export class TelegramChannel extends BaseChannel {
829
857
  await fs.unlink(ogg).catch(() => undefined);
830
858
  }
831
859
  }
860
+ /**
861
+ * Send a local image file (e.g. a chart the agent generated) to the chat as a
862
+ * photo, via multipart sendPhoto. Lets the bot deliver visual artifacts.
863
+ */
864
+ async sendImageFile(channelId, imagePath, caption) {
865
+ const fs = await import('node:fs/promises');
866
+ const path = await import('node:path');
867
+ const bytes = await fs.readFile(imagePath);
868
+ const ext = (path.extname(imagePath).slice(1) || 'png').toLowerCase();
869
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : `image/${ext}`;
870
+ const form = new FormData();
871
+ form.append('chat_id', channelId);
872
+ if (caption)
873
+ form.append('caption', caption.slice(0, 1024));
874
+ form.append('photo', new Blob([bytes], { type: mime }), path.basename(imagePath));
875
+ const url = `${TELEGRAM_API_BASE}/bot${this.telegramConfig.token}/sendPhoto`;
876
+ const res = await fetch(url, { method: 'POST', body: form });
877
+ if (!res.ok)
878
+ throw new Error(`sendPhoto HTTP ${res.status}`);
879
+ }
832
880
  /**
833
881
  * Get file download URL
834
882
  */
@@ -237,8 +237,12 @@ export function convertMCPToolToCodeBuddyTool(mcpTool) {
237
237
  }
238
238
  };
239
239
  }
240
- /** Threshold: defer MCP schemas when there are more than this many MCP tools */
241
- const DEFERRED_SCHEMA_THRESHOLD = 30;
240
+ /** Threshold: defer MCP schemas when there are more than this many MCP tools.
241
+ * Deferred tools become param-less stubs that REQUIRE a `tool_search` round
242
+ * before they can be called — many models skip that and fall back to `bash`,
243
+ * so for moderate tool counts it's better to keep full schemas and let RAG
244
+ * tool-selection pick the relevant ~15. Override with CODEBUDDY_MCP_DEFER_THRESHOLD. */
245
+ const DEFERRED_SCHEMA_THRESHOLD = Number(process.env.CODEBUDDY_MCP_DEFER_THRESHOLD) || 30;
242
246
  /** Full MCP tool schemas stored for deferred retrieval */
243
247
  let _deferredMCPSchemas = null;
244
248
  /**
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `buddy council "<task>"` — capability-aware multi-LLM router + ensemble + learning.
3
+ *
4
+ * Flow (most pieces already exist in the Fleet; this orchestrates them and adds
5
+ * the learning layer):
6
+ * 1. List usable LLMs → buildActiveLlmRegistry (providers/active-llm-registry)
7
+ * 2. Route by capability → strengths heuristic × (1 + historical win rate)
8
+ * 3. Ask several in parallel → createParallelExecutor (agent/parallel) 'ensemble'
9
+ * 4. Judge → keep the best → an impartial LLM scores anonymized, shuffled answers
10
+ * 5. Consensus on divergence → computeTextConsensus (fleet/result-aggregator)
11
+ * 6. Learn / prefer the best → ModelScoreboard records every outcome
12
+ */
13
+ export interface CouncilOptions {
14
+ /** How many models to consult (default 3). */
15
+ count?: number;
16
+ /** Comma list of provider/model substrings to restrict candidates. */
17
+ models?: string;
18
+ /** Provider/model substring to use as the judge (default: a neutral strong model). */
19
+ judge?: string;
20
+ /** Override the inferred task type. */
21
+ taskType?: string;
22
+ /** commander sets this false on --no-consensus. */
23
+ consensus?: boolean;
24
+ /** Just print the learned scoreboard and exit. */
25
+ scoreboard?: boolean;
26
+ }
27
+ type Emit = (s: string) => void;
28
+ export declare function runCouncil(task: string, opts: CouncilOptions, out: Emit): Promise<void>;
29
+ export {};
@@ -0,0 +1,299 @@
1
+ /**
2
+ * `buddy council "<task>"` — capability-aware multi-LLM router + ensemble + learning.
3
+ *
4
+ * Flow (most pieces already exist in the Fleet; this orchestrates them and adds
5
+ * the learning layer):
6
+ * 1. List usable LLMs → buildActiveLlmRegistry (providers/active-llm-registry)
7
+ * 2. Route by capability → strengths heuristic × (1 + historical win rate)
8
+ * 3. Ask several in parallel → createParallelExecutor (agent/parallel) 'ensemble'
9
+ * 4. Judge → keep the best → an impartial LLM scores anonymized, shuffled answers
10
+ * 5. Consensus on divergence → computeTextConsensus (fleet/result-aggregator)
11
+ * 6. Learn / prefer the best → ModelScoreboard records every outcome
12
+ */
13
+ import { CodeBuddyClient } from '../codebuddy/client.js';
14
+ import { computeTextConsensus } from '../fleet/result-aggregator.js';
15
+ import { getModelScoreboard } from '../fleet/model-scoreboard.js';
16
+ /** Per-model wall-clock cap so a slow model never blocks the council. */
17
+ const COUNCIL_TIMEOUT_MS = Number(process.env.CODEBUDDY_COUNCIL_TIMEOUT_MS) || 45000;
18
+ // --- capability heuristics (deriveStrengths in capability-registry is private) ---
19
+ function inferStrengths(model) {
20
+ const m = model.toLowerCase();
21
+ const s = new Set(['tool-calling']);
22
+ if (/code|coder|codex/.test(m))
23
+ s.add('code');
24
+ if (/opus|gpt-5|o1|o3|reason|think|r1|qwq|deepseek/.test(m)) {
25
+ s.add('reasoning');
26
+ s.add('thinking');
27
+ }
28
+ if (/gpt-5|gemini|sonnet|opus|grok-[34]|grok-4/.test(m))
29
+ s.add('reasoning');
30
+ if (/flash|mini|fast|haiku|small|nano|:3b|:4b|:7b|:8b/.test(m)) {
31
+ s.add('fast');
32
+ s.add('cheap');
33
+ }
34
+ if (/gemini|pro|opus|sonnet|long|1m|200k|128k/.test(m))
35
+ s.add('long-context');
36
+ if (/mistral|qwen|gemma|mixtral/.test(m))
37
+ s.add('french');
38
+ if (/vision|gpt-4o|gpt-5|gemini|grok-2-vision/.test(m))
39
+ s.add('vision');
40
+ return [...s];
41
+ }
42
+ const TASK_REQUIRES = {
43
+ code: ['code', 'reasoning'],
44
+ reasoning: ['reasoning', 'thinking'],
45
+ french: ['french', 'reasoning'],
46
+ vision: ['vision'],
47
+ general: ['reasoning', 'fast'],
48
+ };
49
+ function inferTaskType(task) {
50
+ const t = task.toLowerCase();
51
+ if (/\b(code|fonction|function|bug|refactor|impl[ée]ment|classe|class|api|script|compile|regex|sql)\b/.test(t))
52
+ return 'code';
53
+ if (/\b(prouve|d[ée]montre|raisonn|reason|prove|analyse|strat[ée]gie|pourquoi|math|calcul|optimi)\b/.test(t))
54
+ return 'reasoning';
55
+ if (/\b(image|photo|capture|screenshot|diagram|graph)\b/.test(t))
56
+ return 'vision';
57
+ if (/[éèàçùêîô]/.test(task) || /\b(fran[çc]ais|france|french)\b/.test(t))
58
+ return 'french';
59
+ return 'general';
60
+ }
61
+ function matchScore(strengths, required) {
62
+ if (required.length === 0)
63
+ return 0.5;
64
+ const have = new Set(strengths);
65
+ const hits = required.filter((r) => have.has(r)).length;
66
+ return hits / required.length;
67
+ }
68
+ /** Pick top-K, favouring distinct providers for genuine diversity. */
69
+ function pickDiverse(ranked, k) {
70
+ const picked = [];
71
+ const seen = new Set();
72
+ for (const r of ranked) {
73
+ if (picked.length >= k)
74
+ break;
75
+ if (seen.has(r.c.provider))
76
+ continue;
77
+ seen.add(r.c.provider);
78
+ picked.push(r);
79
+ }
80
+ for (const r of ranked) {
81
+ if (picked.length >= k)
82
+ break;
83
+ if (!picked.includes(r))
84
+ picked.push(r);
85
+ }
86
+ return picked;
87
+ }
88
+ function extractJson(text) {
89
+ if (!text)
90
+ return null;
91
+ try {
92
+ return JSON.parse(text);
93
+ }
94
+ catch {
95
+ /* not pure JSON */
96
+ }
97
+ const m = text.match(/\{[\s\S]*\}/);
98
+ if (m) {
99
+ try {
100
+ return JSON.parse(m[0]);
101
+ }
102
+ catch {
103
+ /* salvage failed */
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ async function judgeAnswers(client, task, answers) {
109
+ const scores = new Array(answers.length).fill(0);
110
+ // Shuffle to neutralise position bias; answers are already identity-blind.
111
+ const order = answers.map((_, i) => i);
112
+ for (let i = order.length - 1; i > 0; i--) {
113
+ const j = Math.floor(Math.random() * (i + 1));
114
+ [order[i], order[j]] = [order[j], order[i]];
115
+ }
116
+ const letters = order.map((_, i) => String.fromCharCode(65 + i));
117
+ const blocks = order
118
+ .map((origIdx, pos) => `### Réponse ${letters[pos]}\n${answers[origIdx].content.trim()}`)
119
+ .join('\n\n');
120
+ const sys = 'You are an impartial judge. You receive a task and several ANONYMOUS candidate answers ' +
121
+ '(A, B, C…). Score each from 0.0 to 1.0 on correctness, completeness and usefulness, then ' +
122
+ 'pick the single best. Judge ONLY the content — you do not know which model wrote which. ' +
123
+ 'Respond with STRICT JSON and nothing else: {"scores":{"A":0.0},"winner":"A","why":"one short sentence"}.';
124
+ const user = `TASK:\n${task}\n\nCANDIDATE ANSWERS:\n${blocks}\n\nReturn the JSON now.`;
125
+ let winnerIdx = 0;
126
+ let rationale = '';
127
+ try {
128
+ const resp = await Promise.race([
129
+ client.chat([
130
+ { role: 'system', content: sys },
131
+ { role: 'user', content: user },
132
+ ], []),
133
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`judge timeout >${Math.round(COUNCIL_TIMEOUT_MS / 1000)}s`)), COUNCIL_TIMEOUT_MS)),
134
+ ]);
135
+ const text = resp?.choices?.[0]?.message?.content ?? '';
136
+ const json = extractJson(text);
137
+ if (json) {
138
+ for (let pos = 0; pos < order.length; pos++) {
139
+ const sc = Number(json.scores?.[letters[pos]]);
140
+ if (Number.isFinite(sc))
141
+ scores[order[pos]] = Math.max(0, Math.min(1, sc));
142
+ }
143
+ const winLetter = String(json.winner ?? '').trim().toUpperCase().charAt(0);
144
+ const winPos = letters.indexOf(winLetter);
145
+ winnerIdx = winPos >= 0 ? order[winPos] : scores.indexOf(Math.max(...scores));
146
+ rationale = String(json.why ?? '').trim();
147
+ }
148
+ else {
149
+ winnerIdx = answers.reduce((b, a, i) => (a.content.length > answers[b].content.length ? i : b), 0);
150
+ rationale = '(juge: réponse non-JSON → choix par longueur)';
151
+ }
152
+ }
153
+ catch (err) {
154
+ winnerIdx = 0;
155
+ rationale = `(juge indisponible: ${err instanceof Error ? err.message : String(err)})`;
156
+ }
157
+ if (!(scores[winnerIdx] > 0))
158
+ scores[winnerIdx] = 1;
159
+ return { winnerIdx, scores, rationale };
160
+ }
161
+ function buildJudgeClient(picked, all, judgePref) {
162
+ const pickedModels = new Set(picked.map((p) => p.c.model));
163
+ let judge;
164
+ if (judgePref) {
165
+ const want = judgePref.toLowerCase();
166
+ judge = all.find((c) => c.provider.toLowerCase().includes(want) || c.model.toLowerCase().includes(want));
167
+ }
168
+ // Prefer a strong reasoning model that is NOT one of the candidates (neutral).
169
+ if (!judge) {
170
+ judge = all.find((c) => !pickedModels.has(c.model) && /gpt-5|opus|sonnet|gemini|grok-[34]|grok-4|o3|reason/.test(c.model.toLowerCase()));
171
+ }
172
+ if (!judge)
173
+ judge = picked[0]?.c;
174
+ if (!judge || !judge.apiKey)
175
+ return null;
176
+ try {
177
+ return new CodeBuddyClient(judge.apiKey, judge.model, judge.baseURL);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ // --- main ---
184
+ export async function runCouncil(task, opts, out) {
185
+ const scoreboard = getModelScoreboard();
186
+ if (opts.scoreboard) {
187
+ out(scoreboard.print(opts.taskType));
188
+ return;
189
+ }
190
+ if (!task || !task.trim()) {
191
+ out('Usage: buddy council "<task>" (or --scoreboard to see what it has learned)');
192
+ return;
193
+ }
194
+ const { buildActiveLlmRegistry } = await import('../providers/active-llm-registry.js');
195
+ const registry = await buildActiveLlmRegistry({});
196
+ let candidates = registry.all.filter((c) => c.apiKey);
197
+ if (candidates.length === 0) {
198
+ out('No active LLMs detected. Run `buddy login`, set an API key, or start Ollama.');
199
+ return;
200
+ }
201
+ if (opts.models) {
202
+ const wanted = opts.models.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
203
+ const filtered = candidates.filter((c) => wanted.some((w) => c.provider.toLowerCase().includes(w) || c.model.toLowerCase().includes(w)));
204
+ if (filtered.length)
205
+ candidates = filtered;
206
+ }
207
+ const taskType = (opts.taskType || inferTaskType(task)).toLowerCase();
208
+ const required = TASK_REQUIRES[taskType] ?? TASK_REQUIRES.general;
209
+ const ranked = candidates
210
+ .map((c) => {
211
+ const strengths = inferStrengths(c.model);
212
+ const cap = matchScore(strengths, required);
213
+ const hist = scoreboard.winRate(taskType, c.model);
214
+ const cheapBonus = c.costInputUsdPerMtok === 0 ? 0.05 : 0;
215
+ return { c, strengths, score: (cap + 0.1 + cheapBonus) * (1 + hist), hist };
216
+ })
217
+ .sort((a, b) => b.score - a.score);
218
+ const k = Math.max(1, Math.min(opts.count ?? 3, ranked.length));
219
+ const picked = pickDiverse(ranked, k);
220
+ out(`🧠 Council — tâche "${taskType}" → ${picked.length} IA : ` +
221
+ picked.map((p) => `${p.c.model}${p.hist > 0 ? ` (${Math.round(p.hist * 100)}% hist)` : ''}`).join(', '));
222
+ const settled = await Promise.allSettled(picked.map(async (p) => {
223
+ const client = new CodeBuddyClient(p.c.apiKey ?? '', p.c.model, p.c.baseURL);
224
+ const t0 = Date.now();
225
+ const resp = await Promise.race([
226
+ client.chat([{ role: 'user', content: task }], []),
227
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout >${Math.round(COUNCIL_TIMEOUT_MS / 1000)}s`)), COUNCIL_TIMEOUT_MS)),
228
+ ]);
229
+ const content = resp?.choices?.[0]?.message?.content ?? '';
230
+ if (!content.trim())
231
+ throw new Error('réponse vide');
232
+ const usage = resp?.usage;
233
+ return {
234
+ modelId: p.c.provider,
235
+ modelName: p.c.model,
236
+ content,
237
+ latency: Date.now() - t0,
238
+ tokensUsed: usage?.total_tokens ?? 0,
239
+ cost: ((usage?.prompt_tokens ?? 0) / 1_000_000) * p.c.costInputUsdPerMtok,
240
+ };
241
+ }));
242
+ const answers = settled
243
+ .filter((s) => s.status === 'fulfilled')
244
+ .map((s) => s.value);
245
+ settled.forEach((s, i) => {
246
+ if (s.status === 'rejected') {
247
+ const reason = s.reason instanceof Error ? s.reason.message : String(s.reason);
248
+ out(` ⚠️ ${picked[i].c.model}: ${reason.slice(0, 120)}`);
249
+ }
250
+ });
251
+ if (answers.length === 0) {
252
+ out('❌ Toutes les IA ont échoué.');
253
+ return;
254
+ }
255
+ const judgeClient = buildJudgeClient(picked, candidates, opts.judge);
256
+ const verdict = judgeClient
257
+ ? await judgeAnswers(judgeClient, task, answers)
258
+ : {
259
+ winnerIdx: answers.reduce((b, a, i) => (a.content.length > answers[b].content.length ? i : b), 0),
260
+ scores: answers.map(() => 0.5),
261
+ rationale: '(aucun juge disponible)',
262
+ };
263
+ const sources = answers.map((a) => ({ peerId: a.modelId, model: a.modelName, text: a.content }));
264
+ const consensus = computeTextConsensus(sources);
265
+ // Learn: record every model's outcome for this task type.
266
+ const at = new Date().toISOString();
267
+ for (let i = 0; i < answers.length; i++) {
268
+ const provider = picked.find((p) => p.c.model === answers[i].modelName)?.c.provider ?? answers[i].modelId;
269
+ scoreboard.recordOutcome({
270
+ at,
271
+ taskType,
272
+ model: answers[i].modelName,
273
+ provider,
274
+ won: i === verdict.winnerIdx,
275
+ quality: verdict.scores[i] ?? 0,
276
+ latencyMs: answers[i].latency,
277
+ costUsd: answers[i].cost ?? 0,
278
+ });
279
+ }
280
+ const winner = answers[verdict.winnerIdx];
281
+ out(`\n🏆 Meilleure réponse — ${winner.modelName}${verdict.rationale ? ` : ${verdict.rationale}` : ''}\n`);
282
+ out(winner.content.trim());
283
+ if (opts.consensus !== false && answers.length > 1) {
284
+ const pct = Math.round(consensus.score * 100);
285
+ // Jaccard word-overlap on free-form prose is informational, NOT a verdict:
286
+ // two good answers phrased differently legitimately score low. The judge
287
+ // above is the real quality evaluator; this just flags how lexically close
288
+ // the wordings were (high overlap ⇒ the models genuinely converged).
289
+ out(`\n🤝 Accord lexical inter-IA : ${pct}% (recouvrement de mots — le juge ci-dessus évalue le fond).`);
290
+ }
291
+ out('\n📊 Détail par IA :');
292
+ for (let i = 0; i < answers.length; i++) {
293
+ const mark = i === verdict.winnerIdx ? '🏆' : ' ';
294
+ out(`${mark} ${answers[i].modelName.padEnd(22)} score ${(verdict.scores[i] ?? 0).toFixed(2)} ` +
295
+ `${answers[i].latency}ms ${answers[i].tokensUsed} tok`);
296
+ }
297
+ out(`\n${scoreboard.print(taskType)}`);
298
+ }
299
+ //# sourceMappingURL=council.js.map
@@ -111,13 +111,16 @@ export declare function handleChannels(action: string, options: ChannelOptions):
111
111
  /** Reset the one-shot registration guard. Test-only — never call in production. */
112
112
  export declare function __resetChannelAIHandlerForTests(): void;
113
113
  /**
114
- * Register a message handler that processes incoming messages through the AI agent.
115
- *
116
- * This is the inbound receiver loop (GAP-7): pairing gate → route resolution →
117
- * agent instantiation → session resume → `processUserMessage` → reply. It is the
118
- * single source of truth for inbound handling, shared by the CLI (`buddy
119
- * channels start`) and the embedded server intake.
114
+ * Per-bot persona for multi-bot channels: each bot (keyed by its id) can run its
115
+ * own model + appended system prompt. Registered at instantiateChannel time from
116
+ * the channels.json `options`.
120
117
  */
118
+ interface ChannelBotPersona {
119
+ name?: string;
120
+ systemPrompt?: string;
121
+ model?: string;
122
+ }
123
+ export declare function registerChannelBotPersona(botId: string, persona: ChannelBotPersona): void;
121
124
  export declare function registerAIMessageHandler(manager: import('../../channels/index.js').ChannelManager): Promise<void>;
122
125
  export declare function instantiateChannel(config: ChannelConfigEntry): Promise<import('../../channels/index.js').BaseChannel | null>;
123
126
  export {};