0nmcp 2.7.0 → 2.9.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/engine/index.js CHANGED
@@ -51,6 +51,9 @@ export { TrainingFeedEngine, registerFeedTools, FEED_SOURCES } from "./training-
51
51
  // ── Multi-AI Council ────────────────────────────────────────
52
52
  export { registerCouncilTools, getAvailableProviders, askAll, PROVIDERS } from "./multi-ai.js";
53
53
 
54
+ // ── SXO Blog Writer Engine ──────────────────────────────────
55
+ export { registerSxoWriterTools, scoreContent, SXO_SYSTEM_PROMPT } from "./sxo-writer.js";
56
+
54
57
  // ── Imports for tool handlers ──────────────────────────────
55
58
  import { parseFile } from "./parser.js";
56
59
  import { mapEnvVars, groupByService, validateMapping } from "./mapper.js";
@@ -0,0 +1,439 @@
1
+ // ============================================================
2
+ // 0nMCP — Local AI Engine (0nAI ↔ Ollama/Llama)
3
+ // ============================================================
4
+ // Direct interface to local Llama models via Ollama.
5
+ // Zero cost, fully private, no data leaves the machine.
6
+ //
7
+ // 5 MCP Tools:
8
+ // ai_chat — Chat with local Llama (with optional brain)
9
+ // ai_generate — One-shot text generation
10
+ // ai_models — List/pull/manage local models
11
+ // ai_embed — Create embeddings locally
12
+ // ai_bench — Benchmark a prompt across local + cloud models
13
+ //
14
+ // Requires: Ollama running at localhost:11434
15
+ // ============================================================
16
+
17
+ const OLLAMA_BASE = process.env.OLLAMA_URL || "http://localhost:11434";
18
+ const DEFAULT_MODEL = process.env.OLLAMA_MODEL || "llama3.1";
19
+
20
+ /**
21
+ * Register local AI tools on an MCP server instance.
22
+ *
23
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
24
+ * @param {import("zod").ZodType} z
25
+ */
26
+ export function registerLocalAITools(server, z) {
27
+
28
+ // ─── ai_chat ──────────────────────────────────────────────
29
+ server.tool(
30
+ "ai_chat",
31
+ `Chat with your local Llama model. Zero cost, fully private.
32
+ Supports multi-turn conversation, system prompts, and .brain file loading.
33
+
34
+ Example: ai_chat({ message: "Explain MCP servers in simple terms" })
35
+ Example: ai_chat({ message: "What is 0nMCP?", system: "You are a tech expert" })
36
+ Example: ai_chat({ message: "Score this content", brain: "~/.0n/brains/sxo-writer.brain" })
37
+ Example: ai_chat({ message: "Continue from before", history: [...previous messages...] })
38
+ Example: ai_chat({ message: "Hello", model: "mistral" })`,
39
+ {
40
+ message: z.string().describe("Your message to the AI"),
41
+ system: z.string().optional().describe("System prompt (or use brain parameter)"),
42
+ brain: z.string().optional().describe("Path to .brain file to load as system prompt"),
43
+ model: z.string().optional().describe(`Model to use (default: ${DEFAULT_MODEL})`),
44
+ history: z.array(z.object({
45
+ role: z.enum(["user", "assistant", "system"]),
46
+ content: z.string(),
47
+ })).optional().describe("Previous conversation messages for context"),
48
+ temperature: z.number().optional().describe("Creativity (0-2, default: 0.7)"),
49
+ max_tokens: z.number().optional().describe("Max response length (default: 2048)"),
50
+ },
51
+ async ({ message, system, brain, model, history, temperature, max_tokens }) => {
52
+ try {
53
+ // Load brain file as system prompt if provided
54
+ let systemPrompt = system || "";
55
+ if (brain) {
56
+ const { readFileSync, existsSync } = await import("fs");
57
+ const { homedir } = await import("os");
58
+ const resolvedPath = brain.replace("~", homedir());
59
+ if (existsSync(resolvedPath)) {
60
+ const brainData = JSON.parse(readFileSync(resolvedPath, "utf-8"));
61
+ systemPrompt = brainData.prompt || "";
62
+ if (!systemPrompt && brainData.identity) {
63
+ // Compile on the fly
64
+ const { compileBrainToPrompt } = await import("./brain.js").catch(() => ({}));
65
+ if (compileBrainToPrompt) systemPrompt = compileBrainToPrompt(brainData);
66
+ }
67
+ }
68
+ }
69
+
70
+ // Build messages
71
+ const messages = [];
72
+ if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
73
+ if (history) messages.push(...history);
74
+ messages.push({ role: "user", content: message });
75
+
76
+ const startTime = Date.now();
77
+ const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({
81
+ model: model || DEFAULT_MODEL,
82
+ messages,
83
+ stream: false,
84
+ options: {
85
+ temperature: temperature ?? 0.7,
86
+ num_predict: max_tokens || 2048,
87
+ },
88
+ }),
89
+ });
90
+
91
+ if (!res.ok) {
92
+ const errText = await res.text();
93
+ throw new Error(`Ollama error ${res.status}: ${errText}`);
94
+ }
95
+
96
+ const data = await res.json();
97
+ const duration = Date.now() - startTime;
98
+ const response = data.message?.content || "";
99
+
100
+ return { content: [{ type: "text", text: JSON.stringify({
101
+ status: "ok",
102
+ model: data.model || model || DEFAULT_MODEL,
103
+ response,
104
+ stats: {
105
+ duration_ms: duration,
106
+ eval_count: data.eval_count,
107
+ eval_duration_ms: data.eval_duration ? Math.round(data.eval_duration / 1e6) : null,
108
+ tokens_per_second: data.eval_count && data.eval_duration
109
+ ? Math.round(data.eval_count / (data.eval_duration / 1e9) * 10) / 10
110
+ : null,
111
+ prompt_tokens: data.prompt_eval_count,
112
+ total_duration_ms: data.total_duration ? Math.round(data.total_duration / 1e6) : duration,
113
+ },
114
+ provider: "ollama-local",
115
+ cost: "$0.00",
116
+ }, null, 2) }] };
117
+ } catch (err) {
118
+ if (err.message?.includes("ECONNREFUSED")) {
119
+ return { content: [{ type: "text", text: JSON.stringify({
120
+ status: "offline",
121
+ error: "Ollama is not running. Start it with: ollama serve",
122
+ help: "Install: https://ollama.com | Pull a model: ollama pull llama3.1",
123
+ }) }] };
124
+ }
125
+ return { content: [{ type: "text", text: JSON.stringify({ status: "failed", error: err.message }) }] };
126
+ }
127
+ }
128
+ );
129
+
130
+ // ─── ai_generate ──────────────────────────────────────────
131
+ server.tool(
132
+ "ai_generate",
133
+ `One-shot text generation with local Llama. No conversation context.
134
+ Good for: summarization, code generation, analysis, content creation.
135
+
136
+ Example: ai_generate({ prompt: "Write a Python function to sort a list" })
137
+ Example: ai_generate({ prompt: "Summarize this: ...", model: "llama3.1" })`,
138
+ {
139
+ prompt: z.string().describe("The prompt to generate from"),
140
+ model: z.string().optional().describe(`Model (default: ${DEFAULT_MODEL})`),
141
+ system: z.string().optional().describe("System prompt"),
142
+ temperature: z.number().optional().describe("Creativity (0-2, default: 0.7)"),
143
+ max_tokens: z.number().optional().describe("Max tokens (default: 2048)"),
144
+ format: z.enum(["text", "json"]).optional().describe("Response format"),
145
+ },
146
+ async ({ prompt, model, system, temperature, max_tokens, format }) => {
147
+ try {
148
+ const startTime = Date.now();
149
+ const body = {
150
+ model: model || DEFAULT_MODEL,
151
+ prompt,
152
+ stream: false,
153
+ options: {
154
+ temperature: temperature ?? 0.7,
155
+ num_predict: max_tokens || 2048,
156
+ },
157
+ };
158
+ if (system) body.system = system;
159
+ if (format === "json") body.format = "json";
160
+
161
+ const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify(body),
165
+ });
166
+
167
+ if (!res.ok) throw new Error(`Ollama error ${res.status}: ${await res.text()}`);
168
+
169
+ const data = await res.json();
170
+ const duration = Date.now() - startTime;
171
+
172
+ return { content: [{ type: "text", text: JSON.stringify({
173
+ status: "ok",
174
+ model: data.model || model || DEFAULT_MODEL,
175
+ response: data.response || "",
176
+ stats: {
177
+ duration_ms: duration,
178
+ eval_count: data.eval_count,
179
+ tokens_per_second: data.eval_count && data.eval_duration
180
+ ? Math.round(data.eval_count / (data.eval_duration / 1e9) * 10) / 10
181
+ : null,
182
+ },
183
+ cost: "$0.00",
184
+ }, null, 2) }] };
185
+ } catch (err) {
186
+ return { content: [{ type: "text", text: JSON.stringify({ status: "failed", error: err.message }) }] };
187
+ }
188
+ }
189
+ );
190
+
191
+ // ─── ai_models ────────────────────────────────────────────
192
+ server.tool(
193
+ "ai_models",
194
+ `Manage local AI models. List installed, pull new ones, get model info.
195
+
196
+ Example: ai_models({ action: "list" })
197
+ Example: ai_models({ action: "pull", name: "mistral" })
198
+ Example: ai_models({ action: "info", name: "llama3.1" })
199
+ Example: ai_models({ action: "running" })`,
200
+ {
201
+ action: z.enum(["list", "pull", "info", "delete", "running"]).describe("Action to perform"),
202
+ name: z.string().optional().describe("Model name (for pull/info/delete)"),
203
+ },
204
+ async ({ action, name }) => {
205
+ try {
206
+ switch (action) {
207
+ case "list": {
208
+ const res = await fetch(`${OLLAMA_BASE}/api/tags`);
209
+ const data = await res.json();
210
+ const models = (data.models || []).map(m => ({
211
+ name: m.name,
212
+ size_gb: Math.round(m.size / 1e9 * 10) / 10,
213
+ modified: m.modified_at,
214
+ family: m.details?.family,
215
+ parameters: m.details?.parameter_size,
216
+ quantization: m.details?.quantization_level,
217
+ }));
218
+ return { content: [{ type: "text", text: JSON.stringify({
219
+ status: "ok", count: models.length, models,
220
+ message: models.length === 0
221
+ ? "No models installed. Pull one with: ai_models({ action: 'pull', name: 'llama3.1' })"
222
+ : `${models.length} model(s) installed locally.`,
223
+ }, null, 2) }] };
224
+ }
225
+
226
+ case "running": {
227
+ const res = await fetch(`${OLLAMA_BASE}/api/ps`);
228
+ const data = await res.json();
229
+ return { content: [{ type: "text", text: JSON.stringify({
230
+ status: "ok",
231
+ running: (data.models || []).map(m => ({
232
+ name: m.name, size_gb: Math.round(m.size / 1e9 * 10) / 10,
233
+ expires: m.expires_at,
234
+ })),
235
+ }, null, 2) }] };
236
+ }
237
+
238
+ case "info": {
239
+ if (!name) throw new Error("Model name required");
240
+ const res = await fetch(`${OLLAMA_BASE}/api/show`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({ name }),
244
+ });
245
+ const data = await res.json();
246
+ return { content: [{ type: "text", text: JSON.stringify({
247
+ status: "ok", model: name,
248
+ details: data.details,
249
+ parameters: data.model_info,
250
+ template: data.template,
251
+ system: data.system?.substring(0, 200),
252
+ license: data.license?.substring(0, 200),
253
+ }, null, 2) }] };
254
+ }
255
+
256
+ case "pull": {
257
+ if (!name) throw new Error("Model name required");
258
+ const res = await fetch(`${OLLAMA_BASE}/api/pull`, {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({ name, stream: false }),
262
+ });
263
+ const data = await res.json();
264
+ return { content: [{ type: "text", text: JSON.stringify({
265
+ status: data.status || "pulling",
266
+ model: name,
267
+ message: `Model "${name}" ${data.status || "pull initiated"}. This may take a while for large models.`,
268
+ }, null, 2) }] };
269
+ }
270
+
271
+ case "delete": {
272
+ if (!name) throw new Error("Model name required");
273
+ const res = await fetch(`${OLLAMA_BASE}/api/delete`, {
274
+ method: "DELETE",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify({ name }),
277
+ });
278
+ return { content: [{ type: "text", text: JSON.stringify({
279
+ status: res.ok ? "deleted" : "failed",
280
+ model: name,
281
+ }) }] };
282
+ }
283
+ }
284
+ } catch (err) {
285
+ return { content: [{ type: "text", text: JSON.stringify({ status: "failed", error: err.message }) }] };
286
+ }
287
+ }
288
+ );
289
+
290
+ // ─── ai_embed ─────────────────────────────────────────────
291
+ server.tool(
292
+ "ai_embed",
293
+ `Create text embeddings locally using Ollama. Free, private.
294
+ Useful for: semantic search, similarity matching, RAG pipelines.
295
+
296
+ Example: ai_embed({ text: "What is MCP?" })
297
+ Example: ai_embed({ text: ["Hello world", "Hi there"], model: "llama3.1" })`,
298
+ {
299
+ text: z.union([z.string(), z.array(z.string())]).describe("Text(s) to embed"),
300
+ model: z.string().optional().describe(`Model (default: ${DEFAULT_MODEL})`),
301
+ },
302
+ async ({ text, model }) => {
303
+ try {
304
+ const input = Array.isArray(text) ? text : [text];
305
+ const res = await fetch(`${OLLAMA_BASE}/api/embed`, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify({ model: model || DEFAULT_MODEL, input }),
309
+ });
310
+ if (!res.ok) throw new Error(`Ollama error ${res.status}`);
311
+ const data = await res.json();
312
+
313
+ return { content: [{ type: "text", text: JSON.stringify({
314
+ status: "ok",
315
+ model: data.model || model || DEFAULT_MODEL,
316
+ count: (data.embeddings || []).length,
317
+ dimensions: data.embeddings?.[0]?.length || 0,
318
+ embeddings: (data.embeddings || []).map((e, i) => ({
319
+ text: input[i]?.substring(0, 50) + (input[i]?.length > 50 ? "..." : ""),
320
+ vector_preview: e.slice(0, 5),
321
+ dimensions: e.length,
322
+ })),
323
+ cost: "$0.00",
324
+ }, null, 2) }] };
325
+ } catch (err) {
326
+ return { content: [{ type: "text", text: JSON.stringify({ status: "failed", error: err.message }) }] };
327
+ }
328
+ }
329
+ );
330
+
331
+ // ─── ai_bench ─────────────────────────────────────────────
332
+ server.tool(
333
+ "ai_bench",
334
+ `Benchmark a prompt across local Llama AND cloud models side by side.
335
+ Compare speed, quality, and cost. Cloud calls use real API keys (cost money).
336
+
337
+ Example: ai_bench({ prompt: "Explain quantum computing in one paragraph" })
338
+ Example: ai_bench({ prompt: "Write a haiku about AI", providers: ["local", "anthropic"] })`,
339
+ {
340
+ prompt: z.string().describe("Prompt to benchmark"),
341
+ system: z.string().optional().describe("System prompt"),
342
+ providers: z.array(z.enum(["local", "anthropic", "openai", "gemini"])).optional()
343
+ .describe("Providers to test (default: [local]). WARNING: cloud providers cost money."),
344
+ },
345
+ async ({ prompt, system, providers }) => {
346
+ const targets = providers || ["local"];
347
+ const results = [];
348
+
349
+ for (const provider of targets) {
350
+ const start = Date.now();
351
+ let response = "";
352
+ let tokens = 0;
353
+ let cost = "$0.00";
354
+ let error = null;
355
+
356
+ try {
357
+ if (provider === "local") {
358
+ const body = { model: DEFAULT_MODEL, prompt, stream: false, options: { temperature: 0.7, num_predict: 512 } };
359
+ if (system) body.system = system;
360
+ const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
361
+ method: "POST",
362
+ headers: { "Content-Type": "application/json" },
363
+ body: JSON.stringify(body),
364
+ });
365
+ const data = await res.json();
366
+ response = data.response || "";
367
+ tokens = data.eval_count || 0;
368
+ }
369
+
370
+ else if (provider === "anthropic") {
371
+ if (!process.env.ANTHROPIC_API_KEY) throw new Error("No ANTHROPIC_API_KEY");
372
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
373
+ const client = new Anthropic();
374
+ const msg = await client.messages.create({
375
+ model: "claude-sonnet-4-20250514",
376
+ max_tokens: 512,
377
+ system: system || undefined,
378
+ messages: [{ role: "user", content: prompt }],
379
+ });
380
+ response = msg.content[0]?.text || "";
381
+ tokens = (msg.usage?.input_tokens || 0) + (msg.usage?.output_tokens || 0);
382
+ cost = `~$${(tokens * 0.000003).toFixed(4)}`;
383
+ }
384
+
385
+ else if (provider === "openai") {
386
+ if (!process.env.OPENAI_API_KEY) throw new Error("No OPENAI_API_KEY");
387
+ const messages = [];
388
+ if (system) messages.push({ role: "system", content: system });
389
+ messages.push({ role: "user", content: prompt });
390
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
391
+ method: "POST",
392
+ headers: { "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
393
+ body: JSON.stringify({ model: "gpt-4o", messages, max_tokens: 512 }),
394
+ });
395
+ const data = await res.json();
396
+ response = data.choices?.[0]?.message?.content || "";
397
+ tokens = (data.usage?.total_tokens || 0);
398
+ cost = `~$${(tokens * 0.000005).toFixed(4)}`;
399
+ }
400
+
401
+ else if (provider === "gemini") {
402
+ if (!process.env.GEMINI_API_KEY) throw new Error("No GEMINI_API_KEY");
403
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${process.env.GEMINI_API_KEY}`;
404
+ const body = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: 512 } };
405
+ if (system) body.system_instruction = { parts: [{ text: system }] };
406
+ const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
407
+ const data = await res.json();
408
+ response = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
409
+ tokens = data.usageMetadata?.totalTokenCount || 0;
410
+ cost = `~$${(tokens * 0.0000001).toFixed(6)}`;
411
+ }
412
+ } catch (e) {
413
+ error = e.message;
414
+ }
415
+
416
+ const duration = Date.now() - start;
417
+ results.push({
418
+ provider,
419
+ model: provider === "local" ? DEFAULT_MODEL : provider === "anthropic" ? "claude-sonnet" : provider === "openai" ? "gpt-4o" : "gemini-2.0-flash",
420
+ response: response.substring(0, 300) + (response.length > 300 ? "..." : ""),
421
+ response_length: response.length,
422
+ duration_ms: duration,
423
+ tokens,
424
+ tokens_per_second: tokens > 0 && duration > 0 ? Math.round(tokens / (duration / 1000)) : null,
425
+ cost,
426
+ error,
427
+ });
428
+ }
429
+
430
+ return { content: [{ type: "text", text: JSON.stringify({
431
+ status: "ok",
432
+ prompt: prompt.substring(0, 100),
433
+ results,
434
+ fastest: results.filter(r => !r.error).sort((a, b) => a.duration_ms - b.duration_ms)[0]?.provider || "none",
435
+ cheapest: results.filter(r => !r.error).sort((a, b) => parseFloat(a.cost.replace(/[^0-9.]/g, "")) - parseFloat(b.cost.replace(/[^0-9.]/g, "")))[0]?.provider || "local",
436
+ }, null, 2) }] };
437
+ }
438
+ );
439
+ }