@pheem49/mint 1.3.0 → 1.4.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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Mint Long-Term Memory Store
3
+ * ---------------------------
4
+ * Persists user preferences, session summaries, and usage patterns
5
+ * across all Mint sessions using SQLite (same DB as knowledge_base).
6
+ *
7
+ * Auto-injects a "User Context" block into the system prompt so Mint
8
+ * remembers who it's talking to even after restart.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const crypto = require('crypto');
15
+ const { readConfig } = require('../System/config_manager');
16
+
17
+ // ── Electron-safe app path ──────────────────────────────────────────────────
18
+ let app;
19
+ try {
20
+ const electron = require('electron');
21
+ app = electron.app;
22
+ } catch (_) {
23
+ app = null;
24
+ }
25
+
26
+ function getDbPath() {
27
+ const fileName = 'mint-knowledge.sqlite'; // shared DB with knowledge_base
28
+ if (app && app.getPath) {
29
+ return path.join(app.getPath('userData'), fileName);
30
+ }
31
+ const mintDir = path.join(os.homedir(), '.mint');
32
+ if (!fs.existsSync(mintDir)) fs.mkdirSync(mintDir, { recursive: true });
33
+ return path.join(mintDir, fileName);
34
+ }
35
+
36
+ // ── Lazy DatabaseSync init ─────────────────────────────────────────────────
37
+ let DatabaseSync = null;
38
+ function getDatabaseSync() {
39
+ if (!DatabaseSync) ({ DatabaseSync } = require('node:sqlite'));
40
+ return DatabaseSync;
41
+ }
42
+
43
+ let dbInstance = null;
44
+ function getDb() {
45
+ if (dbInstance) return dbInstance;
46
+ const Database = getDatabaseSync();
47
+ dbInstance = new Database(getDbPath());
48
+
49
+ dbInstance.exec(`
50
+ -- User profile: arbitrary key-value pairs
51
+ CREATE TABLE IF NOT EXISTS user_profile (
52
+ key TEXT PRIMARY KEY,
53
+ value TEXT,
54
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
55
+ );
56
+
57
+ -- Condensed summaries of past sessions
58
+ CREATE TABLE IF NOT EXISTS session_memories (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ summary TEXT NOT NULL,
61
+ tags TEXT DEFAULT '',
62
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
63
+ );
64
+
65
+ -- Frequently used topics / commands
66
+ CREATE TABLE IF NOT EXISTS usage_patterns (
67
+ pattern TEXT PRIMARY KEY,
68
+ count INTEGER DEFAULT 1,
69
+ last_used DATETIME DEFAULT CURRENT_TIMESTAMP
70
+ );
71
+
72
+ -- Response Cache: For repetitive exact queries
73
+ CREATE TABLE IF NOT EXISTS response_cache (
74
+ query_hash TEXT PRIMARY KEY,
75
+ response TEXT NOT NULL,
76
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
77
+ );
78
+ `);
79
+
80
+ return dbInstance;
81
+ }
82
+
83
+ // ── Profile helpers ────────────────────────────────────────────────────────
84
+ function setProfile(key, value) {
85
+ try {
86
+ const db = getDb();
87
+ db.prepare(`
88
+ INSERT INTO user_profile (key, value, updated_at)
89
+ VALUES (?, ?, CURRENT_TIMESTAMP)
90
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
91
+ `).run(key, String(value));
92
+ } catch (err) {
93
+ console.error('[Memory] setProfile error:', err.message);
94
+ }
95
+ }
96
+
97
+ function getProfile(key, defaultValue = null) {
98
+ try {
99
+ const row = getDb().prepare('SELECT value FROM user_profile WHERE key = ?').get(key);
100
+ return row ? row.value : defaultValue;
101
+ } catch (_) {
102
+ return defaultValue;
103
+ }
104
+ }
105
+
106
+ function getAllProfile() {
107
+ try {
108
+ const rows = getDb().prepare('SELECT key, value FROM user_profile').all();
109
+ return Object.fromEntries(rows.map(r => [r.key, r.value]));
110
+ } catch (_) {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ // ── Session memory helpers ─────────────────────────────────────────────────
116
+ const MAX_SESSION_MEMORIES = 20; // keep last N summaries
117
+
118
+ function addSessionMemory(summary, tags = []) {
119
+ try {
120
+ const db = getDb();
121
+ db.prepare('INSERT INTO session_memories (summary, tags) VALUES (?, ?)').run(
122
+ summary.slice(0, 800), // cap length
123
+ tags.join(',')
124
+ );
125
+ // Prune oldest beyond limit
126
+ db.exec(`
127
+ DELETE FROM session_memories WHERE id NOT IN (
128
+ SELECT id FROM session_memories ORDER BY id DESC LIMIT ${MAX_SESSION_MEMORIES}
129
+ )
130
+ `);
131
+ } catch (err) {
132
+ console.error('[Memory] addSessionMemory error:', err.message);
133
+ }
134
+ }
135
+
136
+ function getRecentMemories(limit = 5) {
137
+ try {
138
+ return getDb()
139
+ .prepare('SELECT summary, tags, created_at FROM session_memories ORDER BY id DESC LIMIT ?')
140
+ .all(limit);
141
+ } catch (_) {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ // ── Usage pattern helpers ──────────────────────────────────────────────────
147
+ function recordPattern(pattern) {
148
+ try {
149
+ const db = getDb();
150
+ db.prepare(`
151
+ INSERT INTO usage_patterns (pattern, count, last_used)
152
+ VALUES (?, 1, CURRENT_TIMESTAMP)
153
+ ON CONFLICT(pattern) DO UPDATE
154
+ SET count = count + 1, last_used = CURRENT_TIMESTAMP
155
+ `).run(pattern.slice(0, 120));
156
+ } catch (_) {}
157
+ }
158
+
159
+ function getTopPatterns(limit = 8) {
160
+ try {
161
+ return getDb()
162
+ .prepare('SELECT pattern, count FROM usage_patterns ORDER BY count DESC, last_used DESC LIMIT ?')
163
+ .all(limit);
164
+ } catch (_) {
165
+ return [];
166
+ }
167
+ }
168
+
169
+ // ── Simple keyword extractor (no external deps) ────────────────────────────
170
+ const STOP_WORDS = new Set([
171
+ 'ที่', 'ให้', 'และ', 'ของ', 'กับ', 'ใน', 'บน', 'เป็น', 'อยู่', 'มี', 'ได้', 'the', 'a', 'an',
172
+ 'is', 'are', 'was', 'were', 'it', 'in', 'on', 'at', 'for', 'to', 'of', 'with', 'and', 'or',
173
+ 'this', 'that', 'i', 'you', 'me', 'my', 'your', 'can', 'do', 'be', 'will', 'please', 'how',
174
+ 'what', 'which', 'when', 'where', 'why', 'help', 'want', 'need', 'make', 'create', 'get', 'run'
175
+ ]);
176
+
177
+ function extractKeywords(text) {
178
+ return text
179
+ .toLowerCase()
180
+ .replace(/[^\w\u0E00-\u0E7F\s]/g, ' ')
181
+ .split(/\s+/)
182
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w))
183
+ .slice(0, 6);
184
+ }
185
+
186
+ // ── Main public API ────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Called after every successful chat turn.
190
+ * Extracts patterns & infers preferences — runs async, non-blocking.
191
+ */
192
+ function recordInteraction(userMessage, aiResponseText) {
193
+ try {
194
+ if (!userMessage || !aiResponseText) return;
195
+
196
+ // Extract keywords as usage patterns
197
+ const keywords = extractKeywords(userMessage);
198
+ keywords.forEach(kw => recordPattern(kw));
199
+
200
+ // Detect preferred language
201
+ const thaiRatio = (userMessage.match(/[\u0E00-\u0E7F]/g) || []).length / userMessage.length;
202
+ if (thaiRatio > 0.3) setProfile('preferred_language', 'thai');
203
+ else setProfile('preferred_language', 'english');
204
+
205
+ // Detect coding intent (update project activity)
206
+ const codingKeywords = ['code', 'fix', 'debug', 'function', 'class', 'import', 'script',
207
+ 'แก้', 'เขียน', 'โค้ด', 'สคริปต์', 'ฟังก์ชัน'];
208
+ if (codingKeywords.some(k => userMessage.toLowerCase().includes(k))) {
209
+ const cwd = process.cwd();
210
+ if (cwd !== os.homedir()) {
211
+ setProfile('last_active_project', path.basename(cwd));
212
+ setProfile('last_active_project_path', cwd);
213
+ }
214
+ }
215
+
216
+ // Update interaction counter
217
+ const count = parseInt(getProfile('total_interactions', '0'), 10);
218
+ setProfile('total_interactions', String(count + 1));
219
+ setProfile('last_seen', new Date().toISOString());
220
+ } catch (err) {
221
+ console.error('[Memory] recordInteraction error:', err.message);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Saves a condensed summary of a completed conversation.
227
+ * Call this when user clears history or after N turns.
228
+ */
229
+ function saveSessionSummary(summary, tags = []) {
230
+ if (!summary || summary.trim().length < 10) return;
231
+ addSessionMemory(summary.trim(), tags);
232
+ }
233
+
234
+ /**
235
+ * Returns a formatted context string to inject into the AI system prompt.
236
+ * Lightweight — no async calls.
237
+ */
238
+ function getUserContext() {
239
+ try {
240
+ const profile = getAllProfile();
241
+ const patterns = getTopPatterns(6);
242
+ const memories = getRecentMemories(3);
243
+
244
+ const lines = ['\n\n[LONG-TERM USER CONTEXT — use this to personalize responses]'];
245
+
246
+ // Profile info
247
+ if (Object.keys(profile).length > 0) {
248
+ if (profile.preferred_language)
249
+ lines.push(`• Preferred language: ${profile.preferred_language}`);
250
+ if (profile.last_active_project)
251
+ lines.push(`• Last active project: ${profile.last_active_project} (${profile.last_active_project_path || ''})`);
252
+ if (profile.total_interactions)
253
+ lines.push(`• Total interactions with Mint: ${profile.total_interactions}`);
254
+ if (profile.last_seen) {
255
+ const d = new Date(profile.last_seen);
256
+ lines.push(`• Last session: ${d.toLocaleDateString('th-TH')} ${d.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' })}`);
257
+ }
258
+ }
259
+
260
+ // Usage patterns
261
+ if (patterns.length > 0) {
262
+ const topTopics = patterns.map(p => p.pattern).join(', ');
263
+ lines.push(`• Frequent topics/tools: ${topTopics}`);
264
+ }
265
+
266
+ // Past session memories
267
+ if (memories.length > 0) {
268
+ lines.push('\nRecent session summaries:');
269
+ memories.forEach((m, i) => lines.push(` ${i + 1}. ${m.summary}`));
270
+ }
271
+
272
+ if (lines.length === 1) return ''; // nothing to add
273
+ lines.push('[END USER CONTEXT]\n');
274
+ return lines.join('\n');
275
+ } catch (err) {
276
+ console.error('[Memory] getUserContext error:', err.message);
277
+ return '';
278
+ }
279
+ }
280
+
281
+ // ── Response Cache helpers ────────────────────────────────────────────────
282
+ function getCachedResponse(query) {
283
+ try {
284
+ const hash = crypto.createHash('md5').update(query.trim().toLowerCase()).digest('hex');
285
+ const row = getDb().prepare('SELECT response, created_at FROM response_cache WHERE query_hash = ?').get(hash);
286
+ if (row) {
287
+ // Optional: check TTL (e.g., 24 hours)
288
+ const age = Date.now() - new Date(row.created_at).getTime();
289
+ if (age < 24 * 60 * 60 * 1000) {
290
+ return JSON.parse(row.response);
291
+ }
292
+ }
293
+ } catch (_) {}
294
+ return null;
295
+ }
296
+
297
+ function cacheResponse(query, responseObj) {
298
+ try {
299
+ const hash = crypto.createHash('md5').update(query.trim().toLowerCase()).digest('hex');
300
+ getDb().prepare(`
301
+ INSERT INTO response_cache (query_hash, response, created_at)
302
+ VALUES (?, ?, CURRENT_TIMESTAMP)
303
+ ON CONFLICT(query_hash) DO UPDATE SET response = excluded.response, created_at = CURRENT_TIMESTAMP
304
+ `).run(hash, JSON.stringify(responseObj));
305
+ } catch (_) {}
306
+ }
307
+
308
+ module.exports = {
309
+ recordInteraction,
310
+ saveSessionSummary,
311
+ getUserContext,
312
+ setProfile,
313
+ getProfile,
314
+ getTopPatterns,
315
+ getRecentMemories,
316
+ getCachedResponse,
317
+ cacheResponse
318
+ };
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { GoogleGenAI } = require('@google/genai');
4
4
  const { executeCodeTask } = require('./code_agent');
5
- const { readConfig } = require('../System/config_manager');
5
+ const { readConfig, getAvailableProviders } = require('../System/config_manager');
6
6
 
7
7
  const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash';
8
8
 
@@ -128,7 +128,21 @@ async function runChatRoutedTask(input, context) {
128
128
  const text = input.startsWith('/code ') ? input.slice('/code '.length).trim() : input;
129
129
  const { appendMessage, setThinking, requestApproval, setMode } = context;
130
130
 
131
- appendMessage('system', `Routing this request to Code Mode for workspace: ${process.cwd()}`);
131
+ const config = readConfig();
132
+ const availableProviders = getAvailableProviders(config);
133
+
134
+ // Smart Routing Priority for Code Tasks
135
+ let preferredProvider = config.aiProvider || 'gemini';
136
+
137
+ // If preferred isn't actually available, try best available
138
+ if (!availableProviders.includes(preferredProvider)) {
139
+ if (availableProviders.includes('anthropic')) preferredProvider = 'anthropic';
140
+ else if (availableProviders.includes('openai')) preferredProvider = 'openai';
141
+ else if (availableProviders.includes('gemini')) preferredProvider = 'gemini';
142
+ else preferredProvider = availableProviders[0] || 'gemini';
143
+ }
144
+
145
+ appendMessage('system', `Routing this request to Code Mode for workspace: ${process.cwd()} using [${preferredProvider}]`);
132
146
  if (setMode) setMode('Code');
133
147
 
134
148
  let seconds = 0;
@@ -142,6 +156,7 @@ async function runChatRoutedTask(input, context) {
142
156
  const result = await executeCodeTask(text, {
143
157
  cwd: process.cwd(),
144
158
  requestApproval,
159
+ provider: preferredProvider,
145
160
  onProgress: (message) => appendMessage('system', `[Code] ${message}`)
146
161
  });
147
162
  clearInterval(timer);
@@ -14,6 +14,10 @@ const SLASH_COMMANDS = [
14
14
  { name: '/copy', desc: 'Copy last response to clipboard' },
15
15
  { name: '/clear', desc: 'Clear conversation history' },
16
16
  { name: '/reset', desc: 'Reset conversation history' },
17
+ { name: '/agent', desc: 'Switch AI personas (coder, researcher, etc)' },
18
+ { name: '/workspace', desc: 'Manage project-specific contexts' },
19
+ { name: '/review', desc: 'Request a second-pass review of the last response' },
20
+ { name: '/stats', desc: 'Show system health stats (CPU/RAM/Disk)' },
17
21
  { name: '/help', desc: 'Show help information' },
18
22
  { name: '/exit', desc: 'Exit Mint' }
19
23
  ];
@@ -588,6 +592,84 @@ function createChatUI({ onSubmit, onExit }) {
588
592
  screen.render();
589
593
  }
590
594
 
595
+ /**
596
+ * Opens a streaming message bubble for the assistant.
597
+ * Returns { appendChunk(text), finalize(timestamp) } for typewriter rendering.
598
+ * Usage:
599
+ * const stream = streamMessage('assistant');
600
+ * stream.appendChunk('Hello'); stream.appendChunk(' World');
601
+ * stream.finalize(timestamp);
602
+ */
603
+ function streamMessage(role = 'assistant') {
604
+ const now = new Date();
605
+ const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
606
+ const maxLineWidth = Math.max(screen.width - 20, 36);
607
+
608
+ // Print the header bubble once
609
+ chatBox.log('');
610
+ if (role === 'assistant') {
611
+ chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
612
+ }
613
+
614
+ let buffer = ''; // accumulates the full response text
615
+ let lineBuffer = ''; // current partial line being built
616
+ let lineRendered = false; // whether we already pushed the first line prefix
617
+
618
+ function flushLine(force = false) {
619
+ // Flush content that fits on one line-width or when forced
620
+ if (!lineBuffer && !force) return;
621
+ if (!lineRendered) {
622
+ chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
623
+ lineRendered = true;
624
+ } else {
625
+ // Overwrite the last line by popping + re-pushing (blessed.log limitation)
626
+ // We can't truly overwrite, so we just keep appending new lines for each chunk.
627
+ // For large chunks, split on newline and emit per-line.
628
+ chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
629
+ }
630
+ screen.render();
631
+ }
632
+
633
+ function appendChunk(text) {
634
+ if (!text) return;
635
+ buffer += text;
636
+ const segments = text.split('\n');
637
+ for (let i = 0; i < segments.length; i++) {
638
+ lineBuffer += segments[i];
639
+ if (i < segments.length - 1) {
640
+ // Newline boundary — emit current line
641
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
642
+ lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
643
+ lineBuffer = '';
644
+ lineRendered = true;
645
+ screen.render();
646
+ } else if (lineBuffer.length >= maxLineWidth) {
647
+ // Line overflow — auto-wrap
648
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
649
+ lines.slice(0, -1).forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
650
+ lineBuffer = lines[lines.length - 1] || '';
651
+ lineRendered = true;
652
+ screen.render();
653
+ }
654
+ // Otherwise keep buffering the partial line
655
+ }
656
+ }
657
+
658
+ function finalize(timestamp = null) {
659
+ // Flush remaining buffer
660
+ if (lineBuffer) {
661
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
662
+ lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
663
+ lineBuffer = '';
664
+ }
665
+ // Track last response for clipboard
666
+ lastAssistantResponse = buffer;
667
+ screen.render();
668
+ }
669
+
670
+ return { appendChunk, finalize };
671
+ }
672
+
591
673
  /** Show/hide thinking indicator in status bar */
592
674
  function setThinking(active, secondsElapsed = 0) {
593
675
  if (active) {
@@ -628,7 +710,7 @@ function createChatUI({ onSubmit, onExit }) {
628
710
  });
629
711
  }
630
712
 
631
- return { screen, appendMessage, setThinking, updateStatusModel, copyLastResponse, requestApproval, setMode };
713
+ return { screen, appendMessage, streamMessage, setThinking, updateStatusModel, copyLastResponse, requestApproval, setMode };
632
714
  }
633
715
 
634
716
  module.exports = { createChatUI };
@@ -3,7 +3,8 @@ const path = require('path');
3
3
  const { execFile } = require('child_process');
4
4
  const { promisify } = require('util');
5
5
  const { GoogleGenAI } = require('@google/genai');
6
- const { readConfig } = require('../System/config_manager');
6
+ const axios = require('axios');
7
+ const { readConfig, getAvailableProviders } = require('../System/config_manager');
7
8
  const { readWorkspaceSession, writeWorkspaceSession } = require('./code_session_memory');
8
9
 
9
10
  const execFileAsync = promisify(execFile);
@@ -244,16 +245,101 @@ function writeFile(workspaceRoot, targetPath, content) {
244
245
  return `Wrote ${targetPath}`;
245
246
  }
246
247
 
247
- function getAiClientAndModel() {
248
- const config = readConfig();
249
- const apiKey = (config.apiKey || process.env.GEMINI_API_KEY || '').trim();
250
- if (!apiKey) {
251
- throw new Error("Missing Gemini API key. Run 'mint onboard' first.");
248
+ class UnifiedAgentClient {
249
+ constructor(provider, config) {
250
+ this.provider = provider;
251
+ this.config = config;
252
+ this.history = [];
253
+ this.systemInstruction = CODE_AGENT_PROMPT;
254
+ }
255
+
256
+ async sendMessage(observation) {
257
+ this.history.push({ role: 'user', content: observation });
258
+
259
+ let responseText = '';
260
+ if (this.provider === 'anthropic') {
261
+ responseText = await this._callAnthropic();
262
+ } else if (this.provider === 'openai' || this.provider === 'local_openai') {
263
+ responseText = await this._callOpenAI();
264
+ } else {
265
+ responseText = await this._callGemini();
266
+ }
267
+
268
+ this.history.push({ role: 'assistant', content: responseText });
269
+ return responseText;
270
+ }
271
+
272
+ async _callAnthropic() {
273
+ const apiKey = this.config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
274
+ const messages = this.history.map(m => ({
275
+ role: m.role,
276
+ content: m.content
277
+ }));
278
+
279
+ const response = await axios.post('https://api.anthropic.com/v1/messages', {
280
+ model: this.config.anthropicModel || 'claude-3-5-sonnet-latest',
281
+ max_tokens: 8192,
282
+ system: this.systemInstruction,
283
+ messages: messages
284
+ }, {
285
+ headers: {
286
+ 'x-api-key': apiKey,
287
+ 'anthropic-version': '2023-06-01',
288
+ 'content-type': 'application/json'
289
+ }
290
+ });
291
+ return response.data.content[0].text;
292
+ }
293
+
294
+ async _callOpenAI() {
295
+ const isLocal = this.provider === 'local_openai';
296
+ const apiKey = isLocal ? 'not-needed' : (this.config.openaiApiKey || process.env.OPENAI_API_KEY);
297
+ const baseUrl = isLocal ? (this.config.localApiBaseUrl || 'http://localhost:1234/v1') : 'https://api.openai.com/v1';
298
+ const model = isLocal ? (this.config.localModelName || 'local-model') : (this.config.openaiModel || 'gpt-4o');
299
+
300
+ const messages = [
301
+ { role: 'system', content: this.systemInstruction },
302
+ ...this.history
303
+ ];
304
+
305
+ const response = await axios.post(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
306
+ model: model,
307
+ messages: messages,
308
+ response_format: isLocal ? undefined : { type: "json_object" }
309
+ }, {
310
+ headers: {
311
+ 'Authorization': `Bearer ${apiKey}`,
312
+ 'Content-Type': 'application/json'
313
+ }
314
+ });
315
+ return response.data.choices[0].message.content;
316
+ }
317
+
318
+ async _callGemini() {
319
+ const apiKey = this.config.apiKey || process.env.GEMINI_API_KEY;
320
+ const model = this.config.geminiModel || DEFAULT_GEMINI_MODEL;
321
+ const ai = new GoogleGenAI({ apiKey });
322
+
323
+ // Convert history for Gemini
324
+ const geminiHistory = this.history.slice(0, -1).map(m => ({
325
+ role: m.role === 'assistant' ? 'model' : 'user',
326
+ parts: [{ text: m.content }]
327
+ }));
328
+
329
+ const lastMessage = this.history[this.history.length - 1].content;
330
+
331
+ const chat = ai.chats.create({
332
+ model,
333
+ config: {
334
+ systemInstruction: this.systemInstruction,
335
+ responseMimeType: 'application/json'
336
+ },
337
+ history: geminiHistory
338
+ });
339
+
340
+ const response = await chat.sendMessage({ message: [{ text: lastMessage }] });
341
+ return typeof response.text === 'function' ? response.text() : response.text;
252
342
  }
253
- return {
254
- ai: new GoogleGenAI({ apiKey }),
255
- model: (config.geminiModel || DEFAULT_GEMINI_MODEL).trim() || DEFAULT_GEMINI_MODEL
256
- };
257
343
  }
258
344
 
259
345
  function detectPackageManager(workspaceRoot) {
@@ -327,23 +413,19 @@ async function executeCodeTask(task, options = {}) {
327
413
  const requestApproval = typeof options.requestApproval === 'function'
328
414
  ? options.requestApproval
329
415
  : async () => true;
330
- const { ai, model } = getAiClientAndModel();
331
-
332
- const chat = ai.chats.create({
333
- model,
334
- config: {
335
- systemInstruction: CODE_AGENT_PROMPT,
336
- responseMimeType: 'application/json'
337
- },
338
- history: []
339
- });
416
+ const config = readConfig();
417
+ const provider = options.provider || 'gemini';
418
+ const client = new UnifiedAgentClient(provider, config);
340
419
 
341
420
  let observation = await buildInitialObservation(task, workspaceRoot);
342
421
 
422
+ let finalSummary = '';
423
+ let finalVerification = '';
424
+ let finalSessionSummary = '';
425
+
343
426
  for (let step = 1; step <= MAX_AGENT_STEPS; step++) {
344
427
  onProgress(`Step ${step}: thinking`);
345
- const response = await chat.sendMessage({ message: [{ text: observation }] });
346
- const text = typeof response.text === 'function' ? response.text() : response.text;
428
+ const text = await client.sendMessage(observation);
347
429
  const decision = extractJson(text);
348
430
  const action = decision.action;
349
431
  const input = decision.input || {};
@@ -351,17 +433,15 @@ async function executeCodeTask(task, options = {}) {
351
433
  onProgress(`Step ${step}: ${action}${input.path ? ` ${input.path}` : input.command ? ` ${input.command}` : ''}`);
352
434
 
353
435
  if (action === 'finish') {
354
- const sessionSummary = input.sessionSummary || input.summary || task;
436
+ finalSessionSummary = input.sessionSummary || input.summary || task;
437
+ finalSummary = input.summary || 'Task complete.';
438
+ finalVerification = input.verification || 'Not specified.';
355
439
  writeWorkspaceSession(workspaceRoot, {
356
- summary: sessionSummary,
440
+ summary: finalSessionSummary,
357
441
  lastTask: task,
358
- lastVerification: input.verification || 'Not specified.'
442
+ lastVerification: finalVerification
359
443
  });
360
- return {
361
- summary: input.summary || 'Task complete.',
362
- verification: input.verification || 'Not specified.',
363
- steps: step
364
- };
444
+ break;
365
445
  }
366
446
 
367
447
  let toolResult = '';
@@ -427,6 +507,39 @@ async function executeCodeTask(task, options = {}) {
427
507
  ].join('\n');
428
508
  }
429
509
 
510
+ // Check for Agent Collaboration (Review)
511
+ if (config.enableAgentCollaboration !== false) {
512
+ const availableProviders = getAvailableProviders(config);
513
+ const altProviders = availableProviders.filter(p => p !== provider && p !== 'ollama' && p !== 'huggingface');
514
+ if (altProviders.length > 0 && finalSummary) {
515
+ const reviewerProvider = altProviders[0];
516
+ onProgress(`Invoking Reviewer Agent (${reviewerProvider})...`);
517
+
518
+ const reviewerClient = new UnifiedAgentClient(reviewerProvider, config);
519
+ reviewerClient.systemInstruction = CODE_AGENT_PROMPT + "\n\nYou are the Reviewer Agent. Review the primary agent's changes, test output, and verification. If you spot a critical bug, point it out. Otherwise, confirm it looks good. Return JSON with action: 'finish' and your review in the 'summary' field.";
520
+
521
+ const reviewPrompt = `The primary agent (${provider}) just completed the task: "${task}".\nSummary: ${finalSummary}\nVerification: ${finalVerification}\nGit Status: ${(await getGitContext(workspaceRoot)).status}\n\nPlease review this. Return JSON with action: 'finish'.`;
522
+
523
+ try {
524
+ const reviewResponse = await reviewerClient.sendMessage(reviewPrompt);
525
+ const reviewDecision = extractJson(reviewResponse);
526
+ const reviewInput = reviewDecision.input || {};
527
+
528
+ finalSummary += `\n\n[Review by ${reviewerProvider}]\n${reviewInput.summary || reviewDecision.thought || 'Looks good.'}`;
529
+ } catch (e) {
530
+ onProgress(`Reviewer Agent failed: ${e.message}`);
531
+ }
532
+ }
533
+ }
534
+
535
+ if (finalSummary) {
536
+ return {
537
+ summary: finalSummary,
538
+ verification: finalVerification,
539
+ steps: MAX_AGENT_STEPS
540
+ };
541
+ }
542
+
430
543
  writeWorkspaceSession(workspaceRoot, {
431
544
  summary: `Task stopped before completion: ${task}`,
432
545
  lastTask: task,