@fieldwangai/agentflow 0.1.165 → 0.1.167

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.
@@ -1,71 +1,285 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  let nextCursorApiKeyCursor = 0;
2
- const cursorApiKeyBlockedUntil = new Map();
4
+ const cursorApiKeyStates = new Map();
3
5
 
4
- export function parseCursorApiKeyPool(value = "") {
6
+ export function parseCursorApiKeyRecords(value = "") {
5
7
  const raw = String(value || "").trim();
6
8
  if (!raw) return [];
9
+ const normalizeRecord = (item, index) => {
10
+ if (typeof item === "string") {
11
+ const key = item.trim();
12
+ return key ? { id: legacyKeyId(key), name: `Key ${index + 1}`, key } : null;
13
+ }
14
+ if (!item || typeof item !== "object") return null;
15
+ const key = String(item.key || "").trim();
16
+ if (!key) return null;
17
+ return {
18
+ id: String(item.id || "").trim() || legacyKeyId(key),
19
+ name: String(item.name || "").trim() || `Key ${index + 1}`,
20
+ key,
21
+ createdAt: String(item.createdAt || "").trim(),
22
+ };
23
+ };
7
24
  try {
8
25
  const parsed = JSON.parse(raw);
9
- if (Array.isArray(parsed)) {
10
- return parsed
11
- .map((item) => {
12
- if (typeof item === "string") return item.trim();
13
- if (item && typeof item === "object") return String(item.key || "").trim();
14
- return "";
15
- })
16
- .filter(Boolean);
17
- }
26
+ if (Array.isArray(parsed)) return parsed.map(normalizeRecord).filter(Boolean);
18
27
  } catch {
19
28
  // Legacy comma-separated format.
20
29
  }
21
- return raw.split(",").map((key) => key.trim()).filter(Boolean);
30
+ return raw.split(",").map(normalizeRecord).filter(Boolean);
31
+ }
32
+
33
+ export function parseCursorApiKeyPool(value = "") {
34
+ return parseCursorApiKeyRecords(value).map((record) => record.key);
22
35
  }
23
36
 
24
- export function createCursorApiKeyAttempts(env = {}) {
25
- const keys = parseCursorApiKeyPool(env.CURSOR_API_KEYS);
26
- if (keys.length === 0) return [undefined];
37
+ export function createCursorApiKeyAttempts(env = {}, now = Date.now()) {
38
+ const records = parseCursorApiKeyRecords(env.CURSOR_API_KEYS);
39
+ if (records.length === 0) return [undefined];
27
40
 
28
- const now = Date.now();
29
- const candidates = keys
30
- .map((key, index) => ({ key, index, total: keys.length }))
31
- .filter((selection) => (cursorApiKeyBlockedUntil.get(selection.index) || 0) <= now);
32
- const effective = candidates.length > 0 ? candidates : keys.map((key, index) => ({ key, index, total: keys.length }));
41
+ const selections = records.map((record, index) => ({
42
+ ...record,
43
+ index,
44
+ total: records.length,
45
+ modelSelection: getCursorApiKeyModelSelection(record.id, now),
46
+ }));
47
+ const candidates = selections.filter((selection) => Boolean(selection.modelSelection));
48
+ // Preserve the old pool's last-resort behavior if every key is cooling down.
49
+ // Normal operation always uses candidates and therefore respects lane cooldowns.
50
+ const effective = candidates.length > 0
51
+ ? candidates
52
+ : selections.map((selection) => ({
53
+ ...selection,
54
+ modelSelection: { lane: "auto", modelId: "auto", modelName: "Auto" },
55
+ }));
33
56
  const start = nextCursorApiKeyCursor % effective.length;
34
57
  nextCursorApiKeyCursor += 1;
35
58
  return [...effective.slice(start), ...effective.slice(0, start)];
36
59
  }
37
60
 
38
- export function cursorApiKeyEnv(selection) {
39
- return selection && selection.key ? { CURSOR_API_KEY: selection.key } : {};
61
+ export function getCursorApiKeyModelSelection(keyOrSelection, now = Date.now()) {
62
+ const keyId = selectionId(keyOrSelection);
63
+ const keyState = cursorApiKeyStates.get(keyId);
64
+ const autoBlocked = (keyState?.auto?.blockedUntil || 0) > now;
65
+ if (!autoBlocked) return { lane: "auto", modelId: "auto", modelName: "Auto" };
66
+
67
+ if (keyState?.auto?.errorCategory !== "explicit_limit" || keyState.auto.fallbackEligible !== true) {
68
+ return undefined;
69
+ }
70
+ const fallbackModel = keyState.fallbackModel;
71
+ if (!fallbackModel || (keyState.fallback?.blockedUntil || 0) > now) return undefined;
72
+ return {
73
+ lane: "fallback",
74
+ modelId: fallbackModel.id,
75
+ modelName: fallbackModel.displayName,
76
+ };
77
+ }
78
+
79
+ export function recordCursorApiKeyFallbackModel(keyOrSelection, model) {
80
+ if (!model?.id) return;
81
+ const keyId = selectionId(keyOrSelection);
82
+ const keyState = cursorApiKeyStates.get(keyId) || {};
83
+ keyState.fallbackModel = { ...model };
84
+ cursorApiKeyStates.set(keyId, keyState);
40
85
  }
41
86
 
42
- export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30) {
43
- if (!selection || !Number.isFinite(selection.index)) return;
87
+ export function markCursorApiKeyLaneBlocked(
88
+ keyOrSelection,
89
+ lane,
90
+ cooldownMinutes = 30,
91
+ errorText = "",
92
+ now = Date.now(),
93
+ evidence = {},
94
+ ) {
95
+ const keyId = selectionId(keyOrSelection);
96
+ if (!keyId || !["auto", "fallback"].includes(lane)) return 0;
44
97
  const minutes = Math.max(1, Number(cooldownMinutes) || 30);
45
- cursorApiKeyBlockedUntil.set(selection.index, Date.now() + minutes * 60 * 1000);
98
+ const keyState = cursorApiKeyStates.get(keyId) || {};
99
+ const currentLane = keyState[lane] || {};
100
+ const blockedUntil = Math.max(currentLane.blockedUntil || 0, now + minutes * 60 * 1000);
101
+ const errorCategory = classifyCursorApiKeyLimitError(errorText);
102
+ const fallbackModel = keyState.fallbackModel;
103
+ const lastFailure = {
104
+ triggeredAt: new Date(now).toISOString(),
105
+ ...(errorCategory ? { errorCategory } : {}),
106
+ errorPreview: sanitizeErrorPreview(errorText),
107
+ lane,
108
+ modelId: String(evidence?.modelId || (lane === "auto" ? "auto" : fallbackModel?.id || "fallback")),
109
+ modelName: String(evidence?.modelName || (lane === "auto" ? "Auto" : fallbackModel?.displayName || "降级模型")),
110
+ };
111
+ keyState[lane] = {
112
+ ...currentLane,
113
+ blockedUntil,
114
+ ...(errorCategory ? { errorCategory } : {}),
115
+ ...(lane === "auto" ? { fallbackEligible: isCursorAutoFallbackEligible(errorText) } : {}),
116
+ lastFailure,
117
+ };
118
+ keyState.lastFailure = lastFailure;
119
+ cursorApiKeyStates.set(keyId, keyState);
120
+ return blockedUntil;
121
+ }
122
+
123
+ export function clearCursorApiKeyLaneCooldown(keyOrSelection, lane) {
124
+ const keyState = cursorApiKeyStates.get(selectionId(keyOrSelection));
125
+ if (!keyState?.[lane]) return false;
126
+ keyState[lane].blockedUntil = 0;
127
+ delete keyState[lane].errorCategory;
128
+ delete keyState[lane].fallbackEligible;
129
+ return true;
130
+ }
131
+
132
+ export function clearCursorApiKeyCooldown(keyOrSelection) {
133
+ const keyState = cursorApiKeyStates.get(selectionId(keyOrSelection));
134
+ if (!keyState) return false;
135
+ let changed = false;
136
+ for (const lane of ["auto", "fallback"]) {
137
+ if (!keyState[lane] || (keyState[lane].blockedUntil || 0) <= 0) continue;
138
+ keyState[lane].blockedUntil = 0;
139
+ delete keyState[lane].errorCategory;
140
+ delete keyState[lane].fallbackEligible;
141
+ changed = true;
142
+ }
143
+ return changed;
144
+ }
145
+
146
+ export function recordCursorApiKeyUsage(keyOrSelection, selection = {}, now = Date.now()) {
147
+ const keyId = selectionId(keyOrSelection);
148
+ if (!keyId || keyId === "default") return;
149
+ const keyState = cursorApiKeyStates.get(keyId) || {};
150
+ keyState.lastUsedAt = new Date(now).toISOString();
151
+ keyState.lastSelection = {
152
+ lane: selection?.lane === "fallback" ? "fallback" : "auto",
153
+ modelId: String(selection?.modelId || "auto"),
154
+ modelName: String(selection?.modelName || "Auto"),
155
+ };
156
+ cursorApiKeyStates.set(keyId, keyState);
157
+ }
158
+
159
+ export function getCursorApiKeyPoolStatuses(records = [], now = Date.now()) {
160
+ return (Array.isArray(records) ? records : []).map((record) => {
161
+ const id = selectionId(record);
162
+ const keyState = cursorApiKeyStates.get(id);
163
+ const selection = getCursorApiKeyModelSelection(id, now);
164
+ const laneCooldowns = buildLaneCooldowns(keyState, now);
165
+ const common = {
166
+ id,
167
+ ...(keyState?.lastUsedAt ? { lastUsedAt: keyState.lastUsedAt } : {}),
168
+ ...(keyState?.fallbackModel ? { fallbackModel: keyState.fallbackModel } : {}),
169
+ laneCooldowns,
170
+ ...(keyState?.lastFailure ? { lastFailure: keyState.lastFailure } : {}),
171
+ };
172
+ if (selection) {
173
+ return {
174
+ ...common,
175
+ status: "available",
176
+ activeLane: selection.lane,
177
+ activeModelId: selection.modelId,
178
+ activeModelName: selection.modelName,
179
+ degraded: selection.lane === "fallback",
180
+ };
181
+ }
182
+ const activeCooldowns = laneCooldowns.filter((item) => item.remainingSeconds > 0);
183
+ const earliest = activeCooldowns.reduce(
184
+ (result, item) => !result || item.remainingSeconds < result.remainingSeconds ? item : result,
185
+ undefined,
186
+ );
187
+ const autoState = keyState?.auto;
188
+ return {
189
+ ...common,
190
+ status: "cooling_down",
191
+ ...(autoState?.errorCategory ? { errorCategory: autoState.errorCategory } : {}),
192
+ blockedUntil: earliest?.blockedUntil || new Date(Math.max(now, autoState?.blockedUntil || now)).toISOString(),
193
+ remainingSeconds: earliest?.remainingSeconds || Math.max(0, Math.ceil(((autoState?.blockedUntil || now) - now) / 1000)),
194
+ };
195
+ });
196
+ }
197
+
198
+ export function cursorApiKeyEnv(selection) {
199
+ return selection?.key ? { CURSOR_API_KEY: selection.key } : {};
200
+ }
201
+
202
+ export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30, errorText = "") {
203
+ return markCursorApiKeyLaneBlocked(selection, "auto", cooldownMinutes, errorText);
46
204
  }
47
205
 
48
206
  export function cursorApiKeyLabel(selection) {
49
207
  if (!selection) return "default";
50
- return `${selection.index + 1}/${selection.total}`;
208
+ return selection.name ? `${selection.index + 1}/${selection.total} (${selection.name})` : `${selection.index + 1}/${selection.total}`;
51
209
  }
52
210
 
53
- export function isCursorQuotaError(error = "") {
211
+ export function isCursorAutoFallbackEligible(error = "") {
212
+ const text = String(error || "");
213
+ return [/\bout\s+of\s+usage\b/i, /\busage\s+limit\b/i].some((pattern) => pattern.test(text));
214
+ }
215
+
216
+ export function classifyCursorApiKeyLimitError(error = "") {
54
217
  const text = String(error || "");
55
- if (!text) return false;
56
- return [
218
+ if (!text) return undefined;
219
+ const isExplicitLimit = [
57
220
  /\b429\b/i,
58
221
  /rate[_\s-]*limit/i,
59
222
  /too many requests/i,
60
223
  /quota/i,
61
224
  /usage\s+limit/i,
225
+ /out\s+of\s+usage/i,
62
226
  /limit\s+(?:exceeded|reached)/i,
63
227
  /exceeded\s+(?:your\s+)?limit/i,
64
- /resource[_\s-]*exhausted/i,
65
- /ActionRequiredError/i,
228
+ /insufficient[_\s-]*quota/i,
229
+ /credit[s]?\s+(?:exhausted|limit)/i,
230
+ /request\s+limit/i,
66
231
  ].some((pattern) => pattern.test(text));
232
+ if (isExplicitLimit) return "explicit_limit";
233
+ if (/resource[_\s-]*exhausted/i.test(text)) return "resource_exhausted";
234
+ return undefined;
235
+ }
236
+
237
+ export function isCursorQuotaError(error = "") {
238
+ return classifyCursorApiKeyLimitError(error) !== undefined;
67
239
  }
68
240
 
69
- export function cursorApiKeyCooldownMinutes(env = {}) {
241
+ export function cursorApiKeyCooldownMinutes(env = {}, errorText = "") {
242
+ if (classifyCursorApiKeyLimitError(errorText) === "resource_exhausted") {
243
+ return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_RESOURCE_EXHAUSTED_COOLDOWN_MINUTES || 3) || 3);
244
+ }
70
245
  return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_COOLDOWN_MINUTES || env.CURSOR_API_KEY_COOLDOWN_MINUTES || 30) || 30);
71
246
  }
247
+
248
+ export function resetCursorApiKeyPoolForTests() {
249
+ nextCursorApiKeyCursor = 0;
250
+ cursorApiKeyStates.clear();
251
+ }
252
+
253
+ function selectionId(keyOrSelection) {
254
+ if (typeof keyOrSelection === "string") return keyOrSelection;
255
+ if (keyOrSelection?.id) return String(keyOrSelection.id);
256
+ if (keyOrSelection?.key) return legacyKeyId(String(keyOrSelection.key));
257
+ return "default";
258
+ }
259
+
260
+ function legacyKeyId(key) {
261
+ return `legacy_${createHash("sha256").update(String(key || "")).digest("hex").slice(0, 16)}`;
262
+ }
263
+
264
+ function buildLaneCooldowns(keyState, now) {
265
+ if (!keyState) return [];
266
+ const lanes = [];
267
+ for (const lane of ["auto", "fallback"]) {
268
+ const laneState = keyState[lane];
269
+ if (!laneState || (laneState.blockedUntil || 0) <= now) continue;
270
+ const fallbackModel = keyState.fallbackModel;
271
+ lanes.push({
272
+ lane,
273
+ modelId: lane === "auto" ? "auto" : fallbackModel?.id || "fallback",
274
+ modelName: lane === "auto" ? "Auto" : fallbackModel?.displayName || "降级模型",
275
+ ...(laneState.errorCategory ? { errorCategory: laneState.errorCategory } : {}),
276
+ blockedUntil: new Date(laneState.blockedUntil).toISOString(),
277
+ remainingSeconds: Math.ceil((laneState.blockedUntil - now) / 1000),
278
+ });
279
+ }
280
+ return lanes;
281
+ }
282
+
283
+ function sanitizeErrorPreview(errorText) {
284
+ return String(errorText || "").replace(/(?:sk|key)[-_][A-Za-z0-9_-]{8,}/gi, "[redacted]").trim().slice(0, 500);
285
+ }
@@ -0,0 +1,152 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const MODEL_CATALOG_TIMEOUT_MS = 15_000;
4
+ const MODEL_CATALOG_CACHE_MS = 15 * 60 * 1000;
5
+ const MAX_CATALOG_OUTPUT_LENGTH = 64 * 1024;
6
+ const ANSI_ESCAPE_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
7
+
8
+ const modelCatalogCache = new Map();
9
+
10
+ export async function discoverCursorModels({
11
+ keyId,
12
+ cwd,
13
+ env,
14
+ command = "agent",
15
+ forceRefresh = false,
16
+ } = {}) {
17
+ const now = Date.now();
18
+ const cacheKey = String(keyId || "default");
19
+ const cached = modelCatalogCache.get(cacheKey);
20
+ if (!forceRefresh && cached && cached.expiresAt > now) {
21
+ return buildCatalogResult(cached.models);
22
+ }
23
+
24
+ const commandResult = await runModelsCommand({ cwd, env, command });
25
+ if (!commandResult.success) return { models: [], error: commandResult.error };
26
+
27
+ const models = parseCursorModelsOutput(commandResult.output);
28
+ if (models.length === 0) {
29
+ return { models: [], error: "Cursor CLI did not return any available models." };
30
+ }
31
+ modelCatalogCache.set(cacheKey, {
32
+ expiresAt: now + MODEL_CATALOG_CACHE_MS,
33
+ models,
34
+ });
35
+ return buildCatalogResult(models);
36
+ }
37
+
38
+ export function parseCursorModelsOutput(output = "") {
39
+ const lines = stripAnsi(String(output || ""))
40
+ .split(/\r?\n/)
41
+ .map((line) => line.trim())
42
+ .filter(Boolean);
43
+ const models = [];
44
+ let insideModelList = false;
45
+ for (const line of lines) {
46
+ if (/^available models$/i.test(line)) {
47
+ insideModelList = true;
48
+ continue;
49
+ }
50
+ if (/^tip:/i.test(line)) break;
51
+ if (!insideModelList || /^no models available/i.test(line)) continue;
52
+
53
+ const flagsMatch = line.match(/\s+\(((?:current|default)(?:,\s*(?:current|default))*)\)$/i);
54
+ const flags = flagsMatch?.[1]?.toLowerCase() || "";
55
+ const value = flagsMatch ? line.slice(0, flagsMatch.index).trim() : line;
56
+ const separatorIndex = value.indexOf(" - ");
57
+ const id = (separatorIndex >= 0 ? value.slice(0, separatorIndex) : value).trim();
58
+ const displayName = (separatorIndex >= 0 ? value.slice(separatorIndex + 3) : id).trim();
59
+ if (!id || (separatorIndex < 0 && /\s/.test(id))) continue;
60
+ models.push({
61
+ id,
62
+ displayName: displayName || id,
63
+ isDefault: flags.split(/,\s*/).includes("default"),
64
+ isCurrent: flags.split(/,\s*/).includes("current"),
65
+ });
66
+ }
67
+ return dedupeModels(models);
68
+ }
69
+
70
+ export function selectComposerFallbackModel(models = []) {
71
+ return models.find((model) => [model?.id, model?.displayName].some((value) =>
72
+ /(^|[^a-z0-9])composer(?=$|[^a-z0-9])/i.test(String(value || ""))
73
+ ));
74
+ }
75
+
76
+ export function clearCursorModelCatalogCache() {
77
+ modelCatalogCache.clear();
78
+ }
79
+
80
+ function buildCatalogResult(models) {
81
+ const fallback = selectComposerFallbackModel(models);
82
+ return {
83
+ models,
84
+ ...(fallback ? {
85
+ fallbackModel: {
86
+ id: fallback.id,
87
+ displayName: formatComposerModelName(fallback),
88
+ discoveredAt: new Date().toISOString(),
89
+ },
90
+ } : {}),
91
+ };
92
+ }
93
+
94
+ function runModelsCommand({ cwd, env, command }) {
95
+ return new Promise((resolve) => {
96
+ let stdout = "";
97
+ let stderr = "";
98
+ let settled = false;
99
+ const child = spawn(command || "agent", ["models"], {
100
+ cwd,
101
+ shell: false,
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ env,
104
+ });
105
+ const finish = (result) => {
106
+ if (settled) return;
107
+ settled = true;
108
+ clearTimeout(timeoutId);
109
+ resolve(result);
110
+ };
111
+ const timeoutId = setTimeout(() => {
112
+ child.kill("SIGTERM");
113
+ finish({ success: false, error: "Cursor CLI model discovery timed out." });
114
+ }, MODEL_CATALOG_TIMEOUT_MS);
115
+ child.stdout?.on("data", (chunk) => {
116
+ if (stdout.length < MAX_CATALOG_OUTPUT_LENGTH) stdout += chunk.toString();
117
+ });
118
+ child.stderr?.on("data", (chunk) => {
119
+ if (stderr.length < MAX_CATALOG_OUTPUT_LENGTH) stderr += chunk.toString();
120
+ });
121
+ child.on("error", (error) => finish({ success: false, error: error.message }));
122
+ child.on("exit", (code) => {
123
+ if (code === 0) {
124
+ finish({ success: true, output: stdout.slice(0, MAX_CATALOG_OUTPUT_LENGTH) });
125
+ return;
126
+ }
127
+ finish({
128
+ success: false,
129
+ error: stripAnsi(stderr || stdout || `Cursor CLI models exited with code ${code}`).trim().slice(0, 1000),
130
+ });
131
+ });
132
+ });
133
+ }
134
+
135
+ function stripAnsi(value) {
136
+ return String(value || "").replace(ANSI_ESCAPE_PATTERN, "");
137
+ }
138
+
139
+ function formatComposerModelName(model) {
140
+ if (/(^|[^a-z0-9])composer(?=$|[^a-z0-9])/i.test(model.displayName)) return model.displayName;
141
+ if (model.displayName && model.displayName !== model.id) return `${model.id} · ${model.displayName}`;
142
+ return model.id;
143
+ }
144
+
145
+ function dedupeModels(models) {
146
+ const seen = new Set();
147
+ return models.filter((model) => {
148
+ if (seen.has(model.id)) return false;
149
+ seen.add(model.id);
150
+ return true;
151
+ });
152
+ }
@@ -0,0 +1,17 @@
1
+ const runFinishedListeners = new Set();
2
+
3
+ export function onRepositoryRunFinished(listener) {
4
+ if (typeof listener !== "function") return () => {};
5
+ runFinishedListeners.add(listener);
6
+ return () => runFinishedListeners.delete(listener);
7
+ }
8
+
9
+ export function emitRepositoryRunFinished(workspaceRoot, run, status) {
10
+ for (const listener of runFinishedListeners) {
11
+ try {
12
+ listener(workspaceRoot, run, status);
13
+ } catch {
14
+ // Derived repository updates must never break the authoritative run ledger.
15
+ }
16
+ }
17
+ }