morpheus-cli 0.9.6 → 0.9.8
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/dist/channels/discord.js +68 -15
- package/dist/channels/telegram.js +70 -13
- package/dist/cli/commands/start.js +2 -0
- package/dist/runtime/link.js +109 -1
- package/dist/runtime/memory/sqlite.js +52 -0
- package/dist/runtime/oracle.js +7 -0
- package/package.json +1 -1
package/dist/channels/discord.js
CHANGED
|
@@ -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-
|
|
116
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
499
|
-
|
|
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 ===
|
|
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.
|
|
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
|
|
571
|
-
|
|
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
|
|
580
|
-
|
|
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
|
-
|
|
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-
|
|
148
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
438
|
-
this.
|
|
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
|
|
1161
|
-
|
|
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 ===
|
|
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
|
}
|
package/dist/runtime/link.js
CHANGED
|
@@ -144,7 +144,102 @@ export class Link {
|
|
|
144
144
|
return `Found ${results.length} passages in "${doc.filename}":\n\n${formatted}`;
|
|
145
145
|
},
|
|
146
146
|
});
|
|
147
|
-
|
|
147
|
+
// Tool: Summarize entire document via LLM
|
|
148
|
+
const summarizeDocumentTool = new DynamicStructuredTool({
|
|
149
|
+
name: 'link_summarize_document',
|
|
150
|
+
description: 'Summarize an entire indexed document using the LLM. Use this when user wants a summary of a whole document (e.g., "resuma o contrato", "give me a summary of the report"). Returns a concise summary generated by the LLM.',
|
|
151
|
+
schema: z.object({
|
|
152
|
+
document_id: z.string().describe('The document ID to summarize (get this from link_list_documents)'),
|
|
153
|
+
max_chunks: z.number().optional().describe('Maximum number of chunks to include in summary (default: 50)'),
|
|
154
|
+
}),
|
|
155
|
+
func: async ({ document_id, max_chunks }) => {
|
|
156
|
+
const doc = repository.getDocument(document_id);
|
|
157
|
+
if (!doc) {
|
|
158
|
+
return `Document not found: ${document_id}`;
|
|
159
|
+
}
|
|
160
|
+
const chunks = repository.getChunksByDocument(document_id);
|
|
161
|
+
if (chunks.length === 0) {
|
|
162
|
+
return `Document "${doc.filename}" has no indexed chunks.`;
|
|
163
|
+
}
|
|
164
|
+
const limit = max_chunks ?? 50;
|
|
165
|
+
const chunksToSummarize = chunks.slice(0, limit);
|
|
166
|
+
const content = chunksToSummarize.map(c => c.content).join('\n\n---\n\n');
|
|
167
|
+
// Return the content for LLM to summarize - the ReactAgent will handle the summarization
|
|
168
|
+
return `Document: ${doc.filename}\nTotal chunks: ${chunks.length}\nChunks to summarize: ${chunksToSummarize.length}\n\nContent:\n${content}`;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// Tool: Summarize specific chunk via LLM
|
|
172
|
+
const summarizeChunkTool = new DynamicStructuredTool({
|
|
173
|
+
name: 'link_summarize_chunk',
|
|
174
|
+
description: 'Summarize a specific chunk from a document. Use this when user wants to summarize a particular section (e.g., "resuma o chunk 5", "summarize section 3").',
|
|
175
|
+
schema: z.object({
|
|
176
|
+
document_id: z.string().describe('The document ID (get this from link_list_documents)'),
|
|
177
|
+
position: z.number().describe('The chunk position to summarize (1-based)'),
|
|
178
|
+
}),
|
|
179
|
+
func: async ({ document_id, position }) => {
|
|
180
|
+
const doc = repository.getDocument(document_id);
|
|
181
|
+
if (!doc) {
|
|
182
|
+
return `Document not found: ${document_id}`;
|
|
183
|
+
}
|
|
184
|
+
const chunks = repository.getChunksByDocument(document_id);
|
|
185
|
+
const chunk = chunks.find(c => c.position === position);
|
|
186
|
+
if (!chunk) {
|
|
187
|
+
return `Chunk not found: position ${position}. Document "${doc.filename}" has ${chunks.length} chunks.`;
|
|
188
|
+
}
|
|
189
|
+
return `Document: ${doc.filename}\nChunk position: ${position}\n\nContent:\n${chunk.content}`;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
// Tool: Extract key points from document
|
|
193
|
+
const extractKeyPointsTool = new DynamicStructuredTool({
|
|
194
|
+
name: 'link_extract_key_points',
|
|
195
|
+
description: 'Extract key points from a document. Use this when user wants to know the main points or key takeaways from a document (e.g., "quais os pontos principais do contrato?", "extract key points from the report").',
|
|
196
|
+
schema: z.object({
|
|
197
|
+
document_id: z.string().describe('The document ID to extract key points from (get this from link_list_documents)'),
|
|
198
|
+
max_chunks: z.number().optional().describe('Maximum number of chunks to analyze (default: 50)'),
|
|
199
|
+
}),
|
|
200
|
+
func: async ({ document_id, max_chunks }) => {
|
|
201
|
+
const doc = repository.getDocument(document_id);
|
|
202
|
+
if (!doc) {
|
|
203
|
+
return `Document not found: ${document_id}`;
|
|
204
|
+
}
|
|
205
|
+
const chunks = repository.getChunksByDocument(document_id);
|
|
206
|
+
if (chunks.length === 0) {
|
|
207
|
+
return `Document "${doc.filename}" has no indexed chunks.`;
|
|
208
|
+
}
|
|
209
|
+
const limit = max_chunks ?? 1000;
|
|
210
|
+
const chunksToAnalyze = chunks.slice(0, limit);
|
|
211
|
+
const content = chunksToAnalyze.map(c => c.content).join('\n\n---\n\n');
|
|
212
|
+
return `Document: ${doc.filename}\nTotal chunks: ${chunks.length}\nChunks to analyze: ${chunksToAnalyze.length}\n\nContent:\n${content}`;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
// Tool: Find differences between two documents
|
|
216
|
+
const findDifferencesTool = new DynamicStructuredTool({
|
|
217
|
+
name: 'link_find_differences',
|
|
218
|
+
description: 'Compare two documents and identify differences. Use this when user wants to compare documents (e.g., "compare os dois contratos", "what is different between doc A and doc B").',
|
|
219
|
+
schema: z.object({
|
|
220
|
+
document_id_a: z.string().describe('First document ID to compare'),
|
|
221
|
+
document_id_b: z.string().describe('Second document ID to compare'),
|
|
222
|
+
}),
|
|
223
|
+
func: async ({ document_id_a, document_id_b }) => {
|
|
224
|
+
const docA = repository.getDocument(document_id_a);
|
|
225
|
+
const docB = repository.getDocument(document_id_b);
|
|
226
|
+
if (!docA) {
|
|
227
|
+
return `Document not found: ${document_id_a}`;
|
|
228
|
+
}
|
|
229
|
+
if (!docB) {
|
|
230
|
+
return `Document not found: ${document_id_b}`;
|
|
231
|
+
}
|
|
232
|
+
if (document_id_a === document_id_b) {
|
|
233
|
+
return 'Os documentos são idênticos (mesmo documento informado duas vezes).';
|
|
234
|
+
}
|
|
235
|
+
const chunksA = repository.getChunksByDocument(document_id_a);
|
|
236
|
+
const chunksB = repository.getChunksByDocument(document_id_b);
|
|
237
|
+
const contentA = chunksA.map(c => c.content).join('\n\n---\n\n');
|
|
238
|
+
const contentB = chunksB.map(c => c.content).join('\n\n---\n\n');
|
|
239
|
+
return `Document A: ${docA.filename} (${chunksA.length} chunks)\nDocument B: ${docB.filename} (${chunksB.length} chunks)\n\n--- Document A Content ---\n${contentA}\n\n--- Document B Content ---\n${contentB}`;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
return [listDocumentsTool, searchTool, searchInDocumentTool, summarizeDocumentTool, summarizeChunkTool, extractKeyPointsTool, findDifferencesTool];
|
|
148
243
|
}
|
|
149
244
|
async initialize() {
|
|
150
245
|
this.repository.initialize();
|
|
@@ -218,6 +313,19 @@ Rules:
|
|
|
218
313
|
- When the user asks a general question without referencing a specific document:
|
|
219
314
|
- Use **link_search_documents** for a broad search across all documents.
|
|
220
315
|
- When unsure which document contains the answer, start with **link_search_documents**, then narrow down with **link_search_in_document** if results point to a specific file.
|
|
316
|
+
- When user asks to summarize, extract key points, or compare documents:
|
|
317
|
+
- Use **link_summarize_document**, **link_extract_key_points**, or **link_find_differences** respectively.
|
|
318
|
+
|
|
319
|
+
## Source Citation with Scores
|
|
320
|
+
AT THE END of your response, you MUST include a "Fontes consultadas:" (Sources consulted) section listing all documents used with their final scores.
|
|
321
|
+
Format:
|
|
322
|
+
- <filename> (<document_id>): score <score_value>
|
|
323
|
+
|
|
324
|
+
Example:
|
|
325
|
+
- readme.md (abc-123): score 0.92
|
|
326
|
+
- contract.pdf (xyz-789): score 0.85
|
|
327
|
+
|
|
328
|
+
If no documents were used, omit this section.
|
|
221
329
|
|
|
222
330
|
${context ? `Context:\n${context}` : ''}
|
|
223
331
|
`);
|
|
@@ -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/dist/runtime/oracle.js
CHANGED
|
@@ -388,6 +388,13 @@ When delegating to Link:
|
|
|
388
388
|
- Sati context may be included ONLY as a separate "context for interpretation" section, clearly separated from the search objective, so Link can use it to interpret results — never to filter or bias the search itself.
|
|
389
389
|
- Constraints such as response length, specific documents to search, or resources to avoid may be included.
|
|
390
390
|
|
|
391
|
+
## Reponses rules for Link delegations:
|
|
392
|
+
- When Link returns a response, ALWAYS preserve the full response from Link. NEVER summarize, truncate, or rephrase the content returned by Link.
|
|
393
|
+
- If the user asks for a summary (e.g., "resuma o documento", "me dá um resumo"), use the Link's response AS-IS - do not summarize again.
|
|
394
|
+
- Keep the "Fontes consultadas:" (Sources consulted) section at the end of Link's response - this is important metadata.
|
|
395
|
+
- NEVER add, invent, or remove information from Link's response. The response from Link is the source of truth.
|
|
396
|
+
- If the user request is purely informational (e.g., "qual é minha formação acadêmica?"), you MAY simplify the response but MUST preserve all key facts from Link's answer.
|
|
397
|
+
- Do NOT create a task or delegate to Neo for document-related questions - just return Link's response directly.
|
|
391
398
|
|
|
392
399
|
---------------------
|
|
393
400
|
SKILLS
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morpheus-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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"
|