@jungjaehoon/mama-server 1.0.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,387 @@
1
+ /**
2
+ * MAMA (Memory-Augmented MCP Architecture) - Ollama Client Wrapper
3
+ *
4
+ * Simple wrapper for Ollama API with EXAONE 3.5 support
5
+ * Tasks: 9.2-9.5 (HTTP client, EXAONE wrapper, Error handling, Testing)
6
+ * AC #1: LLM integration ready
7
+ *
8
+ * @module ollama-client
9
+ * @version 1.0
10
+ * @date 2025-11-14
11
+ */
12
+
13
+ const { info, error: logError } = require('./debug-logger');
14
+ const http = require('http');
15
+
16
+ // Ollama configuration
17
+ const OLLAMA_HOST = process.env.OLLAMA_HOST || 'localhost';
18
+ const OLLAMA_PORT = process.env.OLLAMA_PORT || 11434;
19
+ const DEFAULT_MODEL = 'exaone3.5:2.4b';
20
+ const FALLBACK_MODEL = 'gemma:2b';
21
+
22
+ /**
23
+ * Call Ollama API
24
+ *
25
+ * Task 9.2: Implement HTTP client for Ollama API
26
+ * AC #1: LLM API callable
27
+ *
28
+ * @param {string} endpoint - API endpoint (e.g., '/api/generate')
29
+ * @param {Object} payload - Request payload
30
+ * @param {number} timeout - Timeout in milliseconds (default: 30000)
31
+ * @returns {Promise<Object>} API response
32
+ * @throws {Error} If request fails
33
+ */
34
+ function callOllamaAPI(endpoint, payload, timeout = 30000) {
35
+ return new Promise((resolve, reject) => {
36
+ const postData = JSON.stringify(payload);
37
+
38
+ const options = {
39
+ hostname: OLLAMA_HOST,
40
+ port: OLLAMA_PORT,
41
+ path: endpoint,
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Content-Length': Buffer.byteLength(postData),
46
+ },
47
+ };
48
+
49
+ const req = http.request(options, (res) => {
50
+ let data = '';
51
+
52
+ res.on('data', (chunk) => {
53
+ data += chunk;
54
+ });
55
+
56
+ res.on('end', () => {
57
+ if (res.statusCode >= 200 && res.statusCode < 300) {
58
+ try {
59
+ // Ollama returns NDJSON (newline-delimited JSON)
60
+ // For non-streaming, we only get one line
61
+ const lines = data.trim().split('\n');
62
+ const response = JSON.parse(lines[lines.length - 1]);
63
+ resolve(response);
64
+ } catch (error) {
65
+ reject(new Error(`Failed to parse Ollama response: ${error.message}`));
66
+ }
67
+ } else {
68
+ reject(new Error(`Ollama API error: ${res.statusCode} - ${data}`));
69
+ }
70
+ });
71
+ });
72
+
73
+ req.on('error', (error) => {
74
+ reject(new Error(`Ollama connection failed: ${error.message}`));
75
+ });
76
+
77
+ req.setTimeout(timeout, () => {
78
+ req.destroy();
79
+ reject(new Error(`Ollama request timeout (${timeout}ms)`));
80
+ });
81
+
82
+ req.write(postData);
83
+ req.end();
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Generate text with EXAONE 3.5
89
+ *
90
+ * Task 9.3: Implement EXAONE wrapper (with fallback to Gemma)
91
+ * AC #1: Decision detection LLM ready
92
+ *
93
+ * @param {string} prompt - Input prompt
94
+ * @param {Object} options - Generation options
95
+ * @param {string} options.model - Model name (default: EXAONE 3.5)
96
+ * @param {string} options.format - Response format ('json' or null)
97
+ * @param {number} options.temperature - Temperature (default: 0.7)
98
+ * @param {number} options.max_tokens - Max tokens (default: 500)
99
+ * @returns {Promise<string|Object>} Generated text or JSON object
100
+ * @throws {Error} If generation fails
101
+ */
102
+ async function generate(prompt, options = {}) {
103
+ const { model = DEFAULT_MODEL, format = null, temperature = 0.7, max_tokens = 500 } = options;
104
+
105
+ const payload = {
106
+ model,
107
+ prompt,
108
+ stream: false,
109
+ options: {
110
+ temperature,
111
+ num_predict: max_tokens,
112
+ },
113
+ };
114
+
115
+ if (format === 'json') {
116
+ payload.format = 'json';
117
+ }
118
+
119
+ try {
120
+ const response = await callOllamaAPI('/api/generate', payload);
121
+
122
+ // Extract response text
123
+ const responseText = response.response;
124
+
125
+ // Parse JSON if requested
126
+ if (format === 'json') {
127
+ try {
128
+ return JSON.parse(responseText);
129
+ } catch (error) {
130
+ throw new Error(`Failed to parse JSON response: ${responseText}`);
131
+ }
132
+ }
133
+
134
+ return responseText;
135
+ } catch (error) {
136
+ // Task 9.4: Try fallback model if EXAONE fails
137
+ if (model === DEFAULT_MODEL && error.message.includes('not found')) {
138
+ console.warn(`[MAMA] EXAONE not found, trying fallback (${FALLBACK_MODEL})...`);
139
+
140
+ return generate(prompt, {
141
+ ...options,
142
+ model: FALLBACK_MODEL,
143
+ });
144
+ }
145
+
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Analyze decision from tool execution
152
+ *
153
+ * Wrapper for decision detection (used in Story 014.7.3)
154
+ *
155
+ * @param {Object} toolExecution - Tool execution data
156
+ * @param {Object} sessionContext - Session context
157
+ * @returns {Promise<Object>} Decision analysis result
158
+ */
159
+ async function analyzeDecision(toolExecution, sessionContext) {
160
+ const prompt = `
161
+ Analyze if this represents a DECISION (not just an action):
162
+
163
+ Session Context:
164
+ - Latest User Message: ${sessionContext.latest_user_message || 'N/A'}
165
+ - Recent Exchange: ${sessionContext.recent_exchange || 'N/A'}
166
+
167
+ Tool Execution:
168
+ - Tool: ${toolExecution.tool_name}
169
+ - Input: ${JSON.stringify(toolExecution.tool_input)}
170
+ - Result: ${toolExecution.exit_code === 0 ? 'SUCCESS' : 'FAILED'}
171
+
172
+ Decision Indicators:
173
+ 1. User explicitly chose between alternatives?
174
+ Example: "Let's use JWT" (not "Use JWT" - that's just action)
175
+
176
+ 2. User changed previous approach?
177
+ Example: "Complex → Simple approach"
178
+
179
+ 3. User expressed preference?
180
+ Example: "Let's do it this way from now", "This approach is better"
181
+
182
+ 4. Significant architectural choice?
183
+ Example: "Mesh structure: COMPLEX", "Authentication: JWT"
184
+
185
+ Is this a DECISION? Return JSON with "topic" as a short snake_case identifier:
186
+ {
187
+ "is_decision": boolean,
188
+ "topic": string or null (extract main technical topic in snake_case, e.g., "mesh_structure", "database_choice", "auth_strategy"),
189
+ "decision": string or null (the actual choice made, e.g., "COMPLEX", "PostgreSQL", "JWT"),
190
+ "reasoning": "Why this is/isn't a decision",
191
+ "confidence": 0.0-1.0
192
+ }
193
+
194
+ IMPORTANT: Generate "topic" freely based on context. Do NOT limit to predefined values.
195
+ `;
196
+
197
+ try {
198
+ const response = await generate(prompt, {
199
+ format: 'json',
200
+ temperature: 0.3, // Lower temperature for structured output
201
+ max_tokens: 300,
202
+ });
203
+
204
+ return response;
205
+ } catch (error) {
206
+ // CLAUDE.md Rule #1: NO FALLBACK
207
+ // Errors must be thrown for debugging
208
+ logError(`[MAMA] Decision analysis FAILED: ${error.message}`);
209
+ throw new Error(`Decision analysis failed: ${error.message}`);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Analyze query intent
215
+ *
216
+ * Wrapper for query intent detection (used in Story 014.7.2)
217
+ *
218
+ * @param {string} userMessage - User's message
219
+ * @returns {Promise<Object>} Query intent analysis
220
+ */
221
+ async function analyzeQueryIntent(userMessage) {
222
+ const prompt = `
223
+ Analyze this user message to determine if it involves past decisions:
224
+
225
+ User Message: "${userMessage}"
226
+
227
+ Questions to answer:
228
+ 1. Does this query reference past decisions or choices?
229
+ 2. Is the user asking about previous approaches?
230
+ 3. What topic is being discussed? (e.g., "mesh_structure", "authentication", "testing")
231
+
232
+ Return JSON:
233
+ {
234
+ "involves_decision": boolean,
235
+ "topic": "topic_name" | null,
236
+ "query_type": "recall" | "evolution" | "none",
237
+ "reasoning": "Why this involves/doesn't involve decisions"
238
+ }
239
+ `;
240
+
241
+ try {
242
+ const response = await generate(prompt, {
243
+ format: 'json',
244
+ temperature: 0.3,
245
+ max_tokens: 200,
246
+ });
247
+
248
+ return response;
249
+ } catch (error) {
250
+ // CLAUDE.md Rule #1: NO FALLBACK
251
+ // Errors must be thrown for debugging
252
+ logError(`[MAMA] Query intent analysis FAILED: ${error.message}`);
253
+ throw new Error(`Query intent analysis failed: ${error.message}`);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Check if Ollama is available
259
+ *
260
+ * Utility for health checks
261
+ *
262
+ * @returns {Promise<boolean>} True if Ollama is accessible
263
+ */
264
+ async function isAvailable() {
265
+ return new Promise((resolve) => {
266
+ const options = {
267
+ hostname: OLLAMA_HOST,
268
+ port: OLLAMA_PORT,
269
+ path: '/api/tags',
270
+ method: 'GET', // Use GET for health check
271
+ timeout: 2000,
272
+ };
273
+
274
+ const req = http.request(options, (res) => {
275
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
276
+ });
277
+
278
+ req.on('error', () => resolve(false));
279
+ req.on('timeout', () => {
280
+ req.destroy();
281
+ resolve(false);
282
+ });
283
+
284
+ req.end();
285
+ });
286
+ }
287
+
288
+ /**
289
+ * List available models
290
+ *
291
+ * Utility for setup script
292
+ *
293
+ * @returns {Promise<Array<string>>} Array of model names
294
+ */
295
+ async function listModels() {
296
+ return new Promise((resolve, reject) => {
297
+ const options = {
298
+ hostname: OLLAMA_HOST,
299
+ port: OLLAMA_PORT,
300
+ path: '/api/tags',
301
+ method: 'GET', // Use GET for listing models
302
+ timeout: 5000,
303
+ };
304
+
305
+ const req = http.request(options, (res) => {
306
+ let data = '';
307
+
308
+ res.on('data', (chunk) => {
309
+ data += chunk;
310
+ });
311
+
312
+ res.on('end', () => {
313
+ try {
314
+ const response = JSON.parse(data);
315
+ resolve(response.models?.map((m) => m.name) || []);
316
+ } catch (error) {
317
+ reject(new Error(`Failed to parse models response: ${error.message}`));
318
+ }
319
+ });
320
+ });
321
+
322
+ req.on('error', (error) => {
323
+ reject(new Error(`Failed to list models: ${error.message}`));
324
+ });
325
+
326
+ req.on('timeout', () => {
327
+ req.destroy();
328
+ reject(new Error('List models timeout'));
329
+ });
330
+
331
+ req.end();
332
+ });
333
+ }
334
+
335
+ // Export API
336
+ module.exports = {
337
+ generate,
338
+ analyzeDecision,
339
+ analyzeQueryIntent,
340
+ isAvailable,
341
+ listModels,
342
+ DEFAULT_MODEL,
343
+ FALLBACK_MODEL,
344
+ };
345
+
346
+ // CLI execution for testing
347
+ if (require.main === module) {
348
+ info('🧠 MAMA Ollama Client - Test\n');
349
+
350
+ // Task 9.5: Test Ollama connection and generation
351
+ (async () => {
352
+ try {
353
+ info('📋 Test 1: Check Ollama availability...');
354
+ const available = await isAvailable();
355
+ if (!available) {
356
+ throw new Error('Ollama is not available');
357
+ }
358
+ info('✅ Ollama is available\n');
359
+
360
+ info('📋 Test 2: List available models...');
361
+ const models = await listModels();
362
+ info(`✅ Found ${models.length} models:`, models.join(', '), '\n');
363
+
364
+ info('📋 Test 3: Generate text...');
365
+ const text = await generate('What is 2+2?', {
366
+ temperature: 0.1,
367
+ max_tokens: 50,
368
+ });
369
+ info(`✅ Generated: ${text.trim()}\n`);
370
+
371
+ info('📋 Test 4: Generate JSON...');
372
+ const json = await generate('Return {"test": true, "value": 42}', {
373
+ format: 'json',
374
+ temperature: 0.1,
375
+ max_tokens: 50,
376
+ });
377
+ info('✅ Generated JSON:', json, '\n');
378
+
379
+ info('═══════════════════════════');
380
+ info('✅ All tests passed!');
381
+ info('═══════════════════════════');
382
+ } catch (error) {
383
+ logError(`❌ Test failed: ${error.message}`);
384
+ process.exit(1);
385
+ }
386
+ })();
387
+ }