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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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 (
|
|
98
|
+
if (projectPath) {
|
|
97
99
|
return [
|
|
98
|
-
`
|
|
99
|
-
`
|
|
100
|
-
`
|
|
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
|
-
`
|
|
107
|
-
`
|
|
108
|
-
`
|
|
109
|
-
`Keep responses scoped to this project's structure and conventions.`,
|
|
106
|
+
`Project root: not specified — no 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.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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 =
|
|
192
|
+
groupDescription = `Project root: ${projectPath}. ${groupDescription}`.trim();
|
|
181
193
|
}
|
|
182
|
-
groupDescription = groupDescription.slice(0,
|
|
183
|
-
return {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
240
|
-
attributes: ['
|
|
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
|
-
|
|
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
|
|
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
|
|
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(),
|
|
277
|
-
logger_1.logger.info('Session grouping cron started (
|
|
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
|
|
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,
|
package/backend-dist/index.js
CHANGED
|
@@ -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 = '
|
|
82
|
-
const shortVersion = '1.0.
|
|
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.
|
|
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",
|