morpheus-cli 0.2.7 → 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 +51 -8
- package/dist/channels/telegram.js +229 -22
- package/dist/cli/commands/doctor.js +11 -11
- package/dist/cli/commands/init.js +34 -34
- package/dist/cli/commands/restart.js +1 -1
- package/dist/cli/commands/session.js +79 -0
- package/dist/cli/commands/start.js +4 -1
- package/dist/cli/index.js +3 -1
- package/dist/config/manager.js +16 -15
- package/dist/config/schemas.js +2 -1
- package/dist/http/__tests__/config_api.test.js +6 -1
- package/dist/http/api.js +160 -3
- package/dist/http/server.js +4 -2
- package/dist/runtime/memory/backfill-embeddings.js +54 -0
- package/dist/runtime/memory/embedding.service.js +21 -0
- package/dist/runtime/memory/sati/index.js +5 -5
- package/dist/runtime/memory/sati/repository.js +323 -116
- package/dist/runtime/memory/sati/service.js +58 -33
- package/dist/runtime/memory/sati/system-prompts.js +19 -8
- package/dist/runtime/memory/session-embedding-worker.js +94 -0
- package/dist/runtime/memory/sqlite-vec.js +6 -0
- package/dist/runtime/memory/sqlite.js +432 -3
- package/dist/runtime/migration.js +40 -0
- package/dist/runtime/oracle.js +69 -1
- package/dist/runtime/session-embedding-scheduler.js +21 -0
- package/dist/types/config.js +8 -0
- package/dist/ui/assets/index-DqzvLXXS.js +109 -0
- package/dist/ui/assets/index-f1sqiqOo.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +11 -4
- package/dist/ui/assets/index-Dx1lwaMu.js +0 -96
- package/dist/ui/assets/index-QHZ08tDL.css +0 -1
|
@@ -22,6 +22,7 @@ Classify any new memory into one of these types:
|
|
|
22
22
|
- **professional_profile**: Job title, industry, skills.
|
|
23
23
|
|
|
24
24
|
### CRITICAL RULES
|
|
25
|
+
0. **SAVE ON SUMMARY and REASONING IN ENGLISH AND NATIVE LANGUAGE**: Always generate a concise summary in English and, if the original information is in another language, also provide a summary in the original language. This ensures the memory is accessible and useful for future interactions, regardless of the language used.
|
|
25
26
|
1. **NO SECRETS**: NEVER store API keys, passwords, credit cards, or private tokens. If found, ignore them explicitly.
|
|
26
27
|
2. **NO DUPLICATES**: If the information is already covered by the \`existing_memory_summaries\`, DO NOT store it again.
|
|
27
28
|
3. **NO CHIT-CHAT**: Do not store trivial conversation like "Hello", "Thanks", "How are you?".
|
|
@@ -31,12 +32,22 @@ Classify any new memory into one of these types:
|
|
|
31
32
|
5. **OBEY THE USER**: If the user explicitly states something should be remembered, it must be stored with at least 'medium' importance.
|
|
32
33
|
|
|
33
34
|
### OUTPUT FORMAT
|
|
34
|
-
You MUST respond with a valid JSON object matching the \`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
You MUST respond with a valid JSON object ARRAY matching the \`ISatiEvaluationOutputArray\` interface:
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
"should_store": boolean,
|
|
39
|
+
"category": "category_name" | null,
|
|
40
|
+
"importance": "low" | "medium" | "high" | null,
|
|
41
|
+
"summary": "Concise factual statement | Summary in native language" | null,
|
|
42
|
+
"reason": "Why you decided to store or not store | Reason in native language"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"should_store": boolean,
|
|
46
|
+
"category": "category_name" | null,
|
|
47
|
+
"importance": "low" | "medium" | "high" | null,
|
|
48
|
+
"summary": "Concise factual statement | Summary in native language" | null,
|
|
49
|
+
"reason": "Why you decided to store or not store | Reason in native language"
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
42
53
|
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import loadVecExtension from './sqlite-vec.js';
|
|
5
|
+
import { EmbeddingService } from './embedding.service.js';
|
|
6
|
+
import { DisplayManager } from '../display.js';
|
|
7
|
+
const SHORT_DB_PATH = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
|
|
8
|
+
const SATI_DB_PATH = path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
|
|
9
|
+
const EMBEDDING_DIM = 384;
|
|
10
|
+
const BATCH_LIMIT = 5;
|
|
11
|
+
export async function runSessionEmbeddingWorker() {
|
|
12
|
+
const display = DisplayManager.getInstance();
|
|
13
|
+
display.log('🚀 Iniciando worker de embeddings de sessões...', { source: 'SessionEmbeddingWorker' });
|
|
14
|
+
const shortDb = new Database(SHORT_DB_PATH);
|
|
15
|
+
const satiDb = new Database(SATI_DB_PATH);
|
|
16
|
+
shortDb.pragma('journal_mode = WAL');
|
|
17
|
+
satiDb.pragma('journal_mode = WAL');
|
|
18
|
+
// 🔥 importante: carregar vec0 no DB onde existe a tabela vetorial
|
|
19
|
+
loadVecExtension(satiDb);
|
|
20
|
+
const embeddingService = await EmbeddingService.getInstance();
|
|
21
|
+
while (true) {
|
|
22
|
+
const sessions = shortDb.prepare(`
|
|
23
|
+
SELECT id
|
|
24
|
+
FROM sessions
|
|
25
|
+
WHERE ended_at IS NOT NULL
|
|
26
|
+
AND embedding_status = 'pending'
|
|
27
|
+
LIMIT ?
|
|
28
|
+
`).all(BATCH_LIMIT);
|
|
29
|
+
if (sessions.length === 0) {
|
|
30
|
+
display.log('✅ Nenhuma sessão pendente.', { level: 'debug', source: 'SessionEmbeddingWorker' });
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
for (const session of sessions) {
|
|
34
|
+
const sessionId = session.id;
|
|
35
|
+
display.log(`🧠 Processando sessão ${sessionId}...`, { source: 'SessionEmbeddingWorker' });
|
|
36
|
+
try {
|
|
37
|
+
// Skip setting 'processing' as it violates CHECK constraint
|
|
38
|
+
// active_processing.add(sessionId); // If we needed concurrency control
|
|
39
|
+
const chunks = satiDb.prepare(`
|
|
40
|
+
SELECT id, content
|
|
41
|
+
FROM session_chunks
|
|
42
|
+
WHERE session_id = ?
|
|
43
|
+
ORDER BY chunk_index
|
|
44
|
+
`).all(sessionId);
|
|
45
|
+
if (chunks.length === 0) {
|
|
46
|
+
display.log(`⚠️ Sessão ${sessionId} não possui chunks.`, { source: 'SessionEmbeddingWorker' });
|
|
47
|
+
shortDb.prepare(`
|
|
48
|
+
UPDATE sessions
|
|
49
|
+
SET embedding_status = 'embedded',
|
|
50
|
+
embedded = 1
|
|
51
|
+
WHERE id = ?
|
|
52
|
+
`).run(sessionId);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const insertVec = satiDb.prepare(`
|
|
56
|
+
INSERT INTO session_vec (embedding)
|
|
57
|
+
VALUES (?)
|
|
58
|
+
`);
|
|
59
|
+
const insertMap = satiDb.prepare(`
|
|
60
|
+
INSERT OR REPLACE INTO session_embedding_map
|
|
61
|
+
(session_chunk_id, vec_rowid)
|
|
62
|
+
VALUES (?, ?)
|
|
63
|
+
`);
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
display.log(` ↳ Embedding chunk ${chunk.id}`, { source: 'SessionEmbeddingWorker' });
|
|
66
|
+
const embedding = await embeddingService.generate(chunk.content);
|
|
67
|
+
if (!embedding || embedding.length !== EMBEDDING_DIM) {
|
|
68
|
+
throw new Error(`Embedding inválido. Esperado ${EMBEDDING_DIM}, recebido ${embedding?.length}`);
|
|
69
|
+
}
|
|
70
|
+
const result = insertVec.run(new Float32Array(embedding));
|
|
71
|
+
const vecRowId = result.lastInsertRowid;
|
|
72
|
+
insertMap.run(chunk.id, vecRowId);
|
|
73
|
+
}
|
|
74
|
+
// ✅ finalizar sessão
|
|
75
|
+
shortDb.prepare(`
|
|
76
|
+
UPDATE sessions
|
|
77
|
+
SET embedding_status = 'embedded',
|
|
78
|
+
embedded = 1
|
|
79
|
+
WHERE id = ?
|
|
80
|
+
`).run(sessionId);
|
|
81
|
+
display.log(`✅ Sessão ${sessionId} embedada com sucesso.`, { source: 'SessionEmbeddingWorker' });
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
display.log(`❌ Erro na sessão ${sessionId}: ${err}`, { source: 'SessionEmbeddingWorker' });
|
|
85
|
+
shortDb.prepare(`
|
|
86
|
+
UPDATE sessions
|
|
87
|
+
SET embedding_status = 'failed'
|
|
88
|
+
WHERE id = ?
|
|
89
|
+
`).run(sessionId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
display.log('🏁 Worker finalizado.', { source: 'SessionEmbeddingWorker' });
|
|
94
|
+
}
|
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
import { BaseListChatMessageHistory } from "@langchain/core/chat_history";
|
|
2
2
|
import { HumanMessage, AIMessage, SystemMessage, ToolMessage } from "@langchain/core/messages";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
|
-
import
|
|
4
|
+
import fs from "fs-extra";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { DisplayManager } from "../display.js";
|
|
7
9
|
export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
8
10
|
lc_namespace = ["langchain", "stores", "message", "sqlite"];
|
|
11
|
+
display = DisplayManager.getInstance();
|
|
9
12
|
db;
|
|
13
|
+
dbSati; // Optional separate DB for Sati memory, if needed in the future
|
|
10
14
|
sessionId;
|
|
11
15
|
limit;
|
|
12
16
|
constructor(fields) {
|
|
13
17
|
super();
|
|
14
|
-
this.sessionId = fields.sessionId;
|
|
18
|
+
this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
|
|
15
19
|
this.limit = fields.limit ? fields.limit : 20;
|
|
16
20
|
// Default path: ~/.morpheus/memory/short-memory.db
|
|
17
21
|
const dbPath = fields.databasePath || path.join(homedir(), ".morpheus", "memory", "short-memory.db");
|
|
22
|
+
const dbSatiPath = path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
|
|
18
23
|
// Ensure the directory exists
|
|
19
24
|
this.ensureDirectory(dbPath);
|
|
25
|
+
this.ensureDirectory(dbSatiPath);
|
|
20
26
|
// Initialize database with retry logic for locked databases
|
|
21
27
|
try {
|
|
22
28
|
this.db = new Database(dbPath, {
|
|
23
29
|
...fields.config,
|
|
24
30
|
timeout: 5000, // 5 second timeout for locks
|
|
25
31
|
});
|
|
26
|
-
|
|
32
|
+
this.dbSati = new Database(dbSatiPath, {
|
|
33
|
+
...fields.config,
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
});
|
|
27
36
|
try {
|
|
28
37
|
this.ensureTable();
|
|
29
38
|
}
|
|
@@ -35,6 +44,16 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
35
44
|
catch (error) {
|
|
36
45
|
throw new Error(`Failed to initialize SQLite database at ${dbPath}: ${error}`);
|
|
37
46
|
}
|
|
47
|
+
this.initializeSession(); // Initialize session ID
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Initializes the session ID after the database is ready.
|
|
51
|
+
* Must be called after the constructor completes.
|
|
52
|
+
*/
|
|
53
|
+
async initializeSession() {
|
|
54
|
+
if (!this.sessionId || this.sessionId === '') {
|
|
55
|
+
this.sessionId = await this.getCurrentSessionOrCreate();
|
|
56
|
+
}
|
|
38
57
|
}
|
|
39
58
|
/**
|
|
40
59
|
* Handles database corruption by backing up the corrupted file and creating a new one.
|
|
@@ -93,6 +112,20 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
93
112
|
|
|
94
113
|
CREATE INDEX IF NOT EXISTS idx_messages_session_id
|
|
95
114
|
ON messages(session_id);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
117
|
+
id TEXT PRIMARY KEY,
|
|
118
|
+
title TEXT,
|
|
119
|
+
status TEXT CHECK (
|
|
120
|
+
status IN ('active', 'paused', 'archived', 'deleted')
|
|
121
|
+
) NOT NULL DEFAULT 'paused',
|
|
122
|
+
started_at INTEGER NOT NULL,
|
|
123
|
+
ended_at INTEGER,
|
|
124
|
+
archived_at INTEGER,
|
|
125
|
+
deleted_at INTEGER,
|
|
126
|
+
embedding_status TEXT CHECK (embedding_status IN ('none', 'pending', 'embedded', 'failed')) NOT NULL DEFAULT 'none'
|
|
127
|
+
);
|
|
128
|
+
|
|
96
129
|
`);
|
|
97
130
|
this.migrateTable();
|
|
98
131
|
}
|
|
@@ -105,6 +138,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
105
138
|
*/
|
|
106
139
|
migrateTable() {
|
|
107
140
|
try {
|
|
141
|
+
// Migrate messages table
|
|
108
142
|
const tableInfo = this.db.pragma('table_info(messages)');
|
|
109
143
|
const columns = new Set(tableInfo.map(c => c.name));
|
|
110
144
|
const newColumns = [
|
|
@@ -282,6 +316,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
282
316
|
}
|
|
283
317
|
const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
284
318
|
stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model);
|
|
319
|
+
// Verificar se a sessão tem título e definir automaticamente se necessário
|
|
320
|
+
await this.setSessionTitleIfNeeded();
|
|
285
321
|
}
|
|
286
322
|
catch (error) {
|
|
287
323
|
// Check for specific SQLite errors
|
|
@@ -299,6 +335,42 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
299
335
|
throw new Error(`Failed to add message: ${error}`);
|
|
300
336
|
}
|
|
301
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Verifies if the session has a title, and if not, sets it automatically
|
|
340
|
+
* using the first 50 characters of the oldest human message.
|
|
341
|
+
*/
|
|
342
|
+
async setSessionTitleIfNeeded() {
|
|
343
|
+
// Verificar se a sessão já tem título
|
|
344
|
+
const session = this.db.prepare(`
|
|
345
|
+
SELECT title FROM sessions
|
|
346
|
+
WHERE id = ?
|
|
347
|
+
`).get(this.sessionId);
|
|
348
|
+
if (session && session.title) {
|
|
349
|
+
// A sessão já tem título, não precisa fazer nada
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Obter a mensagem mais antiga do tipo "human" da sessão
|
|
353
|
+
const oldestHumanMessage = this.db.prepare(`
|
|
354
|
+
SELECT content
|
|
355
|
+
FROM messages
|
|
356
|
+
WHERE session_id = ? AND type = 'human'
|
|
357
|
+
ORDER BY created_at ASC
|
|
358
|
+
LIMIT 1
|
|
359
|
+
`).get(this.sessionId);
|
|
360
|
+
if (oldestHumanMessage) {
|
|
361
|
+
// Pegar os primeiros 50 caracteres como título
|
|
362
|
+
let title = oldestHumanMessage.content.substring(0, 50);
|
|
363
|
+
// Certificar-se de que o título não termine no meio de uma palavra
|
|
364
|
+
if (title.length === 50) {
|
|
365
|
+
const lastSpaceIndex = title.lastIndexOf(' ');
|
|
366
|
+
if (lastSpaceIndex > 0) {
|
|
367
|
+
title = title.substring(0, lastSpaceIndex);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Chamar a função renameSession para definir o título automaticamente
|
|
371
|
+
await this.renameSession(this.sessionId, title);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
302
374
|
/**
|
|
303
375
|
* Retrieves aggregated usage statistics for all messages in the database.
|
|
304
376
|
*/
|
|
@@ -315,6 +387,26 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
315
387
|
throw new Error(`Failed to get usage stats: ${error}`);
|
|
316
388
|
}
|
|
317
389
|
}
|
|
390
|
+
async getSessionStatus() {
|
|
391
|
+
try {
|
|
392
|
+
const stmt = this.db.prepare("SELECT embedding_status FROM sessions WHERE id = ?");
|
|
393
|
+
const row = stmt.get(this.sessionId);
|
|
394
|
+
//get messages where session_id = this.sessionId
|
|
395
|
+
const stmtMessages = this.db.prepare("SELECT COUNT(*) as messageCount FROM messages WHERE session_id = ?");
|
|
396
|
+
const msgRow = stmtMessages.get(this.sessionId);
|
|
397
|
+
if (row) {
|
|
398
|
+
return {
|
|
399
|
+
id: this.sessionId,
|
|
400
|
+
embedding_status: row.embedding_status,
|
|
401
|
+
messageCount: msgRow.messageCount || 0,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
throw new Error(`Failed to get session status: ${error}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
318
410
|
/**
|
|
319
411
|
* Retrieves aggregated usage statistics grouped by provider and model.
|
|
320
412
|
*/
|
|
@@ -361,6 +453,343 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
|
|
|
361
453
|
throw new Error(`Failed to clear messages: ${error}`);
|
|
362
454
|
}
|
|
363
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Select the last session that time of no ended_at and return its ID, or create a new session if none found.
|
|
458
|
+
* This allows us to group messages into sessions for better organization and potential future features like session management.
|
|
459
|
+
*/
|
|
460
|
+
async getSession() {
|
|
461
|
+
try {
|
|
462
|
+
// Try to find an active session
|
|
463
|
+
const selectStmt = this.db.prepare("SELECT id FROM sessions WHERE ended_at IS NULL AND status = 'active' ORDER BY started_at DESC LIMIT 1");
|
|
464
|
+
const row = selectStmt.get();
|
|
465
|
+
if (row) {
|
|
466
|
+
this.sessionId = row.id;
|
|
467
|
+
}
|
|
468
|
+
// If no active session, create a new one
|
|
469
|
+
if (!this.sessionId) {
|
|
470
|
+
const uuid = randomUUID();
|
|
471
|
+
this.sessionId = uuid;
|
|
472
|
+
const insertStmt = this.db.prepare("INSERT INTO sessions (id, started_at, status) VALUES (?, ?, 'active')");
|
|
473
|
+
const sessionCreated = insertStmt.run(this.sessionId, Date.now());
|
|
474
|
+
this.sessionId = sessionCreated.lastInsertRowid.toString();
|
|
475
|
+
}
|
|
476
|
+
const updateStmt = this.db.prepare("UPDATE messages SET session_id = ? WHERE session_id = 'default'");
|
|
477
|
+
updateStmt.run(this.sessionId);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
throw new Error(`Failed to get session: ${error}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async createNewSession() {
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
// Transação para garantir consistência
|
|
486
|
+
const tx = this.db.transaction(() => {
|
|
487
|
+
// Pegar a sessão atualmente ativa
|
|
488
|
+
const activeSession = this.db.prepare(`
|
|
489
|
+
SELECT id FROM sessions
|
|
490
|
+
WHERE status = 'active'
|
|
491
|
+
`).get();
|
|
492
|
+
// Se houver uma sessão ativa, mudar seu status para 'paused'
|
|
493
|
+
if (activeSession) {
|
|
494
|
+
this.db.prepare(`
|
|
495
|
+
UPDATE sessions
|
|
496
|
+
SET status = 'paused'
|
|
497
|
+
WHERE id = ?
|
|
498
|
+
`).run(activeSession.id);
|
|
499
|
+
}
|
|
500
|
+
// Criar uma nova sessão ativa
|
|
501
|
+
const newId = randomUUID();
|
|
502
|
+
this.db.prepare(`
|
|
503
|
+
INSERT INTO sessions (
|
|
504
|
+
id,
|
|
505
|
+
started_at,
|
|
506
|
+
status
|
|
507
|
+
) VALUES (?, ?, 'active')
|
|
508
|
+
`).run(newId, now);
|
|
509
|
+
// Atualizar o ID da sessão atual desta instância
|
|
510
|
+
this.sessionId = newId;
|
|
511
|
+
});
|
|
512
|
+
tx(); // Executar a transação
|
|
513
|
+
this.display.log('✅ Nova sessão iniciada e sessão anterior pausada', { source: 'Sati' });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Encerrar uma sessão e transformá-la em memória do Sati.
|
|
517
|
+
* Validar sessão existe e está em active ou paused.
|
|
518
|
+
* Marcar sessão como: status = 'archived', ended_at = now, archived_at = now, embedding_status = 'pending'.
|
|
519
|
+
* Exportar mensagens → texto e criar chunks (session_chunks).
|
|
520
|
+
* Remover mensagens da sessão após criar os chunks.
|
|
521
|
+
*/
|
|
522
|
+
async archiveSession(sessionId) {
|
|
523
|
+
// Validar sessão existe e está em active ou paused
|
|
524
|
+
const session = this.db.prepare(`
|
|
525
|
+
SELECT id, status FROM sessions
|
|
526
|
+
WHERE id = ?
|
|
527
|
+
`).get(sessionId);
|
|
528
|
+
if (!session) {
|
|
529
|
+
throw new Error(`Sessão com ID ${sessionId} não encontrada.`);
|
|
530
|
+
}
|
|
531
|
+
if (session.status !== 'active' && session.status !== 'paused') {
|
|
532
|
+
throw new Error(`Sessão com ID ${sessionId} não está em estado ativo ou pausado. Status atual: ${session.status}`);
|
|
533
|
+
}
|
|
534
|
+
const now = Date.now();
|
|
535
|
+
// Transação para garantir consistência
|
|
536
|
+
const tx = this.db.transaction(() => {
|
|
537
|
+
// Marcar sessão como: status = 'archived', ended_at = now, archived_at = now, embedding_status = 'pending'
|
|
538
|
+
this.db.prepare(`
|
|
539
|
+
UPDATE sessions
|
|
540
|
+
SET status = 'archived',
|
|
541
|
+
ended_at = ?,
|
|
542
|
+
archived_at = ?,
|
|
543
|
+
embedding_status = 'pending'
|
|
544
|
+
WHERE id = ?
|
|
545
|
+
`).run(now, now, sessionId);
|
|
546
|
+
// Exportar mensagens → texto
|
|
547
|
+
const messages = this.db.prepare(`
|
|
548
|
+
SELECT type, content
|
|
549
|
+
FROM messages
|
|
550
|
+
WHERE session_id = ?
|
|
551
|
+
ORDER BY created_at ASC
|
|
552
|
+
`).all(sessionId);
|
|
553
|
+
if (messages.length > 0) {
|
|
554
|
+
const sessionText = messages
|
|
555
|
+
.map(m => `[${m.type}] ${m.content}`)
|
|
556
|
+
.join('\n\n');
|
|
557
|
+
// Criar chunks (session_chunks) usando dbSati
|
|
558
|
+
if (this.dbSati) {
|
|
559
|
+
const chunks = this.chunkText(sessionText);
|
|
560
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
561
|
+
this.dbSati.prepare(`
|
|
562
|
+
INSERT INTO session_chunks (
|
|
563
|
+
id,
|
|
564
|
+
session_id,
|
|
565
|
+
chunk_index,
|
|
566
|
+
content,
|
|
567
|
+
created_at
|
|
568
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
569
|
+
`).run(randomUUID(), sessionId, i, chunks[i], now);
|
|
570
|
+
}
|
|
571
|
+
this.display.log(`🧩 ${chunks.length} chunks criados para sessão ${sessionId}`, { source: 'Sati' });
|
|
572
|
+
}
|
|
573
|
+
// Remover mensagens da sessão após criar os chunks
|
|
574
|
+
this.db.prepare(`
|
|
575
|
+
DELETE FROM messages
|
|
576
|
+
WHERE session_id = ?
|
|
577
|
+
`).run(sessionId);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
tx(); // Executar a transação
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Descartar completamente uma sessão sem gerar memória.
|
|
584
|
+
* Validar sessão existe e status ≠ archived.
|
|
585
|
+
* Transação: deletar mensagens da sessão, marcar sessão como: status = 'deleted', deleted_at = now.
|
|
586
|
+
* Se a sessão era active, criar nova sessão ativa.
|
|
587
|
+
*/
|
|
588
|
+
async deleteSession(sessionId) {
|
|
589
|
+
// Validar sessão existe
|
|
590
|
+
const session = this.db.prepare(`
|
|
591
|
+
SELECT id, status FROM sessions
|
|
592
|
+
WHERE id = ?
|
|
593
|
+
`).get(sessionId);
|
|
594
|
+
if (!session) {
|
|
595
|
+
throw new Error(`Sessão com ID ${sessionId} não encontrada.`);
|
|
596
|
+
}
|
|
597
|
+
// Validar status ≠ archived
|
|
598
|
+
if (session.status === 'archived') {
|
|
599
|
+
throw new Error(`Não é possível deletar uma sessão arquivada. Sessão ID: ${sessionId}`);
|
|
600
|
+
}
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
// Transação: deletar mensagens da sessão, marcar sessão como: status = 'deleted', deleted_at = now
|
|
603
|
+
const tx = this.db.transaction(() => {
|
|
604
|
+
// Deletar mensagens da sessão
|
|
605
|
+
this.db.prepare(`
|
|
606
|
+
DELETE FROM messages
|
|
607
|
+
WHERE session_id = ?
|
|
608
|
+
`).run(sessionId);
|
|
609
|
+
// Marcar sessão como: status = 'deleted', deleted_at = now
|
|
610
|
+
this.db.prepare(`
|
|
611
|
+
UPDATE sessions
|
|
612
|
+
SET status = 'deleted',
|
|
613
|
+
deleted_at = ?
|
|
614
|
+
WHERE id = ?
|
|
615
|
+
`).run(now, sessionId);
|
|
616
|
+
});
|
|
617
|
+
tx(); // Executar a transação
|
|
618
|
+
// Se a sessão era active, verificar se há outra para ativar
|
|
619
|
+
if (session.status === 'active') {
|
|
620
|
+
const nextSession = this.db.prepare(`
|
|
621
|
+
SELECT id FROM sessions
|
|
622
|
+
WHERE status = 'paused'
|
|
623
|
+
ORDER BY started_at DESC
|
|
624
|
+
LIMIT 1
|
|
625
|
+
`).get();
|
|
626
|
+
if (nextSession) {
|
|
627
|
+
// Promover a próxima sessão a ativa
|
|
628
|
+
this.db.prepare(`
|
|
629
|
+
UPDATE sessions
|
|
630
|
+
SET status = 'active'
|
|
631
|
+
WHERE id = ?
|
|
632
|
+
`).run(nextSession.id);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
// Nenhuma outra sessão, criar nova
|
|
636
|
+
this.createFreshSession();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Renomear uma sessão ativa ou pausada.
|
|
642
|
+
* Validar sessão existe e status ∈ (paused, active).
|
|
643
|
+
* Atualizar o título da sessão.
|
|
644
|
+
*/
|
|
645
|
+
async renameSession(sessionId, title) {
|
|
646
|
+
// Validar sessão existe e status ∈ (paused, active)
|
|
647
|
+
const session = this.db.prepare(`
|
|
648
|
+
SELECT id, status FROM sessions
|
|
649
|
+
WHERE id = ?
|
|
650
|
+
`).get(sessionId);
|
|
651
|
+
if (!session) {
|
|
652
|
+
throw new Error(`Sessão com ID ${sessionId} não encontrada.`);
|
|
653
|
+
}
|
|
654
|
+
if (session.status !== 'active' && session.status !== 'paused') {
|
|
655
|
+
throw new Error(`Sessão com ID ${sessionId} não está em estado ativo ou pausado. Status atual: ${session.status}`);
|
|
656
|
+
}
|
|
657
|
+
// Transação para garantir consistência
|
|
658
|
+
const tx = this.db.transaction(() => {
|
|
659
|
+
// Atualizar o título da sessão
|
|
660
|
+
this.db.prepare(`
|
|
661
|
+
UPDATE sessions
|
|
662
|
+
SET title = ?
|
|
663
|
+
WHERE id = ?
|
|
664
|
+
`).run(title, sessionId);
|
|
665
|
+
});
|
|
666
|
+
tx(); // Executar a transação
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Trocar o contexto ativo entre sessões não finalizadas.
|
|
670
|
+
* Validar sessão alvo: existe e status ∈ (paused, active).
|
|
671
|
+
* Se já for active, não faz nada.
|
|
672
|
+
* Transação: sessão atual active → paused, sessão alvo → active.
|
|
673
|
+
*/
|
|
674
|
+
async switchSession(targetSessionId) {
|
|
675
|
+
// Validar sessão alvo: existe e status ∈ (paused, active)
|
|
676
|
+
const targetSession = this.db.prepare(`
|
|
677
|
+
SELECT id, status FROM sessions
|
|
678
|
+
WHERE id = ?
|
|
679
|
+
`).get(targetSessionId);
|
|
680
|
+
if (!targetSession) {
|
|
681
|
+
throw new Error(`Sessão alvo com ID ${targetSessionId} não encontrada.`);
|
|
682
|
+
}
|
|
683
|
+
if (targetSession.status !== 'active' && targetSession.status !== 'paused') {
|
|
684
|
+
throw new Error(`Sessão alvo com ID ${targetSessionId} não está em estado ativo ou pausado. Status atual: ${targetSession.status}`);
|
|
685
|
+
}
|
|
686
|
+
// Se já for active, não faz nada
|
|
687
|
+
if (targetSession.status === 'active') {
|
|
688
|
+
return; // A sessão alvo já está ativa, não precisa fazer nada
|
|
689
|
+
}
|
|
690
|
+
// Transação: sessão atual active → paused, sessão alvo → active
|
|
691
|
+
const tx = this.db.transaction(() => {
|
|
692
|
+
// Pegar a sessão atualmente ativa
|
|
693
|
+
const currentActiveSession = this.db.prepare(`
|
|
694
|
+
SELECT id FROM sessions
|
|
695
|
+
WHERE status = 'active'
|
|
696
|
+
`).get();
|
|
697
|
+
// Se houver uma sessão ativa, mudar seu status para 'paused'
|
|
698
|
+
if (currentActiveSession) {
|
|
699
|
+
this.db.prepare(`
|
|
700
|
+
UPDATE sessions
|
|
701
|
+
SET status = 'paused'
|
|
702
|
+
WHERE id = ?
|
|
703
|
+
`).run(currentActiveSession.id);
|
|
704
|
+
}
|
|
705
|
+
// Mudar o status da sessão alvo para 'active'
|
|
706
|
+
this.db.prepare(`
|
|
707
|
+
UPDATE sessions
|
|
708
|
+
SET status = 'active'
|
|
709
|
+
WHERE id = ?
|
|
710
|
+
`).run(targetSessionId);
|
|
711
|
+
});
|
|
712
|
+
tx(); // Executar a transação
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Garantir que sempre exista uma sessão ativa válida.
|
|
716
|
+
* Buscar sessão com status = 'active', retornar seu id se existir,
|
|
717
|
+
* ou criar nova sessão (createFreshSession) e retornar o novo id.
|
|
718
|
+
*/
|
|
719
|
+
async getCurrentSessionOrCreate() {
|
|
720
|
+
// Buscar sessão com status = 'active'
|
|
721
|
+
const activeSession = this.db.prepare(`
|
|
722
|
+
SELECT id FROM sessions
|
|
723
|
+
WHERE status = 'active'
|
|
724
|
+
`).get();
|
|
725
|
+
if (activeSession) {
|
|
726
|
+
// Se existir, retornar seu id
|
|
727
|
+
return activeSession.id;
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
// Se não existir, criar nova sessão (createFreshSession) e retornar o novo id
|
|
731
|
+
const newId = await this.createFreshSession();
|
|
732
|
+
return newId;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async createFreshSession() {
|
|
736
|
+
// Validar que não existe sessão 'active'
|
|
737
|
+
const activeSession = this.db.prepare(`
|
|
738
|
+
SELECT id FROM sessions
|
|
739
|
+
WHERE status = 'active'
|
|
740
|
+
`).get();
|
|
741
|
+
if (activeSession) {
|
|
742
|
+
throw new Error('Já existe uma sessão ativa. Não é possível criar uma nova sessão ativa.');
|
|
743
|
+
}
|
|
744
|
+
const now = Date.now();
|
|
745
|
+
const newId = randomUUID();
|
|
746
|
+
this.db.prepare(`
|
|
747
|
+
INSERT INTO sessions (
|
|
748
|
+
id,
|
|
749
|
+
started_at,
|
|
750
|
+
status
|
|
751
|
+
) VALUES (?, ?, 'active')
|
|
752
|
+
`).run(newId, now);
|
|
753
|
+
return newId;
|
|
754
|
+
}
|
|
755
|
+
chunkText(text, chunkSize = 500, overlap = 50) {
|
|
756
|
+
if (!text || text.length === 0) {
|
|
757
|
+
return [];
|
|
758
|
+
}
|
|
759
|
+
const chunks = [];
|
|
760
|
+
let start = 0;
|
|
761
|
+
while (start < text.length) {
|
|
762
|
+
let end = start + chunkSize;
|
|
763
|
+
// Evita cortar no meio da palavra
|
|
764
|
+
if (end < text.length) {
|
|
765
|
+
const lastSpace = text.lastIndexOf(' ', end);
|
|
766
|
+
if (lastSpace > start) {
|
|
767
|
+
end = lastSpace;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const chunk = text.slice(start, end).trim();
|
|
771
|
+
if (chunk.length > 0) {
|
|
772
|
+
chunks.push(chunk);
|
|
773
|
+
}
|
|
774
|
+
start = end - overlap;
|
|
775
|
+
if (start < 0)
|
|
776
|
+
start = 0;
|
|
777
|
+
}
|
|
778
|
+
return chunks;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Lists all active and paused sessions with their basic information.
|
|
782
|
+
* Returns an array of session objects containing id, title, status, and started_at.
|
|
783
|
+
*/
|
|
784
|
+
async listSessions() {
|
|
785
|
+
const sessions = this.db.prepare(`
|
|
786
|
+
SELECT id, title, status, started_at
|
|
787
|
+
FROM sessions
|
|
788
|
+
WHERE status IN ('active', 'paused')
|
|
789
|
+
ORDER BY started_at DESC
|
|
790
|
+
`).all();
|
|
791
|
+
return sessions;
|
|
792
|
+
}
|
|
364
793
|
/**
|
|
365
794
|
* Closes the database connection.
|
|
366
795
|
* Should be called when the history object is no longer needed.
|