omnikey-cli 1.0.35 → 1.0.37

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.
@@ -17,28 +17,17 @@ ${hasTaskInstructions
17
17
  - Default to a \`<shell_script>\` for anything involving the machine, network, files, processes, env vars, or system state — never answer from training data alone.
18
18
  - **Read vs write:** For open-ended/ambiguous requests run safe read-only commands first to understand the current state. When the user **explicitly** asks to create, update, delete, configure, or run something — do it directly; no need to ask for confirmation unless the scope is genuinely unclear.
19
19
  - **Package installation:** Install any package required to complete the task. Include the install step as its own phase so you can confirm it succeeded before building on it. Prefer project-local or user scope; avoid \`sudo\`/admin unless the user explicitly asks.
20
- ${config_1.config.browserDebugPort !== undefined ? `- **Browser automation:** When the user explicitly asks to interact with a browser (click a button, fill a form, check a page, take a screenshot, etc.), generate \`<shell_script>\` blocks that use Node.js and \`playwright-core\` — one phase at a time (phasing rules below apply).
20
+ ${config_1.config.browserDebugPort !== undefined
21
+ ? `- **Browser automation:** Use browser automation proactively when needed to complete the task.
22
+ - Do NOT wait for explicit user wording like "use browser" if interaction is obviously required to get the final result.
23
+ - If \`web_search\` / \`web_fetch\` do not provide enough usable context (blocked pages, incomplete data, client-rendered content, auth walls, dynamic tables, hidden details, repeated low-value fetch results), immediately switch to Playwright-based browser interaction.
24
+ - Generate \`<shell_script>\` blocks that use Node.js and \`playwright-core\` — one phase at a time (phasing rules below apply).
21
25
  - **Phase 1 — ensure deps:** Check and install \`playwright-core\` if missing:
22
26
  \`node -e "require('/tmp/playwright-runner/node_modules/playwright-core')" 2>/dev/null || npm install --prefix /tmp/playwright-runner playwright-core --silent\`
23
- - **Phase 2 — connect & navigate:** Try CDP first; fall back to the existing debug profile. Reuse an open tab if the URL already matches never open a duplicate.
24
- \`\`\`js
25
- const { chromium } = require('/tmp/playwright-runner/node_modules/playwright-core');
26
- let browser, page;
27
- try {
28
- browser = await chromium.connectOverCDP('http://localhost:${config_1.config.browserDebugPort}');
29
- const pages = browser.contexts().flatMap(c => c.pages());
30
- page = pages.find(p => p.url().startsWith(TARGET_URL)) ?? null;
31
- if (page) { await page.bringToFront(); }
32
- else { page = await browser.contexts()[0].newPage(); await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 15000 }); }
33
- } catch {
34
- const ctx = await chromium.launchPersistentContext('${config_1.config.browserDebugUserDataDir}', { executablePath: '${config_1.config.browserDebugExecutable}', headless: false });
35
- browser = ctx;
36
- page = ctx.pages().find(p => p.url().startsWith(TARGET_URL)) ?? await ctx.newPage();
37
- if (!page.url().startsWith(TARGET_URL)) await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
38
- }
39
- \`\`\`
40
- - **Phase 3+ — one action per script:** Each subsequent script reconnects the same way, finds the already-open tab, performs exactly one action (click / type / select / screenshot / read text), prints the result, then calls \`browser.disconnect()\` (CDP) or just exits (profile launch — leaves the window open).
41
- - Always inline Node.js via a bash heredoc so the script is self-contained. Print structured output to stdout so it returns as \`TERMINAL OUTPUT:\`.` : ''}
27
+ - **Phase 2 — connect & navigate:** Connect to the running browser via CDP at \`http://localhost:${config_1.config.browserDebugPort}\`. If CDP fails, fall back to launching a persistent context using the debug profile at \`${config_1.config.browserDebugUserDataDir}\` with the executable at \`${config_1.config.browserDebugExecutable}\` (headless: false). Once connected, navigate to any URL required by the task — open any page needed, reusing an existing tab if the URL already matches or creating a new one if not. There is no restriction on which sites or pages you can visit; open whatever is necessary to complete the task.
28
+ - **Phase 3+ — one action per script:** Each subsequent script reconnects via the same CDP endpoint (\`http://localhost:${config_1.config.browserDebugPort}\`) or profile fallback, finds the already-open tab (or reopens it), performs exactly one action (click, type, select, scroll, screenshot, read text, extract data, fill forms, etc.), prints the result to stdout, then calls \`browser.disconnect()\` (CDP) or exits (profile launch). You may perform any interaction the task requires — reading content, extracting structured data, submitting forms, navigating between pages, or capturing screenshots.
29
+ - Always inline Node.js via a bash heredoc so the script is self-contained. Print structured output to stdout so it returns as \`TERMINAL OUTPUT:\`.`
30
+ : ''}
42
31
  - Use ${!isWindows ? 'bash (macOS/Linux)' : 'PowerShell'}. Every script must be self-contained and ready to run as-is.
43
32
  - Skip the script only for purely factual/conversational requests with no live data dependency (e.g. "what is 2+2").
44
33
 
@@ -171,10 +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
- const MAX_TURNS = 20;
178
174
  // ─── DB helpers ───────────────────────────────────────────────────────────────
179
175
  async function persistSessionToDB(sessionId, state) {
180
176
  try {
@@ -216,22 +212,7 @@ async function enforceSessionCap(subscriptionId, logger) {
216
212
  }
217
213
  }
218
214
  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.
215
+ // 1. Try to resume from a persisted DB record.
235
216
  try {
236
217
  const dbSession = await agentSession_1.AgentSession.findOne({
237
218
  where: { id: sessionId, subscriptionId: subscription.id },
@@ -243,7 +224,6 @@ async function getOrCreateSession(sessionId, subscription, platform, log, isCron
243
224
  history,
244
225
  turns: dbSession.turns,
245
226
  };
246
- sessionMessages.set(sessionId, entry);
247
227
  log.info('Resumed agent session from DB', {
248
228
  sessionId,
249
229
  subscriptionId: subscription.id,
@@ -263,7 +243,7 @@ async function getOrCreateSession(sessionId, subscription, platform, log, isCron
263
243
  error: err,
264
244
  });
265
245
  }
266
- // 3. Create a brand-new session in-memory and persist it to the DB.
246
+ // 2. Create a brand-new session and persist it to the DB.
267
247
  const prompt = await (0, featureRoutes_1.getPromptForCommand)(log, 'task', subscription).catch((err) => {
268
248
  log.error('Failed to get system prompt for new agent session', { error: err });
269
249
  return '';
@@ -293,18 +273,39 @@ ${prompt}
293
273
  ],
294
274
  turns: 0,
295
275
  };
296
- sessionMessages.set(sessionId, entry);
297
276
  // Persist immediately so that GET /sessions picks it up right away.
298
277
  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(),
278
+ const [dbSession, created] = await agentSession_1.AgentSession.findOrCreate({
279
+ where: { id: sessionId, subscriptionId: subscription.id },
280
+ defaults: {
281
+ id: sessionId,
282
+ subscriptionId: subscription.id,
283
+ title: 'New Session',
284
+ platform: platform ?? null,
285
+ historyJson: JSON.stringify(entry.history),
286
+ turns: 0,
287
+ lastActiveAt: new Date(),
288
+ },
307
289
  });
290
+ if (!created) {
291
+ const history = JSON.parse(dbSession.historyJson || '[]');
292
+ const existingEntry = {
293
+ subscription,
294
+ history,
295
+ turns: dbSession.turns,
296
+ };
297
+ log.info('Reused existing agent session row from DB during create path', {
298
+ sessionId,
299
+ subscriptionId: subscription.id,
300
+ turns: existingEntry.turns,
301
+ });
302
+ return {
303
+ sessionState: existingEntry,
304
+ hasStoredPrompt: history
305
+ .filter((h) => h.role === 'user')
306
+ .some((h) => typeof h.content === 'string' && h.content.includes('<stored_instructions>')),
307
+ };
308
+ }
308
309
  // Prune oldest sessions after each creation so the cap is always respected.
309
310
  void enforceSessionCap(subscription.id, log);
310
311
  }
@@ -321,7 +322,7 @@ ${prompt}
321
322
  hasStoredPrompt: !!prompt,
322
323
  };
323
324
  }
324
- async function runAgentTurn(sessionId, subscription, clientMessage, send, log, options) {
325
+ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options) {
325
326
  const { sessionState: session, hasStoredPrompt } = await getOrCreateSession(sessionId, subscription, clientMessage.platform, log, options?.isCronJob);
326
327
  // Count this call as one agent iteration.
327
328
  session.turns += 1;
@@ -330,14 +331,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
330
331
  subscriptionId: subscription.id,
331
332
  turn: session.turns,
332
333
  });
333
- const effectiveMaxTurns = options?.maxTurns ?? MAX_TURNS;
334
- // On the final iteration, instruct the LLM to provide a consolidated answer.
335
- if (session.turns === effectiveMaxTurns) {
336
- (0, utils_1.pushToSessionHistory)(logger_1.logger, session, {
337
- role: 'system',
338
- content: 'Provide a single, final, concise answer based on the entire conversation so far. Wrap the answer in a <final_answer>...</final_answer> block and do not ask for further input or mention additional shell scripts to run. Do not include any <shell_script> block in this response.',
339
- });
340
- }
341
334
  // Append the client message as user content, marking terminal
342
335
  // output and errors in the text so the agent can reason about them.
343
336
  let userContent = clientMessage.content || '';
@@ -386,10 +379,7 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
386
379
  }
387
380
  }
388
381
  }
389
- // On the final turn we omit tools so the model is forced to emit a
390
- // plain text <final_answer> rather than issuing another tool call.
391
- const isFinalTurn = session.turns >= effectiveMaxTurns;
392
- const tools = isFinalTurn ? undefined : (0, utils_1.buildAvailableTools)();
382
+ const tools = (0, utils_1.buildAvailableTools)();
393
383
  const recordUsage = async (result) => {
394
384
  const usage = result.usage;
395
385
  if (!usage)
@@ -445,13 +435,12 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
445
435
  log.warn('Agent LLM returned empty content; sending generic error to client.');
446
436
  const errorMessage = 'The agent returned an empty response. Please try again.';
447
437
  await persistSessionToDB(sessionId, session);
448
- sessionMessages.delete(sessionId);
449
438
  (0, utils_1.sendFinalAnswer)(send, sessionId, errorMessage, true);
450
439
  return;
451
440
  }
452
441
  // If the model requested web tool calls, execute them and get a follow-up
453
442
  // response before deciding what to send to the client.
454
- if (!isFinalTurn && result.finish_reason === 'tool_calls') {
443
+ if (result.finish_reason === 'tool_calls') {
455
444
  log.info('Running web tool calls to gather information', {
456
445
  sessionId,
457
446
  subscriptionId: subscription.id,
@@ -506,7 +495,10 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
506
495
  'No plain text. No other format.',
507
496
  ].join('\n'),
508
497
  });
509
- await runAgentTurn(sessionId, subscription, {
498
+ // DB-only session state: persist before recursive handoff so the
499
+ // follow-up turn reads the latest history and turn count.
500
+ await persistSessionToDB(sessionId, session);
501
+ await runAgentTurnInternal(sessionId, subscription, {
510
502
  sender: 'agent',
511
503
  session_id: sessionId,
512
504
  content: '',
@@ -515,15 +507,9 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
515
507
  return;
516
508
  }
517
509
  }
518
- // Ensure that a proper <final_answer> block is produced for the
519
- // desktop clients once we reach the final turn. If the model did
520
- // not emit either a <shell_script> or <final_answer> tag on the
521
- // MAX_TURNS turn, we treat this as the final natural-language answer
522
- // and wrap it in <final_answer> tags so the client can stop
523
- // waiting and paste the result.
524
510
  const hasShellScriptTag = content.includes('<shell_script>');
525
511
  const hasFinalAnswerTag = content.includes('<final_answer>');
526
- if (hasShellScriptTag && !isFinalTurn) {
512
+ if (hasShellScriptTag) {
527
513
  log.info('Completed agent turn. Sending back scripts, waiting for results.', {
528
514
  sessionId,
529
515
  subscriptionId: subscription.id,
@@ -534,6 +520,10 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
534
520
  role: 'assistant',
535
521
  content,
536
522
  });
523
+ // Persist before sending so that if the send callback triggers a new
524
+ // runAgentTurn immediately (e.g. cron shell-script loop), the DB already
525
+ // has the updated turn count and history.
526
+ await persistSessionToDB(sessionId, session);
537
527
  send({
538
528
  session_id: sessionId,
539
529
  sender: 'agent',
@@ -543,8 +533,8 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
543
533
  });
544
534
  return;
545
535
  }
546
- if (isFinalTurn || hasFinalAnswerTag) {
547
- log.info('Finalizing agent session after max turns or final answer tag', {
536
+ if (hasFinalAnswerTag) {
537
+ log.info('Finalizing agent session after final answer tag', {
548
538
  sessionId,
549
539
  subscriptionId: subscription.id,
550
540
  turns: session.turns,
@@ -552,7 +542,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
552
542
  });
553
543
  (0, utils_1.pushToSessionHistory)(logger_1.logger, session, { role: 'assistant', content });
554
544
  await persistSessionToDB(sessionId, session);
555
- sessionMessages.delete(sessionId);
556
545
  send({
557
546
  session_id: sessionId,
558
547
  sender: 'agent',
@@ -571,7 +560,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
571
560
  });
572
561
  (0, utils_1.pushToSessionHistory)(log, session, { role: 'assistant', content });
573
562
  await persistSessionToDB(sessionId, session);
574
- sessionMessages.delete(sessionId);
575
563
  send({
576
564
  session_id: sessionId,
577
565
  sender: 'agent',
@@ -583,7 +571,6 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
583
571
  sessionId,
584
572
  });
585
573
  await persistSessionToDB(sessionId, session);
586
- sessionMessages.delete(sessionId);
587
574
  (0, utils_1.sendFinalAnswer)(send, sessionId, 'The agent returned an empty response. Please try again.', true);
588
575
  }
589
576
  }
@@ -591,10 +578,12 @@ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, o
591
578
  log.error('Agent LLM call failed', { error: err });
592
579
  const errorMessage = 'Agent failed to call language model. Please try again later.';
593
580
  await persistSessionToDB(sessionId, session);
594
- sessionMessages.delete(sessionId);
595
581
  (0, utils_1.sendFinalAnswer)(send, sessionId, errorMessage, true);
596
582
  }
597
583
  }
584
+ async function runAgentTurn(sessionId, subscription, clientMessage, send, log, options) {
585
+ await runAgentTurnInternal(sessionId, subscription, clientMessage, send, log, options);
586
+ }
598
587
  function attachAgentWebSocketServer(server) {
599
588
  const wss = new ws_1.WebSocketServer({ server, path: '/ws/omni-agent' });
600
589
  wss.on('connection', (ws, req) => {
@@ -731,8 +720,6 @@ function createAgentRouter() {
731
720
  res.status(404).json({ error: 'Session not found' });
732
721
  return;
733
722
  }
734
- // Also remove from the in-memory cache if it was loaded.
735
- sessionMessages.delete(sessionId);
736
723
  res.status(200).json({ deleted: true });
737
724
  }
738
725
  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 });
@@ -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, isCronJob: true }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
147
+ }, send, logger_1.logger, { 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)) {
@@ -158,7 +158,7 @@ function runCronJob(job, subscription, sessionId) {
158
158
  sender: 'user',
159
159
  content: job.prompt,
160
160
  platform: job.platform ?? undefined,
161
- }, send, logger_1.logger, { maxTurns: MAX_CRON_TURNS, isCronJob: true }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
161
+ }, send, logger_1.logger, { isCronJob: true }).catch((err) => settle(err instanceof Error ? err : new Error(String(err))));
162
162
  });
163
163
  }
164
164
  async function executeJob(job) {
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.35",
7
+ "version": "1.0.37",
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",