morpheus-cli 0.9.32 → 0.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/channels/telegram.js +1 -1
  2. package/dist/cli/commands/start.js +7 -0
  3. package/dist/config/paths.js +1 -0
  4. package/dist/config/schemas.js +7 -0
  5. package/dist/http/api.js +5 -0
  6. package/dist/http/routers/model-presets.js +169 -0
  7. package/dist/http/routers/oauth.js +93 -0
  8. package/dist/http/server.js +4 -0
  9. package/dist/runtime/memory/sati/service.js +13 -3
  10. package/dist/runtime/memory/sqlite.js +162 -20
  11. package/dist/runtime/oauth/manager.js +218 -0
  12. package/dist/runtime/oauth/provider.js +90 -0
  13. package/dist/runtime/oauth/store.js +118 -0
  14. package/dist/runtime/oauth/types.js +1 -0
  15. package/dist/runtime/oracle.js +18 -7
  16. package/dist/runtime/providers/factory.js +93 -1
  17. package/dist/runtime/tasks/event-bus.js +11 -0
  18. package/dist/runtime/tasks/notifier.js +57 -31
  19. package/dist/runtime/tasks/repository.js +21 -0
  20. package/dist/runtime/tasks/worker.js +11 -7
  21. package/dist/runtime/tools/cache.js +66 -47
  22. package/dist/ui/assets/{AuditDashboard-rap15I-4.js → AuditDashboard-EvtKjy5H.js} +1 -1
  23. package/dist/ui/assets/{Chat-CnuRZFBT.js → Chat-yptierPt.js} +3 -3
  24. package/dist/ui/assets/{Chronos-C81_HP6e.js → Chronos-BA77MYbp.js} +1 -1
  25. package/dist/ui/assets/{ConfirmationModal-CT_v8cAi.js → ConfirmationModal-NOZr-ipQ.js} +1 -1
  26. package/dist/ui/assets/{Dashboard-0VfNJ9BZ.js → Dashboard-ly1MJiB4.js} +1 -1
  27. package/dist/ui/assets/{DeleteConfirmationModal-P2foiqon.js → DeleteConfirmationModal-2HMraacH.js} +1 -1
  28. package/dist/ui/assets/{Documents-C8UfbcGD.js → Documents-C31fAm0Z.js} +2 -2
  29. package/dist/ui/assets/{Logs-qdsCW9u9.js → Logs-BiajoLAB.js} +1 -1
  30. package/dist/ui/assets/{MCPManager-CaZLnrKz.js → MCPManager-DS9jfiZT.js} +1 -1
  31. package/dist/ui/assets/ModelPresets-CxhKcalw.js +1 -0
  32. package/dist/ui/assets/{ModelPricing-C73OfGhc.js → ModelPricing-CN8flHnP.js} +1 -1
  33. package/dist/ui/assets/{Notifications-CwqeagwF.js → Notifications-BfP1_CM3.js} +1 -1
  34. package/dist/ui/assets/{Pagination-3P6KG-u6.js → Pagination-Doam4_qd.js} +1 -1
  35. package/dist/ui/assets/SatiMemories-Bk4_ubo7.js +1 -0
  36. package/dist/ui/assets/{SessionAudit-Cykp4Sv_.js → SessionAudit-D3E6QSQw.js} +2 -2
  37. package/dist/ui/assets/Settings-3VBK8muv.js +49 -0
  38. package/dist/ui/assets/{Skills-B6io4GZh.js → Skills-Dp0_GwiW.js} +1 -1
  39. package/dist/ui/assets/{Smiths-XoDzX1K0.js → Smiths-COTgI2R4.js} +1 -1
  40. package/dist/ui/assets/{Tasks-vui0C_76.js → Tasks-COe4lIJ7.js} +1 -1
  41. package/dist/ui/assets/{TrinityDatabases-Dp71dyUn.js → TrinityDatabases-BEU4mmyW.js} +1 -1
  42. package/dist/ui/assets/{UsageStats-Dz4LXfr4.js → UsageStats-BTmDeG2V.js} +1 -1
  43. package/dist/ui/assets/{WebhookManager-CC4Mbo2v.js → WebhookManager-FQVyKyW-.js} +2 -2
  44. package/dist/ui/assets/{agents-DV1oGA7P.js → agents-B6e9N0QI.js} +1 -1
  45. package/dist/ui/assets/{audit-DnegNntR.js → audit-giQG2WRk.js} +1 -1
  46. package/dist/ui/assets/{chronos-BDlP8kzg.js → chronos-sweaRcNj.js} +1 -1
  47. package/dist/ui/assets/{config-BhjCL4aM.js → config-CbUdj76n.js} +1 -1
  48. package/dist/ui/assets/index-CRPT77Yo.css +1 -0
  49. package/dist/ui/assets/{index-C3qfojVn.js → index-yu2c4ry1.js} +7 -7
  50. package/dist/ui/assets/{mcp-uYhIyjyx.js → mcp-v64BBpUk.js} +1 -1
  51. package/dist/ui/assets/modelPresets-BaNh-gxn.js +1 -0
  52. package/dist/ui/assets/{skills-_9hplz7d.js → skills-ClRXBlVt.js} +1 -1
  53. package/dist/ui/assets/{stats-BwaWi9yN.js → stats-nI-89hEX.js} +1 -1
  54. package/dist/ui/assets/{useCurrency-RBarItCC.js → useCurrency-D5An8I2f.js} +1 -1
  55. package/dist/ui/assets/vendor-icons-LSkmAkBj.js +1 -0
  56. package/dist/ui/index.html +3 -3
  57. package/dist/ui/sw.js +1 -1
  58. package/package.json +1 -1
  59. package/dist/ui/assets/SatiMemories-CVhOdyAk.js +0 -1
  60. package/dist/ui/assets/Settings-DnyG6tDx.js +0 -49
  61. package/dist/ui/assets/index-gx__iEcl.css +0 -1
  62. package/dist/ui/assets/vendor-icons-tocJCdt5.js +0 -1
@@ -1408,7 +1408,7 @@ export class TelegramAdapter {
1408
1408
  }
1409
1409
  async handleStartCommand(ctx, user) {
1410
1410
  const welcomeMessage = `
1411
- Hello, @${user}! I am ${this.config.get().agent.name}, ${this.config.get().agent.personality}.
1411
+ Hello, @${user}! I am ${this.config.get().agent.name},
1412
1412
 
1413
1413
  I am your local AI operator/agent. Here are the commands you can use:
1414
1414
 
@@ -23,6 +23,7 @@ import { TaskWorker } from '../../runtime/tasks/worker.js';
23
23
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
24
24
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
25
25
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
26
+ import { OAuthManager } from '../../runtime/oauth/manager.js';
26
27
  import { SkillRegistry } from '../../runtime/skills/index.js';
27
28
  import { MCPToolCache } from '../../runtime/tools/cache.js';
28
29
  import { SmithRegistry } from '../../runtime/smiths/registry.js';
@@ -244,6 +245,12 @@ export const startCommand = new Command('start')
244
245
  // Use CLI port if provided and valid, otherwise fallback to config or default
245
246
  const port = parseInt(options.port) || config.ui.port || 3333;
246
247
  httpServer.start(port);
248
+ // Initialize OAuth manager with the HTTP port (needed for redirect URI)
249
+ const oauthManager = OAuthManager.getInstance(port);
250
+ oauthManager.setNotifyFn(async (serverName, url) => {
251
+ const msg = `🔐 MCP *${serverName}* requires OAuth authorization.\n\nClick to authenticate:\n${url.toString()}`;
252
+ ChannelRegistry.broadcast(msg);
253
+ });
247
254
  }
248
255
  catch (e) {
249
256
  display.log(chalk.red(`Failed to start Web UI: ${e.message}`), { source: 'Zaion' });
@@ -22,4 +22,5 @@ export const PATHS = {
22
22
  trinityDb: path.join(MORPHEUS_ROOT, 'memory', 'trinity.db'),
23
23
  satiDb: path.join(MORPHEUS_ROOT, 'memory', 'sati-memory.db'),
24
24
  linkDb: path.join(MORPHEUS_ROOT, 'memory', 'link.db'),
25
+ oauthTokens: path.join(MORPHEUS_ROOT, 'oauth-tokens.json'),
25
26
  };
@@ -154,6 +154,12 @@ export const ConfigSchema = z.object({
154
154
  retention: z.string().default(DEFAULT_CONFIG.logging.retention),
155
155
  }).default(DEFAULT_CONFIG.logging),
156
156
  });
157
+ export const OAuth2ConfigSchema = z.object({
158
+ grant_type: z.enum(['client_credentials', 'authorization_code']).default('authorization_code'),
159
+ client_id: z.string().optional(),
160
+ client_secret: z.string().optional(),
161
+ scope: z.string().optional(),
162
+ });
157
163
  export const MCPServerConfigSchema = z.discriminatedUnion('transport', [
158
164
  z.object({
159
165
  transport: z.literal('stdio'),
@@ -166,6 +172,7 @@ export const MCPServerConfigSchema = z.discriminatedUnion('transport', [
166
172
  transport: z.literal('http'),
167
173
  url: z.string().url('Valid URL is required for http transport'),
168
174
  headers: z.record(z.string(), z.string()).optional().default({}),
175
+ oauth2: OAuth2ConfigSchema.optional(),
169
176
  args: z.array(z.string()).optional().default([]),
170
177
  env: z.record(z.string(), z.string()).optional().default({}),
171
178
  _comment: z.string().optional(),
package/dist/http/api.js CHANGED
@@ -24,6 +24,7 @@ import { createDangerRouter } from './routers/danger.js';
24
24
  import { createLinkRouter } from './routers/link.js';
25
25
  import { createAgentsRouter } from './routers/agents.js';
26
26
  import { createDisplayRouter } from './routers/display.js';
27
+ import { createModelPresetsRouter } from './routers/model-presets.js';
27
28
  import { getActiveEnvOverrides } from '../config/precedence.js';
28
29
  import { hotReloadConfig, getRestartRequiredChanges } from '../runtime/hot-reload.js';
29
30
  import { AuditRepository } from '../runtime/audit/repository.js';
@@ -61,6 +62,10 @@ export function createApiRouter(oracle, chronosWorker) {
61
62
  router.use('/agents', createAgentsRouter());
62
63
  // Mount Display Stream router
63
64
  router.use('/display', createDisplayRouter());
65
+ // Mount Model Presets router
66
+ router.use('/model-presets', createModelPresetsRouter());
67
+ // NOTE: OAuth router is mounted in server.ts BEFORE auth middleware
68
+ // so the callback endpoint is publicly accessible (browser redirect).
64
69
  // --- Session Management ---
65
70
  router.get('/sessions', async (req, res) => {
66
71
  try {
@@ -0,0 +1,169 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import { randomUUID } from 'crypto';
4
+ import { SQLiteChatMessageHistory } from '../../runtime/memory/sqlite.js';
5
+ import { encrypt, safeDecrypt, looksLikeEncrypted, canEncrypt } from '../../runtime/trinity-crypto.js';
6
+ const PresetBodySchema = z.object({
7
+ name: z.string().min(1).max(100).trim(),
8
+ provider: z.string().min(1),
9
+ model: z.string().min(1),
10
+ api_key: z.string().optional().nullable(),
11
+ base_url: z.string().optional().nullable(),
12
+ temperature: z.number().min(0).max(2).optional().nullable(),
13
+ max_tokens: z.number().int().positive().optional().nullable(),
14
+ });
15
+ function isNameConflict(err) {
16
+ const msg = String(err?.message ?? '');
17
+ return msg.includes('UNIQUE constraint failed: model_presets.name');
18
+ }
19
+ export function createModelPresetsRouter() {
20
+ const router = Router();
21
+ // GET /api/model-presets — list all (api_key masked)
22
+ router.get('/', (req, res) => {
23
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
24
+ try {
25
+ const rows = h.listModelPresets();
26
+ const result = rows.map(({ api_key, ...rest }) => ({
27
+ ...rest,
28
+ has_api_key: !!api_key,
29
+ }));
30
+ res.json(result);
31
+ }
32
+ catch (err) {
33
+ res.status(500).json({ error: err.message });
34
+ }
35
+ finally {
36
+ h.close();
37
+ }
38
+ });
39
+ // GET /api/model-presets/:id/decrypt — returns decrypted api_key (must be before /:id)
40
+ router.get('/:id/decrypt', (req, res) => {
41
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
42
+ try {
43
+ const preset = h.getModelPreset(req.params.id);
44
+ if (!preset)
45
+ return res.status(404).json({ error: 'Preset not found.' });
46
+ if (!preset.api_key)
47
+ return res.json({ api_key: null });
48
+ if (looksLikeEncrypted(preset.api_key)) {
49
+ if (!canEncrypt()) {
50
+ return res.json({ api_key: null, error: 'MORPHEUS_SECRET is not set — cannot decrypt.' });
51
+ }
52
+ return res.json({ api_key: safeDecrypt(preset.api_key) });
53
+ }
54
+ // Plaintext stored (MORPHEUS_SECRET was not set when saved)
55
+ res.json({ api_key: preset.api_key });
56
+ }
57
+ catch (err) {
58
+ res.status(500).json({ error: err.message });
59
+ }
60
+ finally {
61
+ h.close();
62
+ }
63
+ });
64
+ // GET /api/model-presets/:id — single preset (api_key masked)
65
+ router.get('/:id', (req, res) => {
66
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
67
+ try {
68
+ const preset = h.getModelPreset(req.params.id);
69
+ if (!preset)
70
+ return res.status(404).json({ error: 'Preset not found.' });
71
+ const { api_key, ...rest } = preset;
72
+ res.json({ ...rest, has_api_key: !!api_key });
73
+ }
74
+ catch (err) {
75
+ res.status(500).json({ error: err.message });
76
+ }
77
+ finally {
78
+ h.close();
79
+ }
80
+ });
81
+ // POST /api/model-presets — create
82
+ router.post('/', (req, res) => {
83
+ const parsed = PresetBodySchema.safeParse(req.body);
84
+ if (!parsed.success)
85
+ return res.status(400).json({ error: parsed.error.issues });
86
+ const { name, provider, model, api_key, base_url, temperature, max_tokens } = parsed.data;
87
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
88
+ try {
89
+ const now = new Date().toISOString();
90
+ let storedKey = null;
91
+ if (api_key) {
92
+ storedKey = canEncrypt() ? encrypt(api_key) : api_key;
93
+ }
94
+ const id = randomUUID();
95
+ h.upsertModelPreset({ id, name, provider, model, api_key: storedKey, base_url: base_url ?? null, temperature: temperature ?? null, max_tokens: max_tokens ?? null, created_at: now, updated_at: now });
96
+ res.status(201).json({ id, success: true });
97
+ }
98
+ catch (err) {
99
+ if (isNameConflict(err))
100
+ return res.status(409).json({ error: 'A preset with that name already exists.' });
101
+ res.status(500).json({ error: err.message });
102
+ }
103
+ finally {
104
+ h.close();
105
+ }
106
+ });
107
+ // PUT /api/model-presets/:id — update
108
+ router.put('/:id', (req, res) => {
109
+ const parsed = PresetBodySchema.safeParse(req.body);
110
+ if (!parsed.success)
111
+ return res.status(400).json({ error: parsed.error.issues });
112
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
113
+ try {
114
+ const existing = h.getModelPreset(req.params.id);
115
+ if (!existing)
116
+ return res.status(404).json({ error: 'Preset not found.' });
117
+ const { name, provider, model, api_key, base_url, temperature, max_tokens } = parsed.data;
118
+ // api_key update logic:
119
+ // - absent from body (undefined): keep existing
120
+ // - null or "": clear
121
+ // - non-empty string: encrypt and replace
122
+ let storedKey = existing.api_key ?? null;
123
+ if ('api_key' in req.body) {
124
+ if (!api_key) {
125
+ storedKey = null; // clear
126
+ }
127
+ else {
128
+ storedKey = canEncrypt() ? encrypt(api_key) : api_key;
129
+ }
130
+ }
131
+ h.upsertModelPreset({
132
+ id: req.params.id,
133
+ name, provider, model,
134
+ api_key: storedKey,
135
+ base_url: base_url ?? null,
136
+ temperature: temperature ?? null,
137
+ max_tokens: max_tokens ?? null,
138
+ created_at: existing.created_at,
139
+ updated_at: new Date().toISOString(),
140
+ });
141
+ res.json({ success: true });
142
+ }
143
+ catch (err) {
144
+ if (isNameConflict(err))
145
+ return res.status(409).json({ error: 'A preset with that name already exists.' });
146
+ res.status(500).json({ error: err.message });
147
+ }
148
+ finally {
149
+ h.close();
150
+ }
151
+ });
152
+ // DELETE /api/model-presets/:id
153
+ router.delete('/:id', (req, res) => {
154
+ const h = new SQLiteChatMessageHistory({ sessionId: 'presets-api' });
155
+ try {
156
+ const changes = h.deleteModelPreset(req.params.id);
157
+ if (changes === 0)
158
+ return res.status(404).json({ error: 'Preset not found.' });
159
+ res.json({ success: true });
160
+ }
161
+ catch (err) {
162
+ res.status(500).json({ error: err.message });
163
+ }
164
+ finally {
165
+ h.close();
166
+ }
167
+ });
168
+ return router;
169
+ }
@@ -0,0 +1,93 @@
1
+ import { Router } from 'express';
2
+ import { OAuthManager } from '../../runtime/oauth/manager.js';
3
+ import { MCPToolCache } from '../../runtime/tools/cache.js';
4
+ import { DisplayManager } from '../../runtime/display.js';
5
+ const display = DisplayManager.getInstance();
6
+ export function createOAuthRouter() {
7
+ const router = Router();
8
+ /**
9
+ * GET /api/oauth/callback?code=...&state=...
10
+ * Receives the OAuth redirect after user authorizes in their browser.
11
+ */
12
+ router.get('/callback', async (req, res) => {
13
+ const { code, state } = req.query;
14
+ if (!code || typeof code !== 'string') {
15
+ res.status(400).send(renderHtml('Authorization Failed', 'Missing authorization code. Please try again.', false));
16
+ return;
17
+ }
18
+ try {
19
+ const oauthManager = OAuthManager.getInstance();
20
+ const result = await oauthManager.finishAuth(code, state);
21
+ display.log(`OAuth callback: '${result.serverName}' authorized (${result.toolCount} tools)`, {
22
+ level: 'info',
23
+ source: 'OAuth',
24
+ });
25
+ // Trigger MCP tool reload in background
26
+ MCPToolCache.getInstance().reload().catch(err => {
27
+ display.log(`Failed to reload MCP tools after OAuth: ${err}`, {
28
+ level: 'warning',
29
+ source: 'OAuth',
30
+ });
31
+ });
32
+ res.send(renderHtml('Authorization Successful', `MCP server <strong>${result.serverName}</strong> has been authorized. ` +
33
+ `${result.toolCount} tools are now available. You can close this window.`, true));
34
+ }
35
+ catch (error) {
36
+ display.log(`OAuth callback failed: ${error.message}`, {
37
+ level: 'warning',
38
+ source: 'OAuth',
39
+ });
40
+ res.status(500).send(renderHtml('Authorization Failed', `Error: ${error.message}`, false));
41
+ }
42
+ });
43
+ /**
44
+ * GET /api/oauth/status
45
+ * Returns OAuth status for all MCP servers with OAuth data.
46
+ */
47
+ router.get('/status', async (_req, res) => {
48
+ try {
49
+ const oauthManager = OAuthManager.getInstance();
50
+ const statuses = oauthManager.getStatus();
51
+ res.json({ servers: statuses });
52
+ }
53
+ catch (error) {
54
+ res.status(500).json({ error: error.message });
55
+ }
56
+ });
57
+ /**
58
+ * DELETE /api/oauth/tokens/:name
59
+ * Revoke and remove stored OAuth token for an MCP server.
60
+ */
61
+ router.delete('/tokens/:name', async (req, res) => {
62
+ try {
63
+ const oauthManager = OAuthManager.getInstance();
64
+ oauthManager.revokeToken(req.params.name);
65
+ res.json({ ok: true, message: `Token revoked for '${req.params.name}'` });
66
+ }
67
+ catch (error) {
68
+ res.status(500).json({ error: error.message });
69
+ }
70
+ });
71
+ return router;
72
+ }
73
+ function renderHtml(title, message, success) {
74
+ const color = success ? '#22c55e' : '#ef4444';
75
+ const icon = success ? '&#10003;' : '&#10007;';
76
+ return `<!DOCTYPE html>
77
+ <html><head><title>Morpheus OAuth - ${title}</title>
78
+ <style>
79
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center;
80
+ min-height: 100vh; margin: 0; background: #0a0a0a; color: #e0e0e0; }
81
+ .card { text-align: center; padding: 2rem; border: 1px solid ${color}; border-radius: 12px;
82
+ max-width: 400px; background: #111; }
83
+ .icon { font-size: 3rem; color: ${color}; margin-bottom: 1rem; }
84
+ h1 { font-size: 1.25rem; margin: 0 0 1rem; }
85
+ p { color: #999; line-height: 1.5; margin: 0; }
86
+ strong { color: #e0e0e0; }
87
+ </style></head>
88
+ <body><div class="card">
89
+ <div class="icon">${icon}</div>
90
+ <h1>${title}</h1>
91
+ <p>${message}</p>
92
+ </div></body></html>`;
93
+ }
@@ -9,6 +9,7 @@ import { createApiRouter } from './api.js';
9
9
  import { createWebhooksRouter } from './webhooks-router.js';
10
10
  import { authMiddleware } from './middleware/auth.js';
11
11
  import { WebhookDispatcher } from '../runtime/webhooks/dispatcher.js';
12
+ import { createOAuthRouter } from './routers/oauth.js';
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
14
15
  export class HttpServer {
@@ -55,6 +56,9 @@ export class HttpServer {
55
56
  // The trigger endpoint is public (validated via x-api-key header internally).
56
57
  // All other webhook management endpoints apply authMiddleware internally.
57
58
  this.app.use('/api/webhooks', createWebhooksRouter());
59
+ // OAuth callback — public (browser redirect from OAuth provider, no API key).
60
+ // Status/revoke endpoints remain auth-guarded inside the /api block.
61
+ this.app.use('/api/oauth', createOAuthRouter());
58
62
  this.app.use('/api', authMiddleware, createApiRouter(this.oracle, this.chronosWorker));
59
63
  // Serve static frontend from compiled output
60
64
  const uiPath = path.resolve(__dirname, '../ui');
@@ -12,6 +12,8 @@ const display = DisplayManager.getInstance();
12
12
  export class SatiService {
13
13
  repository;
14
14
  static instance;
15
+ cachedAgent = null;
16
+ cachedAgentConfigKey = null;
15
17
  constructor() {
16
18
  this.repository = SatiRepository.getInstance();
17
19
  }
@@ -59,9 +61,17 @@ export class SatiService {
59
61
  const satiConfig = ConfigManager.getInstance().getSatiConfig();
60
62
  if (!satiConfig)
61
63
  return;
62
- // Use the main provider factory to get an agent (Reusing Zion configuration)
63
- // We pass empty tools as Sati is a pure reasoning agent here
64
- const agent = await ProviderFactory.create(satiConfig, []);
64
+ // Reuse cached agent when config hasn't changed to avoid per-call overhead
65
+ const configKey = `${satiConfig.provider}:${satiConfig.model}`;
66
+ let agent;
67
+ if (this.cachedAgent && this.cachedAgentConfigKey === configKey) {
68
+ agent = this.cachedAgent;
69
+ }
70
+ else {
71
+ agent = await ProviderFactory.create(satiConfig, []);
72
+ this.cachedAgent = agent;
73
+ this.cachedAgentConfigKey = configKey;
74
+ }
65
75
  // Get existing memories for context (Simulated "Working Memory" or full list if small)
66
76
  const allMemories = this.repository.getAllMemories();
67
77
  // Map conversation to strict types and sanitize
@@ -10,6 +10,48 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
10
10
  lc_namespace = ["langchain", "stores", "message", "sqlite"];
11
11
  display = DisplayManager.getInstance();
12
12
  static migrationDone = false; // run migrations only once per process
13
+ // ---------------------------------------------------------------------------
14
+ // In-memory message cache — eliminates repeated SQLite reads for active sessions
15
+ // Key: sessionId Value: { messages (DESC order, newest first), touchedAt, limit }
16
+ // ---------------------------------------------------------------------------
17
+ static _cache = new Map();
18
+ static _CACHE_MAX_SESSIONS = 50;
19
+ static _CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
20
+ /** Populate or replace a cache entry, evicting LRU if over capacity. */
21
+ static _setCacheEntry(sessionId, messages, limit) {
22
+ if (!sessionId)
23
+ return;
24
+ if (SQLiteChatMessageHistory._cache.size >= SQLiteChatMessageHistory._CACHE_MAX_SESSIONS) {
25
+ let oldestKey = '';
26
+ let oldestTime = Infinity;
27
+ for (const [k, v] of SQLiteChatMessageHistory._cache) {
28
+ if (v.touchedAt < oldestTime) {
29
+ oldestTime = v.touchedAt;
30
+ oldestKey = k;
31
+ }
32
+ }
33
+ if (oldestKey)
34
+ SQLiteChatMessageHistory._cache.delete(oldestKey);
35
+ }
36
+ SQLiteChatMessageHistory._cache.set(sessionId, { messages: [...messages], touchedAt: Date.now(), limit });
37
+ }
38
+ /** Remove a session from the cache (used on clear()). */
39
+ static invalidateCacheForSession(sessionId) {
40
+ SQLiteChatMessageHistory._cache.delete(sessionId);
41
+ }
42
+ /** Prepend new messages (newest-first order) to an existing cache entry. */
43
+ static _appendToCache(sessionId, newMessages) {
44
+ if (!sessionId || newMessages.length === 0)
45
+ return;
46
+ const entry = SQLiteChatMessageHistory._cache.get(sessionId);
47
+ if (!entry)
48
+ return; // session not cached — will be populated on next read
49
+ // newMessages is in chronological order (oldest first); reverse so newest goes first
50
+ const newestFirst = [...newMessages].reverse();
51
+ const merged = [...newestFirst, ...entry.messages];
52
+ entry.messages = merged.slice(0, entry.limit);
53
+ entry.touchedAt = Date.now();
54
+ }
13
55
  db;
14
56
  sessionId;
15
57
  limit;
@@ -165,6 +207,19 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
165
207
  ('google', 'gemini-1.5-pro', 1.25, 5.0),
166
208
  ('google', 'gemini-1.5-flash', 0.075, 0.3);
167
209
 
210
+ CREATE TABLE IF NOT EXISTS model_presets (
211
+ id TEXT PRIMARY KEY,
212
+ name TEXT UNIQUE NOT NULL,
213
+ provider TEXT NOT NULL,
214
+ model TEXT NOT NULL,
215
+ api_key TEXT,
216
+ base_url TEXT,
217
+ temperature REAL,
218
+ max_tokens INTEGER,
219
+ created_at TEXT NOT NULL,
220
+ updated_at TEXT NOT NULL
221
+ );
222
+
168
223
  `);
169
224
  this.migrateTable();
170
225
  }
@@ -226,52 +281,90 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
226
281
  catch (error) {
227
282
  console.warn(`[SQLite] model_pricing migration failed: ${error}`);
228
283
  }
284
+ // Ensure model_presets table exists for databases created before this feature
285
+ try {
286
+ this.db.exec(`
287
+ CREATE TABLE IF NOT EXISTS model_presets (
288
+ id TEXT PRIMARY KEY,
289
+ name TEXT UNIQUE NOT NULL,
290
+ provider TEXT NOT NULL,
291
+ model TEXT NOT NULL,
292
+ api_key TEXT,
293
+ base_url TEXT,
294
+ temperature REAL,
295
+ max_tokens INTEGER,
296
+ created_at TEXT NOT NULL,
297
+ updated_at TEXT NOT NULL
298
+ )
299
+ `);
300
+ }
301
+ catch (error) {
302
+ console.warn(`[SQLite] model_presets migration failed: ${error}`);
303
+ }
229
304
  }
230
305
  /**
231
306
  * Removes orphaned ToolMessages and incomplete tool-call groups that can
232
- * appear when the LIMIT clause truncates the message window mid-sequence.
307
+ * appear when the LIMIT clause truncates the message window mid-sequence,
308
+ * or when a previous session ended mid-execution leaving dangling tool calls.
233
309
  *
234
- * Messages arrive in DESC order (newest first). An orphaned ToolMessage at
235
- * the end of this array means its parent AIMessage (with tool_calls) was
236
- * outside the window. We also strip AIMessages whose tool_calls have no
237
- * corresponding ToolMessage responses in the window.
310
+ * Handles both ends of the window:
311
+ * START orphaned ToolMessages / AIMessages whose tool responses were cut off
312
+ * END — AIMessages with unanswered tool_calls (session ended mid-execution),
313
+ * which would cause Gemini to reject the next human turn
238
314
  */
239
315
  sanitizeMessageWindow(messages) {
240
316
  if (messages.length === 0)
241
317
  return messages;
242
318
  // Work in chronological order (reverse of DESC) for easier reasoning.
243
319
  const chrono = [...messages].reverse();
244
- // Drop leading ToolMessages that have no preceding AIMessage with matching tool_calls.
320
+ // ── START sanitization ─────────────────────────────────────────────────
321
+ // Drop leading ToolMessages that have no preceding AIMessage with tool_calls.
245
322
  let startIdx = 0;
246
323
  while (startIdx < chrono.length && chrono[startIdx] instanceof ToolMessage) {
247
324
  startIdx++;
248
325
  }
249
- // Also drop a leading AIMessage that has tool_calls but whose ToolMessage
326
+ // Drop a leading AIMessage that has tool_calls but whose ToolMessage
250
327
  // responses were trimmed (they would have been before it in the DB).
251
328
  if (startIdx < chrono.length && chrono[startIdx] instanceof AIMessage) {
252
329
  const ai = chrono[startIdx];
253
330
  if (ai.tool_calls && ai.tool_calls.length > 0) {
254
- // Check if ALL tool_call responses exist after this AIMessage
255
331
  const toolCallIds = ai.tool_calls.map((tc) => tc.id).filter(Boolean);
256
332
  const remaining = chrono.slice(startIdx + 1);
257
- let allFound = true;
258
- for (let i = 0; i < toolCallIds.length; i++) {
259
- const hasResponse = remaining.some((m) => m instanceof ToolMessage && m.tool_call_id === toolCallIds[i]);
260
- if (!hasResponse) {
261
- allFound = false;
262
- break;
263
- }
264
- }
333
+ const allFound = toolCallIds.every((id) => remaining.some((m) => m instanceof ToolMessage && m.tool_call_id === id));
265
334
  if (!allFound)
266
335
  startIdx++;
267
336
  }
268
337
  }
269
- if (startIdx === 0) {
338
+ // ── END sanitization ───────────────────────────────────────────────────
339
+ // Walk backwards from the end and strip any trailing AIMessage that has
340
+ // unanswered tool_calls (session ended mid-execution). Gemini requires that
341
+ // a function_call turn is ALWAYS immediately followed by a function_response
342
+ // turn — a human turn after a dangling tool call causes a 400 error.
343
+ let endIdx = chrono.length;
344
+ while (endIdx > startIdx) {
345
+ const last = chrono[endIdx - 1];
346
+ if (!(last instanceof AIMessage))
347
+ break;
348
+ const ai = last;
349
+ if (!ai.tool_calls || ai.tool_calls.length === 0)
350
+ break;
351
+ // This AIMessage has tool_calls — check if all responses exist before it
352
+ const toolCallIds = ai.tool_calls.map((tc) => tc.id).filter(Boolean);
353
+ const before = chrono.slice(startIdx, endIdx - 1);
354
+ const allAnswered = toolCallIds.every((id) => before.some((m) => m instanceof ToolMessage && m.tool_call_id === id));
355
+ if (allAnswered)
356
+ break; // complete group — keep it
357
+ // Incomplete: strip this AIMessage and any trailing ToolMessages after it
358
+ endIdx--;
359
+ while (endIdx > startIdx && chrono[endIdx - 1] instanceof ToolMessage) {
360
+ endIdx--;
361
+ }
362
+ }
363
+ if (startIdx === 0 && endIdx === chrono.length) {
270
364
  // No sanitization needed — return original DESC order.
271
365
  return messages;
272
366
  }
273
- // Return in the original DESC order (newest first), excluding trimmed messages.
274
- const sanitized = chrono.slice(startIdx);
367
+ const sanitized = chrono.slice(startIdx, endIdx);
275
368
  sanitized.reverse();
276
369
  return sanitized;
277
370
  }
@@ -281,6 +374,21 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
281
374
  */
282
375
  async getMessages() {
283
376
  try {
377
+ // -----------------------------------------------------------------------
378
+ // Cache fast-path: return cached messages if session is warm and not stale
379
+ // -----------------------------------------------------------------------
380
+ if (this.sessionId) {
381
+ const cached = SQLiteChatMessageHistory._cache.get(this.sessionId);
382
+ if (cached) {
383
+ const isStale = Date.now() - cached.touchedAt > SQLiteChatMessageHistory._CACHE_TTL_MS;
384
+ if (!isStale) {
385
+ cached.touchedAt = Date.now();
386
+ return [...cached.messages]; // defensive copy
387
+ }
388
+ // Stale — evict and fall through to DB
389
+ SQLiteChatMessageHistory._cache.delete(this.sessionId);
390
+ }
391
+ }
284
392
  // Fetch new columns
285
393
  const stmt = this.db.prepare(`SELECT type, content, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model
286
394
  FROM messages
@@ -358,7 +466,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
358
466
  // Messages are in DESC order (newest first) here — orphans appear at the tail.
359
467
  // Remove trailing ToolMessages and AIMessages with tool_calls that have no
360
468
  // matching ToolMessage response (i.e. incomplete tool-call groups at the boundary).
361
- return this.sanitizeMessageWindow(mapped);
469
+ const result = this.sanitizeMessageWindow(mapped);
470
+ // Populate cache for subsequent reads in this session
471
+ SQLiteChatMessageHistory._setCacheEntry(this.sessionId, result, this.limit ?? 100);
472
+ return result;
362
473
  }
363
474
  catch (error) {
364
475
  // Check if it's a database lock error
@@ -459,6 +570,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
459
570
  }
460
571
  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, audio_duration_seconds, agent, duration_ms, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
461
572
  stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds, agent, durationMs, source);
573
+ // Update in-memory cache so the next getMessages() call is a cache hit
574
+ SQLiteChatMessageHistory._appendToCache(this.sessionId, [message]);
462
575
  // Verificar se a sessão tem título e definir automaticamente se necessário
463
576
  await this.setSessionTitleIfNeeded();
464
577
  }
@@ -517,6 +630,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
517
630
  });
518
631
  try {
519
632
  insertAll(messages);
633
+ // Update in-memory cache so the next getMessages() call is a cache hit
634
+ SQLiteChatMessageHistory._appendToCache(this.sessionId, messages);
520
635
  await this.setSessionTitleIfNeeded();
521
636
  }
522
637
  catch (error) {
@@ -736,6 +851,32 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
736
851
  const result = this.db.prepare('DELETE FROM model_pricing WHERE provider = ? AND model = ?').run(provider, model);
737
852
  return result.changes;
738
853
  }
854
+ // --- Model Presets CRUD ---
855
+ listModelPresets() {
856
+ return this.db.prepare('SELECT id, name, provider, model, api_key, base_url, temperature, max_tokens, created_at, updated_at FROM model_presets ORDER BY name').all();
857
+ }
858
+ getModelPreset(id) {
859
+ return this.db.prepare('SELECT id, name, provider, model, api_key, base_url, temperature, max_tokens, created_at, updated_at FROM model_presets WHERE id = ?').get(id);
860
+ }
861
+ upsertModelPreset(entry) {
862
+ const now = new Date().toISOString();
863
+ this.db.prepare(`
864
+ INSERT INTO model_presets (id, name, provider, model, api_key, base_url, temperature, max_tokens, created_at, updated_at)
865
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
866
+ ON CONFLICT(id) DO UPDATE SET
867
+ name = excluded.name,
868
+ provider = excluded.provider,
869
+ model = excluded.model,
870
+ api_key = excluded.api_key,
871
+ base_url = excluded.base_url,
872
+ temperature = excluded.temperature,
873
+ max_tokens = excluded.max_tokens,
874
+ updated_at = excluded.updated_at
875
+ `).run(entry.id, entry.name, entry.provider, entry.model, entry.api_key ?? null, entry.base_url ?? null, entry.temperature ?? null, entry.max_tokens ?? null, entry.created_at ?? now, now);
876
+ }
877
+ deleteModelPreset(id) {
878
+ return this.db.prepare('DELETE FROM model_presets WHERE id = ?').run(id).changes;
879
+ }
739
880
  /**
740
881
  * Clears all messages for the current session from the database.
741
882
  */
@@ -743,6 +884,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
743
884
  try {
744
885
  const stmt = this.db.prepare("DELETE FROM messages WHERE session_id = ?");
745
886
  stmt.run(this.sessionId);
887
+ SQLiteChatMessageHistory._cache.delete(this.sessionId);
746
888
  }
747
889
  catch (error) {
748
890
  // Check for database lock errors