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.
- package/dist/channels/telegram.js +1 -1
- package/dist/cli/commands/start.js +7 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/schemas.js +7 -0
- package/dist/http/api.js +5 -0
- package/dist/http/routers/model-presets.js +169 -0
- package/dist/http/routers/oauth.js +93 -0
- package/dist/http/server.js +4 -0
- package/dist/runtime/memory/sati/service.js +13 -3
- package/dist/runtime/memory/sqlite.js +162 -20
- package/dist/runtime/oauth/manager.js +218 -0
- package/dist/runtime/oauth/provider.js +90 -0
- package/dist/runtime/oauth/store.js +118 -0
- package/dist/runtime/oauth/types.js +1 -0
- package/dist/runtime/oracle.js +18 -7
- package/dist/runtime/providers/factory.js +93 -1
- package/dist/runtime/tasks/event-bus.js +11 -0
- package/dist/runtime/tasks/notifier.js +57 -31
- package/dist/runtime/tasks/repository.js +21 -0
- package/dist/runtime/tasks/worker.js +11 -7
- package/dist/runtime/tools/cache.js +66 -47
- package/dist/ui/assets/{AuditDashboard-rap15I-4.js → AuditDashboard-EvtKjy5H.js} +1 -1
- package/dist/ui/assets/{Chat-CnuRZFBT.js → Chat-yptierPt.js} +3 -3
- package/dist/ui/assets/{Chronos-C81_HP6e.js → Chronos-BA77MYbp.js} +1 -1
- package/dist/ui/assets/{ConfirmationModal-CT_v8cAi.js → ConfirmationModal-NOZr-ipQ.js} +1 -1
- package/dist/ui/assets/{Dashboard-0VfNJ9BZ.js → Dashboard-ly1MJiB4.js} +1 -1
- package/dist/ui/assets/{DeleteConfirmationModal-P2foiqon.js → DeleteConfirmationModal-2HMraacH.js} +1 -1
- package/dist/ui/assets/{Documents-C8UfbcGD.js → Documents-C31fAm0Z.js} +2 -2
- package/dist/ui/assets/{Logs-qdsCW9u9.js → Logs-BiajoLAB.js} +1 -1
- package/dist/ui/assets/{MCPManager-CaZLnrKz.js → MCPManager-DS9jfiZT.js} +1 -1
- package/dist/ui/assets/ModelPresets-CxhKcalw.js +1 -0
- package/dist/ui/assets/{ModelPricing-C73OfGhc.js → ModelPricing-CN8flHnP.js} +1 -1
- package/dist/ui/assets/{Notifications-CwqeagwF.js → Notifications-BfP1_CM3.js} +1 -1
- package/dist/ui/assets/{Pagination-3P6KG-u6.js → Pagination-Doam4_qd.js} +1 -1
- package/dist/ui/assets/SatiMemories-Bk4_ubo7.js +1 -0
- package/dist/ui/assets/{SessionAudit-Cykp4Sv_.js → SessionAudit-D3E6QSQw.js} +2 -2
- package/dist/ui/assets/Settings-3VBK8muv.js +49 -0
- package/dist/ui/assets/{Skills-B6io4GZh.js → Skills-Dp0_GwiW.js} +1 -1
- package/dist/ui/assets/{Smiths-XoDzX1K0.js → Smiths-COTgI2R4.js} +1 -1
- package/dist/ui/assets/{Tasks-vui0C_76.js → Tasks-COe4lIJ7.js} +1 -1
- package/dist/ui/assets/{TrinityDatabases-Dp71dyUn.js → TrinityDatabases-BEU4mmyW.js} +1 -1
- package/dist/ui/assets/{UsageStats-Dz4LXfr4.js → UsageStats-BTmDeG2V.js} +1 -1
- package/dist/ui/assets/{WebhookManager-CC4Mbo2v.js → WebhookManager-FQVyKyW-.js} +2 -2
- package/dist/ui/assets/{agents-DV1oGA7P.js → agents-B6e9N0QI.js} +1 -1
- package/dist/ui/assets/{audit-DnegNntR.js → audit-giQG2WRk.js} +1 -1
- package/dist/ui/assets/{chronos-BDlP8kzg.js → chronos-sweaRcNj.js} +1 -1
- package/dist/ui/assets/{config-BhjCL4aM.js → config-CbUdj76n.js} +1 -1
- package/dist/ui/assets/index-CRPT77Yo.css +1 -0
- package/dist/ui/assets/{index-C3qfojVn.js → index-yu2c4ry1.js} +7 -7
- package/dist/ui/assets/{mcp-uYhIyjyx.js → mcp-v64BBpUk.js} +1 -1
- package/dist/ui/assets/modelPresets-BaNh-gxn.js +1 -0
- package/dist/ui/assets/{skills-_9hplz7d.js → skills-ClRXBlVt.js} +1 -1
- package/dist/ui/assets/{stats-BwaWi9yN.js → stats-nI-89hEX.js} +1 -1
- package/dist/ui/assets/{useCurrency-RBarItCC.js → useCurrency-D5An8I2f.js} +1 -1
- package/dist/ui/assets/vendor-icons-LSkmAkBj.js +1 -0
- package/dist/ui/index.html +3 -3
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/SatiMemories-CVhOdyAk.js +0 -1
- package/dist/ui/assets/Settings-DnyG6tDx.js +0 -49
- package/dist/ui/assets/index-gx__iEcl.css +0 -1
- 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},
|
|
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' });
|
package/dist/config/paths.js
CHANGED
|
@@ -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
|
};
|
package/dist/config/schemas.js
CHANGED
|
@@ -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 ? '✓' : '✗';
|
|
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
|
+
}
|
package/dist/http/server.js
CHANGED
|
@@ -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
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|