omnikey-cli 1.0.34 → 1.0.36

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.
@@ -171,9 +171,6 @@ async function runToolLoop(initialResult, session, sessionId, send, log, tools,
171
171
  return result;
172
172
  }
173
173
  const aiModel = (0, ai_client_1.getDefaultModel)(config_1.config.aiProvider, 'smart');
174
- // In-memory cache: sessionId -> live SessionState. Hydrated from DB on first
175
- // access and written back after each turn so restarts resume correctly.
176
- const sessionMessages = new Map();
177
174
  const MAX_TURNS = 20;
178
175
  // ─── DB helpers ───────────────────────────────────────────────────────────────
179
176
  async function persistSessionToDB(sessionId, state) {
@@ -216,22 +213,7 @@ async function enforceSessionCap(subscriptionId, logger) {
216
213
  }
217
214
  }
218
215
  async function getOrCreateSession(sessionId, subscription, platform, log, isCronJob = false) {
219
- // 1. Return the live in-memory entry if already loaded this process lifetime.
220
- const existing = sessionMessages.get(sessionId);
221
- if (existing) {
222
- log.debug('Reusing existing agent session (in-memory)', {
223
- sessionId,
224
- subscriptionId: existing.subscription.id,
225
- turns: existing.turns,
226
- });
227
- return {
228
- sessionState: existing,
229
- hasStoredPrompt: existing.history
230
- .filter((h) => h.role === 'user')
231
- .some((h) => typeof h.content === 'string' && h.content.includes('<stored_instructions>')),
232
- };
233
- }
234
- // 2. Try to resume from a persisted DB record.
216
+ // 1. Try to resume from a persisted DB record.
235
217
  try {
236
218
  const dbSession = await agentSession_1.AgentSession.findOne({
237
219
  where: { id: sessionId, subscriptionId: subscription.id },
@@ -243,7 +225,6 @@ async function getOrCreateSession(sessionId, subscription, platform, log, isCron
243
225
  history,
244
226
  turns: dbSession.turns,
245
227
  };
246
- sessionMessages.set(sessionId, entry);
247
228
  log.info('Resumed agent session from DB', {
248
229
  sessionId,
249
230
  subscriptionId: subscription.id,
@@ -263,7 +244,7 @@ async function getOrCreateSession(sessionId, subscription, platform, log, isCron
263
244
  error: err,
264
245
  });
265
246
  }
266
- // 3. Create a brand-new session in-memory and persist it to the DB.
247
+ // 2. Create a brand-new session and persist it to the DB.
267
248
  const prompt = await (0, featureRoutes_1.getPromptForCommand)(log, 'task', subscription).catch((err) => {
268
249
  log.error('Failed to get system prompt for new agent session', { error: err });
269
250
  return '';
@@ -293,17 +274,19 @@ ${prompt}
293
274
  ],
294
275
  turns: 0,
295
276
  };
296
- sessionMessages.set(sessionId, entry);
297
277
  // Persist immediately so that GET /sessions picks it up right away.
298
278
  try {
299
- await agentSession_1.AgentSession.create({
300
- id: sessionId,
301
- subscriptionId: subscription.id,
302
- title: 'New Session',
303
- platform: platform ?? null,
304
- historyJson: JSON.stringify(entry.history),
305
- turns: 0,
306
- lastActiveAt: new Date(),
279
+ await agentSession_1.AgentSession.findOrCreate({
280
+ where: { id: sessionId, subscriptionId: subscription.id },
281
+ defaults: {
282
+ id: sessionId,
283
+ subscriptionId: subscription.id,
284
+ title: 'New Session',
285
+ platform: platform ?? null,
286
+ historyJson: JSON.stringify(entry.history),
287
+ turns: 0,
288
+ lastActiveAt: new Date(),
289
+ },
307
290
  });
308
291
  // Prune oldest sessions after each creation so the cap is always respected.
309
292
  void enforceSessionCap(subscription.id, log);
@@ -321,7 +304,7 @@ ${prompt}
321
304
  hasStoredPrompt: !!prompt,
322
305
  };
323
306
  }
324
- async function runAgentTurn(sessionId, subscription, clientMessage, send, log, options) {
307
+ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options) {
325
308
  const { sessionState: session, hasStoredPrompt } = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log, options?.isCronJob);
326
309
  // Count this call as one agent iteration.
327
310
  session.turns += 1;
@@ -444,10 +427,8 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
444
427
  if (!content && result.finish_reason !== 'tool_calls') {
445
428
  log.warn('Agent LLM returned empty content; sending generic error to client.');
446
429
  const errorMessage = 'The agent returned an empty response. Please try again.';
430
+ await persistSessionToDB(sessionId, session);
447
431
  (0, utils_1.sendFinalAnswer)(send, sessionId, errorMessage, true);
448
- // Evict from the in-memory cache; the DB record is kept so the session
449
- // appears in the list and can be retried or deleted by the user.
450
- sessionMessages.delete(sessionId);
451
432
  return;
452
433
  }
453
434
  // If the model requested web tool calls, execute them and get a follow-up
@@ -507,7 +488,7 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
507
488
  'No plain text. No other format.',
508
489
  ].join('\n'),
509
490
  });
510
- await runAgentTurn(sessionId, subscription, {
491
+ await runAgentTurnInternal(sessionId, subscription, {
511
492
  sender: 'agent',
512
493
  session_id: sessionId,
513
494
  content: '',
@@ -553,7 +534,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
553
534
  });
554
535
  (0, utils_1.pushToSessionHistory)(logger_1.logger, session, { role: 'assistant', content });
555
536
  await persistSessionToDB(sessionId, session);
556
- sessionMessages.delete(sessionId);
557
537
  send({
558
538
  session_id: sessionId,
559
539
  sender: 'agent',
@@ -572,7 +552,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
572
552
  });
573
553
  (0, utils_1.pushToSessionHistory)(log, session, { role: 'assistant', content });
574
554
  await persistSessionToDB(sessionId, session);
575
- sessionMessages.delete(sessionId);
576
555
  send({
577
556
  session_id: sessionId,
578
557
  sender: 'agent',
@@ -583,20 +562,20 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
583
562
  log.warn('Agent returned empty content with no recognized tags; sending error', {
584
563
  sessionId,
585
564
  });
565
+ await persistSessionToDB(sessionId, session);
586
566
  (0, utils_1.sendFinalAnswer)(send, sessionId, 'The agent returned an empty response. Please try again.', true);
587
- // Evict from in-memory cache; DB record is preserved.
588
- sessionMessages.delete(sessionId);
589
567
  }
590
568
  }
591
569
  catch (err) {
592
570
  log.error('Agent LLM call failed', { error: err });
593
571
  const errorMessage = 'Agent failed to call language model. Please try again later.';
572
+ await persistSessionToDB(sessionId, session);
594
573
  (0, utils_1.sendFinalAnswer)(send, sessionId, errorMessage, true);
595
- // Evict from in-memory cache; DB record is preserved so the user can
596
- // review or delete the session from the client.
597
- sessionMessages.delete(sessionId);
598
574
  }
599
575
  }
576
+ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, options) {
577
+ await runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options);
578
+ }
600
579
  function attachAgentWebSocketServer(server) {
601
580
  const wss = new ws_1.WebSocketServer({ server, path: '/ws/omni-agent' });
602
581
  wss.on('connection', (ws, req) => {
@@ -733,8 +712,6 @@ function createAgentRouter() {
733
712
  res.status(404).json({ error: 'Session not found' });
734
713
  return;
735
714
  }
736
- // Also remove from the in-memory cache if it was loaded.
737
- sessionMessages.delete(sessionId);
738
715
  res.status(200).json({ deleted: true });
739
716
  }
740
717
  catch (err) {
@@ -10,18 +10,33 @@ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
10
10
  const logger_1 = require("./logger");
11
11
  const config_1 = require("./config");
12
12
  const subscription_1 = require("./models/subscription");
13
+ const SELF_HOSTED_SUBSCRIPTION_ID = 'self-hosted-local-subscription';
13
14
  async function selfHostedSubscription() {
14
15
  try {
15
- let subscription = await subscription_1.Subscription.findOne({ where: { isSelfHosted: true } });
16
- if (!subscription) {
17
- subscription = await subscription_1.Subscription.create({
16
+ // Reuse any existing self-hosted record (including legacy IDs) first.
17
+ const existing = await subscription_1.Subscription.findOne({ where: { isSelfHosted: true } });
18
+ if (existing)
19
+ return existing;
20
+ // Use a deterministic primary key so concurrent first-time requests do not
21
+ // create duplicate rows.
22
+ const [subscription, created] = await subscription_1.Subscription.findOrCreate({
23
+ where: { id: SELF_HOSTED_SUBSCRIPTION_ID },
24
+ defaults: {
25
+ id: SELF_HOSTED_SUBSCRIPTION_ID,
18
26
  email: 'local-user@omnikey.ai',
19
27
  licenseKey: 'self-hosted',
20
28
  subscriptionStatus: 'active',
21
29
  isSelfHosted: true,
22
- });
30
+ },
31
+ });
32
+ if (created) {
23
33
  logger_1.logger.info('Created self-hosted subscription record in database.');
24
34
  }
35
+ // Ensure deterministic row remains flagged for self-hosted mode.
36
+ if (!subscription.isSelfHosted) {
37
+ subscription.isSelfHosted = true;
38
+ await subscription.save();
39
+ }
25
40
  return subscription;
26
41
  }
27
42
  catch (err) {
@@ -31,37 +46,37 @@ async function selfHostedSubscription() {
31
46
  }
32
47
  async function authMiddleware(req, res, next) {
33
48
  const authHeader = req.headers.authorization;
34
- logger_1.logger.defaultMeta = { traceId: (0, crypto_1.randomUUID)() };
49
+ const requestLogger = logger_1.logger.child({ traceId: (0, crypto_1.randomUUID)() });
50
+ res.locals.logger = requestLogger;
35
51
  if (config_1.config.blockSaas) {
36
- logger_1.logger.warn('Blocking SaaS access: rejecting request due to BLOCK_SAAS=true');
52
+ requestLogger.warn('Blocking SaaS access: rejecting request due to BLOCK_SAAS=true');
37
53
  return res.status(403).json({ error: 'SaaS access is blocked.' });
38
54
  }
39
55
  if (config_1.config.isSelfHosted || !config_1.config.jwtSecret) {
40
- logger_1.logger.info('Self-hosted mode: skipping auth middleware.');
56
+ requestLogger.info('Self-hosted mode: skipping auth middleware.');
41
57
  if (config_1.config.isSelfHosted) {
42
58
  res.locals.subscription = await selfHostedSubscription();
43
- res.locals.logger = logger_1.logger;
44
59
  }
45
60
  return next();
46
61
  }
47
62
  if (!authHeader) {
48
- logger_1.logger.warn('Missing Authorization header on feature route.');
63
+ requestLogger.warn('Missing Authorization header on feature route.');
49
64
  return res.status(401).json({ error: 'Missing bearer token.' });
50
65
  }
51
66
  const [scheme, token] = authHeader.split(' ');
52
67
  if (scheme !== 'Bearer' || !token) {
53
- logger_1.logger.warn('Malformed Authorization header on feature route.');
68
+ requestLogger.warn('Malformed Authorization header on feature route.');
54
69
  return res.status(401).json({ error: 'Invalid authorization header.' });
55
70
  }
56
71
  try {
57
72
  const decoded = jsonwebtoken_1.default.verify(token, config_1.config.jwtSecret);
58
73
  const subscription = await subscription_1.Subscription.findByPk(decoded.sid);
59
74
  if (!subscription) {
60
- logger_1.logger.warn('Subscription not found for JWT.', { sid: decoded.sid });
75
+ requestLogger.warn('Subscription not found for JWT.', { sid: decoded.sid });
61
76
  return res.status(403).json({ error: 'Invalid or expired token.' });
62
77
  }
63
78
  if (subscription.subscriptionStatus == 'expired') {
64
- logger_1.logger.warn('Inactive subscription for JWT.', {
79
+ requestLogger.warn('Inactive subscription for JWT.', {
65
80
  sid: decoded.sid,
66
81
  status: subscription.subscriptionStatus,
67
82
  });
@@ -71,19 +86,18 @@ async function authMiddleware(req, res, next) {
71
86
  if (subscription.licenseKeyExpiresAt && subscription.licenseKeyExpiresAt <= now) {
72
87
  subscription.subscriptionStatus = 'expired';
73
88
  await subscription.save();
74
- logger_1.logger.info('Subscription key has expired during activation.', {
89
+ requestLogger.info('Subscription key has expired during activation.', {
75
90
  subscriptionId: subscription.id,
76
91
  });
77
92
  return res
78
93
  .status(403)
79
94
  .json({ error: 'Subscription has expired.', subscriptionStatus: 'expired' });
80
95
  }
81
- res.locals.logger = logger_1.logger;
82
96
  res.locals.subscription = subscription;
83
97
  next();
84
98
  }
85
99
  catch (err) {
86
- logger_1.logger.warn('Invalid or expired JWT on feature route.', { error: err });
100
+ requestLogger.warn('Invalid or expired JWT on feature route.', { error: err });
87
101
  return res.status(403).json({ error: 'Invalid or expired token.' });
88
102
  }
89
103
  }
@@ -3,9 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getDownloadCounts = getDownloadCounts;
4
4
  exports.incrementDownloadCount = incrementDownloadCount;
5
5
  const storage_1 = require("@google-cloud/storage");
6
+ const zod_1 = require("zod");
6
7
  const logger_1 = require("../logger");
7
8
  const config_1 = require("../config");
8
9
  const DEFAULT_COUNTS = { macos: 0, windows: 0 };
10
+ const downloadCountsSchema = zod_1.z.object({
11
+ macos: zod_1.z.number().nonnegative().optional(),
12
+ windows: zod_1.z.number().nonnegative().optional(),
13
+ });
14
+ function parseDownloadCounts(raw) {
15
+ const json = JSON.parse(raw);
16
+ const parsed = downloadCountsSchema.safeParse(json);
17
+ if (!parsed.success) {
18
+ return { ...DEFAULT_COUNTS };
19
+ }
20
+ return {
21
+ macos: parsed.data.macos ?? 0,
22
+ windows: parsed.data.windows ?? 0,
23
+ };
24
+ }
9
25
  // Initialised once at module load — uses Application Default Credentials when
10
26
  // running on Cloud Run (or any GCP environment), and falls back to ADC from
11
27
  // the local environment during development.
@@ -30,28 +46,57 @@ async function readCounts(bucketName, objectPath) {
30
46
  return { ...DEFAULT_COUNTS };
31
47
  }
32
48
  const [contents] = await file.download();
33
- const parsed = JSON.parse(contents.toString('utf8'));
49
+ return parseDownloadCounts(contents.toString('utf8'));
50
+ }
51
+ async function readCountsWithGeneration(bucketName, objectPath) {
52
+ const file = storage.bucket(bucketName).file(objectPath);
53
+ const [exists] = await file.exists();
54
+ if (!exists) {
55
+ return { counts: { ...DEFAULT_COUNTS }, generation: null, exists: false };
56
+ }
57
+ const [[metadata], [contents]] = await Promise.all([file.getMetadata(), file.download()]);
58
+ const counts = parseDownloadCounts(contents.toString('utf8'));
34
59
  return {
35
- macos: typeof parsed.macos === 'number' ? parsed.macos : 0,
36
- windows: typeof parsed.windows === 'number' ? parsed.windows : 0,
60
+ counts,
61
+ generation: metadata.generation ?? null,
62
+ exists: true,
37
63
  };
38
64
  }
39
- async function writeCounts(bucketName, objectPath, counts) {
40
- const file = storage.bucket(bucketName).file(objectPath);
41
- await file.save(JSON.stringify(counts), {
42
- contentType: 'application/json',
43
- resumable: false,
44
- });
65
+ function isGcsPreconditionError(err) {
66
+ const maybe = err;
67
+ return (maybe?.code === 412 ||
68
+ maybe?.message?.includes('conditionNotMet') === true ||
69
+ maybe?.message?.includes('Precondition Failed') === true);
45
70
  }
46
71
  async function incrementDownloadCount(platform) {
47
72
  const gcs = getGcsConfig();
48
73
  if (!gcs)
49
74
  return;
75
+ const file = storage.bucket(gcs.bucketName).file(gcs.objectPath);
76
+ const MAX_RETRIES = 6;
50
77
  try {
51
- const counts = await readCounts(gcs.bucketName, gcs.objectPath);
52
- counts[platform] += 1;
53
- await writeCounts(gcs.bucketName, gcs.objectPath, counts);
54
- logger_1.logger.info(`Download count incremented for ${platform}.`, { counts });
78
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
79
+ const { counts, generation, exists } = await readCountsWithGeneration(gcs.bucketName, gcs.objectPath);
80
+ counts[platform] += 1;
81
+ try {
82
+ await file.save(JSON.stringify(counts), {
83
+ contentType: 'application/json',
84
+ resumable: false,
85
+ preconditionOpts: exists
86
+ ? { ifGenerationMatch: Number(generation) }
87
+ : { ifGenerationMatch: 0 },
88
+ });
89
+ logger_1.logger.info(`Download count incremented for ${platform}.`, { counts, attempt });
90
+ return;
91
+ }
92
+ catch (err) {
93
+ if (isGcsPreconditionError(err) && attempt < MAX_RETRIES) {
94
+ continue;
95
+ }
96
+ throw err;
97
+ }
98
+ }
99
+ logger_1.logger.warn(`Download count increment exhausted retries for ${platform}.`);
55
100
  }
56
101
  catch (err) {
57
102
  logger_1.logger.error(`Failed to increment download count for ${platform}.`, { error: err });
@@ -8,6 +8,7 @@ const cors_1 = __importDefault(require("cors"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const zlib_1 = __importDefault(require("zlib"));
11
+ const child_process_1 = require("child_process");
11
12
  const subscriptionRoutes_1 = require("./subscriptionRoutes");
12
13
  const featureRoutes_1 = require("./featureRoutes");
13
14
  const db_1 = require("./db");
@@ -23,6 +24,7 @@ require("./models/scheduledJob");
23
24
  const bucket_adapter_1 = require("./bucket-adapter");
24
25
  const app = (0, express_1.default)();
25
26
  const PORT = Number(config_1.config.port);
27
+ const IS_CRON_CHILD = process.env.OMNIKEY_CRON_CHILD === '1';
26
28
  app.set('trust proxy', 1);
27
29
  app.use((0, cors_1.default)());
28
30
  app.use(express_1.default.json());
@@ -74,8 +76,8 @@ app.get('/macos/appcast', (req, res) => {
74
76
  const appcastUrl = `${baseUrl}/macos/appcast`;
75
77
  // These should match the values embedded into the macOS app
76
78
  // Info.plist in macOS/build_release_dmg.sh.
77
- const bundleVersion = '24';
78
- const shortVersion = '1.0.23';
79
+ const bundleVersion = '25';
80
+ const shortVersion = '1.0.24';
79
81
  const xml = `<?xml version="1.0" encoding="utf-8"?>
80
82
  <rss version="2.0"
81
83
  xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
@@ -165,9 +167,38 @@ app.get('*', (_req, res) => {
165
167
  res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
166
168
  });
167
169
  let server = null;
170
+ let cronChildProcess = null;
171
+ function startCronChildProcess() {
172
+ if (IS_CRON_CHILD || cronChildProcess)
173
+ return;
174
+ const childPort = PORT + 1;
175
+ const entry = process.argv[1] || __filename;
176
+ cronChildProcess = (0, child_process_1.fork)(entry, [], {
177
+ env: {
178
+ ...process.env,
179
+ OMNIKEY_CRON_CHILD: '1',
180
+ OMNIKEY_PORT: String(childPort),
181
+ },
182
+ execArgv: process.execArgv,
183
+ stdio: 'inherit',
184
+ });
185
+ logger_1.logger.info('Spawned cron child process.', {
186
+ pid: cronChildProcess.pid,
187
+ port: childPort,
188
+ });
189
+ cronChildProcess.on('exit', (code, signal) => {
190
+ logger_1.logger.warn('Cron child process exited.', { code, signal });
191
+ cronChildProcess = null;
192
+ });
193
+ }
168
194
  async function start() {
169
195
  try {
170
196
  await (0, db_1.initDatabase)(logger_1.logger);
197
+ if (IS_CRON_CHILD) {
198
+ logger_1.logger.info('Starting cron child process mode.', { port: PORT });
199
+ (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
200
+ return;
201
+ }
171
202
  server = app.listen(PORT, () => {
172
203
  logger_1.logger.info(`Enhancer API listening on http://localhost:${PORT}`, {
173
204
  isSelfHosted: config_1.config.isSelfHosted,
@@ -181,7 +212,7 @@ async function start() {
181
212
  (0, agentServer_1.attachAgentWebSocketServer)(server);
182
213
  }
183
214
  if (config_1.config.isSelfHosted) {
184
- (0, scheduledJobExecutor_1.startScheduledJobExecutor)();
215
+ startCronChildProcess();
185
216
  }
186
217
  }
187
218
  catch (err) {
@@ -192,6 +223,15 @@ async function start() {
192
223
  start();
193
224
  function gracefulShutdown(signal) {
194
225
  logger_1.logger.info(`Received ${signal}. Starting graceful shutdown...`);
226
+ if (cronChildProcess) {
227
+ cronChildProcess.kill('SIGTERM');
228
+ cronChildProcess = null;
229
+ }
230
+ if (IS_CRON_CHILD) {
231
+ logger_1.logger.info('Cron child process exiting.');
232
+ process.exit(0);
233
+ return;
234
+ }
195
235
  if (!server) {
196
236
  logger_1.logger.info('Server was not started or already closed. Exiting process.');
197
237
  process.exit(0);
@@ -144,7 +144,7 @@ function runCronJob(job, subscription, sessionId) {
144
144
  content: output,
145
145
  is_terminal_output: true,
146
146
  is_error: isError,
147
- }, send, logger_1.logger, { maxTurns: MAX_CRON_TURNS }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
147
+ }, send, logger_1.logger, { maxTurns: MAX_CRON_TURNS, isCronJob: true }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
148
148
  return;
149
149
  }
150
150
  if (FINAL_ANSWER_RE.test(content)) {
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.34",
7
+ "version": "1.0.36",
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",