morpheus-cli 0.9.33 → 0.9.41

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 (56) 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/sqlite.js +96 -19
  10. package/dist/runtime/oauth/manager.js +218 -0
  11. package/dist/runtime/oauth/provider.js +90 -0
  12. package/dist/runtime/oauth/store.js +118 -0
  13. package/dist/runtime/oauth/types.js +1 -0
  14. package/dist/runtime/providers/factory.js +93 -1
  15. package/dist/runtime/tools/cache.js +66 -47
  16. package/dist/ui/assets/{AuditDashboard-rap15I-4.js → AuditDashboard-EvtKjy5H.js} +1 -1
  17. package/dist/ui/assets/{Chat-CnuRZFBT.js → Chat-yptierPt.js} +3 -3
  18. package/dist/ui/assets/{Chronos-C81_HP6e.js → Chronos-BA77MYbp.js} +1 -1
  19. package/dist/ui/assets/{ConfirmationModal-CT_v8cAi.js → ConfirmationModal-NOZr-ipQ.js} +1 -1
  20. package/dist/ui/assets/{Dashboard-0VfNJ9BZ.js → Dashboard-ly1MJiB4.js} +1 -1
  21. package/dist/ui/assets/{DeleteConfirmationModal-P2foiqon.js → DeleteConfirmationModal-2HMraacH.js} +1 -1
  22. package/dist/ui/assets/{Documents-C8UfbcGD.js → Documents-C31fAm0Z.js} +2 -2
  23. package/dist/ui/assets/{Logs-qdsCW9u9.js → Logs-BiajoLAB.js} +1 -1
  24. package/dist/ui/assets/{MCPManager-CaZLnrKz.js → MCPManager-DS9jfiZT.js} +1 -1
  25. package/dist/ui/assets/ModelPresets-CxhKcalw.js +1 -0
  26. package/dist/ui/assets/{ModelPricing-C73OfGhc.js → ModelPricing-CN8flHnP.js} +1 -1
  27. package/dist/ui/assets/{Notifications-CwqeagwF.js → Notifications-BfP1_CM3.js} +1 -1
  28. package/dist/ui/assets/{Pagination-3P6KG-u6.js → Pagination-Doam4_qd.js} +1 -1
  29. package/dist/ui/assets/SatiMemories-Bk4_ubo7.js +1 -0
  30. package/dist/ui/assets/{SessionAudit-Cykp4Sv_.js → SessionAudit-D3E6QSQw.js} +2 -2
  31. package/dist/ui/assets/Settings-3VBK8muv.js +49 -0
  32. package/dist/ui/assets/{Skills-B6io4GZh.js → Skills-Dp0_GwiW.js} +1 -1
  33. package/dist/ui/assets/{Smiths-XoDzX1K0.js → Smiths-COTgI2R4.js} +1 -1
  34. package/dist/ui/assets/{Tasks-vui0C_76.js → Tasks-COe4lIJ7.js} +1 -1
  35. package/dist/ui/assets/{TrinityDatabases-Dp71dyUn.js → TrinityDatabases-BEU4mmyW.js} +1 -1
  36. package/dist/ui/assets/{UsageStats-Dz4LXfr4.js → UsageStats-BTmDeG2V.js} +1 -1
  37. package/dist/ui/assets/{WebhookManager-CC4Mbo2v.js → WebhookManager-FQVyKyW-.js} +2 -2
  38. package/dist/ui/assets/{agents-DV1oGA7P.js → agents-B6e9N0QI.js} +1 -1
  39. package/dist/ui/assets/{audit-DnegNntR.js → audit-giQG2WRk.js} +1 -1
  40. package/dist/ui/assets/{chronos-BDlP8kzg.js → chronos-sweaRcNj.js} +1 -1
  41. package/dist/ui/assets/{config-BhjCL4aM.js → config-CbUdj76n.js} +1 -1
  42. package/dist/ui/assets/index-CRPT77Yo.css +1 -0
  43. package/dist/ui/assets/{index-C3qfojVn.js → index-yu2c4ry1.js} +7 -7
  44. package/dist/ui/assets/{mcp-uYhIyjyx.js → mcp-v64BBpUk.js} +1 -1
  45. package/dist/ui/assets/modelPresets-BaNh-gxn.js +1 -0
  46. package/dist/ui/assets/{skills-_9hplz7d.js → skills-ClRXBlVt.js} +1 -1
  47. package/dist/ui/assets/{stats-BwaWi9yN.js → stats-nI-89hEX.js} +1 -1
  48. package/dist/ui/assets/{useCurrency-RBarItCC.js → useCurrency-D5An8I2f.js} +1 -1
  49. package/dist/ui/assets/vendor-icons-LSkmAkBj.js +1 -0
  50. package/dist/ui/index.html +3 -3
  51. package/dist/ui/sw.js +1 -1
  52. package/package.json +1 -1
  53. package/dist/ui/assets/SatiMemories-CVhOdyAk.js +0 -1
  54. package/dist/ui/assets/Settings-DnyG6tDx.js +0 -49
  55. package/dist/ui/assets/index-gx__iEcl.css +0 -1
  56. 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');
@@ -207,6 +207,19 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
207
207
  ('google', 'gemini-1.5-pro', 1.25, 5.0),
208
208
  ('google', 'gemini-1.5-flash', 0.075, 0.3);
209
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
+
210
223
  `);
211
224
  this.migrateTable();
212
225
  }
@@ -268,52 +281,90 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
268
281
  catch (error) {
269
282
  console.warn(`[SQLite] model_pricing migration failed: ${error}`);
270
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
+ }
271
304
  }
272
305
  /**
273
306
  * Removes orphaned ToolMessages and incomplete tool-call groups that can
274
- * 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.
275
309
  *
276
- * Messages arrive in DESC order (newest first). An orphaned ToolMessage at
277
- * the end of this array means its parent AIMessage (with tool_calls) was
278
- * outside the window. We also strip AIMessages whose tool_calls have no
279
- * 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
280
314
  */
281
315
  sanitizeMessageWindow(messages) {
282
316
  if (messages.length === 0)
283
317
  return messages;
284
318
  // Work in chronological order (reverse of DESC) for easier reasoning.
285
319
  const chrono = [...messages].reverse();
286
- // 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.
287
322
  let startIdx = 0;
288
323
  while (startIdx < chrono.length && chrono[startIdx] instanceof ToolMessage) {
289
324
  startIdx++;
290
325
  }
291
- // Also drop a leading AIMessage that has tool_calls but whose ToolMessage
326
+ // Drop a leading AIMessage that has tool_calls but whose ToolMessage
292
327
  // responses were trimmed (they would have been before it in the DB).
293
328
  if (startIdx < chrono.length && chrono[startIdx] instanceof AIMessage) {
294
329
  const ai = chrono[startIdx];
295
330
  if (ai.tool_calls && ai.tool_calls.length > 0) {
296
- // Check if ALL tool_call responses exist after this AIMessage
297
331
  const toolCallIds = ai.tool_calls.map((tc) => tc.id).filter(Boolean);
298
332
  const remaining = chrono.slice(startIdx + 1);
299
- let allFound = true;
300
- for (let i = 0; i < toolCallIds.length; i++) {
301
- const hasResponse = remaining.some((m) => m instanceof ToolMessage && m.tool_call_id === toolCallIds[i]);
302
- if (!hasResponse) {
303
- allFound = false;
304
- break;
305
- }
306
- }
333
+ const allFound = toolCallIds.every((id) => remaining.some((m) => m instanceof ToolMessage && m.tool_call_id === id));
307
334
  if (!allFound)
308
335
  startIdx++;
309
336
  }
310
337
  }
311
- 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) {
312
364
  // No sanitization needed — return original DESC order.
313
365
  return messages;
314
366
  }
315
- // Return in the original DESC order (newest first), excluding trimmed messages.
316
- const sanitized = chrono.slice(startIdx);
367
+ const sanitized = chrono.slice(startIdx, endIdx);
317
368
  sanitized.reverse();
318
369
  return sanitized;
319
370
  }
@@ -800,6 +851,32 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
800
851
  const result = this.db.prepare('DELETE FROM model_pricing WHERE provider = ? AND model = ?').run(provider, model);
801
852
  return result.changes;
802
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
+ }
803
880
  /**
804
881
  * Clears all messages for the current session from the database.
805
882
  */