morpheus-cli 0.9.6 → 0.9.7

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.
@@ -112,8 +112,8 @@ export class DiscordAdapter {
112
112
  display = DisplayManager.getInstance();
113
113
  config = ConfigManager.getInstance();
114
114
  history = new SQLiteChatMessageHistory({ sessionId: '' });
115
- /** Per-channel session tracking — which session this Discord adapter is currently using */
116
- currentSessionId = null;
115
+ /** Per-user session tracking — maps userId to sessionId */
116
+ userSessions = new Map();
117
117
  telephonist = null;
118
118
  telephonistProvider = null;
119
119
  telephonistModel = null;
@@ -189,8 +189,7 @@ export class DiscordAdapter {
189
189
  return;
190
190
  this.display.log(`${message.author.tag}: ${text}`, { source: 'Discord' });
191
191
  try {
192
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
193
- this.currentSessionId = sessionId;
192
+ const sessionId = await this.getSessionForUser(userId);
194
193
  const response = await this.oracle.chat(text, undefined, false, {
195
194
  origin_channel: 'discord',
196
195
  session_id: sessionId,
@@ -303,8 +302,7 @@ export class DiscordAdapter {
303
302
  // Show transcription
304
303
  await channel.send(`🎤 "${text}"`);
305
304
  // Process with Oracle
306
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
307
- this.currentSessionId = sessionId;
305
+ const sessionId = await this.getSessionForUser(userId);
308
306
  const response = await this.oracle.chat(text, usage, true, {
309
307
  origin_channel: 'discord',
310
308
  session_id: sessionId,
@@ -495,8 +493,10 @@ export class DiscordAdapter {
495
493
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
496
494
  const newSessionId = await history.createNewSession();
497
495
  history.close();
498
- // Track the new session as the current one for this Discord channel
499
- this.currentSessionId = newSessionId;
496
+ // Track the new session as the current one for this user
497
+ const userId = interaction.user.id;
498
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
499
+ this.userSessions.set(userId, newSessionId);
500
500
  await interaction.reply({ content: '✅ New session started.' });
501
501
  }
502
502
  catch (err) {
@@ -506,6 +506,13 @@ export class DiscordAdapter {
506
506
  async cmdSessions(interaction) {
507
507
  try {
508
508
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
509
+ const userId = interaction.user.id;
510
+ // Get user's current session (string | undefined)
511
+ let userCurrentSession = this.userSessions.get(userId) || undefined;
512
+ if (userCurrentSession === undefined) {
513
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
514
+ userCurrentSession = fromDb ?? undefined;
515
+ }
509
516
  const sessions = (await history.listSessions()).filter((s) => !s.id.startsWith('chronos-job-') && !s.id.startsWith('sati-evaluation'));
510
517
  history.close();
511
518
  if (sessions.length === 0) {
@@ -516,7 +523,7 @@ export class DiscordAdapter {
516
523
  const rows = [];
517
524
  for (const session of sessions) {
518
525
  const title = session.title || 'Untitled Session';
519
- const isCurrent = session.id === this.currentSessionId;
526
+ const isCurrent = session.id === userCurrentSession;
520
527
  const icon = isCurrent ? '🟢' : '⚪';
521
528
  const started = new Date(session.started_at).toLocaleString();
522
529
  lines.push(`${icon} **${title}**`);
@@ -554,12 +561,14 @@ export class DiscordAdapter {
554
561
  });
555
562
  collector.on('collect', async (btn) => {
556
563
  try {
564
+ const uid = btn.user.id;
557
565
  if (btn.customId.startsWith('session_switch_')) {
558
566
  const sid = btn.customId.replace('session_switch_', '');
559
567
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
560
568
  await h.switchSession(sid);
561
569
  h.close();
562
- this.currentSessionId = sid;
570
+ await this.history.setUserChannelSession(this.channel, uid, sid);
571
+ this.userSessions.set(uid, sid);
563
572
  await btn.reply({ content: `✅ Switched to session \`${sid}\`.`, ephemeral: true });
564
573
  }
565
574
  else if (btn.customId.startsWith('session_archive_')) {
@@ -567,8 +576,12 @@ export class DiscordAdapter {
567
576
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
568
577
  await h.archiveSession(sid);
569
578
  h.close();
570
- if (this.currentSessionId === sid)
571
- this.currentSessionId = null;
579
+ // Remove user-channel mapping if this was their current session
580
+ const current = this.userSessions.get(uid);
581
+ if (current === sid) {
582
+ await this.history.deleteUserChannelSession(this.channel, uid);
583
+ this.userSessions.delete(uid);
584
+ }
572
585
  await btn.reply({ content: `📂 Session \`${sid}\` archived.`, ephemeral: true });
573
586
  }
574
587
  else if (btn.customId.startsWith('session_delete_')) {
@@ -576,8 +589,12 @@ export class DiscordAdapter {
576
589
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
577
590
  await h.deleteSession(sid);
578
591
  h.close();
579
- if (this.currentSessionId === sid)
580
- this.currentSessionId = null;
592
+ // Remove user-channel mapping if this was their current session
593
+ const current = this.userSessions.get(uid);
594
+ if (current === sid) {
595
+ await this.history.deleteUserChannelSession(this.channel, uid);
596
+ this.userSessions.delete(uid);
597
+ }
581
598
  await btn.reply({ content: `🗑️ Session \`${sid}\` deleted.`, ephemeral: true });
582
599
  }
583
600
  }
@@ -602,7 +619,9 @@ export class DiscordAdapter {
602
619
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
603
620
  await history.switchSession(sessionId);
604
621
  history.close();
605
- this.currentSessionId = sessionId;
622
+ const userId = interaction.user.id;
623
+ await this.history.setUserChannelSession(this.channel, userId, sessionId);
624
+ this.userSessions.set(userId, sessionId);
606
625
  await interaction.reply({ content: `✅ Switched to session \`${sessionId}\`.` });
607
626
  }
608
627
  catch (err) {
@@ -895,6 +914,40 @@ export class DiscordAdapter {
895
914
  isAuthorized(userId) {
896
915
  return this.allowedUsers.includes(userId);
897
916
  }
917
+ /**
918
+ * Gets or creates a session for a specific user.
919
+ * Uses triple fallback: memory → DB → global session.
920
+ */
921
+ async getSessionForUser(userId) {
922
+ // 1. Try memory
923
+ const fromMemory = this.userSessions.get(userId);
924
+ if (fromMemory)
925
+ return fromMemory;
926
+ // 2. Try DB
927
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
928
+ if (fromDb !== null) {
929
+ this.userSessions.set(userId, fromDb);
930
+ return fromDb;
931
+ }
932
+ // 3. Create/use global session
933
+ const newSessionId = await this.history.getCurrentSessionOrCreate();
934
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
935
+ this.userSessions.set(userId, newSessionId);
936
+ return newSessionId;
937
+ }
938
+ /**
939
+ * Restores user sessions from DB on startup.
940
+ * Reads user_channel_sessions table and populates userSessions Map.
941
+ */
942
+ async restoreUserSessions() {
943
+ const rows = await this.history.listUserChannelSessions(this.channel);
944
+ for (const row of rows) {
945
+ this.userSessions.set(row.userId, row.sessionId);
946
+ }
947
+ if (rows.length > 0) {
948
+ this.display.log(`✓ Restored ${rows.length} user session(s) from database`, { source: 'Discord', level: 'info' });
949
+ }
950
+ }
898
951
  isRateLimited(userId) {
899
952
  const now = Date.now();
900
953
  const last = this.rateLimitMap.get(userId);
@@ -144,8 +144,8 @@ export class TelegramAdapter {
144
144
  telephonistProvider = null;
145
145
  telephonistModel = null;
146
146
  history = new SQLiteChatMessageHistory({ sessionId: '' });
147
- /** Per-channel session tracking — which session this Telegram adapter is currently using */
148
- currentSessionId = null;
147
+ /** Per-user session tracking — maps userId to sessionId */
148
+ userSessions = new Map();
149
149
  RATE_LIMIT_MS = 3000; // minimum ms between requests per user
150
150
  rateLimiter = new Map(); // userId -> last request timestamp
151
151
  // Pending Chronos create confirmations (userId -> job data + expiry)
@@ -232,8 +232,7 @@ export class TelegramAdapter {
232
232
  try {
233
233
  // Send "typing" status
234
234
  await ctx.sendChatAction('typing');
235
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
236
- this.currentSessionId = sessionId;
235
+ const sessionId = await this.getSessionForUser(userId);
237
236
  // Process with Agent
238
237
  const response = await this.oracle.chat(text, undefined, false, {
239
238
  origin_channel: 'telegram',
@@ -319,7 +318,7 @@ export class TelegramAdapter {
319
318
  this.display.log(`Transcription success for @${user}: "${text}"`, { source: 'Telephonist', level: 'success' });
320
319
  // Audit: record telephonist execution
321
320
  try {
322
- const auditSessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
321
+ const auditSessionId = await this.getSessionForUser(userId);
323
322
  AuditRepository.getInstance().insert({
324
323
  session_id: auditSessionId,
325
324
  event_type: 'telephonist',
@@ -347,8 +346,7 @@ export class TelegramAdapter {
347
346
  // So I should treat 'text' as if it was a text message.
348
347
  await ctx.reply(`🎤 Transcription: "${text}"`);
349
348
  await ctx.sendChatAction('typing');
350
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
351
- this.currentSessionId = sessionId;
349
+ const sessionId = await this.getSessionForUser(userId);
352
350
  // Process with Agent
353
351
  const response = await this.oracle.chat(text, usage, true, {
354
352
  origin_channel: 'telegram',
@@ -382,7 +380,7 @@ export class TelegramAdapter {
382
380
  const detail = error?.cause?.message || error?.response?.data?.error?.message || error.message;
383
381
  this.display.log(`Audio processing error for @${user}: ${detail}`, { source: 'Telephonist', level: 'error' });
384
382
  try {
385
- const auditSessionId = this.currentSessionId ?? 'default';
383
+ const auditSessionId = await this.getSessionForUser(userId);
386
384
  AuditRepository.getInstance().insert({
387
385
  session_id: auditSessionId,
388
386
  event_type: 'telephonist',
@@ -425,6 +423,7 @@ export class TelegramAdapter {
425
423
  const callbackQuery = ctx.callbackQuery;
426
424
  const data = callbackQuery && 'data' in callbackQuery ? callbackQuery.data : undefined;
427
425
  const sessionId = typeof data === 'string' ? data.replace('switch_session_', '') : '';
426
+ const userId = ctx.from.id.toString();
428
427
  if (!sessionId || sessionId === '') {
429
428
  await ctx.answerCbQuery('Invalid session ID');
430
429
  return;
@@ -434,8 +433,9 @@ export class TelegramAdapter {
434
433
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
435
434
  await history.switchSession(sessionId);
436
435
  history.close();
437
- // Track this session as the current one for this Telegram channel
438
- this.currentSessionId = sessionId;
436
+ // Update user-channel session mapping
437
+ await this.history.setUserChannelSession(this.channel, userId, sessionId);
438
+ this.userSessions.set(userId, sessionId);
439
439
  await ctx.answerCbQuery();
440
440
  // Remove the previous message and send confirmation
441
441
  if (ctx.updateType === 'callback_query') {
@@ -468,9 +468,16 @@ export class TelegramAdapter {
468
468
  this.bot.action(/^confirm_archive_session_/, async (ctx) => {
469
469
  const data = ctx.callbackQuery.data;
470
470
  const sessionId = data.replace('confirm_archive_session_', '');
471
+ const userId = ctx.from.id.toString();
471
472
  try {
472
473
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
473
474
  await history.archiveSession(sessionId);
475
+ // Remove user-channel session mapping if this was their current session
476
+ const currentSession = this.userSessions.get(userId);
477
+ if (currentSession === sessionId) {
478
+ await this.history.deleteUserChannelSession(this.channel, userId);
479
+ this.userSessions.delete(userId);
480
+ }
474
481
  await ctx.answerCbQuery('Session archived successfully');
475
482
  if (ctx.updateType === 'callback_query') {
476
483
  ctx.deleteMessage().catch(() => { });
@@ -501,9 +508,16 @@ export class TelegramAdapter {
501
508
  this.bot.action(/^confirm_delete_session_/, async (ctx) => {
502
509
  const data = ctx.callbackQuery.data;
503
510
  const sessionId = data.replace('confirm_delete_session_', '');
511
+ const userId = ctx.from.id.toString();
504
512
  try {
505
513
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
506
514
  await history.deleteSession(sessionId);
515
+ // Remove user-channel session mapping if this was their current session
516
+ const currentSession = this.userSessions.get(userId);
517
+ if (currentSession === sessionId) {
518
+ await this.history.deleteUserChannelSession(this.channel, userId);
519
+ this.userSessions.delete(userId);
520
+ }
507
521
  await ctx.answerCbQuery('Session deleted successfully');
508
522
  if (ctx.updateType === 'callback_query') {
509
523
  ctx.deleteMessage().catch(() => { });
@@ -690,6 +704,40 @@ export class TelegramAdapter {
690
704
  isAuthorized(userId, allowedUsers) {
691
705
  return allowedUsers.includes(userId);
692
706
  }
707
+ /**
708
+ * Gets or creates a session for a specific user.
709
+ * Uses triple fallback: memory → DB → global session.
710
+ */
711
+ async getSessionForUser(userId) {
712
+ // 1. Try memory
713
+ const fromMemory = this.userSessions.get(userId);
714
+ if (fromMemory)
715
+ return fromMemory;
716
+ // 2. Try DB
717
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
718
+ if (fromDb !== null) {
719
+ this.userSessions.set(userId, fromDb);
720
+ return fromDb;
721
+ }
722
+ // 3. Create/use global session
723
+ const newSessionId = await this.history.getCurrentSessionOrCreate();
724
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
725
+ this.userSessions.set(userId, newSessionId);
726
+ return newSessionId;
727
+ }
728
+ /**
729
+ * Restores user sessions from DB on startup.
730
+ * Reads user_channel_sessions table and populates userSessions Map.
731
+ */
732
+ async restoreUserSessions() {
733
+ const rows = await this.history.listUserChannelSessions(this.channel);
734
+ for (const row of rows) {
735
+ this.userSessions.set(row.userId, row.sessionId);
736
+ }
737
+ if (rows.length > 0) {
738
+ this.display.log(`✓ Restored ${rows.length} user session(s) from database`, { source: 'Telegram', level: 'info' });
739
+ }
740
+ }
693
741
  async downloadToTemp(url, extension = '.ogg') {
694
742
  const response = await fetch(url);
695
743
  if (!response.ok)
@@ -1157,8 +1205,10 @@ export class TelegramAdapter {
1157
1205
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
1158
1206
  const newSessionId = await history.createNewSession();
1159
1207
  history.close();
1160
- // Track the new session as the current one for this Telegram channel
1161
- this.currentSessionId = newSessionId;
1208
+ // Track the new session as the current one for this user
1209
+ const userId = ctx.from.id.toString();
1210
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
1211
+ this.userSessions.set(userId, newSessionId);
1162
1212
  }
1163
1213
  catch (e) {
1164
1214
  await ctx.reply(`Error creating new session: ${e.message}`);
@@ -1167,6 +1217,13 @@ export class TelegramAdapter {
1167
1217
  async handleSessionStatusCommand(ctx, user) {
1168
1218
  try {
1169
1219
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
1220
+ const userId = ctx.from.id.toString();
1221
+ // Get user's current session (string | undefined)
1222
+ let userCurrentSession = this.userSessions.get(userId) || undefined;
1223
+ if (userCurrentSession === undefined) {
1224
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
1225
+ userCurrentSession = fromDb ?? undefined;
1226
+ }
1170
1227
  // Exclude automated Chronos sessions — their IDs exceed Telegram's 64-byte
1171
1228
  // callback_data limit and they are not user-managed sessions.
1172
1229
  const sessions = (await history.listSessions()).filter((s) => !s.id.startsWith('chronos-job-') && !s.id.startsWith('sati-evaluation'));
@@ -1178,7 +1235,7 @@ export class TelegramAdapter {
1178
1235
  const keyboard = [];
1179
1236
  for (const session of sessions) {
1180
1237
  const title = session.title || 'Untitled Session';
1181
- const isCurrent = session.id === this.currentSessionId;
1238
+ const isCurrent = session.id === userCurrentSession;
1182
1239
  const statusEmoji = isCurrent ? '🟢' : '⚪';
1183
1240
  response += `${statusEmoji} *${escMdRaw(title)}*\n`;
1184
1241
  response += `\\- ID: \`${escMdRaw(session.id)}\`\n`;
@@ -240,6 +240,7 @@ export const startCommand = new Command('start')
240
240
  const telegram = new TelegramAdapter(oracle);
241
241
  try {
242
242
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
243
+ await telegram.restoreUserSessions();
243
244
  ChannelRegistry.register(telegram);
244
245
  adapters.push(telegram);
245
246
  }
@@ -257,6 +258,7 @@ export const startCommand = new Command('start')
257
258
  const discord = new DiscordAdapter(oracle);
258
259
  try {
259
260
  await discord.connect(config.channels.discord.token, config.channels.discord.allowedUsers || []);
261
+ await discord.restoreUserSessions();
260
262
  ChannelRegistry.register(discord);
261
263
  adapters.push(discord);
262
264
  }
@@ -130,6 +130,14 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
130
130
  embedding_status TEXT CHECK (embedding_status IN ('none', 'pending', 'embedded', 'failed')) NOT NULL DEFAULT 'none'
131
131
  );
132
132
 
133
+ CREATE TABLE IF NOT EXISTS user_channel_sessions (
134
+ channel TEXT NOT NULL,
135
+ user_id TEXT NOT NULL,
136
+ session_id TEXT NOT NULL,
137
+ updated_at INTEGER NOT NULL,
138
+ PRIMARY KEY (channel, user_id)
139
+ );
140
+
133
141
  CREATE TABLE IF NOT EXISTS model_pricing (
134
142
  provider TEXT NOT NULL,
135
143
  model TEXT NOT NULL,
@@ -957,6 +965,50 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
957
965
  `).all();
958
966
  return sessions;
959
967
  }
968
+ /**
969
+ * Gets the session ID for a specific channel+user combination.
970
+ * Returns null if not found.
971
+ */
972
+ async getUserChannelSession(channel, userId) {
973
+ const result = this.db.prepare(`
974
+ SELECT session_id FROM user_channel_sessions
975
+ WHERE channel = ? AND user_id = ?
976
+ `).get(channel, userId);
977
+ return result ? result.session_id : null;
978
+ }
979
+ /**
980
+ * Sets or updates the session ID for a specific channel+user.
981
+ * Uses INSERT ... ON CONFLICT for upsert behavior.
982
+ */
983
+ async setUserChannelSession(channel, userId, sessionId) {
984
+ this.db.prepare(`
985
+ INSERT INTO user_channel_sessions (channel, user_id, session_id, updated_at)
986
+ VALUES (?, ?, ?, ?)
987
+ ON CONFLICT(channel, user_id) DO UPDATE SET
988
+ session_id = excluded.session_id,
989
+ updated_at = excluded.updated_at
990
+ `).run(channel, userId, sessionId, Date.now());
991
+ }
992
+ /**
993
+ * Lists all user sessions for a channel.
994
+ * Returns an array of {userId, sessionId} objects.
995
+ */
996
+ async listUserChannelSessions(channel) {
997
+ const rows = this.db.prepare(`
998
+ SELECT user_id, session_id FROM user_channel_sessions
999
+ WHERE channel = ?
1000
+ `).all(channel);
1001
+ return rows.map(row => ({ userId: row.user_id, sessionId: row.session_id }));
1002
+ }
1003
+ /**
1004
+ * Removes the session mapping for a channel+user.
1005
+ */
1006
+ async deleteUserChannelSession(channel, userId) {
1007
+ this.db.prepare(`
1008
+ DELETE FROM user_channel_sessions
1009
+ WHERE channel = ? AND user_id = ?
1010
+ `).run(channel, userId);
1011
+ }
960
1012
  /**
961
1013
  * Closes the database connection.
962
1014
  * Should be called when the history object is no longer needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morpheus-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Morpheus is a local AI agent for developers, running as a CLI daemon that connects to LLMs, local tools, and MCPs, enabling interaction via Terminal, Telegram, and Discord. Inspired by the character Morpheus from *The Matrix*, the project acts as an intelligent orchestrator, bridging the gap between the developer and complex systems.",
5
5
  "bin": {
6
6
  "morpheus": "./bin/morpheus.js"