omnikey-cli 1.0.42 → 1.1.0
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/backend-dist/agent/agentServer.js +69 -4
- package/backend-dist/agent/mcpRuntime.js +239 -4
- package/backend-dist/agent/sessionGrouping.js +296 -0
- package/backend-dist/agent/utils.js +7 -1
- package/backend-dist/ai-client.js +24 -11
- package/backend-dist/db.js +23 -2
- package/backend-dist/index.js +18 -4
- package/backend-dist/models/agentSession.js +16 -0
- package/package.json +1 -1
|
@@ -40,6 +40,7 @@ exports.runAgentTurn = runAgentTurn;
|
|
|
40
40
|
exports.attachAgentWebSocketServer = attachAgentWebSocketServer;
|
|
41
41
|
exports.createAgentRouter = createAgentRouter;
|
|
42
42
|
const express_1 = __importDefault(require("express"));
|
|
43
|
+
const sequelize_1 = require("sequelize");
|
|
43
44
|
const ws_1 = __importStar(require("ws"));
|
|
44
45
|
const cuid_1 = __importDefault(require("cuid"));
|
|
45
46
|
const config_1 = require("../config");
|
|
@@ -56,6 +57,7 @@ const agentAuth_1 = require("./agentAuth");
|
|
|
56
57
|
const authMiddleware_1 = require("../authMiddleware");
|
|
57
58
|
const imageTool_1 = require("./imageTool");
|
|
58
59
|
const utils_1 = require("./utils");
|
|
60
|
+
const sessionGrouping_1 = require("./sessionGrouping");
|
|
59
61
|
const ai_client_1 = require("../ai-client");
|
|
60
62
|
async function runToolLoop(initialResult, session, sessionId, send, log, tools, mcpDispatch, onUsage) {
|
|
61
63
|
const MAX_TOOL_ITERATIONS = 10;
|
|
@@ -191,6 +193,7 @@ async function runToolLoop(initialResult, session, sessionId, send, log, tools,
|
|
|
191
193
|
return result;
|
|
192
194
|
}
|
|
193
195
|
const aiModel = (0, ai_client_1.getDefaultModel)(config_1.config.aiProvider, 'smart');
|
|
196
|
+
const contextWindowSize = (0, ai_client_1.getContextWindowSize)(config_1.config.aiProvider);
|
|
194
197
|
// ─── DB helpers ───────────────────────────────────────────────────────────────
|
|
195
198
|
async function persistSessionToDB(sessionId, state) {
|
|
196
199
|
try {
|
|
@@ -363,6 +366,27 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
|
|
|
363
366
|
if (isErrorFlag) {
|
|
364
367
|
userContent = `COMMAND ERROR:\n${userContent}`;
|
|
365
368
|
}
|
|
369
|
+
// If the client specified a group_name, look up the stored description
|
|
370
|
+
// and prepend it as a <project_context> block. The frontend never sends
|
|
371
|
+
// the description itself — the server is the single source of truth.
|
|
372
|
+
if (clientMessage.group_name && !isTerminalOutput && !isErrorFlag && !clientMessage.is_web_call) {
|
|
373
|
+
try {
|
|
374
|
+
const groupRow = await agentSession_1.AgentSession.findOne({
|
|
375
|
+
where: {
|
|
376
|
+
subscriptionId: subscription.id,
|
|
377
|
+
groupName: clientMessage.group_name,
|
|
378
|
+
groupDescription: { [sequelize_1.Op.not]: null },
|
|
379
|
+
},
|
|
380
|
+
attributes: ['groupName', 'groupDescription'],
|
|
381
|
+
});
|
|
382
|
+
if (groupRow?.groupDescription) {
|
|
383
|
+
userContent = `<project_context name="${groupRow.groupName}">\n${groupRow.groupDescription}\n</project_context>\n\n${userContent}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
log.warn('Failed to fetch group description for context injection', { error: err });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
366
390
|
log.info('Agent turn received client message', {
|
|
367
391
|
sessionId,
|
|
368
392
|
isTerminalOutput,
|
|
@@ -413,6 +437,9 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
|
|
|
413
437
|
completionTokensUsed: usage.completion_tokens,
|
|
414
438
|
totalTokensUsed: usage.total_tokens,
|
|
415
439
|
}, { where: { id: sessionId } });
|
|
440
|
+
// Track the most recent prompt size so the UI can show accurate
|
|
441
|
+
// "tokens remaining" without the cumulative-sum skew of promptTokensUsed.
|
|
442
|
+
await agentSession_1.AgentSession.update({ lastPromptTokens: usage.prompt_tokens }, { where: { id: sessionId } });
|
|
416
443
|
}
|
|
417
444
|
catch (err) {
|
|
418
445
|
log.error('Failed to update agent session token usage', { sessionId, error: err });
|
|
@@ -569,6 +596,7 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
|
|
|
569
596
|
sender: 'agent',
|
|
570
597
|
content: hasFinalAnswerTag ? content : `<final_answer>\n${content}\n</final_answer>`,
|
|
571
598
|
});
|
|
599
|
+
void (0, sessionGrouping_1.updateSessionGroup)(sessionId, subscription.id);
|
|
572
600
|
}
|
|
573
601
|
else if (content) {
|
|
574
602
|
// Fallback: the LLM returned content without any recognized tag and it
|
|
@@ -587,6 +615,7 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
|
|
|
587
615
|
sender: 'agent',
|
|
588
616
|
content: `<final_answer>\n${content}\n</final_answer>`,
|
|
589
617
|
});
|
|
618
|
+
void (0, sessionGrouping_1.updateSessionGroup)(sessionId, subscription.id);
|
|
590
619
|
}
|
|
591
620
|
else {
|
|
592
621
|
log.warn('Agent returned empty content with no recognized tags; sending error', {
|
|
@@ -691,6 +720,7 @@ function cleanUserTranscriptText(text) {
|
|
|
691
720
|
return text
|
|
692
721
|
.replace(/<user_input>([\s\S]*?)<\/user_input>/gi, '$1')
|
|
693
722
|
.replace(/<stored_instructions>[\s\S]*?<\/stored_instructions>/gi, '')
|
|
723
|
+
.replace(/<project_context[^>]*>[\s\S]*?<\/project_context>/gi, '')
|
|
694
724
|
.replace(/@omniagent/gi, '')
|
|
695
725
|
.trim();
|
|
696
726
|
}
|
|
@@ -850,6 +880,9 @@ function createAgentRouter() {
|
|
|
850
880
|
'totalTokensUsed',
|
|
851
881
|
'promptTokensUsed',
|
|
852
882
|
'completionTokensUsed',
|
|
883
|
+
'lastPromptTokens',
|
|
884
|
+
'groupName',
|
|
885
|
+
'groupDescription',
|
|
853
886
|
'lastActiveAt',
|
|
854
887
|
'createdAt',
|
|
855
888
|
'updatedAt',
|
|
@@ -863,8 +896,10 @@ function createAgentRouter() {
|
|
|
863
896
|
totalTokensUsed: Number(s.totalTokensUsed),
|
|
864
897
|
promptTokensUsed: Number(s.promptTokensUsed),
|
|
865
898
|
completionTokensUsed: Number(s.completionTokensUsed),
|
|
866
|
-
remainingContextTokens: Math.max(0,
|
|
867
|
-
contextBudget:
|
|
899
|
+
remainingContextTokens: Math.max(0, contextWindowSize - Number(s.lastPromptTokens)),
|
|
900
|
+
contextBudget: contextWindowSize,
|
|
901
|
+
groupName: s.groupName ?? null,
|
|
902
|
+
groupDescription: s.groupDescription ?? null,
|
|
868
903
|
lastActiveAt: s.lastActiveAt,
|
|
869
904
|
createdAt: s.createdAt,
|
|
870
905
|
updatedAt: s.updatedAt,
|
|
@@ -919,6 +954,7 @@ function createAgentRouter() {
|
|
|
919
954
|
'totalTokensUsed',
|
|
920
955
|
'promptTokensUsed',
|
|
921
956
|
'completionTokensUsed',
|
|
957
|
+
'lastPromptTokens',
|
|
922
958
|
'lastActiveAt',
|
|
923
959
|
],
|
|
924
960
|
});
|
|
@@ -933,8 +969,8 @@ function createAgentRouter() {
|
|
|
933
969
|
totalTokensUsed: Number(session.totalTokensUsed),
|
|
934
970
|
promptTokensUsed: Number(session.promptTokensUsed),
|
|
935
971
|
completionTokensUsed: Number(session.completionTokensUsed),
|
|
936
|
-
remainingContextTokens: Math.max(0,
|
|
937
|
-
contextBudget:
|
|
972
|
+
remainingContextTokens: Math.max(0, contextWindowSize - Number(session.lastPromptTokens)),
|
|
973
|
+
contextBudget: contextWindowSize,
|
|
938
974
|
lastActiveAt: session.lastActiveAt,
|
|
939
975
|
});
|
|
940
976
|
}
|
|
@@ -973,5 +1009,34 @@ function createAgentRouter() {
|
|
|
973
1009
|
res.status(500).json({ error: 'Internal server error' });
|
|
974
1010
|
}
|
|
975
1011
|
});
|
|
1012
|
+
// GET /api/agent/groups
|
|
1013
|
+
// Returns distinct group names and descriptions for the authenticated
|
|
1014
|
+
// subscription. The client uses this to populate the project-path dropdown
|
|
1015
|
+
// and to filter the sidebar session list by project.
|
|
1016
|
+
router.get('/groups', async (_req, res) => {
|
|
1017
|
+
const { subscription, logger: log } = res.locals;
|
|
1018
|
+
try {
|
|
1019
|
+
const rows = await agentSession_1.AgentSession.findAll({
|
|
1020
|
+
where: {
|
|
1021
|
+
subscriptionId: subscription.id,
|
|
1022
|
+
groupName: { [sequelize_1.Op.not]: null },
|
|
1023
|
+
},
|
|
1024
|
+
attributes: ['groupName', 'groupDescription'],
|
|
1025
|
+
group: ['group_name'],
|
|
1026
|
+
order: [['groupName', 'ASC']],
|
|
1027
|
+
});
|
|
1028
|
+
const groups = rows
|
|
1029
|
+
.filter((r) => r.groupName)
|
|
1030
|
+
.map((r) => ({
|
|
1031
|
+
groupName: r.groupName,
|
|
1032
|
+
groupDescription: r.groupDescription ?? null,
|
|
1033
|
+
}));
|
|
1034
|
+
res.json({ groups });
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
log.error('Failed to fetch session groups', { error: err });
|
|
1038
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
976
1041
|
return router;
|
|
977
1042
|
}
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
// exposes their tools to the agent as `AITool` entries. The agent's tool
|
|
6
6
|
// dispatcher routes any tool call whose name starts with `MCP_TOOL_PREFIX`
|
|
7
7
|
// back here so it is forwarded to the originating MCP server.
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
8
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
12
|
exports.MCP_TOOL_PREFIX = void 0;
|
|
13
|
+
exports.resolveLoginShell = resolveLoginShell;
|
|
14
|
+
exports.wrapWithLoginShell = wrapWithLoginShell;
|
|
15
|
+
exports.buildStdioChildEnv = buildStdioChildEnv;
|
|
10
16
|
exports.getMcpToolsForSubscription = getMcpToolsForSubscription;
|
|
11
17
|
exports.executeMcpTool = executeMcpTool;
|
|
12
18
|
exports.invalidateMcpRuntimeForServer = invalidateMcpRuntimeForServer;
|
|
13
19
|
exports.shutdownAllMcpClients = shutdownAllMcpClients;
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
14
22
|
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
15
23
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
16
24
|
const sse_js_1 = require("@modelcontextprotocol/sdk/client/sse.js");
|
|
@@ -20,6 +28,130 @@ const mcpServer_1 = require("../models/mcpServer");
|
|
|
20
28
|
exports.MCP_TOOL_PREFIX = 'mcp_';
|
|
21
29
|
const MAX_TOOL_NAME_LEN = 64;
|
|
22
30
|
const CONNECT_TIMEOUT_MS = 15000;
|
|
31
|
+
const STDIO_STDERR_MAX_BYTES = 16 * 1024;
|
|
32
|
+
const STDIO_STDERR_DRAIN_MS = 2000;
|
|
33
|
+
// Ordered candidate list for resolving the user's login shell on Unix/macOS,
|
|
34
|
+
// mirroring the resolvedLoginShell() logic in the macOS terminal launch path.
|
|
35
|
+
// The SHELL environment variable is checked first at call-time (not here).
|
|
36
|
+
const UNIX_SHELL_CANDIDATES = [
|
|
37
|
+
// zsh — default on macOS Catalina+
|
|
38
|
+
'/bin/zsh',
|
|
39
|
+
'/usr/bin/zsh',
|
|
40
|
+
'/usr/local/bin/zsh', // Homebrew (Intel)
|
|
41
|
+
'/opt/homebrew/bin/zsh', // Homebrew (Apple Silicon)
|
|
42
|
+
// bash — default on older macOS / most Linux distros
|
|
43
|
+
'/bin/bash',
|
|
44
|
+
'/usr/bin/bash',
|
|
45
|
+
'/usr/local/bin/bash',
|
|
46
|
+
'/opt/homebrew/bin/bash',
|
|
47
|
+
// fish — popular third-party shell
|
|
48
|
+
'/usr/local/bin/fish',
|
|
49
|
+
'/opt/homebrew/bin/fish',
|
|
50
|
+
'/usr/bin/fish',
|
|
51
|
+
// ksh — KornShell, common on Linux
|
|
52
|
+
'/bin/ksh',
|
|
53
|
+
'/usr/bin/ksh',
|
|
54
|
+
'/usr/local/bin/ksh',
|
|
55
|
+
// tcsh — legacy, still present on some macOS/BSD systems
|
|
56
|
+
'/bin/tcsh',
|
|
57
|
+
'/usr/bin/tcsh',
|
|
58
|
+
// sh — POSIX fallback, always present
|
|
59
|
+
'/bin/sh',
|
|
60
|
+
'/usr/bin/sh',
|
|
61
|
+
];
|
|
62
|
+
// Ordered candidate list for Windows shells. COMSPEC is checked first at call-time.
|
|
63
|
+
const WINDOWS_SHELL_CANDIDATES = [
|
|
64
|
+
// PowerShell Core (7+) — preferred for modern Windows
|
|
65
|
+
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
66
|
+
'C:\\Program Files\\PowerShell\\6\\pwsh.exe',
|
|
67
|
+
// Windows PowerShell — built-in on all Windows versions
|
|
68
|
+
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
69
|
+
// cmd.exe — last resort
|
|
70
|
+
'C:\\Windows\\System32\\cmd.exe',
|
|
71
|
+
'C:\\Windows\\cmd.exe',
|
|
72
|
+
];
|
|
73
|
+
// Homebrew / system binary directories to prepend on Unix when the daemon's
|
|
74
|
+
// inherited PATH is bare (e.g. launched by launchctl). This is a belt-and-
|
|
75
|
+
// suspenders fallback; running through a login shell already handles this.
|
|
76
|
+
const UNIX_EXTRA_PATH_ENTRIES = [
|
|
77
|
+
'/opt/homebrew/bin',
|
|
78
|
+
'/opt/homebrew/sbin',
|
|
79
|
+
'/usr/local/bin',
|
|
80
|
+
'/usr/local/sbin',
|
|
81
|
+
];
|
|
82
|
+
// Common Windows binary directories to prepend when PATHEXT / system dirs are
|
|
83
|
+
// missing from the inherited PATH.
|
|
84
|
+
const WINDOWS_EXTRA_PATH_ENTRIES = [
|
|
85
|
+
'C:\\Windows\\System32',
|
|
86
|
+
'C:\\Windows',
|
|
87
|
+
'C:\\Windows\\System32\\Wbem',
|
|
88
|
+
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0',
|
|
89
|
+
// Scoop (user-level package manager)
|
|
90
|
+
`${process.env.USERPROFILE ?? 'C:\\Users\\Default'}\\scoop\\shims`,
|
|
91
|
+
// Node.js user-level installer
|
|
92
|
+
`${process.env.APPDATA ?? 'C:\\Users\\Default\\AppData\\Roaming'}\\npm`,
|
|
93
|
+
];
|
|
94
|
+
function isExecutable(filePath) {
|
|
95
|
+
try {
|
|
96
|
+
(0, fs_1.accessSync)(filePath, fs_1.constants.X_OK);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the login shell to use when wrapping stdio MCP child processes.
|
|
105
|
+
* On Unix/macOS mirrors the resolvedLoginShell() logic from the macOS terminal
|
|
106
|
+
* launch path: check $SHELL first, then walk a fixed candidate list.
|
|
107
|
+
* On Windows: check %COMSPEC%, then prefer PowerShell Core > Windows PowerShell > cmd.
|
|
108
|
+
*/
|
|
109
|
+
function resolveLoginShell() {
|
|
110
|
+
if (process.platform === 'win32') {
|
|
111
|
+
const comspec = process.env.COMSPEC ?? '';
|
|
112
|
+
if (comspec && (0, fs_1.existsSync)(comspec))
|
|
113
|
+
return comspec;
|
|
114
|
+
for (const candidate of WINDOWS_SHELL_CANDIDATES) {
|
|
115
|
+
if ((0, fs_1.existsSync)(candidate))
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
118
|
+
return 'cmd.exe';
|
|
119
|
+
}
|
|
120
|
+
const envShell = process.env.SHELL ?? '';
|
|
121
|
+
const candidates = envShell ? [envShell, ...UNIX_SHELL_CANDIDATES] : [...UNIX_SHELL_CANDIDATES];
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
if (candidate && (0, fs_1.existsSync)(candidate) && isExecutable(candidate))
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
return '/bin/sh';
|
|
127
|
+
}
|
|
128
|
+
/** Single-quote a Unix shell argument, safely escaping embedded single quotes. */
|
|
129
|
+
function shellEscapeUnix(arg) {
|
|
130
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Wrap `command args` so it is executed through the resolved login shell.
|
|
134
|
+
*
|
|
135
|
+
* On Unix: `shell -l -c 'command args...'`
|
|
136
|
+
* `-l` sources the login profile (.zprofile, .bash_profile, etc.) so the
|
|
137
|
+
* child inherits the same PATH as an interactive terminal session.
|
|
138
|
+
*
|
|
139
|
+
* On Windows (powershell / pwsh): `shell -NoProfile -Command "command args..."`
|
|
140
|
+
* On Windows (cmd): `shell /c "command args..."`
|
|
141
|
+
*/
|
|
142
|
+
function wrapWithLoginShell(shell, command, args) {
|
|
143
|
+
if (process.platform === 'win32') {
|
|
144
|
+
const shellName = path_1.default.basename(shell).toLowerCase();
|
|
145
|
+
const cmdStr = [command, ...args].join(' ');
|
|
146
|
+
if (shellName === 'pwsh.exe' || shellName === 'powershell.exe') {
|
|
147
|
+
return { command: shell, args: ['-NoProfile', '-Command', cmdStr] };
|
|
148
|
+
}
|
|
149
|
+
// cmd.exe — /c runs the rest of the line as a command
|
|
150
|
+
return { command: shell, args: ['/c', cmdStr] };
|
|
151
|
+
}
|
|
152
|
+
const fullCmd = [command, ...args].map(shellEscapeUnix).join(' ');
|
|
153
|
+
return { command: shell, args: ['-l', '-c', fullCmd] };
|
|
154
|
+
}
|
|
23
155
|
const clients = new Map(); // by MCPServer.id
|
|
24
156
|
function slug(s) {
|
|
25
157
|
return s
|
|
@@ -38,7 +170,54 @@ function isStdioAllowed() {
|
|
|
38
170
|
// outbound HTTP/SSE transports are permitted.
|
|
39
171
|
return config_1.config.isSelfHosted === true || config_1.config.isLocal === true;
|
|
40
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Build the environment for a spawned stdio MCP child process.
|
|
175
|
+
*
|
|
176
|
+
* Starts from the parent `process.env`, then prepends the standard
|
|
177
|
+
* Homebrew / `/usr/local` binary directories (Unix) or common Windows system
|
|
178
|
+
* directories to PATH, preserving the existing PATH after them and never
|
|
179
|
+
* duplicating entries already present. This is a belt-and-suspenders measure
|
|
180
|
+
* on top of the login-shell wrapping: the shell's `-l` flag sources the login
|
|
181
|
+
* profile, but augmenting PATH here also helps when the shell is not found.
|
|
182
|
+
* User-supplied `serverEnv` is overlaid last so an explicit PATH wins.
|
|
183
|
+
* The result is a strict `Record<string, string>` with any `undefined`
|
|
184
|
+
* values stripped out (process.env can legally contain those).
|
|
185
|
+
*/
|
|
186
|
+
function buildStdioChildEnv(serverEnv) {
|
|
187
|
+
const base = {};
|
|
188
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
189
|
+
if (typeof v === 'string')
|
|
190
|
+
base[k] = v;
|
|
191
|
+
}
|
|
192
|
+
if (process.platform === 'win32') {
|
|
193
|
+
// On Windows, PATH lookup is case-insensitive; normalize to the 'Path' key
|
|
194
|
+
// that Node uses, then prepend common system directories.
|
|
195
|
+
const pathKey = Object.keys(base).find((k) => k.toLowerCase() === 'path') ?? 'Path';
|
|
196
|
+
const currentPath = base[pathKey] ?? '';
|
|
197
|
+
const existing = currentPath.split(';').filter((p) => p.length > 0);
|
|
198
|
+
const existingSet = new Set(existing.map((p) => p.toLowerCase()));
|
|
199
|
+
const toPrepend = WINDOWS_EXTRA_PATH_ENTRIES.filter((p) => !existingSet.has(p.toLowerCase()));
|
|
200
|
+
base[pathKey] = [...toPrepend, ...existing].join(';');
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const currentPath = base.PATH ?? '';
|
|
204
|
+
const existing = currentPath.split(':').filter((p) => p.length > 0);
|
|
205
|
+
const existingSet = new Set(existing);
|
|
206
|
+
const toPrepend = UNIX_EXTRA_PATH_ENTRIES.filter((p) => !existingSet.has(p));
|
|
207
|
+
base.PATH = [...toPrepend, ...existing].join(':');
|
|
208
|
+
}
|
|
209
|
+
if (serverEnv) {
|
|
210
|
+
for (const [k, v] of Object.entries(serverEnv)) {
|
|
211
|
+
if (typeof v === 'string')
|
|
212
|
+
base[k] = v;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return base;
|
|
216
|
+
}
|
|
41
217
|
async function connectOne(server, log) {
|
|
218
|
+
// Hoisted so the catch block can drain transport.stderr for diagnostics.
|
|
219
|
+
let stdioTransport;
|
|
220
|
+
let stderrBuffer = '';
|
|
42
221
|
try {
|
|
43
222
|
if (server.transport === 'stdio' && !isStdioAllowed()) {
|
|
44
223
|
throw new Error('stdio MCP transport is disabled in this deployment.');
|
|
@@ -47,14 +226,39 @@ async function connectOne(server, log) {
|
|
|
47
226
|
if (server.transport === 'stdio') {
|
|
48
227
|
if (!server.command)
|
|
49
228
|
throw new Error('command is required for stdio transport');
|
|
50
|
-
const
|
|
229
|
+
const childEnv = buildStdioChildEnv(server.env);
|
|
230
|
+
// Wrap through the login shell so the child process inherits the same
|
|
231
|
+
// PATH as an interactive terminal session (sources .zprofile, .bash_profile,
|
|
232
|
+
// etc.). This is equivalent to how macOS Terminal launches a new window.
|
|
233
|
+
const loginShell = resolveLoginShell();
|
|
234
|
+
const { command: wrappedCmd, args: wrappedArgs } = wrapWithLoginShell(loginShell, server.command, server.args ?? []);
|
|
235
|
+
log.info('Spawning stdio MCP server', {
|
|
236
|
+
mcpServerId: server.id,
|
|
237
|
+
mcpServerName: server.name,
|
|
51
238
|
command: server.command,
|
|
52
239
|
args: server.args ?? [],
|
|
53
|
-
|
|
54
|
-
|
|
240
|
+
loginShell,
|
|
241
|
+
wrappedCommand: wrappedCmd,
|
|
242
|
+
wrappedArgs,
|
|
243
|
+
path: childEnv.PATH,
|
|
244
|
+
});
|
|
245
|
+
stdioTransport = new stdio_js_1.StdioClientTransport({
|
|
246
|
+
command: wrappedCmd,
|
|
247
|
+
args: wrappedArgs,
|
|
248
|
+
env: childEnv,
|
|
55
249
|
stderr: 'pipe',
|
|
56
250
|
});
|
|
57
|
-
|
|
251
|
+
// Attach the stderr listener eagerly: the child can fail (e.g. `env: node:
|
|
252
|
+
// No such file or directory`) and close the stream before our catch block
|
|
253
|
+
// runs, so we have to start buffering before we await client.connect().
|
|
254
|
+
stdioTransport.stderr?.on('data', (chunk) => {
|
|
255
|
+
if (stderrBuffer.length >= STDIO_STDERR_MAX_BYTES)
|
|
256
|
+
return;
|
|
257
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
258
|
+
const remaining = STDIO_STDERR_MAX_BYTES - stderrBuffer.length;
|
|
259
|
+
stderrBuffer += text.length > remaining ? text.slice(0, remaining) : text;
|
|
260
|
+
});
|
|
261
|
+
await withTimeout(client.connect(stdioTransport), CONNECT_TIMEOUT_MS, 'MCP stdio connect');
|
|
58
262
|
}
|
|
59
263
|
else if (server.transport === 'http') {
|
|
60
264
|
if (!server.url)
|
|
@@ -89,16 +293,47 @@ async function connectOne(server, log) {
|
|
|
89
293
|
}
|
|
90
294
|
catch (err) {
|
|
91
295
|
const message = err instanceof Error ? err.message : String(err);
|
|
296
|
+
let childStderr;
|
|
297
|
+
if (server.transport === 'stdio' && stdioTransport) {
|
|
298
|
+
childStderr = await drainStdioStderr(stdioTransport, () => stderrBuffer);
|
|
299
|
+
}
|
|
92
300
|
log.warn('Failed to connect to MCP server', {
|
|
93
301
|
mcpServerId: server.id,
|
|
94
302
|
mcpServerName: server.name,
|
|
95
303
|
transport: server.transport,
|
|
96
304
|
error: message,
|
|
305
|
+
...(childStderr !== undefined ? { childStderr } : {}),
|
|
97
306
|
});
|
|
98
307
|
await mcpServer_1.MCPServer.update({ lastError: message }, { where: { id: server.id } }).catch(() => undefined);
|
|
99
308
|
return null;
|
|
100
309
|
}
|
|
101
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Return the buffered stderr from a failed stdio transport. Waits up to
|
|
313
|
+
* STDIO_STDERR_DRAIN_MS for the stream to emit any final chunks (the child
|
|
314
|
+
* process may still be flushing its stderr when we land in the catch block).
|
|
315
|
+
*/
|
|
316
|
+
async function drainStdioStderr(transport, read) {
|
|
317
|
+
const stderr = transport.stderr;
|
|
318
|
+
if (!stderr)
|
|
319
|
+
return read().trim();
|
|
320
|
+
await new Promise((resolve) => {
|
|
321
|
+
let settled = false;
|
|
322
|
+
const finish = () => {
|
|
323
|
+
if (settled)
|
|
324
|
+
return;
|
|
325
|
+
settled = true;
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
stderr.removeListener('end', finish);
|
|
328
|
+
stderr.removeListener('close', finish);
|
|
329
|
+
resolve();
|
|
330
|
+
};
|
|
331
|
+
const timer = setTimeout(finish, STDIO_STDERR_DRAIN_MS);
|
|
332
|
+
stderr.once('end', finish);
|
|
333
|
+
stderr.once('close', finish);
|
|
334
|
+
});
|
|
335
|
+
return read().trim();
|
|
336
|
+
}
|
|
102
337
|
async function getOrConnect(server, log) {
|
|
103
338
|
const cached = clients.get(server.id);
|
|
104
339
|
if (cached)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.updateSessionGroup = updateSessionGroup;
|
|
4
|
+
exports.refreshAllSessionGroups = refreshAllSessionGroups;
|
|
5
|
+
exports.startGroupingCronJob = startGroupingCronJob;
|
|
6
|
+
const sequelize_1 = require("sequelize");
|
|
7
|
+
const zod_1 = require("zod");
|
|
8
|
+
const agentSession_1 = require("../models/agentSession");
|
|
9
|
+
const subscription_1 = require("../models/subscription");
|
|
10
|
+
const ai_client_1 = require("../ai-client");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const logger_1 = require("../logger");
|
|
13
|
+
const aiModel = (0, ai_client_1.getDefaultModel)(config_1.config.aiProvider, 'fast');
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Extract user_input text from persisted session history
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
function extractUserInputs(historyJson) {
|
|
18
|
+
try {
|
|
19
|
+
const history = JSON.parse(historyJson);
|
|
20
|
+
const inputs = [];
|
|
21
|
+
for (const msg of history) {
|
|
22
|
+
if (msg.role !== 'user')
|
|
23
|
+
continue;
|
|
24
|
+
const raw = typeof msg.content === 'string' ? msg.content : '';
|
|
25
|
+
if (!raw)
|
|
26
|
+
continue;
|
|
27
|
+
// Skip injected feedback/control messages
|
|
28
|
+
if (raw.startsWith('TERMINAL OUTPUT:'))
|
|
29
|
+
continue;
|
|
30
|
+
if (raw.startsWith('COMMAND ERROR:'))
|
|
31
|
+
continue;
|
|
32
|
+
if (raw.startsWith('Web research is complete'))
|
|
33
|
+
continue;
|
|
34
|
+
if (raw.startsWith('IMPORTANT: The web search tool failed'))
|
|
35
|
+
continue;
|
|
36
|
+
if (raw.startsWith('Content was truncated'))
|
|
37
|
+
continue;
|
|
38
|
+
if (raw.includes('<stored_instructions>'))
|
|
39
|
+
continue;
|
|
40
|
+
// Extract the inner text from <user_input> wrapper if present
|
|
41
|
+
const match = /<user_input>([\s\S]*?)<\/user_input>/i.exec(raw);
|
|
42
|
+
const text = match ? match[1].trim() : raw.trim();
|
|
43
|
+
const cleaned = text.replace(/@omniagent\s*/gi, '').trim();
|
|
44
|
+
if (cleaned.length > 5) {
|
|
45
|
+
inputs.push(cleaned.slice(0, 400));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return inputs.slice(0, 8);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Extract the deepest meaningful project root from absolute paths in text.
|
|
56
|
+
// Strategy: find all /abs/path segments, resolve the most commonly referenced
|
|
57
|
+
// root (stops at depth 4 from /, so ~/projects/foo/src/bar → ~/projects/foo).
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function extractProjectPath(texts) {
|
|
60
|
+
const combined = texts.join(' ');
|
|
61
|
+
// Match absolute paths (Unix) — capture up to 5 path segments
|
|
62
|
+
const pathRe = /(\/(?:[^\s/<>"'`]+\/){1,5}[^\s/<>"'`]*)/g;
|
|
63
|
+
const matches = Array.from(combined.matchAll(pathRe), (m) => m[1]);
|
|
64
|
+
if (!matches.length)
|
|
65
|
+
return null;
|
|
66
|
+
// Score candidate roots by frequency: walk up each path up to depth 5
|
|
67
|
+
// counting how many times each ancestor appears across all matches.
|
|
68
|
+
const score = new Map();
|
|
69
|
+
for (const p of matches) {
|
|
70
|
+
const parts = p.split('/').filter(Boolean);
|
|
71
|
+
// Build ancestors from depth 2 up to depth 5 (skip / and single-segment)
|
|
72
|
+
for (let depth = 2; depth <= Math.min(5, parts.length); depth++) {
|
|
73
|
+
const candidate = '/' + parts.slice(0, depth).join('/');
|
|
74
|
+
score.set(candidate, (score.get(candidate) ?? 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Prefer the deepest path that still has a frequency >= half the top score
|
|
78
|
+
const entries = Array.from(score.entries()).sort((a, b) => b[1] - a[1]);
|
|
79
|
+
if (!entries.length)
|
|
80
|
+
return null;
|
|
81
|
+
const topScore = entries[0][1];
|
|
82
|
+
const threshold = Math.max(1, Math.floor(topScore / 2));
|
|
83
|
+
// Among candidates meeting the threshold, pick the deepest (most segments)
|
|
84
|
+
const qualified = entries
|
|
85
|
+
.filter(([, s]) => s >= threshold)
|
|
86
|
+
.sort((a, b) => b[0].split('/').length - a[0].split('/').length);
|
|
87
|
+
return qualified[0]?.[0] ?? null;
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Build a deterministic 3-4 sentence description from the project path.
|
|
91
|
+
// Used as a fallback when the LLM does not return a usable description.
|
|
92
|
+
// When a project path is available it is included verbatim so downstream
|
|
93
|
+
// agent prompts can rely on it as the project root.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
function buildDescription(projectPath, groupName) {
|
|
96
|
+
if (!projectPath) {
|
|
97
|
+
return [
|
|
98
|
+
`You are working on the ${groupName} project.`,
|
|
99
|
+
`This group collects sessions related to ${groupName}.`,
|
|
100
|
+
`No specific file path has been associated with this group yet.`,
|
|
101
|
+
`Use this context to keep responses focused on the ${groupName} topic.`,
|
|
102
|
+
].join(' ');
|
|
103
|
+
}
|
|
104
|
+
const projectName = projectPath.split('/').filter(Boolean).pop() ?? groupName;
|
|
105
|
+
return [
|
|
106
|
+
`You are working in ${projectPath} — the ${projectName} project.`,
|
|
107
|
+
`This group collects sessions related to the ${projectName} codebase.`,
|
|
108
|
+
`Treat ${projectPath} as the project root when interpreting file references and commands.`,
|
|
109
|
+
`Keep responses scoped to this project's structure and conventions.`,
|
|
110
|
+
].join(' ');
|
|
111
|
+
}
|
|
112
|
+
async function classifyGroup(userInputs, existingGroups) {
|
|
113
|
+
if (!userInputs.length)
|
|
114
|
+
return null;
|
|
115
|
+
const existingText = existingGroups.length
|
|
116
|
+
? existingGroups.map((g) => `- "${g.groupName}"`).join('\n')
|
|
117
|
+
: 'None.';
|
|
118
|
+
const prompt = `Analyze these chat messages and assign a project group.
|
|
119
|
+
|
|
120
|
+
Messages:
|
|
121
|
+
${userInputs.map((m, i) => `${i + 1}. ${m}`).join('\n')}
|
|
122
|
+
|
|
123
|
+
Existing groups:
|
|
124
|
+
${existingText}
|
|
125
|
+
|
|
126
|
+
Rules:
|
|
127
|
+
1. Look for file system paths, repository names, or project names in the messages.
|
|
128
|
+
2. Identify the root project — if "/Users/john/projects/my-app/src/file.ts" appears, the group is "my-app".
|
|
129
|
+
3. If an existing group clearly matches, return its EXACT name.
|
|
130
|
+
4. Otherwise create a concise group name: 2-4 words, Title Case (e.g. "OmniKey AI", "Music Video Editor", "Client Website").
|
|
131
|
+
5. ALWAYS write a 3-4 sentence description (roughly 3-4 lines, 250-500 characters) that explains:
|
|
132
|
+
- what the project / group is about,
|
|
133
|
+
- the kind of work that happens in these sessions,
|
|
134
|
+
- any relevant tech stack, repo, or domain hints inferred from the messages,
|
|
135
|
+
- and the absolute file path of the project root when one is present in the messages.
|
|
136
|
+
If a file path is found, you MUST include the exact absolute path verbatim in the description (e.g. "Project root: /Users/john/projects/my-app."). Start the description with "You are working in <path> — the <project-name> project." when a path is available, otherwise start with "You are working on the <project-name> project.". Do not use markdown, bullet points, or newlines — keep it as a single paragraph.
|
|
137
|
+
6. If no paths exist and the session is purely general/conversational, use group name "General" and still produce a 3-4 sentence description summarizing the recurring topic.
|
|
138
|
+
|
|
139
|
+
Respond with ONLY valid JSON, no markdown:
|
|
140
|
+
{"groupName":"...","groupDescription":"..."}`;
|
|
141
|
+
try {
|
|
142
|
+
const result = await ai_client_1.aiClient.complete(aiModel, [
|
|
143
|
+
{
|
|
144
|
+
role: 'system',
|
|
145
|
+
content: 'You are a session categorization assistant. Respond only with the requested JSON object, no extra text.',
|
|
146
|
+
},
|
|
147
|
+
{ role: 'user', content: prompt },
|
|
148
|
+
], { temperature: 0 });
|
|
149
|
+
const raw = result.content
|
|
150
|
+
.trim()
|
|
151
|
+
.replace(/^```(?:json)?\n?/, '')
|
|
152
|
+
.replace(/\n?```$/, '')
|
|
153
|
+
.trim();
|
|
154
|
+
const parsed = JSON.parse(raw);
|
|
155
|
+
const response = zod_1.z
|
|
156
|
+
.object({ groupName: zod_1.z.string(), groupDescription: zod_1.z.string() })
|
|
157
|
+
.parse(parsed);
|
|
158
|
+
const groupName = response.groupName.trim().slice(0, 100);
|
|
159
|
+
if (!groupName)
|
|
160
|
+
return null;
|
|
161
|
+
// If this matches an existing group, always reuse the stored description.
|
|
162
|
+
const existingMatch = existingGroups.find((g) => g.groupName.toLowerCase() === groupName.toLowerCase());
|
|
163
|
+
if (existingMatch) {
|
|
164
|
+
const groupDescription = existingMatch.groupDescription ??
|
|
165
|
+
buildDescription(extractProjectPath(userInputs), groupName);
|
|
166
|
+
return { groupName: existingMatch.groupName, groupDescription };
|
|
167
|
+
}
|
|
168
|
+
// New group: prefer the LLM description but fall back to the deterministic builder.
|
|
169
|
+
// Description is now a 3-4 sentence paragraph (no newlines, capped at 1000 chars
|
|
170
|
+
// to leave headroom over the ~500 char target while still bounding storage).
|
|
171
|
+
const rawDesc = response.groupDescription.trim();
|
|
172
|
+
const projectPath = extractProjectPath(userInputs);
|
|
173
|
+
let groupDescription = (rawDesc || buildDescription(projectPath, groupName))
|
|
174
|
+
.replace(/\s*\n+\s*/g, ' ')
|
|
175
|
+
.replace(/\s{2,}/g, ' ')
|
|
176
|
+
.trim();
|
|
177
|
+
// Safety net: if the LLM ignored the rule and a path exists in the messages
|
|
178
|
+
// but is missing from the description, append it so the contract holds.
|
|
179
|
+
if (projectPath && !groupDescription.includes(projectPath)) {
|
|
180
|
+
groupDescription = `${groupDescription} Project root: ${projectPath}.`.trim();
|
|
181
|
+
}
|
|
182
|
+
groupDescription = groupDescription.slice(0, 1000);
|
|
183
|
+
return { groupName, groupDescription };
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger_1.logger.warn('Session group classification failed', { error: err });
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Public: update one session's group
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
async function updateSessionGroup(sessionId, subscriptionId) {
|
|
194
|
+
try {
|
|
195
|
+
const session = await agentSession_1.AgentSession.findOne({
|
|
196
|
+
where: { id: sessionId, subscriptionId },
|
|
197
|
+
attributes: ['id', 'historyJson'],
|
|
198
|
+
});
|
|
199
|
+
if (!session)
|
|
200
|
+
return;
|
|
201
|
+
const inputs = extractUserInputs(session.historyJson);
|
|
202
|
+
if (!inputs.length)
|
|
203
|
+
return;
|
|
204
|
+
// Fetch existing distinct groups (by name) to encourage reuse
|
|
205
|
+
const rows = await agentSession_1.AgentSession.findAll({
|
|
206
|
+
where: {
|
|
207
|
+
subscriptionId,
|
|
208
|
+
groupName: { [sequelize_1.Op.not]: null },
|
|
209
|
+
id: { [sequelize_1.Op.ne]: sessionId },
|
|
210
|
+
},
|
|
211
|
+
attributes: ['groupName', 'groupDescription'],
|
|
212
|
+
group: ['group_name'],
|
|
213
|
+
limit: 50,
|
|
214
|
+
});
|
|
215
|
+
const existingGroups = rows
|
|
216
|
+
.filter((s) => s.groupName)
|
|
217
|
+
.map((s) => ({
|
|
218
|
+
groupName: s.groupName,
|
|
219
|
+
groupDescription: s.groupDescription ?? null,
|
|
220
|
+
}));
|
|
221
|
+
const result = await classifyGroup(inputs, existingGroups);
|
|
222
|
+
if (!result)
|
|
223
|
+
return;
|
|
224
|
+
await agentSession_1.AgentSession.update({ groupName: result.groupName, groupDescription: result.groupDescription }, { where: { id: sessionId } });
|
|
225
|
+
logger_1.logger.info('Session group updated', { sessionId, groupName: result.groupName });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
logger_1.logger.error('Failed to update session group', { sessionId, error: err });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Public: refresh all sessions for a subscription (used by cron)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
async function refreshAllSessionGroups(subscriptionId) {
|
|
235
|
+
try {
|
|
236
|
+
const sessions = await agentSession_1.AgentSession.findAll({
|
|
237
|
+
where: { subscriptionId },
|
|
238
|
+
order: [['last_active_at', 'DESC']],
|
|
239
|
+
limit: 50,
|
|
240
|
+
attributes: ['id', 'historyJson'],
|
|
241
|
+
});
|
|
242
|
+
logger_1.logger.info('Refreshing session groups', {
|
|
243
|
+
subscriptionId,
|
|
244
|
+
count: sessions.length,
|
|
245
|
+
});
|
|
246
|
+
for (const session of sessions) {
|
|
247
|
+
await updateSessionGroup(session.id, subscriptionId);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
logger_1.logger.error('Failed to refresh session groups for subscription', {
|
|
252
|
+
subscriptionId,
|
|
253
|
+
error: err,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Cron: run every 6 hours across all subscriptions
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
function startGroupingCronJob() {
|
|
261
|
+
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
|
262
|
+
const tick = async () => {
|
|
263
|
+
try {
|
|
264
|
+
const subscriptions = await subscription_1.Subscription.findAll({ attributes: ['id'] });
|
|
265
|
+
logger_1.logger.info('Running session grouping cron', {
|
|
266
|
+
subscriptionCount: subscriptions.length,
|
|
267
|
+
});
|
|
268
|
+
for (const sub of subscriptions) {
|
|
269
|
+
await refreshAllSessionGroups(sub.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
logger_1.logger.error('Session grouping cron failed', { error: err });
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
setInterval(() => void tick(), SIX_HOURS_MS);
|
|
277
|
+
logger_1.logger.info('Session grouping cron started (6h interval)');
|
|
278
|
+
// If no session has a group yet (e.g. first startup after the feature was
|
|
279
|
+
// added, or a fresh self-hosted install with existing sessions), run the
|
|
280
|
+
// full backfill immediately rather than waiting 6 hours.
|
|
281
|
+
void (async () => {
|
|
282
|
+
try {
|
|
283
|
+
const ungrouped = await agentSession_1.AgentSession.count({ where: { groupName: null } });
|
|
284
|
+
const grouped = await agentSession_1.AgentSession.count({ where: { groupName: { [sequelize_1.Op.not]: null } } });
|
|
285
|
+
if (ungrouped > 0 && grouped === 0) {
|
|
286
|
+
logger_1.logger.info('No sessions have a group yet — running initial grouping backfill', {
|
|
287
|
+
sessionCount: ungrouped,
|
|
288
|
+
});
|
|
289
|
+
await tick();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
logger_1.logger.error('Initial grouping backfill check failed', { error: err });
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
}
|
|
@@ -113,7 +113,13 @@ function pushToSessionHistory(logger, session, message) {
|
|
|
113
113
|
limitHit = true;
|
|
114
114
|
}
|
|
115
115
|
// 2. Total history length limit.
|
|
116
|
-
const currentTotal = session.history.reduce((acc, msg) =>
|
|
116
|
+
const currentTotal = session.history.reduce((acc, msg) => {
|
|
117
|
+
if (typeof msg.content === 'string')
|
|
118
|
+
return acc + msg.content.length;
|
|
119
|
+
if (msg.content != null)
|
|
120
|
+
return acc + JSON.stringify(msg.content).length;
|
|
121
|
+
return acc;
|
|
122
|
+
}, 0);
|
|
117
123
|
const remaining = exports.MAX_HISTORY_TOTAL - currentTotal;
|
|
118
124
|
if (content.length > remaining) {
|
|
119
125
|
content = content.slice(0, Math.max(0, remaining - FINAL_ANSWER_REQUEST.content.length));
|
|
@@ -7,6 +7,7 @@ exports.aiClient = exports.AIClient = void 0;
|
|
|
7
7
|
exports.getDefaultModel = getDefaultModel;
|
|
8
8
|
exports.getMaxMessageContentLength = getMaxMessageContentLength;
|
|
9
9
|
exports.getMaxHistoryLength = getMaxHistoryLength;
|
|
10
|
+
exports.getContextWindowSize = getContextWindowSize;
|
|
10
11
|
const openai_1 = __importDefault(require("openai"));
|
|
11
12
|
const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
|
|
12
13
|
const genai_1 = require("@google/genai");
|
|
@@ -40,20 +41,29 @@ const MAX_MESSAGE_CONTENT_LENGTH_BY_PROVIDER = {
|
|
|
40
41
|
};
|
|
41
42
|
/**
|
|
42
43
|
* Maximum total character length across all messages in the conversation
|
|
43
|
-
* history
|
|
44
|
-
*
|
|
44
|
+
* history. Uses 2 chars/token (conservative) instead of 4 to account for
|
|
45
|
+
* content with low chars-per-token ratios (JSON, code, tool results).
|
|
45
46
|
*
|
|
46
|
-
* - anthropic:
|
|
47
|
-
*
|
|
48
|
-
* - openai:
|
|
49
|
-
*
|
|
50
|
-
* - gemini:
|
|
51
|
-
*
|
|
47
|
+
* - anthropic: 1M token ctx, reserve 100K for output + system prompt
|
|
48
|
+
* → 900K target tokens × 2 chars ≈ 1.8M chars
|
|
49
|
+
* - openai: ~272K token ctx, reserve 40K
|
|
50
|
+
* → 230K target tokens × 2 chars ≈ 460K chars
|
|
51
|
+
* - gemini: 1M token ctx, reserve 100K
|
|
52
|
+
* → 900K target tokens × 2 chars ≈ 1.8M chars
|
|
52
53
|
*/
|
|
53
54
|
const MAX_HISTORY_LENGTH_BY_PROVIDER = {
|
|
54
|
-
anthropic:
|
|
55
|
-
openai:
|
|
56
|
-
gemini:
|
|
55
|
+
anthropic: 1800000,
|
|
56
|
+
openai: 460000,
|
|
57
|
+
gemini: 1800000,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Hard token limit of the context window for each provider/model tier.
|
|
61
|
+
* Used to compute the accurate "tokens remaining" value shown in the UI.
|
|
62
|
+
*/
|
|
63
|
+
const CONTEXT_WINDOW_BY_PROVIDER = {
|
|
64
|
+
anthropic: 1000000,
|
|
65
|
+
openai: 272000,
|
|
66
|
+
gemini: 1000000,
|
|
57
67
|
};
|
|
58
68
|
function getMaxMessageContentLength(provider) {
|
|
59
69
|
return MAX_MESSAGE_CONTENT_LENGTH_BY_PROVIDER[provider];
|
|
@@ -61,6 +71,9 @@ function getMaxMessageContentLength(provider) {
|
|
|
61
71
|
function getMaxHistoryLength(provider) {
|
|
62
72
|
return MAX_HISTORY_LENGTH_BY_PROVIDER[provider];
|
|
63
73
|
}
|
|
74
|
+
function getContextWindowSize(provider) {
|
|
75
|
+
return CONTEXT_WINDOW_BY_PROVIDER[provider];
|
|
76
|
+
}
|
|
64
77
|
// ---------------------------------------------------------------------------
|
|
65
78
|
// OpenAI adapter
|
|
66
79
|
// ---------------------------------------------------------------------------
|
package/backend-dist/db.js
CHANGED
|
@@ -28,13 +28,34 @@ else if (config_1.config.databaseUrl) {
|
|
|
28
28
|
logging: config_1.config.dbLogging ? console.log : false,
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
const COLUMN_MIGRATIONS = [
|
|
32
|
+
// Added: context-window tracking (prompt token count of last API call)
|
|
33
|
+
{ table: 'agent_sessions', column: 'last_prompt_tokens', definition: 'INTEGER NOT NULL DEFAULT 0' },
|
|
34
|
+
// Added: project grouping
|
|
35
|
+
{ table: 'agent_sessions', column: 'group_name', definition: 'VARCHAR(255)' },
|
|
36
|
+
{ table: 'agent_sessions', column: 'group_description', definition: 'TEXT' },
|
|
37
|
+
];
|
|
38
|
+
async function runSQLiteMigrations(logger) {
|
|
39
|
+
for (const { table, column, definition } of COLUMN_MIGRATIONS) {
|
|
40
|
+
const rows = (await sequelize.query(`PRAGMA table_info(${table})`))[0];
|
|
41
|
+
const exists = rows.some((r) => r.name === column);
|
|
42
|
+
if (!exists) {
|
|
43
|
+
await sequelize.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
44
|
+
logger.info(`SQLite migration: added column ${table}.${column}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
async function initDatabase(logger) {
|
|
32
49
|
try {
|
|
33
50
|
await sequelize.authenticate();
|
|
34
|
-
// Use `alter: true` only for Postgres, not for SQLite
|
|
51
|
+
// Use `alter: true` only for Postgres, not for SQLite.
|
|
52
|
+
// On SQLite, sync() creates any missing tables from scratch (safe for new
|
|
53
|
+
// installs) and then runSQLiteMigrations() adds any columns that were
|
|
54
|
+
// introduced after the table was first created (safe for upgrades).
|
|
35
55
|
if (sequelize.getDialect() === 'sqlite') {
|
|
36
56
|
await sequelize.sync();
|
|
37
|
-
|
|
57
|
+
await runSQLiteMigrations(logger);
|
|
58
|
+
logger.info('Database connection established and models synchronized (SQLite).');
|
|
38
59
|
}
|
|
39
60
|
else {
|
|
40
61
|
await sequelize.sync({ alter: true });
|
package/backend-dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
|
|
|
16
16
|
const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
|
|
17
17
|
const mcpServerRoutes_1 = require("./mcpServerRoutes");
|
|
18
18
|
const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
|
|
19
|
+
const sessionGrouping_1 = require("./agent/sessionGrouping");
|
|
19
20
|
const config_1 = require("./config");
|
|
20
21
|
const agentServer_1 = require("./agent/agentServer");
|
|
21
22
|
// Importing AgentSession and ScheduledJob ensures the models are registered with Sequelize before initDatabase().
|
|
@@ -77,8 +78,8 @@ app.get('/macos/appcast', (req, res) => {
|
|
|
77
78
|
const appcastUrl = `${baseUrl}/macos/appcast`;
|
|
78
79
|
// These should match the values embedded into the macOS app
|
|
79
80
|
// Info.plist in macOS/build_release_dmg.sh.
|
|
80
|
-
const bundleVersion = '
|
|
81
|
-
const shortVersion = '1.0.
|
|
81
|
+
const bundleVersion = '36';
|
|
82
|
+
const shortVersion = '1.0.35';
|
|
82
83
|
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
|
83
84
|
<rss version="2.0"
|
|
84
85
|
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
|
@@ -106,7 +107,7 @@ app.get('/macos/appcast', (req, res) => {
|
|
|
106
107
|
// ── Windows distribution endpoints ───────────────────────────────────────────
|
|
107
108
|
// These should match the values in windows/OmniKey.Windows.csproj
|
|
108
109
|
// <Version> and windows/build_release_zip.ps1 $APP_VERSION.
|
|
109
|
-
const WIN_VERSION = '1.
|
|
110
|
+
const WIN_VERSION = '1.12';
|
|
110
111
|
const WIN_ZIP_FILENAME = 'OmniKeyAI-windows-win-x64.zip';
|
|
111
112
|
const WIN_ZIP_PATH = path_1.default.join(process.cwd(), 'windows', WIN_ZIP_FILENAME);
|
|
112
113
|
// Serves the pre-built ZIP produced by windows/build_release_zip.ps1.
|
|
@@ -148,7 +149,19 @@ app.get('/windows/update', (req, res) => {
|
|
|
148
149
|
version: WIN_VERSION,
|
|
149
150
|
downloadUrl: `${baseUrl}/windows/download`,
|
|
150
151
|
fileSize,
|
|
151
|
-
releaseNotes:
|
|
152
|
+
releaseNotes: [
|
|
153
|
+
`What's new in ${WIN_VERSION}`,
|
|
154
|
+
``,
|
|
155
|
+
`• Brand-new WPF shell — Agent Chat, OmniAgent Session, Task Instructions, Scheduled Jobs, Job Run History, MCP Servers, Manual, Subscription, and Check Updates all reachable from a unified sidebar.`,
|
|
156
|
+
`• Agent Chat with session sidebar, search, hover-to-delete, context-window indicator, default task-instruction picker (with a built-in "No task instructions" option).`,
|
|
157
|
+
`• OmniAgent Session replaces the old thinking window — sticky session picker, streaming collapsible timeline (web search, MCP, reasoning, terminal) and a copyable final-answer card. Selecting an existing session previews its last turn in-place.`,
|
|
158
|
+
`• Scheduled Jobs redesigned: clearer labels, self-contained Active toggle, restructured Save / Run now / Reset / Delete action bar, tooltips throughout.`,
|
|
159
|
+
`• Theme reworked to match the app icon — cyan + light purple. The Windows system-accent leak (orange/red buttons) is gone everywhere.`,
|
|
160
|
+
`• Modern typography (Aptos / Inter / Segoe UI Variable Display).`,
|
|
161
|
+
`• Mouse-wheel scroll fixed across Chat and OmniAgent pages.`,
|
|
162
|
+
`• Production-default backend URL: shipping binary defaults to https://omnikeyai.ca.`,
|
|
163
|
+
`• Internals: removed ~5,200 lines of unwired WinForms code; build is warning-free.`,
|
|
164
|
+
].join('\n'),
|
|
152
165
|
});
|
|
153
166
|
});
|
|
154
167
|
app.get('/downloads/stats', async (_req, res) => {
|
|
@@ -185,6 +198,7 @@ async function start() {
|
|
|
185
198
|
}
|
|
186
199
|
if (config_1.config.isSelfHosted) {
|
|
187
200
|
(0, scheduledJobExecutor_1.startScheduledJobExecutor)();
|
|
201
|
+
(0, sessionGrouping_1.startGroupingCronJob)();
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
204
|
catch (err) {
|
|
@@ -67,6 +67,22 @@ AgentSession.init({
|
|
|
67
67
|
defaultValue: 0,
|
|
68
68
|
field: 'total_tokens_used',
|
|
69
69
|
},
|
|
70
|
+
lastPromptTokens: {
|
|
71
|
+
type: sequelize_1.DataTypes.INTEGER,
|
|
72
|
+
allowNull: false,
|
|
73
|
+
defaultValue: 0,
|
|
74
|
+
field: 'last_prompt_tokens',
|
|
75
|
+
},
|
|
76
|
+
groupName: {
|
|
77
|
+
type: sequelize_1.DataTypes.STRING,
|
|
78
|
+
allowNull: true,
|
|
79
|
+
field: 'group_name',
|
|
80
|
+
},
|
|
81
|
+
groupDescription: {
|
|
82
|
+
type: sequelize_1.DataTypes.TEXT,
|
|
83
|
+
allowNull: true,
|
|
84
|
+
field: 'group_description',
|
|
85
|
+
},
|
|
70
86
|
lastActiveAt: {
|
|
71
87
|
type: sequelize_1.DataTypes.DATE,
|
|
72
88
|
allowNull: false,
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.0
|
|
7
|
+
"version": "1.1.0",
|
|
8
8
|
"description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=14.0.0",
|