@link-assistant/hive-mind 1.2.10 → 1.3.0
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 +18 -1
- package/package.json +4 -2
- package/src/claude.lib.mjs +7 -0
- package/src/limits.lib.mjs +27 -38
- package/src/solve.auto-pr.lib.mjs +21 -14
- package/src/solve.config.lib.mjs +6 -1
- package/src/solve.mjs +7 -3
- package/src/usage-limit.lib.mjs +172 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a403c0e: Add --auto-gitkeep-file option to automatically fallback to .gitkeep when CLAUDE.md is in .gitignore
|
|
8
|
+
|
|
9
|
+
This feature pre-checks if CLAUDE.md would be ignored by .gitignore BEFORE creating the file, preventing the "paths are ignored by one of your .gitignore files" error. When detected, automatically switches to .gitkeep mode. Enabled by default (--auto-gitkeep-file=true).
|
|
10
|
+
|
|
11
|
+
## 1.2.11
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 8404b75: fix: Support weekly limit date parsing in extractResetTime and parseResetTime
|
|
16
|
+
- Added Pattern 0 to extractResetTime() to handle date+time formats like "resets Jan 15, 8am"
|
|
17
|
+
- Updated parseResetTime() to parse date+time strings with month name and day
|
|
18
|
+
- This ensures weekly limit messages are displayed with the "Usage Limit Reached" format
|
|
19
|
+
|
|
3
20
|
## 1.2.10
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -943,7 +960,7 @@
|
|
|
943
960
|
|
|
944
961
|
This feature allows users to choose which file type to use for PR creation:
|
|
945
962
|
- `--claude-file` (default: true): Use CLAUDE.md file for task details
|
|
946
|
-
- `--gitkeep-file` (default: false
|
|
963
|
+
- `--gitkeep-file` (default: false): Use .gitkeep file instead
|
|
947
964
|
|
|
948
965
|
The flags are mutually exclusive:
|
|
949
966
|
- Using `--gitkeep-file` automatically disables `--claude-file`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
/**
|
|
@@ -31,12 +31,20 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
// Determine which file to create based on CLI flags
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
let useClaudeFile = argv.claudeFile !== false;
|
|
35
|
+
const useAutoGitkeepFile = argv.autoGitkeepFile !== false;
|
|
36
|
+
|
|
37
|
+
// Pre-check: If CLAUDE.md would be ignored by .gitignore, automatically switch to .gitkeep mode
|
|
38
|
+
if (useClaudeFile && useAutoGitkeepFile) {
|
|
39
|
+
const checkResult = await $({ cwd: tempDir, silent: true })`git check-ignore CLAUDE.md 2>/dev/null`;
|
|
40
|
+
if (checkResult.code === 0) {
|
|
41
|
+
await log(formatAligned('ℹ️', 'Pre-check:', 'CLAUDE.md is in .gitignore, switching to .gitkeep mode\n'));
|
|
42
|
+
useClaudeFile = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
36
45
|
|
|
37
|
-
// Log which mode we're using
|
|
38
46
|
if (argv.verbose) {
|
|
39
|
-
await log(` Using ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'} mode (--claude-file=${
|
|
47
|
+
await log(` Using ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'} mode (--claude-file=${argv.claudeFile !== false}, --gitkeep-file=${argv.gitkeepFile === true}, --auto-gitkeep-file=${useAutoGitkeepFile})`, { verbose: true });
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
let filePath;
|
|
@@ -62,14 +70,13 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
|
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
} else {
|
|
65
|
-
//
|
|
66
|
-
|
|
73
|
+
// .gitkeep mode (via explicit --gitkeep-file or auto-gitkeep-file fallback)
|
|
74
|
+
const modeDesc = argv.gitkeepFile === true ? '.gitkeep (explicit --gitkeep-file)' : '.gitkeep (CLAUDE.md is ignored)';
|
|
75
|
+
await log(formatAligned('📝', 'Creating:', modeDesc));
|
|
67
76
|
|
|
68
77
|
filePath = path.join(tempDir, '.gitkeep');
|
|
69
78
|
fileName = '.gitkeep';
|
|
70
|
-
|
|
71
|
-
// .gitkeep files are typically small, no need to check for existing content
|
|
72
|
-
// But we'll check if it exists for proper handling
|
|
79
|
+
// Check if .gitkeep already exists for proper handling
|
|
73
80
|
try {
|
|
74
81
|
existingContent = await fs.readFile(filePath, 'utf8');
|
|
75
82
|
fileExisted = true;
|
|
@@ -125,12 +132,13 @@ Proceed.
|
|
|
125
132
|
finalContent = taskInfo;
|
|
126
133
|
}
|
|
127
134
|
} else {
|
|
128
|
-
// .gitkeep: Use minimal metadata format
|
|
135
|
+
// .gitkeep: Use minimal metadata format (explicit --gitkeep-file or auto-gitkeep-file fallback)
|
|
136
|
+
const creationReason = argv.gitkeepFile === true ? '# This file was created with --gitkeep-file flag' : '# This file was created because CLAUDE.md is in .gitignore (--auto-gitkeep-file=true)';
|
|
129
137
|
const gitkeepContent = `# Auto-generated file for PR creation
|
|
130
138
|
# Issue: ${issueUrl}
|
|
131
139
|
# Branch: ${branchName}
|
|
132
140
|
# Timestamp: ${timestamp}
|
|
133
|
-
|
|
141
|
+
${creationReason}
|
|
134
142
|
# It will be removed when the task is complete`;
|
|
135
143
|
|
|
136
144
|
if (fileExisted && existingContent) {
|
|
@@ -289,9 +297,8 @@ Proceed.
|
|
|
289
297
|
}
|
|
290
298
|
|
|
291
299
|
await log(formatAligned('📝', 'Creating commit:', `With ${commitFileName} file`));
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const fileDesc = commitFileName === 'CLAUDE.md' ? 'CLAUDE.md with task information for AI processing' : `.gitkeep for PR creation (${useGitkeepFile ? 'created with --gitkeep-file flag (experimental)' : 'CLAUDE.md is in .gitignore'})`;
|
|
300
|
+
// Commit message distinguishes between explicit --gitkeep-file and auto-gitkeep-file fallback
|
|
301
|
+
const fileDesc = commitFileName === 'CLAUDE.md' ? 'CLAUDE.md with task information for AI processing' : `.gitkeep for PR creation (${argv.gitkeepFile === true ? 'created with --gitkeep-file flag' : 'CLAUDE.md is in .gitignore'})`;
|
|
295
302
|
const commitMessage = `Initial commit with task details\n\nAdding ${fileDesc}.\nThis file will be removed when the task is complete.\n\nIssue: ${issueUrl}`;
|
|
296
303
|
|
|
297
304
|
// Use explicit cwd option for better reliability
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -130,9 +130,14 @@ export const createYargsConfig = yargsInstance => {
|
|
|
130
130
|
})
|
|
131
131
|
.option('gitkeep-file', {
|
|
132
132
|
type: 'boolean',
|
|
133
|
-
description: 'Create .gitkeep file instead of CLAUDE.md (
|
|
133
|
+
description: 'Create .gitkeep file instead of CLAUDE.md (mutually exclusive with --claude-file)',
|
|
134
134
|
default: false,
|
|
135
135
|
})
|
|
136
|
+
.option('auto-gitkeep-file', {
|
|
137
|
+
type: 'boolean',
|
|
138
|
+
description: 'Automatically use .gitkeep if CLAUDE.md is in .gitignore (pre-checks before creating file)',
|
|
139
|
+
default: true,
|
|
140
|
+
})
|
|
136
141
|
.option('attach-logs', {
|
|
137
142
|
type: 'boolean',
|
|
138
143
|
description: 'Upload the solution draft log file to the Pull Request on completion (⚠️ WARNING: May expose sensitive data)',
|
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}`;
|
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
|
}
|