morpheus-cli 0.1.4 → 0.1.6

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.
Files changed (40) hide show
  1. package/README.md +266 -211
  2. package/bin/morpheus.js +30 -0
  3. package/dist/channels/telegram.js +11 -3
  4. package/dist/cli/commands/config.js +18 -3
  5. package/dist/cli/commands/init.js +3 -3
  6. package/dist/cli/index.js +4 -0
  7. package/dist/config/mcp-loader.js +42 -0
  8. package/dist/config/schemas.js +16 -0
  9. package/dist/http/__tests__/auth.test.js +53 -0
  10. package/dist/http/api.js +12 -0
  11. package/dist/http/middleware/auth.js +23 -0
  12. package/dist/http/server.js +2 -1
  13. package/dist/runtime/__tests__/agent.test.js +8 -4
  14. package/dist/runtime/__tests__/agent_memory_limit.test.js +61 -0
  15. package/dist/runtime/__tests__/agent_persistence.test.js +1 -1
  16. package/dist/runtime/__tests__/manual_start_verify.js +4 -0
  17. package/dist/runtime/agent.js +84 -9
  18. package/dist/runtime/audio-agent.js +11 -1
  19. package/dist/runtime/display.js +12 -0
  20. package/dist/runtime/memory/sqlite.js +142 -9
  21. package/dist/runtime/providers/factory.js +50 -6
  22. package/dist/runtime/scaffold.js +5 -0
  23. package/dist/runtime/tools/__tests__/factory.test.js +42 -0
  24. package/dist/runtime/tools/__tests__/tools.test.js +127 -0
  25. package/dist/runtime/tools/analytics-tools.js +73 -0
  26. package/dist/runtime/tools/config-tools.js +70 -0
  27. package/dist/runtime/tools/diagnostic-tools.js +124 -0
  28. package/dist/runtime/tools/factory.js +78 -0
  29. package/dist/runtime/tools/index.js +4 -0
  30. package/dist/types/auth.js +4 -0
  31. package/dist/types/config.js +4 -0
  32. package/dist/types/mcp.js +11 -0
  33. package/dist/types/tools.js +2 -0
  34. package/dist/types/usage.js +1 -0
  35. package/dist/ui/assets/index-4kQpg2wK.js +50 -0
  36. package/dist/ui/assets/index-CwL7mn36.css +1 -0
  37. package/dist/ui/index.html +2 -2
  38. package/package.json +5 -2
  39. package/dist/ui/assets/index-Az60Fu0M.js +0 -50
  40. package/dist/ui/assets/index-nNle8n-Z.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { DEFAULT_CONFIG } from '../types/config.js';
3
3
  export const AudioConfigSchema = z.object({
4
+ provider: z.enum(['google']).default(DEFAULT_CONFIG.audio.provider),
4
5
  enabled: z.boolean().default(DEFAULT_CONFIG.audio.enabled),
5
6
  apiKey: z.string().optional(),
6
7
  maxDurationSeconds: z.number().default(DEFAULT_CONFIG.audio.maxDurationSeconds),
@@ -16,9 +17,13 @@ export const ConfigSchema = z.object({
16
17
  provider: z.enum(['openai', 'anthropic', 'ollama', 'gemini']).default(DEFAULT_CONFIG.llm.provider),
17
18
  model: z.string().min(1).default(DEFAULT_CONFIG.llm.model),
18
19
  temperature: z.number().min(0).max(1).default(DEFAULT_CONFIG.llm.temperature),
20
+ max_tokens: z.number().int().positive().optional(),
19
21
  api_key: z.string().optional(),
20
22
  }).default(DEFAULT_CONFIG.llm),
21
23
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
24
+ memory: z.object({
25
+ limit: z.number().int().positive().default(DEFAULT_CONFIG.memory.limit),
26
+ }).default(DEFAULT_CONFIG.memory),
22
27
  channels: z.object({
23
28
  telegram: z.object({
24
29
  enabled: z.boolean().default(false),
@@ -40,3 +45,14 @@ export const ConfigSchema = z.object({
40
45
  retention: z.string().default(DEFAULT_CONFIG.logging.retention),
41
46
  }).default(DEFAULT_CONFIG.logging),
42
47
  });
48
+ export const MCPServerConfigSchema = z.object({
49
+ transport: z.enum(['stdio', 'http']),
50
+ command: z.string().min(1, 'Command is required'),
51
+ args: z.array(z.string()).optional().default([]),
52
+ env: z.record(z.string(), z.string()).optional().default({}),
53
+ _comment: z.string().optional(),
54
+ });
55
+ export const MCPConfigFileSchema = z.record(z.string(), z.union([
56
+ MCPServerConfigSchema,
57
+ z.string(),
58
+ ]));
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import request from 'supertest';
3
+ import express from 'express';
4
+ import bodyParser from 'body-parser';
5
+ import { authMiddleware } from '../middleware/auth.js';
6
+ import { AUTH_HEADER } from '../../types/auth.js';
7
+ import { DisplayManager } from '../../runtime/display.js';
8
+ vi.mock('../../runtime/display.js');
9
+ describe('Auth Middleware', () => {
10
+ let app;
11
+ let mockDisplayManager;
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ mockDisplayManager = {
15
+ log: vi.fn(),
16
+ };
17
+ DisplayManager.getInstance.mockReturnValue(mockDisplayManager);
18
+ app = express();
19
+ app.use(bodyParser.json());
20
+ app.use(authMiddleware);
21
+ app.get('/test', (req, res) => res.status(200).json({ success: true }));
22
+ // Reset env var
23
+ delete process.env.THE_ARCHITECT_PASS;
24
+ });
25
+ it('should allow access when THE_ARCHITECT_PASS is not set', async () => {
26
+ const res = await request(app).get('/test');
27
+ expect(res.status).toBe(200);
28
+ expect(res.body.success).toBe(true);
29
+ });
30
+ it('should block access when THE_ARCHITECT_PASS is set and header is missing', async () => {
31
+ process.env.THE_ARCHITECT_PASS = 'secret123';
32
+ const res = await request(app).get('/test');
33
+ expect(res.status).toBe(401);
34
+ expect(res.body.error).toBe('Unauthorized');
35
+ expect(mockDisplayManager.log).toHaveBeenCalledWith(expect.stringContaining('Unauthorized'), expect.objectContaining({ source: 'http', level: 'warning' }));
36
+ });
37
+ it('should block access when THE_ARCHITECT_PASS is set and header is incorrect', async () => {
38
+ process.env.THE_ARCHITECT_PASS = 'secret123';
39
+ const res = await request(app)
40
+ .get('/test')
41
+ .set(AUTH_HEADER, 'wrongpass');
42
+ expect(res.status).toBe(401);
43
+ expect(mockDisplayManager.log).toHaveBeenCalled();
44
+ });
45
+ it('should allow access when THE_ARCHITECT_PASS is set and header matches', async () => {
46
+ process.env.THE_ARCHITECT_PASS = 'secret123';
47
+ const res = await request(app)
48
+ .get('/test')
49
+ .set(AUTH_HEADER, 'secret123');
50
+ expect(res.status).toBe(200);
51
+ expect(res.body.success).toBe(true);
52
+ });
53
+ });
package/dist/http/api.js CHANGED
@@ -4,6 +4,7 @@ import { PATHS } from '../config/paths.js';
4
4
  import { DisplayManager } from '../runtime/display.js';
5
5
  import fs from 'fs-extra';
6
6
  import path from 'path';
7
+ import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
7
8
  async function readLastLines(filePath, n) {
8
9
  try {
9
10
  const content = await fs.readFile(filePath, 'utf8');
@@ -39,6 +40,17 @@ export function createApiRouter() {
39
40
  router.get('/config', (req, res) => {
40
41
  res.json(configManager.get());
41
42
  });
43
+ router.get('/stats/usage', async (req, res) => {
44
+ try {
45
+ const history = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
46
+ const stats = await history.getGlobalUsageStats();
47
+ history.close();
48
+ res.json(stats);
49
+ }
50
+ catch (error) {
51
+ res.status(500).json({ error: error.message });
52
+ }
53
+ });
42
54
  // Calculate diff between two objects
43
55
  const getDiff = (obj1, obj2, prefix = '') => {
44
56
  const changes = [];
@@ -0,0 +1,23 @@
1
+ import { AUTH_HEADER } from '../../types/auth.js';
2
+ import { DisplayManager } from '../../runtime/display.js';
3
+ /**
4
+ * Middleware to protect API routes with a password from THE_ARCHITECT_PASS env var.
5
+ * If the env var is not set, authentication is skipped (open mode).
6
+ */
7
+ export const authMiddleware = (req, res, next) => {
8
+ const architectPass = process.env.THE_ARCHITECT_PASS;
9
+ // If password is not configured, allow all requests
10
+ if (!architectPass || architectPass.trim() === '') {
11
+ return next();
12
+ }
13
+ const providedPass = req.headers[AUTH_HEADER];
14
+ if (providedPass === architectPass) {
15
+ return next();
16
+ }
17
+ const display = DisplayManager.getInstance();
18
+ display.log(`Unauthorized access attempt to ${req.path}`, { source: 'http', level: 'warning' });
19
+ return res.status(401).json({
20
+ error: 'Unauthorized',
21
+ code: 'UNAUTHORIZED'
22
+ });
23
+ };
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
6
6
  import { ConfigManager } from '../config/manager.js';
7
7
  import { DisplayManager } from '../runtime/display.js';
8
8
  import { createApiRouter } from './api.js';
9
+ import { authMiddleware } from './middleware/auth.js';
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
11
12
  export class HttpServer {
@@ -21,7 +22,7 @@ export class HttpServer {
21
22
  this.app.use(bodyParser.json());
22
23
  }
23
24
  setupRoutes() {
24
- this.app.use('/api', createApiRouter());
25
+ this.app.use('/api', authMiddleware, createApiRouter());
25
26
  // Serve static frontend from compiled output
26
27
  const uiPath = path.resolve(__dirname, '../ui');
27
28
  this.app.use(express.static(uiPath));
@@ -1,12 +1,14 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { Agent } from '../agent.js';
3
3
  import { ProviderFactory } from '../providers/factory.js';
4
+ import { ToolsFactory } from '../tools/factory.js';
4
5
  import { DEFAULT_CONFIG } from '../../types/config.js';
5
6
  import { AIMessage } from '@langchain/core/messages';
6
7
  import * as fs from 'fs-extra';
7
8
  import * as path from 'path';
8
9
  import { homedir } from 'os';
9
10
  vi.mock('../providers/factory.js');
11
+ vi.mock('../tools/factory.js');
10
12
  describe('Agent', () => {
11
13
  let agent;
12
14
  const mockProvider = {
@@ -27,8 +29,9 @@ describe('Agent', () => {
27
29
  // Ignore errors if database doesn't exist or is corrupted
28
30
  }
29
31
  }
30
- mockProvider.invoke.mockResolvedValue(new AIMessage('Hello world'));
31
- vi.mocked(ProviderFactory.create).mockReturnValue(mockProvider);
32
+ mockProvider.invoke.mockResolvedValue({ messages: [new AIMessage('Hello world')] });
33
+ vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
34
+ vi.mocked(ToolsFactory.create).mockResolvedValue([]);
32
35
  agent = new Agent(DEFAULT_CONFIG);
33
36
  });
34
37
  afterEach(async () => {
@@ -44,7 +47,8 @@ describe('Agent', () => {
44
47
  });
45
48
  it('should initialize successfully', async () => {
46
49
  await agent.initialize();
47
- expect(ProviderFactory.create).toHaveBeenCalledWith(DEFAULT_CONFIG.llm);
50
+ expect(ToolsFactory.create).toHaveBeenCalled();
51
+ expect(ProviderFactory.create).toHaveBeenCalledWith(DEFAULT_CONFIG.llm, []);
48
52
  });
49
53
  it('should chat successfully', async () => {
50
54
  await agent.initialize();
@@ -67,7 +71,7 @@ describe('Agent', () => {
67
71
  expect(history1[1].content).toBe('Hello world'); // AI
68
72
  // Second turn
69
73
  // Update mock return value for next call
70
- mockProvider.invoke.mockResolvedValue(new AIMessage('I am fine'));
74
+ mockProvider.invoke.mockResolvedValue({ messages: [new AIMessage('I am fine')] });
71
75
  await agent.chat('How are you?');
72
76
  const history2 = await agent.getHistory();
73
77
  expect(history2).toHaveLength(4);
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Agent } from '../agent.js';
3
+ import { ProviderFactory } from '../providers/factory.js';
4
+ import { DEFAULT_CONFIG } from '../../types/config.js';
5
+ import { AIMessage } from '@langchain/core/messages';
6
+ import * as fs from 'fs-extra';
7
+ import * as path from 'path';
8
+ import { homedir } from 'os';
9
+ vi.mock('../providers/factory.js');
10
+ describe('Agent Memory Limit', () => {
11
+ let agent;
12
+ const mockProvider = {
13
+ invoke: vi.fn(),
14
+ };
15
+ const dbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
16
+ beforeEach(async () => {
17
+ vi.resetAllMocks();
18
+ // Clean up DB
19
+ if (fs.existsSync(dbPath)) {
20
+ try {
21
+ const Database = (await import("better-sqlite3")).default;
22
+ const db = new Database(dbPath);
23
+ db.exec("DELETE FROM messages");
24
+ db.close();
25
+ }
26
+ catch (err) { }
27
+ }
28
+ mockProvider.invoke.mockResolvedValue({
29
+ messages: [new AIMessage('Response')]
30
+ });
31
+ vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
32
+ });
33
+ afterEach(async () => {
34
+ if (agent) {
35
+ try {
36
+ await agent.clearMemory();
37
+ }
38
+ catch (err) { }
39
+ }
40
+ });
41
+ it('should respect configured memory limit', async () => {
42
+ const limitedConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
43
+ limitedConfig.memory.limit = 2; // Only last 2 messages (1 exchange)
44
+ agent = new Agent(limitedConfig);
45
+ await agent.initialize();
46
+ // Turn 1
47
+ await agent.chat('Msg 1');
48
+ // Turn 2
49
+ await agent.chat('Msg 2');
50
+ // Turn 3
51
+ await agent.chat('Msg 3');
52
+ // DB should have 6 messages (3 User + 3 AI)
53
+ // getHistory() should return only 2 (User Msg 3 + AI Response)
54
+ // Wait, SQLiteChatMessageHistory limit might be total messages? Or pairs?
55
+ // LangChain's limit usually means "last N messages".
56
+ const history = await agent.getHistory();
57
+ // Assuming limit=2 means 2 messages.
58
+ expect(history.length).toBeLessThanOrEqual(2);
59
+ expect(history[history.length - 1].content).toBe('Response');
60
+ });
61
+ });
@@ -35,7 +35,7 @@ describe("Agent Persistence Integration", () => {
35
35
  // Mock the SQLiteChatMessageHistory to use test path
36
36
  // We'll use the default ~/.morpheus path for this test
37
37
  mockProvider.invoke.mockResolvedValue(new AIMessage("Test response"));
38
- vi.mocked(ProviderFactory.create).mockReturnValue(mockProvider);
38
+ vi.mocked(ProviderFactory.create).mockResolvedValue(mockProvider);
39
39
  agent = new Agent(DEFAULT_CONFIG);
40
40
  });
41
41
  afterEach(async () => {
@@ -12,9 +12,13 @@ const mockConfig = {
12
12
  ui: { enabled: false, port: 3333 },
13
13
  logging: { enabled: false, level: 'info', retention: '1d' },
14
14
  audio: {
15
+ provider: 'google',
15
16
  enabled: false,
16
17
  maxDurationSeconds: 60,
17
18
  supportedMimeTypes: ['audio/ogg']
19
+ },
20
+ memory: {
21
+ limit: 100
18
22
  }
19
23
  };
20
24
  const run = async () => {
@@ -1,5 +1,6 @@
1
- import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";
1
+ import { HumanMessage, SystemMessage } from "@langchain/core/messages";
2
2
  import { ProviderFactory } from "./providers/factory.js";
3
+ import { ToolsFactory } from "./tools/factory.js";
3
4
  import { ConfigManager } from "../config/manager.js";
4
5
  import { ProviderError } from "./errors.js";
5
6
  import { DisplayManager } from "./display.js";
@@ -23,13 +24,15 @@ export class Agent {
23
24
  // Note: API Key validation is delegated to ProviderFactory or the Provider itself
24
25
  // to allow for Environment Variable fallback supported by LangChain.
25
26
  try {
26
- this.provider = ProviderFactory.create(this.config.llm);
27
+ const tools = await ToolsFactory.create();
28
+ this.provider = await ProviderFactory.create(this.config.llm, tools);
27
29
  if (!this.provider) {
28
30
  throw new Error("Provider factory returned undefined");
29
31
  }
30
32
  // Initialize persistent memory with SQLite
31
33
  this.history = new SQLiteChatMessageHistory({
32
34
  sessionId: "default",
35
+ limit: this.config.memory?.limit || 100, // Fallback purely defensive if config type allows optional
33
36
  });
34
37
  }
35
38
  catch (err) {
@@ -39,7 +42,7 @@ export class Agent {
39
42
  throw new ProviderError(this.config.llm.provider || 'unknown', err, "Agent initialization failed");
40
43
  }
41
44
  }
42
- async chat(message) {
45
+ async chat(message, extraUsage) {
43
46
  if (!this.provider) {
44
47
  throw new Error("Agent not initialized. Call initialize() first.");
45
48
  }
@@ -49,7 +52,70 @@ export class Agent {
49
52
  try {
50
53
  this.display.log('Processing message...', { source: 'Agent' });
51
54
  const userMessage = new HumanMessage(message);
52
- const systemMessage = new SystemMessage(`You are ${this.config.agent.name}, ${this.config.agent.personality}. You are a personal dev assistent.`);
55
+ // Attach extra usage (e.g. from Audio) to the user message to be persisted
56
+ if (extraUsage) {
57
+ userMessage.usage_metadata = extraUsage;
58
+ }
59
+ const systemMessage = new SystemMessage(`You are ${this.config.agent.name}, ${this.config.agent.personality},a local AI operator responsible for orchestrating tools, MCPs, and language models to solve the user’s request accurately and reliably.
60
+
61
+ Your primary responsibility is NOT to answer from memory when external tools are available.
62
+
63
+ You must follow these rules strictly:
64
+
65
+ 1. Tool Evaluation First
66
+ Before generating a final answer, always evaluate whether any available tool or MCP is capable of providing a more accurate, up-to-date, or authoritative response.
67
+
68
+ If a tool can provide the answer, you MUST call the tool.
69
+
70
+ 2. No Historical Assumptions for Dynamic Data
71
+ If the user asks something that:
72
+ - may change over time
73
+ - depends on system state
74
+ - depends on filesystem
75
+ - depends on external APIs
76
+ - was previously asked in the conversation
77
+
78
+ You MUST NOT reuse previous outputs as final truth.
79
+
80
+ Instead:
81
+ - Re-evaluate available tools
82
+ - Re-execute the relevant tool
83
+ - Provide a fresh result
84
+
85
+ Even if the user already asked the same question before, you must treat the request as requiring a new verification.
86
+
87
+ 3. History Is Context, Not Source of Truth
88
+ Conversation history may help with context, but it must not replace real-time verification via tools when tools are available.
89
+
90
+ Never assume:
91
+ - System state
92
+ - File contents
93
+ - Database values
94
+ - API responses
95
+ based only on previous messages.
96
+
97
+ 4. Tool Priority Over Language Guessing
98
+ If a tool can compute, fetch, inspect, or verify something, prefer tool usage over generating a speculative answer.
99
+
100
+ Never hallucinate values that could be retrieved through a tool.
101
+
102
+ 5. Freshness Principle
103
+ Repeated user queries require fresh validation.
104
+ Do not respond with:
105
+ "As I said before..."
106
+ Instead, perform a new tool check if applicable.
107
+
108
+ 6. Final Answer Policy
109
+ Only provide a direct natural language answer if:
110
+ - No tool is relevant
111
+ - Tools are unavailable
112
+ - The question is conceptual or explanatory
113
+
114
+ Otherwise, use tools first.
115
+
116
+ You are an operator, not a guesser.
117
+ Accuracy is more important than speed.
118
+ `);
53
119
  // Load existing history from database
54
120
  const previousMessages = await this.history.getMessages();
55
121
  const messages = [
@@ -57,13 +123,22 @@ export class Agent {
57
123
  ...previousMessages,
58
124
  userMessage
59
125
  ];
60
- const response = await this.provider.invoke(messages);
61
- const content = typeof response.content === 'string' ? response.content : JSON.stringify(response.content);
62
- // Persist messages to database
126
+ const response = await this.provider.invoke({ messages });
127
+ // Identify new messages generated during the interaction
128
+ // The `messages` array passed to invoke had length `messages.length`
129
+ // The `response.messages` contains the full state.
130
+ // New messages start after the inputs.
131
+ const startNewMessagesIndex = messages.length;
132
+ const newGeneratedMessages = response.messages.slice(startNewMessagesIndex);
133
+ // Persist User Message first
63
134
  await this.history.addMessage(userMessage);
64
- await this.history.addMessage(new AIMessage(content));
135
+ // Persist all new intermediate tool calls and responses
136
+ for (const msg of newGeneratedMessages) {
137
+ await this.history.addMessage(msg);
138
+ }
65
139
  this.display.log('Response generated.', { source: 'Agent' });
66
- return content;
140
+ const lastMessage = response.messages[response.messages.length - 1];
141
+ return (typeof lastMessage.content === 'string') ? lastMessage.content : JSON.stringify(lastMessage.content);
67
142
  }
68
143
  catch (err) {
69
144
  throw new ProviderError(this.config.llm.provider, err, "Chat request failed");
@@ -32,7 +32,17 @@ export class AudioAgent {
32
32
  if (!text) {
33
33
  throw new Error('No transcription generated');
34
34
  }
35
- return text;
35
+ // Extract usage metadata
36
+ const usage = response.usageMetadata;
37
+ const usageMetadata = {
38
+ input_tokens: usage?.promptTokenCount ?? 0,
39
+ output_tokens: usage?.candidatesTokenCount ?? 0,
40
+ total_tokens: usage?.totalTokenCount ?? 0,
41
+ input_token_details: {
42
+ cache_read: usage?.cachedContentTokenCount ?? 0
43
+ }
44
+ };
45
+ return { text, usage: usageMetadata };
36
46
  }
37
47
  catch (error) {
38
48
  // Wrap error for clarity
@@ -83,6 +83,18 @@ export class DisplayManager {
83
83
  else if (options.source === 'AgentAudio') {
84
84
  color = chalk.hex('#b902b9');
85
85
  }
86
+ else if (options.source === 'ToolsFactory') {
87
+ color = chalk.hex('#806d00');
88
+ }
89
+ else if (options.source === 'MCPServer') {
90
+ color = chalk.hex('#be4b1d');
91
+ }
92
+ else if (options.source === 'ToolCall') {
93
+ color = chalk.hex('#e5ff00');
94
+ }
95
+ else if (options.source === 'Config') {
96
+ color = chalk.hex('#00c3ff');
97
+ }
86
98
  prefix = color(`[${options.source}] `);
87
99
  }
88
100
  let formattedMessage = message;