@link-assistant/hive-mind 1.2.9 → 1.2.11
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.
- package/CHANGELOG.md +19 -0
- package/package.json +4 -2
- package/src/claude.lib.mjs +7 -0
- package/src/limits.lib.mjs +27 -38
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.mjs +28 -3
- package/src/solve.watch.lib.mjs +33 -3
- package/src/usage-limit.lib.mjs +172 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.2.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8404b75: fix: Support weekly limit date parsing in extractResetTime and parseResetTime
|
|
8
|
+
- Added Pattern 0 to extractResetTime() to handle date+time formats like "resets Jan 15, 8am"
|
|
9
|
+
- Updated parseResetTime() to parse date+time strings with month name and day
|
|
10
|
+
- This ensures weekly limit messages are displayed with the "Usage Limit Reached" format
|
|
11
|
+
|
|
12
|
+
## 1.2.10
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 7ba1476: Auto-cleanup .playwright-mcp/ folder to prevent false auto-restart triggers
|
|
17
|
+
- Add auto-cleanup of .playwright-mcp/ folder before checking uncommitted changes
|
|
18
|
+
- Add --playwright-mcp-auto-cleanup option (enabled by default)
|
|
19
|
+
- Use --no-playwright-mcp-auto-cleanup to disable cleanup for debugging
|
|
20
|
+
- Add comprehensive case study documentation for issue #1124
|
|
21
|
+
|
|
3
22
|
## 1.2.9
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.11",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node tests/solve-queue.test.mjs",
|
|
16
|
+
"test": "node tests/solve-queue.test.mjs && node tests/test-usage-limit.mjs",
|
|
17
17
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
18
|
+
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
18
19
|
"lint": "eslint 'src/**/*.{js,mjs,cjs}'",
|
|
19
20
|
"lint:fix": "eslint 'src/**/*.{js,mjs,cjs}' --fix",
|
|
20
21
|
"format": "prettier --write \"**/*.{js,mjs,json,md}\"",
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"@secretlint/secretlint-rule-preset-recommend": "^11.2.5",
|
|
66
67
|
"@sentry/node": "^10.15.0",
|
|
67
68
|
"@sentry/profiling-node": "^10.15.0",
|
|
69
|
+
"dayjs": "^1.11.19",
|
|
68
70
|
"secretlint": "^11.2.5"
|
|
69
71
|
},
|
|
70
72
|
"lint-staged": {
|
package/src/claude.lib.mjs
CHANGED
|
@@ -896,6 +896,7 @@ export const executeClaudeCommand = async params => {
|
|
|
896
896
|
let sessionId = null;
|
|
897
897
|
let limitReached = false;
|
|
898
898
|
let limitResetTime = null;
|
|
899
|
+
let limitTimezone = null;
|
|
899
900
|
let messageCount = 0;
|
|
900
901
|
let toolUseCount = 0;
|
|
901
902
|
let lastMessage = '';
|
|
@@ -1162,6 +1163,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1162
1163
|
sessionId,
|
|
1163
1164
|
limitReached: false,
|
|
1164
1165
|
limitResetTime: null,
|
|
1166
|
+
limitTimezone: null,
|
|
1165
1167
|
messageCount,
|
|
1166
1168
|
toolUseCount,
|
|
1167
1169
|
};
|
|
@@ -1208,6 +1210,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1208
1210
|
sessionId,
|
|
1209
1211
|
limitReached: false,
|
|
1210
1212
|
limitResetTime: null,
|
|
1213
|
+
limitTimezone: null,
|
|
1211
1214
|
messageCount,
|
|
1212
1215
|
toolUseCount,
|
|
1213
1216
|
is503Error: true,
|
|
@@ -1220,6 +1223,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1220
1223
|
if (limitInfo.isUsageLimit) {
|
|
1221
1224
|
limitReached = true;
|
|
1222
1225
|
limitResetTime = limitInfo.resetTime;
|
|
1226
|
+
limitTimezone = limitInfo.timezone;
|
|
1223
1227
|
|
|
1224
1228
|
// Format and display user-friendly message
|
|
1225
1229
|
const messageLines = formatUsageLimitMessage({
|
|
@@ -1284,6 +1288,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1284
1288
|
sessionId,
|
|
1285
1289
|
limitReached,
|
|
1286
1290
|
limitResetTime,
|
|
1291
|
+
limitTimezone,
|
|
1287
1292
|
messageCount,
|
|
1288
1293
|
toolUseCount,
|
|
1289
1294
|
errorDuringExecution,
|
|
@@ -1353,6 +1358,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1353
1358
|
sessionId,
|
|
1354
1359
|
limitReached,
|
|
1355
1360
|
limitResetTime,
|
|
1361
|
+
limitTimezone,
|
|
1356
1362
|
messageCount,
|
|
1357
1363
|
toolUseCount,
|
|
1358
1364
|
anthropicTotalCostUSD, // Pass Anthropic's official total cost
|
|
@@ -1401,6 +1407,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1401
1407
|
sessionId,
|
|
1402
1408
|
limitReached,
|
|
1403
1409
|
limitResetTime: null,
|
|
1410
|
+
limitTimezone: null,
|
|
1404
1411
|
messageCount,
|
|
1405
1412
|
toolUseCount,
|
|
1406
1413
|
};
|
package/src/limits.lib.mjs
CHANGED
|
@@ -9,6 +9,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { promisify } from 'node:util';
|
|
12
|
+
import dayjs from 'dayjs';
|
|
13
|
+
import utc from 'dayjs/plugin/utc.js';
|
|
14
|
+
|
|
15
|
+
// Initialize dayjs plugins
|
|
16
|
+
dayjs.extend(utc);
|
|
12
17
|
|
|
13
18
|
// Import cache TTL configuration
|
|
14
19
|
import { cacheTtl } from './config.lib.mjs';
|
|
@@ -51,7 +56,7 @@ async function readCredentials(credentialsPath = DEFAULT_CREDENTIALS_PATH, verbo
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
|
-
* Format an ISO date string to a human-readable reset time
|
|
59
|
+
* Format an ISO date string to a human-readable reset time using dayjs
|
|
55
60
|
*
|
|
56
61
|
* @param {string} isoDate - ISO date string (e.g., "2025-12-03T17:59:59.626485+00:00")
|
|
57
62
|
* @param {boolean} includeTimezone - Whether to include timezone suffix (default: true)
|
|
@@ -61,18 +66,11 @@ function formatResetTime(isoDate, includeTimezone = true) {
|
|
|
61
66
|
if (!isoDate) return null;
|
|
62
67
|
|
|
63
68
|
try {
|
|
64
|
-
const date =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const minutes = date.getUTCMinutes();
|
|
70
|
-
|
|
71
|
-
// Convert 24h to 12h format
|
|
72
|
-
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
|
73
|
-
const ampm = hours >= 12 ? 'pm' : 'am';
|
|
74
|
-
|
|
75
|
-
const timeStr = `${month} ${day}, ${hour12}:${minutes.toString().padStart(2, '0')}${ampm}`;
|
|
69
|
+
const date = dayjs(isoDate).utc();
|
|
70
|
+
if (!date.isValid()) return isoDate;
|
|
71
|
+
|
|
72
|
+
// dayjs format: MMM=Jan, D=day, h=12-hour, mm=minutes, a=am/pm
|
|
73
|
+
const timeStr = date.format('MMM D, h:mma');
|
|
76
74
|
return includeTimezone ? `${timeStr} UTC` : timeStr;
|
|
77
75
|
} catch {
|
|
78
76
|
return isoDate;
|
|
@@ -80,7 +78,7 @@ function formatResetTime(isoDate, includeTimezone = true) {
|
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
/**
|
|
83
|
-
* Format relative time from now to a future date
|
|
81
|
+
* Format relative time from now to a future date using dayjs
|
|
84
82
|
*
|
|
85
83
|
* @param {string} isoDate - ISO date string
|
|
86
84
|
* @returns {string|null} Relative time string (e.g., "1h 34m" or "6d 20h 13m") or null if date is in the past
|
|
@@ -89,49 +87,40 @@ function formatRelativeTime(isoDate) {
|
|
|
89
87
|
if (!isoDate) return null;
|
|
90
88
|
|
|
91
89
|
try {
|
|
92
|
-
const now =
|
|
93
|
-
const target =
|
|
94
|
-
const diffMs = target - now;
|
|
90
|
+
const now = dayjs();
|
|
91
|
+
const target = dayjs(isoDate);
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
if (isNaN(diffMs)) return null;
|
|
93
|
+
if (!target.isValid()) return null;
|
|
98
94
|
|
|
95
|
+
const diffMs = target.diff(now);
|
|
99
96
|
if (diffMs < 0) return null; // Past date
|
|
100
97
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
98
|
+
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
|
99
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
100
|
+
const totalDays = Math.floor(totalHours / 24);
|
|
101
|
+
|
|
102
|
+
const days = totalDays;
|
|
103
|
+
const hours = totalHours % 24;
|
|
104
|
+
const minutes = totalMinutes % 60;
|
|
103
105
|
|
|
104
106
|
// If hours >= 24, show days
|
|
105
|
-
if (
|
|
106
|
-
const days = Math.floor(totalHours / 24);
|
|
107
|
-
const hours = totalHours % 24;
|
|
107
|
+
if (days > 0) {
|
|
108
108
|
return `${days}d ${hours}h ${minutes}m`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
return `${
|
|
111
|
+
return `${hours}h ${minutes}m`;
|
|
112
112
|
} catch {
|
|
113
113
|
return null;
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Format current time in UTC
|
|
118
|
+
* Format current time in UTC using dayjs
|
|
119
119
|
*
|
|
120
120
|
* @returns {string} Current time in UTC (e.g., "Dec 3, 6:45pm UTC")
|
|
121
121
|
*/
|
|
122
122
|
function formatCurrentTime() {
|
|
123
|
-
|
|
124
|
-
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
125
|
-
const month = months[now.getUTCMonth()];
|
|
126
|
-
const day = now.getUTCDate();
|
|
127
|
-
const hours = now.getUTCHours();
|
|
128
|
-
const minutes = now.getUTCMinutes();
|
|
129
|
-
|
|
130
|
-
// Convert 24h to 12h format
|
|
131
|
-
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
|
132
|
-
const ampm = hours >= 12 ? 'pm' : 'am';
|
|
133
|
-
|
|
134
|
-
return `${month} ${day}, ${hour12}:${minutes.toString().padStart(2, '0')}${ampm} UTC`;
|
|
123
|
+
return dayjs().utc().format('MMM D, h:mma [UTC]');
|
|
135
124
|
}
|
|
136
125
|
|
|
137
126
|
/**
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -311,6 +311,11 @@ export const createYargsConfig = yargsInstance => {
|
|
|
311
311
|
description: 'Include prompt to check related/sibling pull requests when studying related work. Enabled by default, use --no-prompt-check-sibling-pull-requests to disable.',
|
|
312
312
|
default: true,
|
|
313
313
|
})
|
|
314
|
+
.option('playwright-mcp-auto-cleanup', {
|
|
315
|
+
type: 'boolean',
|
|
316
|
+
description: 'Automatically remove .playwright-mcp/ folder before checking for uncommitted changes. This prevents browser automation artifacts from triggering auto-restart. Use --no-playwright-mcp-auto-cleanup to keep the folder for debugging.',
|
|
317
|
+
default: true,
|
|
318
|
+
})
|
|
314
319
|
.parserConfiguration({
|
|
315
320
|
'boolean-negation': true,
|
|
316
321
|
})
|
package/src/solve.mjs
CHANGED
|
@@ -898,10 +898,13 @@ try {
|
|
|
898
898
|
limitReached = toolResult.limitReached;
|
|
899
899
|
cleanupContext.limitReached = limitReached;
|
|
900
900
|
|
|
901
|
-
// Capture limit reset time globally for downstream handlers (auto-continue, cleanup decisions)
|
|
901
|
+
// Capture limit reset time and timezone globally for downstream handlers (auto-continue, cleanup decisions)
|
|
902
902
|
if (toolResult && toolResult.limitResetTime) {
|
|
903
903
|
global.limitResetTime = toolResult.limitResetTime;
|
|
904
904
|
}
|
|
905
|
+
if (toolResult && toolResult.limitTimezone) {
|
|
906
|
+
global.limitTimezone = toolResult.limitTimezone;
|
|
907
|
+
}
|
|
905
908
|
|
|
906
909
|
// Handle limit reached scenario
|
|
907
910
|
if (limitReached) {
|
|
@@ -974,8 +977,9 @@ try {
|
|
|
974
977
|
const tool = argv.tool || 'claude';
|
|
975
978
|
const resumeCmd = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : null;
|
|
976
979
|
const resumeSection = resumeCmd ? `To resume after the limit resets, use:\n\`\`\`bash\n${resumeCmd}\n\`\`\`` : `Session ID: \`${sessionId}\``;
|
|
977
|
-
// Format the reset time with relative time if available
|
|
978
|
-
const
|
|
980
|
+
// Format the reset time with relative time and UTC conversion if available
|
|
981
|
+
const timezone = global.limitTimezone || null;
|
|
982
|
+
const formattedResetTime = resetTime ? formatResetTimeWithRelative(resetTime, timezone) : null;
|
|
979
983
|
const failureComment = formattedResetTime ? `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. The limit will reset at: **${formattedResetTime}**\n\n${resumeSection}` : `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. Please wait for the limit to reset.\n\n${resumeSection}`;
|
|
980
984
|
|
|
981
985
|
const commentResult = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${failureComment}`;
|
|
@@ -1116,6 +1120,27 @@ try {
|
|
|
1116
1120
|
await safeExit(1, `${argv.tool.toUpperCase()} execution failed`);
|
|
1117
1121
|
}
|
|
1118
1122
|
|
|
1123
|
+
// Clean up .playwright-mcp/ folder before checking for uncommitted changes
|
|
1124
|
+
// This prevents browser automation artifacts from triggering auto-restart (Issue #1124)
|
|
1125
|
+
if (argv.playwrightMcpAutoCleanup !== false) {
|
|
1126
|
+
const playwrightMcpDir = path.join(tempDir, '.playwright-mcp');
|
|
1127
|
+
try {
|
|
1128
|
+
const playwrightMcpExists = await fs
|
|
1129
|
+
.stat(playwrightMcpDir)
|
|
1130
|
+
.then(() => true)
|
|
1131
|
+
.catch(() => false);
|
|
1132
|
+
if (playwrightMcpExists) {
|
|
1133
|
+
await fs.rm(playwrightMcpDir, { recursive: true, force: true });
|
|
1134
|
+
await log('🧹 Cleaned up .playwright-mcp/ folder (browser automation artifacts)', { verbose: true });
|
|
1135
|
+
}
|
|
1136
|
+
} catch (cleanupError) {
|
|
1137
|
+
// Non-critical error, just log and continue
|
|
1138
|
+
await log(`⚠️ Could not clean up .playwright-mcp/ folder: ${cleanupError.message}`, { verbose: true });
|
|
1139
|
+
}
|
|
1140
|
+
} else {
|
|
1141
|
+
await log('ℹ️ Playwright MCP auto-cleanup disabled via --no-playwright-mcp-auto-cleanup', { verbose: true });
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1119
1144
|
// Check for uncommitted changes
|
|
1120
1145
|
// When limit is reached, force auto-commit of any uncommitted changes to preserve work
|
|
1121
1146
|
const shouldAutoCommit = argv['auto-commit-uncommitted-changes'] || limitReached;
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -15,6 +15,10 @@ const use = globalThis.use;
|
|
|
15
15
|
// Use command-stream for consistent $ behavior across runtimes
|
|
16
16
|
const { $ } = await use('command-stream');
|
|
17
17
|
|
|
18
|
+
// Import path and fs for cleanup operations
|
|
19
|
+
const path = (await use('path')).default;
|
|
20
|
+
const fs = (await use('fs')).promises;
|
|
21
|
+
|
|
18
22
|
// Import shared library functions
|
|
19
23
|
const lib = await import('./lib.mjs');
|
|
20
24
|
const { log, cleanErrorMessage, formatAligned } = lib;
|
|
@@ -50,10 +54,36 @@ const checkPRMerged = async (owner, repo, prNumber) => {
|
|
|
50
54
|
return false;
|
|
51
55
|
};
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Clean up .playwright-mcp/ folder to prevent browser automation artifacts
|
|
59
|
+
* from triggering auto-restart (Issue #1124)
|
|
60
|
+
*/
|
|
61
|
+
const cleanupPlaywrightMcpFolder = async (tempDir, argv) => {
|
|
62
|
+
if (argv.playwrightMcpAutoCleanup !== false) {
|
|
63
|
+
const playwrightMcpDir = path.join(tempDir, '.playwright-mcp');
|
|
64
|
+
try {
|
|
65
|
+
const playwrightMcpExists = await fs
|
|
66
|
+
.stat(playwrightMcpDir)
|
|
67
|
+
.then(() => true)
|
|
68
|
+
.catch(() => false);
|
|
69
|
+
if (playwrightMcpExists) {
|
|
70
|
+
await fs.rm(playwrightMcpDir, { recursive: true, force: true });
|
|
71
|
+
await log('🧹 Cleaned up .playwright-mcp/ folder (browser automation artifacts)', { verbose: true });
|
|
72
|
+
}
|
|
73
|
+
} catch (cleanupError) {
|
|
74
|
+
// Non-critical error, just log and continue
|
|
75
|
+
await log(`⚠️ Could not clean up .playwright-mcp/ folder: ${cleanupError.message}`, { verbose: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
53
80
|
/**
|
|
54
81
|
* Check if there are uncommitted changes in the repository
|
|
55
82
|
*/
|
|
56
|
-
const checkForUncommittedChanges = async (tempDir,
|
|
83
|
+
const checkForUncommittedChanges = async (tempDir, $, argv = {}) => {
|
|
84
|
+
// First, clean up .playwright-mcp/ folder to prevent false positives (Issue #1124)
|
|
85
|
+
await cleanupPlaywrightMcpFolder(tempDir, argv);
|
|
86
|
+
|
|
57
87
|
try {
|
|
58
88
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
59
89
|
if (gitStatusResult.code === 0) {
|
|
@@ -130,7 +160,7 @@ export const watchForFeedback = async params => {
|
|
|
130
160
|
|
|
131
161
|
// In temporary watch mode, check if all changes have been committed
|
|
132
162
|
if (isTemporaryWatch && !firstIterationInTemporaryMode) {
|
|
133
|
-
const hasUncommitted = await checkForUncommittedChanges(tempDir,
|
|
163
|
+
const hasUncommitted = await checkForUncommittedChanges(tempDir, $, argv);
|
|
134
164
|
if (!hasUncommitted) {
|
|
135
165
|
await log('');
|
|
136
166
|
await log(formatAligned('✅', 'CHANGES COMMITTED!', 'Exiting auto-restart mode'));
|
|
@@ -185,7 +215,7 @@ export const watchForFeedback = async params => {
|
|
|
185
215
|
// In temporary watch mode, also check for uncommitted changes as a restart trigger
|
|
186
216
|
let hasUncommittedInTempMode = false;
|
|
187
217
|
if (isTemporaryWatch && !firstIterationInTemporaryMode) {
|
|
188
|
-
hasUncommittedInTempMode = await checkForUncommittedChanges(tempDir,
|
|
218
|
+
hasUncommittedInTempMode = await checkForUncommittedChanges(tempDir, $, argv);
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
const shouldRestart = hasFeedback || firstIterationInTemporaryMode || hasUncommittedInTempMode;
|
package/src/usage-limit.lib.mjs
CHANGED
|
@@ -4,9 +4,21 @@
|
|
|
4
4
|
* This module provides utilities for detecting and handling usage limit errors
|
|
5
5
|
* from AI tools (Claude, Codex, OpenCode).
|
|
6
6
|
*
|
|
7
|
-
* Related
|
|
7
|
+
* Related issues:
|
|
8
|
+
* - https://github.com/link-assistant/hive-mind/issues/719 (original)
|
|
9
|
+
* - https://github.com/link-assistant/hive-mind/issues/1122 (weekly limit date parsing with timezone)
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
import dayjs from 'dayjs';
|
|
13
|
+
import utc from 'dayjs/plugin/utc.js';
|
|
14
|
+
import timezone from 'dayjs/plugin/timezone.js';
|
|
15
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
|
16
|
+
|
|
17
|
+
// Initialize dayjs plugins
|
|
18
|
+
dayjs.extend(utc);
|
|
19
|
+
dayjs.extend(timezone);
|
|
20
|
+
dayjs.extend(customParseFormat);
|
|
21
|
+
|
|
10
22
|
/**
|
|
11
23
|
* Detect if an error message indicates a usage limit has been reached
|
|
12
24
|
*
|
|
@@ -46,11 +58,48 @@ export function isUsageLimitError(message) {
|
|
|
46
58
|
return patterns.some(pattern => lowerMessage.includes(pattern));
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Extract timezone from usage limit error message
|
|
63
|
+
*
|
|
64
|
+
* Extracts IANA timezone identifiers like "Europe/Berlin", "UTC", "America/New_York"
|
|
65
|
+
* from messages like "resets Jan 15, 8am (Europe/Berlin)"
|
|
66
|
+
*
|
|
67
|
+
* @param {string} message - Error message to analyze
|
|
68
|
+
* @returns {string|null} - Timezone string or null if not found
|
|
69
|
+
*/
|
|
70
|
+
export function extractTimezone(message) {
|
|
71
|
+
if (!message || typeof message !== 'string') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pattern: (Timezone) - matches IANA timezone format or "UTC"
|
|
76
|
+
// IANA format: Continent/City or Continent/Region/City
|
|
77
|
+
const timezoneMatch = message.match(/\(([A-Za-z_]+(?:\/[A-Za-z_]+){0,2})\)/);
|
|
78
|
+
if (timezoneMatch) {
|
|
79
|
+
const tz = timezoneMatch[1];
|
|
80
|
+
// Validate it's a recognizable timezone by trying to use it with dayjs
|
|
81
|
+
try {
|
|
82
|
+
const testDate = dayjs().tz(tz);
|
|
83
|
+
if (testDate.isValid()) {
|
|
84
|
+
return tz;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Invalid timezone, return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
/**
|
|
50
95
|
* Extract reset time from usage limit error message
|
|
51
96
|
*
|
|
97
|
+
* Supports both time-only formats (5-hour limits) and date+time formats (weekly limits):
|
|
98
|
+
* - "resets 10pm" → "10:00 PM"
|
|
99
|
+
* - "resets Jan 15, 8am" → "Jan 15, 8:00 AM"
|
|
100
|
+
*
|
|
52
101
|
* @param {string} message - Error message to analyze
|
|
53
|
-
* @returns {string|null} - Reset time string (e.g., "12:16 PM") or null if not found
|
|
102
|
+
* @returns {string|null} - Reset time string (e.g., "12:16 PM" or "Jan 15, 8:00 AM") or null if not found
|
|
54
103
|
*/
|
|
55
104
|
export function extractResetTime(message) {
|
|
56
105
|
if (!message || typeof message !== 'string') {
|
|
@@ -60,6 +109,21 @@ export function extractResetTime(message) {
|
|
|
60
109
|
// Normalize whitespace for easier matching
|
|
61
110
|
const normalized = message.replace(/\s+/g, ' ');
|
|
62
111
|
|
|
112
|
+
// Pattern 0: Weekly limit with date - "resets Jan 15, 8am" or "resets January 15, 8:00am"
|
|
113
|
+
// This pattern must come first to avoid partial matches by time-only patterns
|
|
114
|
+
const monthPattern = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
|
|
115
|
+
const resetsWithDateRegex = new RegExp(`resets\\s+(${monthPattern})\\s+(\\d{1,2}),?\\s+([0-9]{1,2})(?::([0-9]{2}))?\\s*([ap]m)`, 'i');
|
|
116
|
+
const resetsWithDate = normalized.match(resetsWithDateRegex);
|
|
117
|
+
if (resetsWithDate) {
|
|
118
|
+
const month = resetsWithDate[1];
|
|
119
|
+
const day = resetsWithDate[2];
|
|
120
|
+
const hour = resetsWithDate[3];
|
|
121
|
+
const minute = resetsWithDate[4] || '00';
|
|
122
|
+
const ampm = resetsWithDate[5].toUpperCase();
|
|
123
|
+
// Return formatted date+time string for weekly limits
|
|
124
|
+
return `${month} ${day}, ${hour}:${minute} ${ampm}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
// Pattern 1: "try again at 12:16 PM"
|
|
64
128
|
const tryAgainMatch = normalized.match(/try again at ([0-9]{1,2}:[0-9]{2}\s*[AP]M)/i);
|
|
65
129
|
if (tryAgainMatch) {
|
|
@@ -130,78 +194,142 @@ export function extractResetTime(message) {
|
|
|
130
194
|
* Detect usage limit error and extract all relevant information
|
|
131
195
|
*
|
|
132
196
|
* @param {string} message - Error message to analyze
|
|
133
|
-
* @returns {Object} - { isUsageLimit: boolean, resetTime: string|null }
|
|
197
|
+
* @returns {Object} - { isUsageLimit: boolean, resetTime: string|null, timezone: string|null }
|
|
134
198
|
*/
|
|
135
199
|
export function detectUsageLimit(message) {
|
|
136
200
|
const isUsageLimit = isUsageLimitError(message);
|
|
137
201
|
const resetTime = isUsageLimit ? extractResetTime(message) : null;
|
|
202
|
+
const timezone = isUsageLimit ? extractTimezone(message) : null;
|
|
138
203
|
|
|
139
204
|
return {
|
|
140
205
|
isUsageLimit,
|
|
141
206
|
resetTime,
|
|
207
|
+
timezone,
|
|
142
208
|
};
|
|
143
209
|
}
|
|
144
210
|
|
|
145
211
|
/**
|
|
146
|
-
* Parse time string
|
|
212
|
+
* Parse time string and convert to dayjs object using dayjs custom parse format
|
|
213
|
+
*
|
|
214
|
+
* Supports both formats:
|
|
215
|
+
* - Time only: "11:00 PM" → today or tomorrow at that time
|
|
216
|
+
* - Date+time: "Jan 15, 8:00 AM" → specific date at that time
|
|
217
|
+
*
|
|
218
|
+
* Uses dayjs customParseFormat plugin for cleaner parsing.
|
|
147
219
|
*
|
|
148
|
-
* @param {string} timeStr - Time string in format "HH:MM AM/PM"
|
|
149
|
-
* @
|
|
220
|
+
* @param {string} timeStr - Time string in format "HH:MM AM/PM" or "Mon DD, HH:MM AM/PM"
|
|
221
|
+
* @param {string|null} tz - Optional IANA timezone (e.g., "Europe/Berlin")
|
|
222
|
+
* @returns {dayjs.Dayjs|null} - dayjs object or null if parsing fails
|
|
150
223
|
*/
|
|
151
|
-
export function parseResetTime(timeStr) {
|
|
224
|
+
export function parseResetTime(timeStr, tz = null) {
|
|
152
225
|
if (!timeStr || typeof timeStr !== 'string') {
|
|
153
226
|
return null;
|
|
154
227
|
}
|
|
155
228
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
229
|
+
const now = dayjs();
|
|
230
|
+
|
|
231
|
+
// Normalize "Sept" to "Sep" for dayjs compatibility
|
|
232
|
+
const normalized = timeStr.replace(/\bSept\b/gi, 'Sep');
|
|
161
233
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
234
|
+
// Try date+time formats using dayjs custom parse
|
|
235
|
+
// dayjs uses: MMM=Jan, MMMM=January, D=day, h=12-hour, mm=minutes, A=AM/PM
|
|
236
|
+
const dateTimeFormats = ['MMM D, h:mm A', 'MMMM D, h:mm A'];
|
|
237
|
+
|
|
238
|
+
for (const format of dateTimeFormats) {
|
|
239
|
+
let parsed;
|
|
240
|
+
if (tz) {
|
|
241
|
+
try {
|
|
242
|
+
// Parse in the specified timezone
|
|
243
|
+
parsed = dayjs.tz(normalized, format, tz);
|
|
244
|
+
} catch {
|
|
245
|
+
parsed = dayjs(normalized, format);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
parsed = dayjs(normalized, format);
|
|
249
|
+
}
|
|
165
250
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
251
|
+
if (parsed.isValid()) {
|
|
252
|
+
// dayjs parses without year, so it defaults to current year
|
|
253
|
+
// If the date is in the past, assume next year
|
|
254
|
+
if (parsed.isBefore(now)) {
|
|
255
|
+
parsed = parsed.add(1, 'year');
|
|
256
|
+
}
|
|
257
|
+
return parsed;
|
|
258
|
+
}
|
|
171
259
|
}
|
|
172
260
|
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
261
|
+
// Try time-only format: "8:00 PM" or "8:00PM"
|
|
262
|
+
const timeOnlyFormats = ['h:mm A', 'h:mmA'];
|
|
263
|
+
|
|
264
|
+
for (const format of timeOnlyFormats) {
|
|
265
|
+
let parsed;
|
|
266
|
+
if (tz) {
|
|
267
|
+
try {
|
|
268
|
+
parsed = dayjs.tz(normalized, format, tz);
|
|
269
|
+
} catch {
|
|
270
|
+
parsed = dayjs(normalized, format);
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
parsed = dayjs(normalized, format);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (parsed.isValid()) {
|
|
277
|
+
// For time-only, set to today's date
|
|
278
|
+
parsed = parsed.year(now.year()).month(now.month()).date(now.date());
|
|
279
|
+
|
|
280
|
+
// Re-apply timezone after setting date components
|
|
281
|
+
if (tz) {
|
|
282
|
+
try {
|
|
283
|
+
const dateStr = parsed.format('YYYY-MM-DD HH:mm');
|
|
284
|
+
parsed = dayjs.tz(dateStr, tz);
|
|
285
|
+
} catch {
|
|
286
|
+
// Keep the parsed value
|
|
287
|
+
}
|
|
288
|
+
}
|
|
176
289
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
290
|
+
// If the time is in the past today, assume tomorrow
|
|
291
|
+
if (parsed.isBefore(now)) {
|
|
292
|
+
parsed = parsed.add(1, 'day');
|
|
293
|
+
}
|
|
294
|
+
return parsed;
|
|
295
|
+
}
|
|
180
296
|
}
|
|
181
297
|
|
|
182
|
-
return
|
|
298
|
+
return null;
|
|
183
299
|
}
|
|
184
300
|
|
|
185
301
|
/**
|
|
186
302
|
* Format relative time (e.g., "in 1h 23m")
|
|
187
303
|
*
|
|
188
|
-
*
|
|
304
|
+
* Uses dayjs for accurate time difference calculations.
|
|
305
|
+
* Accepts both Date objects and dayjs objects.
|
|
306
|
+
*
|
|
307
|
+
* @param {Date|dayjs.Dayjs} resetDate - Date or dayjs object for reset time
|
|
189
308
|
* @returns {string} - Formatted relative time string
|
|
190
309
|
*/
|
|
191
310
|
export function formatRelativeTime(resetDate) {
|
|
192
|
-
|
|
311
|
+
// Accept both Date objects and dayjs objects
|
|
312
|
+
let resetDayjs;
|
|
313
|
+
if (resetDate instanceof Date) {
|
|
314
|
+
resetDayjs = dayjs(resetDate);
|
|
315
|
+
} else if (dayjs.isDayjs(resetDate)) {
|
|
316
|
+
resetDayjs = resetDate;
|
|
317
|
+
} else {
|
|
193
318
|
return '';
|
|
194
319
|
}
|
|
195
320
|
|
|
196
|
-
|
|
197
|
-
|
|
321
|
+
if (!resetDayjs.isValid()) {
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const now = dayjs();
|
|
326
|
+
const diffMs = resetDayjs.diff(now);
|
|
198
327
|
|
|
199
328
|
if (diffMs <= 0) {
|
|
200
329
|
return 'now';
|
|
201
330
|
}
|
|
202
331
|
|
|
203
|
-
const
|
|
204
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
332
|
+
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
|
205
333
|
const totalHours = Math.floor(totalMinutes / 60);
|
|
206
334
|
const totalDays = Math.floor(totalHours / 24);
|
|
207
335
|
|
|
@@ -219,17 +347,20 @@ export function formatRelativeTime(resetDate) {
|
|
|
219
347
|
|
|
220
348
|
/**
|
|
221
349
|
* Format reset time with relative time and UTC time
|
|
222
|
-
* Example: "in 1h 23m (
|
|
350
|
+
* Example: "in 1h 23m (Jan 15, 7:00 AM UTC)"
|
|
351
|
+
*
|
|
352
|
+
* Uses dayjs for proper timezone conversion to UTC.
|
|
223
353
|
*
|
|
224
|
-
* @param {string} resetTime - Time string in format "HH:MM AM/PM"
|
|
225
|
-
* @
|
|
354
|
+
* @param {string} resetTime - Time string in format "HH:MM AM/PM" or "Mon DD, HH:MM AM/PM"
|
|
355
|
+
* @param {string|null} timezone - Optional IANA timezone (e.g., "Europe/Berlin")
|
|
356
|
+
* @returns {string} - Formatted string with relative and absolute UTC time
|
|
226
357
|
*/
|
|
227
|
-
export function formatResetTimeWithRelative(resetTime) {
|
|
358
|
+
export function formatResetTimeWithRelative(resetTime, timezone = null) {
|
|
228
359
|
if (!resetTime) {
|
|
229
360
|
return resetTime;
|
|
230
361
|
}
|
|
231
362
|
|
|
232
|
-
const resetDate = parseResetTime(resetTime);
|
|
363
|
+
const resetDate = parseResetTime(resetTime, timezone);
|
|
233
364
|
if (!resetDate) {
|
|
234
365
|
// If we can't parse it, return the original time
|
|
235
366
|
return resetTime;
|
|
@@ -237,12 +368,9 @@ export function formatResetTimeWithRelative(resetTime) {
|
|
|
237
368
|
|
|
238
369
|
const relativeTime = formatRelativeTime(resetDate);
|
|
239
370
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const utcAmPm = utcHours >= 12 ? 'PM' : 'AM';
|
|
244
|
-
const utcHour12 = utcHours % 12 || 12;
|
|
245
|
-
const utcTimeStr = `${utcHour12}:${String(utcMinutes).padStart(2, '0')} ${utcAmPm} UTC`;
|
|
371
|
+
// Convert to UTC and format
|
|
372
|
+
const utcDate = resetDate.utc();
|
|
373
|
+
const utcTimeStr = utcDate.format('MMM D, h:mm A [UTC]');
|
|
246
374
|
|
|
247
375
|
return `${relativeTime} (${utcTimeStr})`;
|
|
248
376
|
}
|