omnikey-cli 1.0.43 → 1.2.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.
@@ -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,24 +596,26 @@ 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
575
603
  // is not the final turn (e.g. plain-text conclusion after terminal
576
604
  // output). Treat it as a final answer so the client is never left
577
605
  // hanging.
578
- log.info('Agent returned untagged content on a non-final turn; treating as final answer', {
606
+ log.info('Agent returned untagged content on a non-final turn; treating as assistant response and looping the function again.', {
579
607
  sessionId,
580
608
  subscriptionId: subscription.id,
581
609
  turn: session.turns,
582
610
  });
583
611
  (0, utils_1.pushToSessionHistory)(log, session, { role: 'assistant', content });
584
612
  await persistSessionToDB(sessionId, session);
585
- send({
586
- session_id: sessionId,
613
+ await runAgentTurnInternal(sessionId, subscription, {
587
614
  sender: 'agent',
588
- content: `<final_answer>\n${content}\n</final_answer>`,
589
- });
615
+ session_id: sessionId,
616
+ content: '',
617
+ is_web_call: true,
618
+ }, send, logger_1.logger, options);
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
  }
@@ -767,7 +797,12 @@ function buildTranscript(raw) {
767
797
  break;
768
798
  }
769
799
  }
770
- currentAssistant.text = finalText || blocks.map((b) => b.text).join('\n\n').trim();
800
+ currentAssistant.text =
801
+ finalText ||
802
+ blocks
803
+ .map((b) => b.text)
804
+ .join('\n\n')
805
+ .trim();
771
806
  messages.push(currentAssistant);
772
807
  currentAssistant = null;
773
808
  };
@@ -850,6 +885,9 @@ function createAgentRouter() {
850
885
  'totalTokensUsed',
851
886
  'promptTokensUsed',
852
887
  'completionTokensUsed',
888
+ 'lastPromptTokens',
889
+ 'groupName',
890
+ 'groupDescription',
853
891
  'lastActiveAt',
854
892
  'createdAt',
855
893
  'updatedAt',
@@ -863,8 +901,10 @@ function createAgentRouter() {
863
901
  totalTokensUsed: Number(s.totalTokensUsed),
864
902
  promptTokensUsed: Number(s.promptTokensUsed),
865
903
  completionTokensUsed: Number(s.completionTokensUsed),
866
- remainingContextTokens: Math.max(0, utils_1.MAX_HISTORY_TOTAL - Number(s.totalTokensUsed)),
867
- contextBudget: utils_1.MAX_HISTORY_TOTAL,
904
+ remainingContextTokens: Math.max(0, contextWindowSize - Number(s.lastPromptTokens)),
905
+ contextBudget: contextWindowSize,
906
+ groupName: s.groupName ?? null,
907
+ groupDescription: s.groupDescription ?? null,
868
908
  lastActiveAt: s.lastActiveAt,
869
909
  createdAt: s.createdAt,
870
910
  updatedAt: s.updatedAt,
@@ -919,6 +959,7 @@ function createAgentRouter() {
919
959
  'totalTokensUsed',
920
960
  'promptTokensUsed',
921
961
  'completionTokensUsed',
962
+ 'lastPromptTokens',
922
963
  'lastActiveAt',
923
964
  ],
924
965
  });
@@ -933,8 +974,8 @@ function createAgentRouter() {
933
974
  totalTokensUsed: Number(session.totalTokensUsed),
934
975
  promptTokensUsed: Number(session.promptTokensUsed),
935
976
  completionTokensUsed: Number(session.completionTokensUsed),
936
- remainingContextTokens: Math.max(0, utils_1.MAX_HISTORY_TOTAL - Number(session.totalTokensUsed)),
937
- contextBudget: utils_1.MAX_HISTORY_TOTAL,
977
+ remainingContextTokens: Math.max(0, contextWindowSize - Number(session.lastPromptTokens)),
978
+ contextBudget: contextWindowSize,
938
979
  lastActiveAt: session.lastActiveAt,
939
980
  });
940
981
  }
@@ -973,5 +1014,34 @@ function createAgentRouter() {
973
1014
  res.status(500).json({ error: 'Internal server error' });
974
1015
  }
975
1016
  });
1017
+ // GET /api/agent/groups
1018
+ // Returns distinct group names and descriptions for the authenticated
1019
+ // subscription. The client uses this to populate the project-path dropdown
1020
+ // and to filter the sidebar session list by project.
1021
+ router.get('/groups', async (_req, res) => {
1022
+ const { subscription, logger: log } = res.locals;
1023
+ try {
1024
+ const rows = await agentSession_1.AgentSession.findAll({
1025
+ where: {
1026
+ subscriptionId: subscription.id,
1027
+ groupName: { [sequelize_1.Op.not]: null },
1028
+ },
1029
+ attributes: ['groupName', 'groupDescription'],
1030
+ group: ['group_name'],
1031
+ order: [['groupName', 'ASC']],
1032
+ });
1033
+ const groups = rows
1034
+ .filter((r) => r.groupName)
1035
+ .map((r) => ({
1036
+ groupName: r.groupName,
1037
+ groupDescription: r.groupDescription ?? null,
1038
+ }));
1039
+ res.json({ groups });
1040
+ }
1041
+ catch (err) {
1042
+ log.error('Failed to fetch session groups', { error: err });
1043
+ res.status(500).json({ error: 'Internal server error' });
1044
+ }
1045
+ });
976
1046
  return router;
977
1047
  }
@@ -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) => acc + (typeof msg.content === 'string' ? msg.content.length : 0), 0);
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, derived from each provider's context-window size minus headroom
44
- * for the system prompt and max output tokens.
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: Claude Opus 4.7 — 1M token ctx, 64K max output
47
- * (1,000,000 - 64,000 - 10,000) tokens × 4 chars ≈ 3.7M chars
48
- * - openai: GPT-5.5 — ~272K token ctx, ~32K max output
49
- * (272,000 - 32,000 - 5,000) tokens × 4 chars ≈ 940K chars
50
- * - gemini: Gemini 2.5 Pro — 1M token ctx, ~32K max output
51
- * (1,000,000 - 32,000 - 10,000) tokens × 4 chars ≈ 3.8M chars
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: 3500000,
55
- openai: 800000,
56
- gemini: 3500000,
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
  // ---------------------------------------------------------------------------
@@ -28,13 +28,96 @@ 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
+ // mcp_servers was originally created with UNIQUE on both subscription_id and
48
+ // name as column-level constraints (SQLite auto-indexes). These can't be
49
+ // dropped with DROP INDEX — the only fix is to recreate the table with the
50
+ // correct schema (composite unique on subscription_id+name only).
51
+ await migrateMcpServersTableIfNeeded(logger);
52
+ }
53
+ async function migrateMcpServersTableIfNeeded(logger) {
54
+ // Check if the old schema is still in place by inspecting the CREATE TABLE sql.
55
+ const rows = (await sequelize.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`))[0];
56
+ if (!rows.length)
57
+ return; // table doesn't exist yet — sync() will create it correctly
58
+ const createSql = rows[0].sql;
59
+ // Old schema has UNIQUE on subscription_id at the column level.
60
+ // New schema only has the composite index mcp_servers_subscription_id_name.
61
+ const needsMigration = /`subscription_id`[^,]*UNIQUE/i.test(createSql);
62
+ if (!needsMigration)
63
+ return;
64
+ logger.info('SQLite migration: recreating mcp_servers table to remove stale UNIQUE constraints');
65
+ await sequelize.query('PRAGMA foreign_keys = OFF');
66
+ try {
67
+ await sequelize.query('BEGIN TRANSACTION');
68
+ await sequelize.query(`
69
+ CREATE TABLE \`mcp_servers_new\` (
70
+ \`id\` VARCHAR(255) NOT NULL PRIMARY KEY,
71
+ \`subscription_id\` VARCHAR(255) NOT NULL REFERENCES \`subscriptions\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
72
+ \`name\` VARCHAR(100) NOT NULL,
73
+ \`description\` VARCHAR(500),
74
+ \`transport\` VARCHAR(16) NOT NULL DEFAULT 'stdio',
75
+ \`command\` VARCHAR(500),
76
+ \`args\` JSON NOT NULL DEFAULT '[]',
77
+ \`env\` JSON NOT NULL DEFAULT '{}',
78
+ \`url\` VARCHAR(1000),
79
+ \`headers\` JSON NOT NULL DEFAULT '{}',
80
+ \`is_enabled\` TINYINT(1) NOT NULL DEFAULT 1,
81
+ \`last_connected_at\` DATETIME,
82
+ \`last_error\` TEXT,
83
+ \`createdAt\` DATETIME NOT NULL,
84
+ \`updatedAt\` DATETIME NOT NULL
85
+ )
86
+ `);
87
+ await sequelize.query(`
88
+ INSERT INTO \`mcp_servers_new\`
89
+ SELECT id, subscription_id, name, description, transport, command, args, env,
90
+ url, headers, is_enabled, last_connected_at, last_error, createdAt, updatedAt
91
+ FROM \`mcp_servers\`
92
+ `);
93
+ await sequelize.query('DROP TABLE `mcp_servers`');
94
+ await sequelize.query('ALTER TABLE `mcp_servers_new` RENAME TO `mcp_servers`');
95
+ await sequelize.query(`
96
+ CREATE UNIQUE INDEX IF NOT EXISTS \`mcp_servers_subscription_id_name\`
97
+ ON \`mcp_servers\` (\`subscription_id\`, \`name\`)
98
+ `);
99
+ await sequelize.query('COMMIT');
100
+ logger.info('SQLite migration: mcp_servers table recreated successfully');
101
+ }
102
+ catch (err) {
103
+ await sequelize.query('ROLLBACK');
104
+ throw err;
105
+ }
106
+ finally {
107
+ await sequelize.query('PRAGMA foreign_keys = ON');
108
+ }
109
+ }
31
110
  async function initDatabase(logger) {
32
111
  try {
33
112
  await sequelize.authenticate();
34
- // Use `alter: true` only for Postgres, not for SQLite
113
+ // Use `alter: true` only for Postgres, not for SQLite.
114
+ // On SQLite, sync() creates any missing tables from scratch (safe for new
115
+ // installs) and then runSQLiteMigrations() adds any columns that were
116
+ // introduced after the table was first created (safe for upgrades).
35
117
  if (sequelize.getDialect() === 'sqlite') {
36
118
  await sequelize.sync();
37
- logger.info('Database connection established and models synchronized (SQLite, no alter).');
119
+ await runSQLiteMigrations(logger);
120
+ logger.info('Database connection established and models synchronized (SQLite).');
38
121
  }
39
122
  else {
40
123
  await sequelize.sync({ alter: true });
@@ -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 = '33';
81
- const shortVersion = '1.0.32';
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.11';
110
+ const WIN_VERSION = '1.13';
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,23 @@ app.get('/windows/update', (req, res) => {
148
149
  version: WIN_VERSION,
149
150
  downloadUrl: `${baseUrl}/windows/download`,
150
151
  fileSize,
151
- releaseNotes: `What's new in ${WIN_VERSION}\n\n• OmniAgent flow improvements\n• Bug fixes and performance enhancements\n\n Support for MCP servers now you can add any custom MCP server to OmniKeyAI using CLI or Windows app.`,
152
+ releaseNotes: [
153
+ `What's new in ${WIN_VERSION}`,
154
+ ``,
155
+ `• Projects: chats are now grouped by project in the sidebar — collapsible "folder" headers per group, a session count badge, and per-header collapse state that survives streaming turns.`,
156
+ `• Projects: new project picker in the composer toolbar (next to the task-instruction selector) — pick the project for your next turn, mirrors the macOS "Select project" menu. Auto-hides until the backend has classified at least one group.`,
157
+ `• Projects: the chosen project is stamped onto the outbound message and the optimistic session placeholder, so new chats appear under the right header immediately.`,
158
+ `• Chat: messages now sit in a centered 820 DIP reading column on large monitors and stretch edge-to-edge on smaller windows — matches the macOS layout exactly. User bubbles stay pinned right, assistant content stays pinned left, on every viewport.`,
159
+ `• Chat: new Final Answer card with a soft "paper" surface (mirrors macOS), copy button anchored bottom-right so it no longer overlaps long markdown headings, and an "Answer" tooltip on copy.`,
160
+ `• Chat: animated typing indicator (pulsing sparkle + three staggered dots) appears the moment you send your first message — matches macOS TypingDotsView.`,
161
+ `• Chat: extra breathing room between thinking-timeline steps so the agent's intermediate reasoning reads as discrete actions instead of a cramped wall.`,
162
+ `• Markdown: brand-new Nord-themed renderer — no more white-background leaks from the underlying MdXaml engine on paragraphs, blockquotes, lists, tables, or inline code.`,
163
+ `• Markdown: bullets and numbered lists are no longer clipped on the left edge.`,
164
+ `• Markdown: inline code now renders as a soft pill (BadgeFill) instead of a dark slab; fenced code blocks keep their rounded macOS-style chrome with language label + copy.`,
165
+ `• MCP Servers: editor now supports custom HTTP headers — one Key: Value per line, monospace input, persisted alongside the URL. Authorization headers are unredacted on edit so they round-trip cleanly, and stale fetches won't clobber what you're typing.`,
166
+ `• Composer: capped + centered at 820 DIP on wide monitors for a balanced layout, full pane width on smaller windows.`,
167
+ `• Theme: shared interactive-surface brushes (Hover, Press, CodeBackground, UserBubble, AssistantText, DangerSoft, FinalAnswerSurface, BadgeFill) promoted to NordTheme.xaml so every page stays in visual lockstep.`,
168
+ ].join('\n'),
152
169
  });
153
170
  });
154
171
  app.get('/downloads/stats', async (_req, res) => {
@@ -185,6 +202,7 @@ async function start() {
185
202
  }
186
203
  if (config_1.config.isSelfHosted) {
187
204
  (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
205
+ (0, sessionGrouping_1.startGroupingCronJob)();
188
206
  }
189
207
  }
190
208
  catch (err) {
@@ -122,7 +122,12 @@ function mcpServerRouter() {
122
122
  return res.status(400).json({ error: 'Invalid MCP server data.' });
123
123
  }
124
124
  if (err?.name === 'SequelizeUniqueConstraintError') {
125
- return res.status(409).json({ error: 'An MCP server with that name already exists.' });
125
+ const isNameConflict = err?.fields?.includes('name') || err?.errors?.some((e) => e.path === 'name');
126
+ return res.status(409).json({
127
+ error: isNameConflict
128
+ ? 'An MCP server with that name already exists.'
129
+ : 'Failed to create MCP server due to a conflict.',
130
+ });
126
131
  }
127
132
  res.status(500).json({ error: 'Failed to create MCP server.' });
128
133
  }
@@ -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.43",
7
+ "version": "1.2.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",