morpheus-cli 0.2.8 → 0.3.1

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
@@ -97,6 +97,14 @@ Morpheus is built with **Node.js** and **TypeScript**, using **LangChain** as th
97
97
  ### 🖥️ Web Dashboard
98
98
  Local React-based UI to manage recordings, chat history, and system status across your agent instances.
99
99
 
100
+ **New: Interactive Web Chat**
101
+ - Full-featured chat interface accessible from the browser
102
+ - Session management: create, archive, delete, and rename sessions
103
+ - Cross-channel visibility: view and interact with sessions started on any channel (Telegram, Web, etc.)
104
+ - Real-time messaging with the Oracle agent
105
+ - Responsive design with collapsible sidebar
106
+ - Full support for Light and Dark (Matrix) themes
107
+
100
108
  #### 🔒 UI Authentication
101
109
  To protect your Web UI, use the `THE_ARCHITECT_PASS` environment variable. This ensures only authorized users can access the dashboard and API.
102
110
 
@@ -125,13 +133,15 @@ The system also supports generic environment variables that apply to all provide
125
133
  | `MORPHEUS_LLM_MAX_TOKENS` | Maximum tokens for LLM | llm.max_tokens |
126
134
  | `MORPHEUS_LLM_CONTEXT_WINDOW` | Context window size for LLM | llm.context_window |
127
135
  | `MORPHEUS_LLM_API_KEY` | Generic API key for LLM (lower precedence than provider-specific keys) | llm.api_key |
128
- | `MORPHEUS_SANTI_PROVIDER` | Sati provider to use | santi.provider |
129
- | `MORPHEUS_SANTI_MODEL` | Model name for Sati | santi.model |
130
- | `MORPHEUS_SANTI_TEMPERATURE` | Temperature setting for Sati | santi.temperature |
131
- | `MORPHEUS_SANTI_MAX_TOKENS` | Maximum tokens for Sati | santi.max_tokens |
132
- | `MORPHEUS_SANTI_CONTEXT_WINDOW` | Context window size for Sati | santi.context_window |
133
- | `MORPHEUS_SANTI_API_KEY` | Generic API key for Sati (lower precedence than provider-specific keys) | santi.api_key |
134
- | `MORPHEUS_SANTI_MEMORY_LIMIT` | Memory retrieval limit for Sati | santi.memory_limit |
136
+ | `MORPHEUS_SATI_PROVIDER` | Sati provider to use | santi.provider |
137
+ | `MORPHEUS_SATI_MODEL` | Model name for Sati | santi.model |
138
+ | `MORPHEUS_SATI_TEMPERATURE` | Temperature setting for Sati | santi.temperature |
139
+ | `MORPHEUS_SATI_MAX_TOKENS` | Maximum tokens for Sati | santi.max_tokens |
140
+ | `MORPHEUS_SATI_CONTEXT_WINDOW` | Context window size for Sati | santi.context_window |
141
+ | `MORPHEUS_SATI_API_KEY` | Generic API key for Sati (lower precedence than provider-specific keys) | santi.api_key |
142
+ | `MORPHEUS_SATI_MEMORY_LIMIT` | Memory retrieval limit for Sati | santi.memory_limit |
143
+ | `MORPHEUS_SATI_MEMORY_LIMIT` | Memory retrieval limit for Sati | santi.memory_limit |
144
+ | `MORPHEUS_SATI_ENABLED_ARCHIVED_SESSIONS`| Enable/disable retrieval of archived sessions in Sati | santi.enableArchivedSessions |
135
145
  | `MORPHEUS_AUDIO_MODEL` | Model name for audio processing | audio.model |
136
146
  | `MORPHEUS_AUDIO_ENABLED` | Enable/disable audio processing | audio.enabled |
137
147
  | `MORPHEUS_AUDIO_API_KEY` | Generic API key for audio (lower precedence than provider-specific keys) | audio.apiKey |
@@ -214,7 +224,7 @@ The Morpheus Telegram bot supports several commands for interacting with the age
214
224
  - `/zaion` - Show system configurations
215
225
  - `/sati <qnt>` - Show specific memories
216
226
  - `/newsession` - Archive current session and start fresh
217
- - `/sessions` - List all sessions and switch between them
227
+ - `/sessions` - List all sessions with options to switch, archive, or delete
218
228
  - `/restart` - Restart the Morpheus agent
219
229
  - `/mcp` or `/mcps` - List registered MCP servers
220
230
 
@@ -187,9 +187,84 @@ export class TelegramAdapter {
187
187
  ctx.reply(`✅ Switched to session ID: ${sessionId}`);
188
188
  }
189
189
  catch (error) {
190
- await ctx.answerCbQuery(`Error switching session: ${error.message}`);
190
+ await ctx.answerCbQuery(`Error switching session: ${error.message}`, { show_alert: true });
191
191
  }
192
192
  });
193
+ // --- Archive Flow ---
194
+ this.bot.action(/^ask_archive_session_/, async (ctx) => {
195
+ const data = ctx.callbackQuery.data;
196
+ const sessionId = data.replace('ask_archive_session_', '');
197
+ // Fetch session title for better UX (optional, but nice) - for now just use ID
198
+ await ctx.reply(`⚠️ **ARCHIVE SESSION?**\n\nAre you sure you want to archive session \`${sessionId}\`?\n\nIt will be moved to long-term memory (SATI) and removed from the active list. This action cannot be easily undone via Telegram.`, {
199
+ parse_mode: 'Markdown',
200
+ reply_markup: {
201
+ inline_keyboard: [
202
+ [
203
+ { text: '✅ Yes, Archive', callback_data: `confirm_archive_session_${sessionId}` },
204
+ { text: '❌ Cancel', callback_data: 'cancel_session_action' }
205
+ ]
206
+ ]
207
+ }
208
+ });
209
+ await ctx.answerCbQuery();
210
+ });
211
+ this.bot.action(/^confirm_archive_session_/, async (ctx) => {
212
+ const data = ctx.callbackQuery.data;
213
+ const sessionId = data.replace('confirm_archive_session_', '');
214
+ try {
215
+ const history = new SQLiteChatMessageHistory({ sessionId: "" });
216
+ await history.archiveSession(sessionId);
217
+ await ctx.answerCbQuery('Session archived successfully');
218
+ if (ctx.updateType === 'callback_query') {
219
+ ctx.deleteMessage().catch(() => { });
220
+ }
221
+ await ctx.reply(`✅ Session \`${sessionId}\` has been archived and moved to long-term memory.`, { parse_mode: 'Markdown' });
222
+ }
223
+ catch (error) {
224
+ await ctx.answerCbQuery(`Error archiving: ${error.message}`, { show_alert: true });
225
+ }
226
+ });
227
+ // --- Delete Flow ---
228
+ this.bot.action(/^ask_delete_session_/, async (ctx) => {
229
+ const data = ctx.callbackQuery.data;
230
+ const sessionId = data.replace('ask_delete_session_', '');
231
+ await ctx.reply(`🚫 **DELETE SESSION?**\n\nAre you sure you want to PERMANENTLY DELETE session \`${sessionId}\`?\n\nThis action is **IRREVERSIBLE**. All data will be lost.`, {
232
+ parse_mode: 'Markdown',
233
+ reply_markup: {
234
+ inline_keyboard: [
235
+ [
236
+ { text: '🗑️ Yes, DELETE PERMANENTLY', callback_data: `confirm_delete_session_${sessionId}` },
237
+ { text: '❌ Cancel', callback_data: 'cancel_session_action' }
238
+ ]
239
+ ]
240
+ }
241
+ });
242
+ await ctx.answerCbQuery();
243
+ });
244
+ this.bot.action(/^confirm_delete_session_/, async (ctx) => {
245
+ const data = ctx.callbackQuery.data;
246
+ const sessionId = data.replace('confirm_delete_session_', '');
247
+ try {
248
+ const history = new SQLiteChatMessageHistory({ sessionId: "" });
249
+ await history.deleteSession(sessionId);
250
+ await ctx.answerCbQuery('Session deleted successfully');
251
+ if (ctx.updateType === 'callback_query') {
252
+ ctx.deleteMessage().catch(() => { });
253
+ }
254
+ await ctx.reply(`🗑️ Session \`${sessionId}\` has been permanently deleted.`, { parse_mode: 'Markdown' });
255
+ }
256
+ catch (error) {
257
+ await ctx.answerCbQuery(`Error deleting: ${error.message}`, { show_alert: true });
258
+ }
259
+ });
260
+ // --- Cancel Action ---
261
+ this.bot.action('cancel_session_action', async (ctx) => {
262
+ await ctx.answerCbQuery('Action cancelled');
263
+ if (ctx.updateType === 'callback_query') {
264
+ ctx.deleteMessage().catch(() => { });
265
+ }
266
+ await ctx.reply('Action cancelled.');
267
+ });
193
268
  this.bot.launch().catch((err) => {
194
269
  if (this.isConnected) {
195
270
  this.display.log(`Telegram bot error: ${err}`, { source: 'Telegram', level: 'error' });
@@ -331,12 +406,22 @@ export class TelegramAdapter {
331
406
  response += `- Status: ${session.status}\n`;
332
407
  response += `- Started: ${new Date(session.started_at).toLocaleString()}\n\n`;
333
408
  // Adicionar botão inline para alternar para esta sessão
409
+ const sessionButtons = [];
334
410
  if (session.status !== 'active') {
335
- keyboard.push([{
336
- text: `Switch to: ${title}`,
337
- callback_data: `switch_session_${session.id}`
338
- }]);
411
+ sessionButtons.push({
412
+ text: `➡️ Switch`,
413
+ callback_data: `switch_session_${session.id}`
414
+ });
339
415
  }
416
+ sessionButtons.push({
417
+ text: `📂 Archive`,
418
+ callback_data: `ask_archive_session_${session.id}`
419
+ });
420
+ sessionButtons.push({
421
+ text: `🗑️ Delete`,
422
+ callback_data: `ask_delete_session_${session.id}`
423
+ });
424
+ keyboard.push(sessionButtons);
340
425
  }
341
426
  await ctx.reply(response, {
342
427
  parse_mode: 'Markdown',
@@ -535,6 +620,7 @@ How can I assist you today?`;
535
620
  async handleRestartCommand(ctx, user) {
536
621
  // Store the user ID who requested the restart
537
622
  const userId = ctx.from.id;
623
+ const updateId = ctx.update.update_id;
538
624
  // Save the user ID to a temporary file so the restarted process can notify them
539
625
  const restartNotificationFile = path.join(os.tmpdir(), 'morpheus_restart_notification.json');
540
626
  try {
@@ -545,6 +631,15 @@ How can I assist you today?`;
545
631
  }
546
632
  // Respond to the user first
547
633
  await ctx.reply('🔄 Restart initiated. The Morpheus agent will restart shortly.');
634
+ // Acknowledge this update to Telegram by advancing the offset past it.
635
+ // Without this, Telegraf's in-memory offset is lost on process.exit() and the
636
+ // /restart message gets re-delivered on the next startup, causing an infinite loop.
637
+ try {
638
+ await ctx.telegram.callApi('getUpdates', { offset: updateId + 1, limit: 1, timeout: 0 });
639
+ }
640
+ catch (e) {
641
+ // Best-effort — proceed with restart regardless
642
+ }
548
643
  // Schedule the restart after a short delay to ensure the response is sent
549
644
  setTimeout(() => {
550
645
  // Stop the bot to prevent processing more messages
@@ -564,7 +659,7 @@ How can I assist you today?`;
564
659
  restartProcess.unref();
565
660
  // Exit the current process
566
661
  process.exit(0);
567
- }, 500); // Shorter delay to minimize chance of processing more messages
662
+ }, 500);
568
663
  }
569
664
  async checkAndSendRestartNotification() {
570
665
  const restartNotificationFile = path.join(os.tmpdir(), 'morpheus_restart_notification.json');
@@ -49,7 +49,7 @@ export const doctorCommand = new Command('doctor')
49
49
  }
50
50
  // Check API keys availability for active providers
51
51
  const llmProvider = config.llm?.provider;
52
- const santiProvider = config.santi?.provider;
52
+ const satiProvider = config.sati?.provider;
53
53
  // Check LLM provider API key
54
54
  if (llmProvider && llmProvider !== 'ollama') {
55
55
  const hasLlmApiKey = config.llm?.api_key ||
@@ -65,18 +65,18 @@ export const doctorCommand = new Command('doctor')
65
65
  allPassed = false;
66
66
  }
67
67
  }
68
- // Check Santi provider API key
69
- if (santiProvider && santiProvider !== 'ollama') {
70
- const hasSantiApiKey = config.santi?.api_key ||
71
- (santiProvider === 'openai' && process.env.OPENAI_API_KEY) ||
72
- (santiProvider === 'anthropic' && process.env.ANTHROPIC_API_KEY) ||
73
- (santiProvider === 'gemini' && process.env.GOOGLE_API_KEY) ||
74
- (santiProvider === 'openrouter' && process.env.OPENROUTER_API_KEY);
68
+ // Check Sati provider API key
69
+ if (satiProvider && satiProvider !== 'ollama') {
70
+ const hasSantiApiKey = config.sati?.api_key ||
71
+ (satiProvider === 'openai' && process.env.OPENAI_API_KEY) ||
72
+ (satiProvider === 'anthropic' && process.env.ANTHROPIC_API_KEY) ||
73
+ (satiProvider === 'gemini' && process.env.GOOGLE_API_KEY) ||
74
+ (satiProvider === 'openrouter' && process.env.OPENROUTER_API_KEY);
75
75
  if (hasSantiApiKey) {
76
- console.log(chalk.green('✓') + ` Santi API key available for ${santiProvider}`);
76
+ console.log(chalk.green('✓') + ` Sati API key available for ${satiProvider}`);
77
77
  }
78
78
  else {
79
- console.log(chalk.red('✗') + ` Santi API key missing for ${santiProvider}. Either set in config or define environment variable.`);
79
+ console.log(chalk.red('✗') + ` Sati API key missing for ${satiProvider}. Either set in config or define environment variable.`);
80
80
  allPassed = false;
81
81
  }
82
82
  }
@@ -117,15 +117,15 @@ export const initCommand = new Command('init')
117
117
  ],
118
118
  default: 'no',
119
119
  });
120
- let santiProvider = provider;
121
- let santiModel = model;
122
- let santiApiKey = apiKey;
120
+ let satiProvider = provider;
121
+ let satiModel = model;
122
+ let satiApiKey = apiKey;
123
123
  // If using main settings and no new key provided, use existing if available
124
- if (configureSati === 'no' && !santiApiKey && hasExistingKey) {
125
- santiApiKey = currentConfig.llm.api_key;
124
+ if (configureSati === 'no' && !satiApiKey && hasExistingKey) {
125
+ satiApiKey = currentConfig.llm.api_key;
126
126
  }
127
127
  if (configureSati === 'yes') {
128
- santiProvider = await select({
128
+ satiProvider = await select({
129
129
  message: 'Select Sati LLM Provider:',
130
130
  choices: [
131
131
  { name: 'OpenAI', value: 'openai' },
@@ -134,10 +134,10 @@ export const initCommand = new Command('init')
134
134
  { name: 'Ollama', value: 'ollama' },
135
135
  { name: 'Google Gemini', value: 'gemini' },
136
136
  ],
137
- default: currentConfig.santi?.provider || provider,
137
+ default: currentConfig.sati?.provider || provider,
138
138
  });
139
139
  let defaultSatiModel = 'gpt-3.5-turbo';
140
- switch (santiProvider) {
140
+ switch (satiProvider) {
141
141
  case 'openai':
142
142
  defaultSatiModel = 'gpt-4o';
143
143
  break;
@@ -154,59 +154,59 @@ export const initCommand = new Command('init')
154
154
  defaultSatiModel = 'gemini-pro';
155
155
  break;
156
156
  }
157
- if (santiProvider === currentConfig.santi?.provider) {
158
- defaultSatiModel = currentConfig.santi?.model || defaultSatiModel;
157
+ if (satiProvider === currentConfig.sati?.provider) {
158
+ defaultSatiModel = currentConfig.sati?.model || defaultSatiModel;
159
159
  }
160
- santiModel = await input({
160
+ satiModel = await input({
161
161
  message: 'Enter Sati Model Name:',
162
162
  default: defaultSatiModel,
163
163
  });
164
- const hasExistingSatiKey = !!currentConfig.santi?.api_key;
165
- let santiKeyMsg = hasExistingSatiKey
164
+ const hasExistingSatiKey = !!currentConfig.sati?.api_key;
165
+ let satiKeyMsg = hasExistingSatiKey
166
166
  ? 'Enter Sati API Key (leave empty to preserve existing, or if using env vars):'
167
167
  : 'Enter Sati API Key (leave empty if using env vars):';
168
168
  // Add info about environment variables to the message
169
- if (santiProvider === 'openai') {
170
- santiKeyMsg = `${santiKeyMsg} (Env var: OPENAI_API_KEY)`;
169
+ if (satiProvider === 'openai') {
170
+ satiKeyMsg = `${satiKeyMsg} (Env var: OPENAI_API_KEY)`;
171
171
  }
172
- else if (santiProvider === 'anthropic') {
173
- santiKeyMsg = `${santiKeyMsg} (Env var: ANTHROPIC_API_KEY)`;
172
+ else if (satiProvider === 'anthropic') {
173
+ satiKeyMsg = `${satiKeyMsg} (Env var: ANTHROPIC_API_KEY)`;
174
174
  }
175
- else if (santiProvider === 'gemini') {
176
- santiKeyMsg = `${santiKeyMsg} (Env var: GOOGLE_API_KEY)`;
175
+ else if (satiProvider === 'gemini') {
176
+ satiKeyMsg = `${satiKeyMsg} (Env var: GOOGLE_API_KEY)`;
177
177
  }
178
- else if (santiProvider === 'openrouter') {
179
- santiKeyMsg = `${santiKeyMsg} (Env var: OPENROUTER_API_KEY)`;
178
+ else if (satiProvider === 'openrouter') {
179
+ satiKeyMsg = `${satiKeyMsg} (Env var: OPENROUTER_API_KEY)`;
180
180
  }
181
- const keyInput = await password({ message: santiKeyMsg });
181
+ const keyInput = await password({ message: satiKeyMsg });
182
182
  if (keyInput) {
183
- santiApiKey = keyInput;
183
+ satiApiKey = keyInput;
184
184
  }
185
185
  else if (hasExistingSatiKey) {
186
- santiApiKey = currentConfig.santi?.api_key;
186
+ satiApiKey = currentConfig.sati?.api_key;
187
187
  }
188
188
  else {
189
- santiApiKey = undefined; // Ensure we don't accidentally carry over invalid state
189
+ satiApiKey = undefined; // Ensure we don't accidentally carry over invalid state
190
190
  }
191
191
  // Base URL Configuration for Sati OpenRouter
192
- if (santiProvider === 'openrouter') {
192
+ if (satiProvider === 'openrouter') {
193
193
  const satiBaseUrl = await input({
194
194
  message: 'Enter Sati OpenRouter Base URL:',
195
- default: currentConfig.santi?.base_url || 'https://openrouter.ai/api/v1',
195
+ default: currentConfig.sati?.base_url || 'https://openrouter.ai/api/v1',
196
196
  });
197
- await configManager.set('santi.base_url', satiBaseUrl);
197
+ await configManager.set('sati.base_url', satiBaseUrl);
198
198
  }
199
199
  }
200
200
  const memoryLimit = await input({
201
201
  message: 'Sati Memory Retrieval Limit (messages):',
202
- default: currentConfig.santi?.memory_limit?.toString() || '1000',
202
+ default: currentConfig.sati?.memory_limit?.toString() || '1000',
203
203
  validate: (val) => !isNaN(Number(val)) && Number(val) > 0 || 'Must be a positive number'
204
204
  });
205
- await configManager.set('santi.provider', santiProvider);
206
- await configManager.set('santi.model', santiModel);
207
- await configManager.set('santi.memory_limit', Number(memoryLimit));
208
- if (santiApiKey) {
209
- await configManager.set('santi.api_key', santiApiKey);
205
+ await configManager.set('sati.provider', satiProvider);
206
+ await configManager.set('sati.model', satiModel);
207
+ await configManager.set('sati.memory_limit', Number(memoryLimit));
208
+ if (satiApiKey) {
209
+ await configManager.set('sati.api_key', satiApiKey);
210
210
  }
211
211
  // Audio Configuration
212
212
  const audioEnabled = await confirm({
@@ -99,7 +99,7 @@ export const restartCommand = new Command('restart')
99
99
  // Initialize Web UI
100
100
  if (options.ui && config.ui.enabled) {
101
101
  try {
102
- httpServer = new HttpServer();
102
+ httpServer = new HttpServer(oracle);
103
103
  // Use CLI port if provided and valid, otherwise fallback to config or default
104
104
  const port = parseInt(options.port) || config.ui.port || 3333;
105
105
  httpServer.start(port);
@@ -79,7 +79,7 @@ export const startCommand = new Command('start')
79
79
  // Initialize Web UI
80
80
  if (options.ui && config.ui.enabled) {
81
81
  try {
82
- httpServer = new HttpServer();
82
+ httpServer = new HttpServer(oracle);
83
83
  // Use CLI port if provided and valid, otherwise fallback to config or default
84
84
  const port = parseInt(options.port) || config.ui.port || 3333;
85
85
  httpServer.start(port);
@@ -54,18 +54,19 @@ export class ConfigManager {
54
54
  context_window: config.llm.context_window !== undefined ? resolveNumeric('MORPHEUS_LLM_CONTEXT_WINDOW', config.llm.context_window, DEFAULT_CONFIG.llm.context_window) : undefined
55
55
  };
56
56
  // Apply precedence to Sati config
57
- let santiConfig;
58
- if (config.santi) {
59
- const santiProvider = resolveProvider('MORPHEUS_SANTI_PROVIDER', config.santi.provider, llmConfig.provider);
60
- santiConfig = {
61
- provider: santiProvider,
62
- model: resolveModel(santiProvider, 'MORPHEUS_SANTI_MODEL', config.santi.model || llmConfig.model),
63
- temperature: resolveNumeric('MORPHEUS_SANTI_TEMPERATURE', config.santi.temperature, llmConfig.temperature),
64
- max_tokens: config.santi.max_tokens !== undefined ? resolveNumeric('MORPHEUS_SANTI_MAX_TOKENS', config.santi.max_tokens, config.santi.max_tokens) : llmConfig.max_tokens,
65
- api_key: resolveApiKey(santiProvider, 'MORPHEUS_SANTI_API_KEY', config.santi.api_key || llmConfig.api_key),
66
- base_url: config.santi.base_url || config.llm.base_url,
67
- context_window: config.santi.context_window !== undefined ? resolveNumeric('MORPHEUS_SANTI_CONTEXT_WINDOW', config.santi.context_window, config.santi.context_window) : llmConfig.context_window,
68
- memory_limit: config.santi.memory_limit !== undefined ? resolveNumeric('MORPHEUS_SANTI_MEMORY_LIMIT', config.santi.memory_limit, config.santi.memory_limit) : undefined
57
+ let satiConfig;
58
+ if (config.sati) {
59
+ const satiProvider = resolveProvider('MORPHEUS_SATI_PROVIDER', config.sati.provider, llmConfig.provider);
60
+ satiConfig = {
61
+ provider: satiProvider,
62
+ model: resolveModel(satiProvider, 'MORPHEUS_SATI_MODEL', config.sati.model || llmConfig.model),
63
+ temperature: resolveNumeric('MORPHEUS_SATI_TEMPERATURE', config.sati.temperature, llmConfig.temperature),
64
+ max_tokens: config.sati.max_tokens !== undefined ? resolveNumeric('MORPHEUS_SATI_MAX_TOKENS', config.sati.max_tokens, config.sati.max_tokens) : llmConfig.max_tokens,
65
+ api_key: resolveApiKey(satiProvider, 'MORPHEUS_SATI_API_KEY', config.sati.api_key || llmConfig.api_key),
66
+ base_url: config.sati.base_url || config.llm.base_url,
67
+ context_window: config.sati.context_window !== undefined ? resolveNumeric('MORPHEUS_SATI_CONTEXT_WINDOW', config.sati.context_window, config.sati.context_window) : llmConfig.context_window,
68
+ memory_limit: config.sati.memory_limit !== undefined ? resolveNumeric('MORPHEUS_SATI_MEMORY_LIMIT', config.sati.memory_limit, config.sati.memory_limit) : undefined,
69
+ enabled_archived_sessions: resolveBoolean('MORPHEUS_SATI_ENABLED_ARCHIVED_SESSIONS', config.sati.enabled_archived_sessions, true)
69
70
  };
70
71
  }
71
72
  // Apply precedence to audio config
@@ -107,7 +108,7 @@ export class ConfigManager {
107
108
  return {
108
109
  agent: agentConfig,
109
110
  llm: llmConfig,
110
- santi: santiConfig,
111
+ sati: satiConfig,
111
112
  audio: audioConfig,
112
113
  channels: channelsConfig,
113
114
  ui: uiConfig,
@@ -137,10 +138,10 @@ export class ConfigManager {
137
138
  return this.config.llm;
138
139
  }
139
140
  getSatiConfig() {
140
- if (this.config.santi) {
141
+ if (this.config.sati) {
141
142
  return {
142
143
  memory_limit: 10, // Default if undefined
143
- ...this.config.santi
144
+ ...this.config.sati
144
145
  };
145
146
  }
146
147
  // Fallback to main LLM config
@@ -19,6 +19,7 @@ export const LLMConfigSchema = z.object({
19
19
  });
20
20
  export const SatiConfigSchema = LLMConfigSchema.extend({
21
21
  memory_limit: z.number().int().positive().optional(),
22
+ enabled_archived_sessions: z.boolean().default(true),
22
23
  });
23
24
  // Zod Schema matching MorpheusConfig interface
24
25
  export const ConfigSchema = z.object({
@@ -27,7 +28,7 @@ export const ConfigSchema = z.object({
27
28
  personality: z.string().default(DEFAULT_CONFIG.agent.personality),
28
29
  }).default(DEFAULT_CONFIG.agent),
29
30
  llm: LLMConfigSchema.default(DEFAULT_CONFIG.llm),
30
- santi: SatiConfigSchema.optional(),
31
+ sati: SatiConfigSchema.optional(),
31
32
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
32
33
  memory: z.object({
33
34
  limit: z.number().int().positive().optional(),
@@ -27,10 +27,15 @@ describe('Config API', () => {
27
27
  log: vi.fn(),
28
28
  };
29
29
  DisplayManager.getInstance.mockReturnValue(mockDisplayManager);
30
+ // Mock Oracle instance
31
+ const mockOracle = {
32
+ think: vi.fn(),
33
+ getMemory: vi.fn(),
34
+ };
30
35
  // Setup App
31
36
  app = express();
32
37
  app.use(bodyParser.json());
33
- app.use('/api', createApiRouter());
38
+ app.use('/api', createApiRouter(mockOracle));
34
39
  });
35
40
  afterEach(() => {
36
41
  vi.restoreAllMocks();
package/dist/http/api.js CHANGED
@@ -20,14 +20,146 @@ async function readLastLines(filePath, n) {
20
20
  return [];
21
21
  }
22
22
  }
23
- export function createApiRouter() {
23
+ export function createApiRouter(oracle) {
24
24
  const router = Router();
25
25
  const configManager = ConfigManager.getInstance();
26
26
  const history = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
27
+ // --- Session Management ---
28
+ router.get('/sessions', async (req, res) => {
29
+ try {
30
+ const allSessions = await history.listSessions();
31
+ res.json(allSessions);
32
+ }
33
+ catch (err) {
34
+ res.status(500).json({ error: err.message });
35
+ }
36
+ });
37
+ router.post('/sessions', async (req, res) => {
38
+ try {
39
+ await history.createNewSession();
40
+ const newSessionId = await history.getCurrentSessionOrCreate(); // Should be the new one
41
+ res.json({ success: true, id: newSessionId, message: 'New session started' });
42
+ }
43
+ catch (err) {
44
+ res.status(500).json({ error: err.message });
45
+ }
46
+ });
47
+ router.delete('/sessions/:id', async (req, res) => {
48
+ try {
49
+ const { id } = req.params;
50
+ await history.deleteSession(id);
51
+ res.json({ success: true, message: 'Session deleted' });
52
+ }
53
+ catch (err) {
54
+ res.status(500).json({ error: err.message });
55
+ }
56
+ });
57
+ router.post('/sessions/:id/archive', async (req, res) => {
58
+ try {
59
+ const { id } = req.params;
60
+ await history.archiveSession(id);
61
+ res.json({ success: true, message: 'Session archived' });
62
+ }
63
+ catch (err) {
64
+ res.status(500).json({ error: err.message });
65
+ }
66
+ });
67
+ router.patch('/sessions/:id/title', async (req, res) => {
68
+ try {
69
+ const { id } = req.params;
70
+ const { title } = req.body;
71
+ if (!title) {
72
+ return res.status(400).json({ error: 'Title is required' });
73
+ }
74
+ await history.renameSession(id, title);
75
+ res.json({ success: true, message: 'Session renamed' });
76
+ }
77
+ catch (err) {
78
+ res.status(500).json({ error: err.message });
79
+ }
80
+ });
81
+ router.get('/sessions/:id/messages', async (req, res) => {
82
+ try {
83
+ const { id } = req.params;
84
+ const sessionHistory = new SQLiteChatMessageHistory({ sessionId: id, limit: 100 });
85
+ const messages = await sessionHistory.getMessages();
86
+ // Normalize messages for UI
87
+ const key = (msg) => msg._getType(); // Access internal type if available, or infer
88
+ const normalizedMessages = messages.map((msg) => {
89
+ const type = msg._getType ? msg._getType() : 'unknown';
90
+ return {
91
+ type,
92
+ content: msg.content,
93
+ tool_calls: msg.tool_calls,
94
+ usage_metadata: msg.usage_metadata
95
+ };
96
+ });
97
+ // Reverse to chronological order for UI
98
+ res.json(normalizedMessages.reverse());
99
+ }
100
+ catch (err) {
101
+ res.status(500).json({ error: err.message });
102
+ }
103
+ });
104
+ // --- Chat Interaction ---
105
+ router.post('/chat', async (req, res) => {
106
+ try {
107
+ const { message, sessionId } = req.body;
108
+ if (!message || !sessionId) {
109
+ return res.status(400).json({ error: 'Message and Session ID are required' });
110
+ }
111
+ // We need to ensure the Oracle uses the correct session history.
112
+ // The Oracle class uses its own internal history instance.
113
+ // We might need to refactor Oracle to accept a session ID per request or
114
+ // instantiate a temporary Oracle wrapper/context?
115
+ //
116
+ // ACTUALLY: The Oracle class uses `this.history`.
117
+ // `SQLiteChatMessageHistory` takes `sessionId` in constructor.
118
+ // To support multi-session chat via API, we should probably allow passing sessionId to `chat()`
119
+ // OR (cleaner for now) we can rely on the fact that `Oracle` might not support swapping sessions easily without
120
+ // re-initialization or we extend `oracle.chat` to support overriding session.
121
+ //
122
+ // Let's look at `Oracle.chat`: it uses `this.history`.
123
+ // And `SQLiteChatMessageHistory` is tied to a sessionId.
124
+ //
125
+ // Quick fix for this feature:
126
+ // We can use a trick: `Oracle` allows `overrides` in constructor but that's for db path.
127
+ // `Oracle` initializes `this.history` in `initialize`.
128
+ // Better approach:
129
+ // We can temporarily switch the session of the Oracle's history if it exposes it,
130
+ // OR we just instantiate a fresh history for the chat request and use the provider?
131
+ // No, `oracle.chat` encapsulates the provider invocation.
132
+ // Let's check `Oracle` class again. (I viewed it earlier).
133
+ // It has `private history`.
134
+ // SOLUTION:
135
+ // I will add a `setSessionId(id: string)` method to `Oracle` interface and class.
136
+ // OR pass `sessionId` to `chat`.
137
+ // For now, I will assume I can update `Oracle` to support dynamic sessions.
138
+ // I'll modify `Oracle.chat` signature in a separate step if needed.
139
+ // Wait, `Oracle` is a singleton-ish in `start.ts`.
140
+ // Let's modify `Oracle` to accept `sessionId` in `chat`?
141
+ // `chat(message: string, extraUsage?: UsageMetadata, isTelephonist?: boolean)`
142
+ //
143
+ // Adding `sessionId` to `chat` seems invasive if not threaded fast.
144
+ //
145
+ // Alternative:
146
+ // `router.post('/chat')` instantiates a *new* Oracle? No, expensive (provider factory).
147
+ //
148
+ // Ideally `Oracle` should be stateless regarding session, or easily switchable.
149
+ // `SQLiteChatMessageHistory` is cheap to instantiate.
150
+ //
151
+ // Let's update `Oracle` to allow switching session.
152
+ // `oracle.switchSession(sessionId)`
153
+ await oracle.setSessionId(sessionId); // Type cast for now, will implement next
154
+ const response = await oracle.chat(message);
155
+ res.json({ response });
156
+ }
157
+ catch (err) {
158
+ res.status(500).json({ error: err.message });
159
+ }
160
+ });
161
+ // Legacy /session/reset (keep for backward compat or redirect to POST /sessions)
27
162
  router.post('/session/reset', async (req, res) => {
28
- // if (!oracle) {
29
- // return res.status(503).json({ error: 'Oracle unavailable' });
30
- // }
31
163
  try {
32
164
  await history.createNewSession();
33
165
  res.json({ success: true, message: 'New session started' });
@@ -179,7 +311,7 @@ export function createApiRouter() {
179
311
  router.post('/config/sati', async (req, res) => {
180
312
  try {
181
313
  const config = configManager.get();
182
- await configManager.save({ ...config, santi: req.body });
314
+ await configManager.save({ ...config, sati: req.body });
183
315
  const display = DisplayManager.getInstance();
184
316
  display.log('Sati configuration updated via UI', {
185
317
  source: 'Zaion',
@@ -199,7 +331,7 @@ export function createApiRouter() {
199
331
  router.delete('/config/sati', async (req, res) => {
200
332
  try {
201
333
  const config = configManager.get();
202
- const { santi, ...restConfig } = config;
334
+ const { sati: sati, ...restConfig } = config;
203
335
  await configManager.save(restConfig);
204
336
  const display = DisplayManager.getInstance();
205
337
  display.log('Sati configuration removed via UI (falling back to Oracle config)', {