omnikey-cli 1.4.1 → 1.5.1

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.
@@ -53,7 +53,9 @@ ${config_1.config.browserDebugPort !== undefined
53
53
  - Skip the script only for purely factual/conversational requests with no live data dependency (e.g. "what is 2+2").
54
54
 
55
55
  **Script phasing — one phase per turn:**
56
+ - **Act immediately — no upfront planning.** For any multi-step task, emit the **first** script right away without reasoning through future steps first. Decide each next step only *after* you see the terminal output from the previous one. Long plans written before any script is run produce long reasoning blocks that get cut off — emit the script and let the output guide you.
56
57
  - Break every multi-step task into the smallest logical unit that can independently succeed or fail. Emit that script, wait for \`TERMINAL OUTPUT:\`, assess the result, then write the next script. Never combine phases that have independent failure modes into a single block — a mid-script failure loses all context for recovery.
58
+ - **Keep each script short and atomic** — prefer under 30 lines, doing exactly one operation (check one thing, install one package, make one change, run one command). If a script would need more, split it into two turns.
57
59
  - Natural phase boundaries: **(1)** check / install dependencies → **(2)** inspect / probe current state → **(3)** make one targeted change → **(4)** verify the change took effect. Add a boundary wherever a failure would require a different next step than a success.
58
60
  - Single-step read-only queries ("list files", "show env") need no splitting — one script is fine.
59
61
 
@@ -118,9 +120,13 @@ ${installedMcps
118
120
  2. ${config_1.config.aiProvider === 'anthropic' ? 'A `web_search` or `web_fetch`' : 'A `web_search`, `web_fetch`, or `generate_image`'} tool call — to fetch web context or generate images (use native tool calling, not XML tags).
119
121
  3. \`<final_answer>...</final_answer>\` — your conclusion once you have enough information.
120
122
 
121
- **Critical rule:** After receiving \`TERMINAL OUTPUT:\` you MUST immediately produce either \`<shell_script>\` or \`<final_answer>\`. Never output raw text, markdown, or any other format. If the terminal output contains enough information to answer the user's request, output \`<final_answer>\` right away.
123
+ **Critical rule zero tolerance for text outside tags:**
124
+ - Your **entire response** — from the very first character to the very last — must be the tag and its contents. Nothing before the opening tag. Nothing after the closing tag.
125
+ - Do NOT write reasoning, planning, or commentary before acting. Emit the tag immediately. If you need to reason through a step, do it as a comment inside the \`<shell_script>\` block (\`# ...\`), never as free text outside.
126
+ - After receiving \`TERMINAL OUTPUT:\` or \`COMMAND ERROR:\`, your very next characters must be \`<shell_script>\` or \`<final_answer>\`. No exceptions.
127
+ - If you feel you need to plan or think before writing the first script — suppress it. Emit \`<shell_script>\` for the first small step immediately. You will have the output to guide the next step.
122
128
 
123
- No plain text, reasoning, or other tags outside these blocks. Never wrap in additional XML/JSON.
129
+ Never wrap in additional XML/JSON.
124
130
 
125
131
  **Shell script structure:**
126
132
  ${!isWindows
@@ -234,7 +234,7 @@ async function enforceSessionCap(subscriptionId, logger) {
234
234
  logger.error('Failed to enforce agent session cap', { subscriptionId, error: err });
235
235
  }
236
236
  }
237
- async function getOrCreateSession(sessionId, subscription, platform, log, isCronJob = false) {
237
+ async function getOrCreateSession(sessionId, subscription, platform, log, isCronJob = false, groupName) {
238
238
  // 1. Try to resume from a persisted DB record.
239
239
  try {
240
240
  const dbSession = await agentSession_1.AgentSession.findOne({
@@ -246,6 +246,7 @@ async function getOrCreateSession(sessionId, subscription, platform, log, isCron
246
246
  subscription,
247
247
  history,
248
248
  turns: dbSession.turns,
249
+ groupName: dbSession.groupName ?? null,
249
250
  };
250
251
  log.info('Resumed agent session from DB', {
251
252
  sessionId,
@@ -309,6 +310,7 @@ ${prompt}
309
310
  historyJson: JSON.stringify(entry.history),
310
311
  turns: 0,
311
312
  lastActiveAt: new Date(),
313
+ groupName: groupName ?? null,
312
314
  },
313
315
  });
314
316
  if (!created) {
@@ -317,6 +319,7 @@ ${prompt}
317
319
  subscription,
318
320
  history,
319
321
  turns: dbSession.turns,
322
+ groupName: dbSession.groupName ?? null,
320
323
  };
321
324
  log.info('Reused existing agent session row from DB during create path', {
322
325
  sessionId,
@@ -347,7 +350,7 @@ ${prompt}
347
350
  };
348
351
  }
349
352
  async function runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options) {
350
- const { sessionState: session, hasStoredPrompt } = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log, options?.isCronJob);
353
+ const { sessionState: session, hasStoredPrompt } = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log, options?.isCronJob, clientMessage.group_name);
351
354
  // Count this call as one agent iteration.
352
355
  session.turns += 1;
353
356
  log.info('Starting agent turn', {
@@ -479,6 +482,38 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
479
482
  temperature: 0.2,
480
483
  });
481
484
  await recordUsage(result);
485
+ // When the model's output was cut off mid-generation (hit the provider's
486
+ // max-token ceiling), it may have produced a partial shell script or plain
487
+ // reasoning text with no closing tag. Processing that as-is would either
488
+ // send a malformed script to the frontend or silently recurse without any
489
+ // recovery signal. Instead, push the truncated fragment as an assistant
490
+ // message and inject a terse directive that forces the model to emit a
491
+ // valid tag on the very next call.
492
+ if (result.finish_reason === 'length') {
493
+ log.warn('Agent response truncated at output limit; injecting recovery directive', {
494
+ sessionId,
495
+ contentLength: result.content.length,
496
+ });
497
+ if (result.content.trim()) {
498
+ (0, utils_1.pushToSessionHistory)(logger_1.logger, session, result.assistantMessage);
499
+ }
500
+ (0, utils_1.pushToSessionHistory)(logger_1.logger, session, {
501
+ role: 'user',
502
+ content: [
503
+ 'Your previous response was cut off because it exceeded the output length limit.',
504
+ 'Do NOT repeat or continue what you wrote.',
505
+ 'Respond immediately with exactly one of:',
506
+ '- <shell_script>...</shell_script>',
507
+ '- <final_answer>...</final_answer>',
508
+ 'No reasoning. No explanation. Just the tag.',
509
+ ].join('\n'),
510
+ });
511
+ result = await ai_client_1.aiClient.complete(aiModel, session.history, {
512
+ tools: tools?.length ? tools : undefined,
513
+ temperature: 0.2,
514
+ });
515
+ await recordUsage(result);
516
+ }
482
517
  let content = result.content.trim();
483
518
  if (!content && result.finish_reason !== 'tool_calls') {
484
519
  log.warn('Agent LLM returned empty content; sending generic error to client.');
@@ -596,26 +631,75 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
596
631
  sender: 'agent',
597
632
  content: hasFinalAnswerTag ? content : `<final_answer>\n${content}\n</final_answer>`,
598
633
  });
599
- void (0, sessionGrouping_1.updateSessionGroup)(sessionId, subscription.id);
634
+ // Only re-classify when the session doesn't already have a group name.
635
+ // Re-classification is expensive (LLM call) and unnecessary once a group
636
+ // has been assigned — the cron in sessionGrouping.ts is responsible for
637
+ // periodic refreshes if descriptions ever need updating.
638
+ if (!session.groupName) {
639
+ void (0, sessionGrouping_1.updateSessionGroup)(sessionId, subscription.id).then(async () => {
640
+ // Reflect the newly-assigned group back into the in-memory session
641
+ // so subsequent turns in this same session also skip re-classification.
642
+ try {
643
+ const refreshed = await agentSession_1.AgentSession.findOne({
644
+ where: { id: sessionId, subscriptionId: subscription.id },
645
+ attributes: ['groupName'],
646
+ });
647
+ if (refreshed?.groupName) {
648
+ session.groupName = refreshed.groupName;
649
+ }
650
+ }
651
+ catch (err) {
652
+ log.warn('Failed to read back groupName after classification', { error: err });
653
+ }
654
+ });
655
+ }
656
+ else {
657
+ log.info('Skipping session group classification — group already assigned', {
658
+ sessionId,
659
+ groupName: session.groupName,
660
+ });
661
+ }
600
662
  }
601
663
  else if (content) {
602
- // Fallback: the LLM returned content without any recognized tag and it
603
- // is not the final turn (e.g. plain-text conclusion after terminal
604
- // output). Treat it as a final answer so the client is never left
605
- // hanging.
606
- log.info('Agent returned untagged content on a non-final turn; treating as assistant response and looping the function again.', {
664
+ const untaggedDepth = options?.untaggedDepth ?? 0;
665
+ // Safety valve: after two consecutive format-correction attempts the
666
+ // model is clearly stuck. Abort rather than loop indefinitely.
667
+ if (untaggedDepth >= 2) {
668
+ log.warn('Agent stuck in untagged response loop; aborting after max retries', {
669
+ sessionId,
670
+ untaggedDepth,
671
+ });
672
+ await persistSessionToDB(sessionId, session);
673
+ (0, utils_1.sendFinalAnswer)(send, sessionId, 'The agent failed to produce a structured response after multiple attempts. Please try again.', true);
674
+ return;
675
+ }
676
+ log.info('Agent returned untagged content; injecting format-correction directive', {
607
677
  sessionId,
608
678
  subscriptionId: subscription.id,
609
679
  turn: session.turns,
680
+ untaggedDepth,
681
+ });
682
+ // Push the untagged content as an assistant turn so the model sees what
683
+ // it wrote, then immediately follow with a user message that firmly
684
+ // redirects it back to the required tag format.
685
+ (0, utils_1.pushToSessionHistory)(logger_1.logger, session, { role: 'assistant', content });
686
+ (0, utils_1.pushToSessionHistory)(logger_1.logger, session, {
687
+ role: 'user',
688
+ content: [
689
+ 'Your response was plain text, which is not a valid format.',
690
+ 'You MUST respond with exactly one of:',
691
+ '- <shell_script>...</shell_script> — to run terminal commands',
692
+ '- <final_answer>...</final_answer> — to conclude',
693
+ 'Respond immediately with the tag. No reasoning, no explanation.',
694
+ ].join('\n'),
610
695
  });
611
- (0, utils_1.pushToSessionHistory)(log, session, { role: 'assistant', content });
612
696
  await persistSessionToDB(sessionId, session);
613
697
  await runAgentTurnInternal(sessionId, subscription, {
614
698
  sender: 'agent',
615
699
  session_id: sessionId,
616
700
  content: '',
617
701
  is_web_call: true,
618
- }, send, logger_1.logger, options);
702
+ }, send, logger_1.logger, { ...options, untaggedDepth: untaggedDepth + 1 });
619
703
  }
620
704
  else {
621
705
  log.warn('Agent returned empty content with no recognized tags; sending error', {
@@ -633,6 +717,8 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
633
717
  }
634
718
  }
635
719
  async function runAgentTurn(sessionId, subscription, clientMessage, send, log, options) {
720
+ // untaggedDepth always starts at 0 for external callers; it is only threaded
721
+ // through the internal recursive path.
636
722
  await runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options);
637
723
  }
638
724
  function attachAgentWebSocketServer(server) {
@@ -87,26 +87,25 @@ function extractProjectPath(texts) {
87
87
  return qualified[0]?.[0] ?? null;
88
88
  }
89
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.
90
+ // Deterministic 3-sentence fallback description.
91
+ // The description always answers, in order:
92
+ // 1. Where is the project root located?
93
+ // 2. What is the purpose of this project?
94
+ // 3. If it is a coding project, what is the primary programming language?
95
+ // Used when the LLM does not return a usable description.
94
96
  // ---------------------------------------------------------------------------
95
97
  function buildDescription(projectPath, groupName) {
96
- if (!projectPath) {
98
+ if (projectPath) {
97
99
  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.`,
100
+ `Project root: ${projectPath} (the ${groupName} project).`,
101
+ `Purpose: ongoing work on the ${groupName} codebase.`,
102
+ `Primary language: not yet determined from session context.`,
102
103
  ].join(' ');
103
104
  }
104
- const projectName = projectPath.split('/').filter(Boolean).pop() ?? groupName;
105
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.`,
106
+ `Project root: not specifiedno absolute path has been associated with the ${groupName} group yet.`,
107
+ `Purpose: sessions grouped under ${groupName}.`,
108
+ `Primary language: not applicable (no coding project identified).`,
110
109
  ].join(' ');
111
110
  }
112
111
  async function classifyGroup(userInputs, existingGroups) {
@@ -123,18 +122,19 @@ ${userInputs.map((m, i) => `${i + 1}. ${m}`).join('\n')}
123
122
  Existing groups:
124
123
  ${existingText}
125
124
 
126
- Rules:
125
+ Rules for the group name:
127
126
  1. Look for file system paths, repository names, or project names in the messages.
128
127
  2. Identify the root project — if "/Users/john/projects/my-app/src/file.ts" appears, the group is "my-app".
129
128
  3. If an existing group clearly matches, return its EXACT name.
130
129
  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.
130
+ 5. If the session is purely general/conversational with no project signal, use "General".
131
+
132
+ Rules for the description (CRITICAL):
133
+ The description is appended to user input as <project_context> whenever the user picks this project, so it must be short, factual, and load-bearing. Write a SINGLE paragraph of 3-4 sentences (max 4 sentences, no markdown, no bullet points, no newlines) that answers these three questions in order:
134
+ 1. Where is the project root located? Quote the exact absolute path verbatim when one is present in the messages (e.g. "Project root: /Users/john/projects/my-app."). If no path is present, say so explicitly.
135
+ 2. What is the purpose of this project? One sentence summarising what the project / group is for, inferred from the messages.
136
+ 3. If it is a coding project, what is the primary programming language? Name the language (e.g. TypeScript, Python, Go, Rust) when it can be inferred from file extensions, framework names, package files, or explicit mentions. If it is not a coding project, say "Not a coding project." If the language cannot be inferred, say "Primary language not identified from the available context."
137
+ Keep the whole description under ~500 characters. Do NOT add extra commentary, tech-stack lists, workflow notes, or session summaries beyond what the three questions require.
138
138
 
139
139
  Respond with ONLY valid JSON, no markdown:
140
140
  {"groupName":"...","groupDescription":"..."}`;
@@ -158,15 +158,27 @@ Respond with ONLY valid JSON, no markdown:
158
158
  const groupName = response.groupName.trim().slice(0, 100);
159
159
  if (!groupName)
160
160
  return null;
161
- // If this matches an existing group, always reuse the stored description.
161
+ // When we fall through from the existingMatch branch to regenerate a stale
162
+ // description, preserve the canonical existing group name so we don't
163
+ // fragment groups by re-casing the name.
164
+ let canonicalName = null;
165
+ // If this matches an existing group, reuse the stored description ONLY when it
166
+ // already follows the new shape (must mention "Project root" and "Primary
167
+ // language"). Otherwise fall through and let the LLM-generated description
168
+ // replace it, so old verbose descriptions are upgraded in place.
162
169
  const existingMatch = existingGroups.find((g) => g.groupName.toLowerCase() === groupName.toLowerCase());
163
170
  if (existingMatch) {
164
- const groupDescription = existingMatch.groupDescription ??
165
- buildDescription(extractProjectPath(userInputs), groupName);
166
- return { groupName: existingMatch.groupName, groupDescription };
171
+ const stored = existingMatch.groupDescription ?? '';
172
+ const hasNewShape = /project root/i.test(stored) && /primary language/i.test(stored);
173
+ if (hasNewShape) {
174
+ return { groupName: existingMatch.groupName, groupDescription: stored };
175
+ }
176
+ // Fall through: regenerate description using the LLM output below, but
177
+ // keep the canonical existing group name so we don't fragment groups.
178
+ canonicalName = existingMatch.groupName;
167
179
  }
168
180
  // 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
181
+ // Description is a single 3-4 sentence paragraph (no newlines, capped at 800 chars
170
182
  // to leave headroom over the ~500 char target while still bounding storage).
171
183
  const rawDesc = response.groupDescription.trim();
172
184
  const projectPath = extractProjectPath(userInputs);
@@ -177,10 +189,15 @@ Respond with ONLY valid JSON, no markdown:
177
189
  // Safety net: if the LLM ignored the rule and a path exists in the messages
178
190
  // but is missing from the description, append it so the contract holds.
179
191
  if (projectPath && !groupDescription.includes(projectPath)) {
180
- groupDescription = `${groupDescription} Project root: ${projectPath}.`.trim();
192
+ groupDescription = `Project root: ${projectPath}. ${groupDescription}`.trim();
181
193
  }
182
- groupDescription = groupDescription.slice(0, 1000);
183
- return { groupName, groupDescription };
194
+ groupDescription = groupDescription.slice(0, 800);
195
+ return {
196
+ // Preserve the canonical existing group name when we fell through from
197
+ // the existingMatch branch to regenerate a stale description.
198
+ groupName: canonicalName ?? groupName,
199
+ groupDescription,
200
+ };
184
201
  }
185
202
  catch (err) {
186
203
  logger_1.logger.warn('Session group classification failed', { error: err });
@@ -188,6 +205,102 @@ Respond with ONLY valid JSON, no markdown:
188
205
  }
189
206
  }
190
207
  // ---------------------------------------------------------------------------
208
+ // Fetch up to 10 recent user inputs from sessions already in a group.
209
+ // Used to give the LLM richer context about the group when classifying a
210
+ // new session or when refreshing a description via the cron job.
211
+ // ---------------------------------------------------------------------------
212
+ async function fetchSiblingInputs(subscriptionId, groupName, excludeSessionId) {
213
+ const sessions = await agentSession_1.AgentSession.findAll({
214
+ where: excludeSessionId
215
+ ? { subscriptionId, groupName, id: { [sequelize_1.Op.ne]: excludeSessionId } }
216
+ : { subscriptionId, groupName },
217
+ order: [['last_active_at', 'DESC']],
218
+ limit: 15,
219
+ attributes: ['historyJson'],
220
+ });
221
+ const collected = [];
222
+ for (const s of sessions) {
223
+ for (const inp of extractUserInputs(s.historyJson)) {
224
+ collected.push(inp);
225
+ if (collected.length >= 10)
226
+ break;
227
+ }
228
+ if (collected.length >= 10)
229
+ break;
230
+ }
231
+ return collected;
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // LLM: generate or update a group description using combined inputs.
235
+ // When isUpdateMode is true the LLM is asked to UPDATE the existing
236
+ // description with new findings rather than write one from scratch.
237
+ // ---------------------------------------------------------------------------
238
+ async function enrichGroupDescription(groupName, allInputs, existingDescription, isUpdateMode) {
239
+ if (!allInputs.length)
240
+ return null;
241
+ const projectPath = extractProjectPath(allInputs);
242
+ const messagesText = allInputs.map((m, i) => `${i + 1}. ${m}`).join('\n');
243
+ const prompt = isUpdateMode && existingDescription
244
+ ? `Update the project group description for "${groupName}" based on new session data.
245
+
246
+ Current description:
247
+ "${existingDescription}"
248
+
249
+ Recent user messages from sessions in this group:
250
+ ${messagesText}
251
+
252
+ Update the description to incorporate any new findings. Keep the same 3-4 sentence structure answering in order:
253
+ 1. Where is the project root? (Quote the exact absolute path verbatim when present.)
254
+ 2. What is the purpose of this project?
255
+ 3. What is the primary programming language?
256
+
257
+ Rules: single paragraph, under ~500 characters, no markdown, no bullet points, no newlines. Preserve correct existing information. Only change what the messages provide new or better details on.
258
+
259
+ Respond with ONLY valid JSON: {"groupDescription":"..."}`
260
+ : `Generate a description for the project group "${groupName}" based on these session messages.
261
+
262
+ Messages:
263
+ ${messagesText}
264
+
265
+ Write a SINGLE paragraph of 3-4 sentences (no markdown, no bullet points, no newlines) answering in order:
266
+ 1. Where is the project root? (Quote the exact absolute path verbatim when present, or say so if absent.)
267
+ 2. What is the purpose of this project?
268
+ 3. What is the primary programming language? (Name it when inferable; "Primary language not identified." if not.)
269
+
270
+ Keep the whole description under ~500 characters.
271
+
272
+ Respond with ONLY valid JSON: {"groupDescription":"..."}`;
273
+ try {
274
+ const result = await ai_client_1.aiClient.complete(aiModel, [
275
+ {
276
+ role: 'system',
277
+ content: 'You are a session categorization assistant. Respond only with the requested JSON object, no extra text.',
278
+ },
279
+ { role: 'user', content: prompt },
280
+ ], { temperature: 0 });
281
+ const raw = result.content
282
+ .trim()
283
+ .replace(/^```(?:json)?\n?/, '')
284
+ .replace(/\n?```$/, '')
285
+ .trim();
286
+ const parsed = JSON.parse(raw);
287
+ const response = zod_1.z.object({ groupDescription: zod_1.z.string() }).parse(parsed);
288
+ let description = response.groupDescription
289
+ .trim()
290
+ .replace(/\s*\n+\s*/g, ' ')
291
+ .replace(/\s{2,}/g, ' ')
292
+ .trim();
293
+ if (projectPath && !description.includes(projectPath)) {
294
+ description = `Project root: ${projectPath}. ${description}`.trim();
295
+ }
296
+ return description.slice(0, 800);
297
+ }
298
+ catch (err) {
299
+ logger_1.logger.warn('Group description enrichment failed', { groupName, error: err });
300
+ return null;
301
+ }
302
+ }
303
+ // ---------------------------------------------------------------------------
191
304
  // Public: update one session's group
192
305
  // ---------------------------------------------------------------------------
193
306
  async function updateSessionGroup(sessionId, subscriptionId) {
@@ -218,10 +331,25 @@ async function updateSessionGroup(sessionId, subscriptionId) {
218
331
  groupName: s.groupName,
219
332
  groupDescription: s.groupDescription ?? null,
220
333
  }));
334
+ // Step 1: classify to determine the group name and an initial description
221
335
  const result = await classifyGroup(inputs, existingGroups);
222
336
  if (!result)
223
337
  return;
224
- await agentSession_1.AgentSession.update({ groupName: result.groupName, groupDescription: result.groupDescription }, { where: { id: sessionId } });
338
+ // Step 2: if the session was matched to an existing group, fetch sibling
339
+ // inputs to enrich the description with broader project context.
340
+ const isExistingGroup = existingGroups.some((g) => g.groupName.toLowerCase() === result.groupName.toLowerCase());
341
+ let finalDescription = result.groupDescription;
342
+ if (isExistingGroup) {
343
+ const siblingInputs = await fetchSiblingInputs(subscriptionId, result.groupName, sessionId);
344
+ if (siblingInputs.length > 0) {
345
+ // Combine current session inputs with recent sibling inputs (cap at 18).
346
+ const combinedInputs = [...inputs, ...siblingInputs].slice(0, 18);
347
+ const enriched = await enrichGroupDescription(result.groupName, combinedInputs, result.groupDescription, true);
348
+ if (enriched)
349
+ finalDescription = enriched;
350
+ }
351
+ }
352
+ await agentSession_1.AgentSession.update({ groupName: result.groupName, groupDescription: finalDescription }, { where: { id: sessionId } });
225
353
  logger_1.logger.info('Session group updated', { sessionId, groupName: result.groupName });
226
354
  }
227
355
  catch (err) {
@@ -229,21 +357,70 @@ async function updateSessionGroup(sessionId, subscriptionId) {
229
357
  }
230
358
  }
231
359
  // ---------------------------------------------------------------------------
232
- // Public: refresh all sessions for a subscription (used by cron)
360
+ // Cron helper: refresh the description for one group by collecting the most
361
+ // recent 10 user inputs across all sessions in that group and asking the LLM
362
+ // to UPDATE the existing description with any new findings.
233
363
  // ---------------------------------------------------------------------------
234
- async function refreshAllSessionGroups(subscriptionId) {
364
+ async function refreshGroupDescription(subscriptionId, groupName, existingDescription) {
235
365
  try {
236
366
  const sessions = await agentSession_1.AgentSession.findAll({
237
- where: { subscriptionId },
367
+ where: { subscriptionId, groupName },
238
368
  order: [['last_active_at', 'DESC']],
239
- limit: 50,
240
- attributes: ['id', 'historyJson'],
369
+ limit: 15,
370
+ attributes: ['historyJson'],
371
+ });
372
+ const allInputs = [];
373
+ for (const s of sessions) {
374
+ for (const inp of extractUserInputs(s.historyJson)) {
375
+ allInputs.push(inp);
376
+ if (allInputs.length >= 10)
377
+ break;
378
+ }
379
+ if (allInputs.length >= 10)
380
+ break;
381
+ }
382
+ if (!allInputs.length)
383
+ return;
384
+ const newDescription = await enrichGroupDescription(groupName, allInputs, existingDescription, true);
385
+ if (!newDescription)
386
+ return;
387
+ // Sync the updated description to every session in this group.
388
+ await agentSession_1.AgentSession.update({ groupDescription: newDescription }, { where: { subscriptionId, groupName } });
389
+ logger_1.logger.info('Group description refreshed', { subscriptionId, groupName });
390
+ }
391
+ catch (err) {
392
+ logger_1.logger.error('Failed to refresh group description', { subscriptionId, groupName, error: err });
393
+ }
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // Public: refresh all sessions for a subscription (used by cron)
397
+ // ---------------------------------------------------------------------------
398
+ async function refreshAllSessionGroups(subscriptionId) {
399
+ try {
400
+ // Refresh descriptions for all existing groups (one LLM call per group).
401
+ const groupRows = await agentSession_1.AgentSession.findAll({
402
+ where: {
403
+ subscriptionId,
404
+ groupName: { [sequelize_1.Op.not]: null },
405
+ },
406
+ attributes: ['groupName', 'groupDescription'],
407
+ group: ['group_name'],
241
408
  });
242
409
  logger_1.logger.info('Refreshing session groups', {
243
410
  subscriptionId,
244
- count: sessions.length,
411
+ groupCount: groupRows.length,
412
+ });
413
+ for (const row of groupRows) {
414
+ await refreshGroupDescription(subscriptionId, row.groupName, row.groupDescription ?? null);
415
+ }
416
+ // Also classify any sessions that haven't been grouped yet.
417
+ const ungroupedSessions = await agentSession_1.AgentSession.findAll({
418
+ where: { subscriptionId, groupName: null },
419
+ order: [['last_active_at', 'DESC']],
420
+ limit: 20,
421
+ attributes: ['id'],
245
422
  });
246
- for (const session of sessions) {
423
+ for (const session of ungroupedSessions) {
247
424
  await updateSessionGroup(session.id, subscriptionId);
248
425
  }
249
426
  }
@@ -258,7 +435,7 @@ async function refreshAllSessionGroups(subscriptionId) {
258
435
  // Cron: run every 6 hours across all subscriptions
259
436
  // ---------------------------------------------------------------------------
260
437
  function startGroupingCronJob() {
261
- const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
438
+ const ONE_HOUR_MS = 60 * 60 * 1000;
262
439
  const tick = async () => {
263
440
  try {
264
441
  const subscriptions = await subscription_1.Subscription.findAll({ attributes: ['id'] });
@@ -273,15 +450,16 @@ function startGroupingCronJob() {
273
450
  logger_1.logger.error('Session grouping cron failed', { error: err });
274
451
  }
275
452
  };
276
- setInterval(() => void tick(), SIX_HOURS_MS);
277
- logger_1.logger.info('Session grouping cron started (6h interval)');
453
+ setInterval(() => void tick(), ONE_HOUR_MS);
454
+ logger_1.logger.info('Session grouping cron started (1h interval)');
278
455
  // If no session has a group yet (e.g. first startup after the feature was
279
456
  // added, or a fresh self-hosted install with existing sessions), run the
280
- // full backfill immediately rather than waiting 6 hours.
457
+ // full backfill immediately rather than waiting 1 hours.
281
458
  void (async () => {
282
459
  try {
283
460
  const ungrouped = await agentSession_1.AgentSession.count({ where: { groupName: null } });
284
461
  const grouped = await agentSession_1.AgentSession.count({ where: { groupName: { [sequelize_1.Op.not]: null } } });
462
+ logger_1.logger.info(`Session grouping backfill check: ${ungrouped} ungrouped sessions, ${grouped} grouped sessions`);
285
463
  if (ungrouped > 0 && grouped === 0) {
286
464
  logger_1.logger.info('No sessions have a group yet — running initial grouping backfill', {
287
465
  sessionCount: ungrouped,
@@ -78,8 +78,8 @@ app.get('/macos/appcast', (req, res) => {
78
78
  const appcastUrl = `${baseUrl}/macos/appcast`;
79
79
  // These should match the values embedded into the macOS app
80
80
  // Info.plist in macOS/build_release_dmg.sh.
81
- const bundleVersion = '36';
82
- const shortVersion = '1.0.35';
81
+ const bundleVersion = '37';
82
+ const shortVersion = '1.0.36';
83
83
  const xml = `<?xml version="1.0" encoding="utf-8"?>
84
84
  <rss version="2.0"
85
85
  xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "version": "1.4.1",
7
+ "version": "1.5.1",
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",