@fieldwangai/agentflow 0.1.164 → 0.1.166
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/bin/lib/agent-runners.mjs +105 -13
- package/bin/lib/cursor-api-key-pool.mjs +140 -31
- package/bin/lib/cursor-model-catalog.mjs +152 -0
- package/bin/lib/marketplace-usage.mjs +4 -3
- package/bin/lib/workspace-routes.mjs +677 -21
- package/bin/lib/workspace-server.mjs +30 -14
- package/builtin/web-ui/dist/assets/{WorkflowAssistantThread-CffIx5BY.js → WorkflowAssistantThread-DKdBGu3q.js} +1 -1
- package/builtin/web-ui/dist/assets/{index-CDItaRfX.css → index-B7YuvFR2.css} +1 -1
- package/builtin/web-ui/dist/assets/index-BUljbvrW.js +877 -0
- package/builtin/web-ui/dist/index.html +2 -2
- package/package.json +2 -2
- package/skills/agentflow-cli/runtime/bin/lib/skill-runtime.mjs +242 -137
- package/skills/agentflow-cli/runtime/package.json +1 -1
- package/builtin/web-ui/dist/assets/index-CWIcfWHO.js +0 -873
|
@@ -7,13 +7,18 @@ import { normalizeCursorModelForCli } from "./model-config.mjs";
|
|
|
7
7
|
import { t } from "./i18n.mjs";
|
|
8
8
|
import { readMergedEnvObject } from "./user-env.mjs";
|
|
9
9
|
import {
|
|
10
|
+
classifyCursorApiKeyLimitError,
|
|
11
|
+
clearCursorApiKeyLaneCooldown,
|
|
10
12
|
createCursorApiKeyAttempts,
|
|
11
13
|
cursorApiKeyCooldownMinutes,
|
|
12
14
|
cursorApiKeyEnv,
|
|
13
15
|
cursorApiKeyLabel,
|
|
16
|
+
isCursorAutoFallbackEligible,
|
|
14
17
|
isCursorQuotaError,
|
|
15
|
-
|
|
18
|
+
markCursorApiKeyLaneBlocked,
|
|
19
|
+
recordCursorApiKeyFallbackModel,
|
|
16
20
|
} from "./cursor-api-key-pool.mjs";
|
|
21
|
+
import { discoverCursorModels } from "./cursor-model-catalog.mjs";
|
|
17
22
|
import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
|
|
18
23
|
|
|
19
24
|
function shouldPassCursorModelArg(model) {
|
|
@@ -53,6 +58,16 @@ function nextCursorAttemptOptions(options = {}, attempts = [], attemptIndex = 0)
|
|
|
53
58
|
...options,
|
|
54
59
|
_agentflowCursorApiKeyAttempts: attempts,
|
|
55
60
|
_agentflowCursorApiKeyAttemptIndex: attemptIndex + 1,
|
|
61
|
+
_agentflowCursorModelSelection: undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cursorModelAttemptOptions(options = {}, attempts = [], attemptIndex = 0, modelSelection) {
|
|
66
|
+
return {
|
|
67
|
+
...options,
|
|
68
|
+
_agentflowCursorApiKeyAttempts: attempts,
|
|
69
|
+
_agentflowCursorApiKeyAttemptIndex: attemptIndex,
|
|
70
|
+
_agentflowCursorModelSelection: modelSelection,
|
|
56
71
|
};
|
|
57
72
|
}
|
|
58
73
|
|
|
@@ -402,7 +417,7 @@ function tryEmitOpenCodeLineAsNatural(line, emit) {
|
|
|
402
417
|
export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {}) {
|
|
403
418
|
const onStreamEvent = typeof options.onStreamEvent === "function" ? options.onStreamEvent : null;
|
|
404
419
|
const ws = path.resolve(cliWorkspace);
|
|
405
|
-
const
|
|
420
|
+
const requestedModel = normalizeCursorModelForCli(options.model ?? process.env.CURSOR_AGENT_MODEL ?? null);
|
|
406
421
|
const agentCmd = process.env.CURSOR_AGENT_CMD || "agent";
|
|
407
422
|
const {
|
|
408
423
|
baseEnv: cursorBaseEnv,
|
|
@@ -410,6 +425,13 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
|
|
|
410
425
|
attemptIndex: cursorAttemptIndex,
|
|
411
426
|
selection: cursorSelection,
|
|
412
427
|
} = cursorAttemptOptions(options);
|
|
428
|
+
const hasExplicitModel = shouldPassCursorModelArg(requestedModel);
|
|
429
|
+
const cursorModelSelection = hasExplicitModel
|
|
430
|
+
? { lane: "auto", modelId: requestedModel, modelName: requestedModel }
|
|
431
|
+
: options._agentflowCursorModelSelection
|
|
432
|
+
|| cursorSelection?.modelSelection
|
|
433
|
+
|| { lane: "auto", modelId: "auto", modelName: "Auto" };
|
|
434
|
+
const model = hasExplicitModel ? requestedModel : cursorModelSelection.modelId;
|
|
413
435
|
// Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
|
|
414
436
|
const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
|
|
415
437
|
const approveMcps = process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false";
|
|
@@ -448,6 +470,12 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
|
|
|
448
470
|
line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} / ${cursorAttempts.length}`,
|
|
449
471
|
});
|
|
450
472
|
}
|
|
473
|
+
if (cursorModelSelection.lane === "fallback") {
|
|
474
|
+
emit({
|
|
475
|
+
type: "status",
|
|
476
|
+
line: `Cursor is using Composer fallback: ${cursorModelSelection.modelName}`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
451
479
|
|
|
452
480
|
if (!useStderrInherit) {
|
|
453
481
|
child.stderr.on("data", (chunk) => {
|
|
@@ -588,20 +616,83 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
|
|
|
588
616
|
}
|
|
589
617
|
const retryCursorQuota = (errorText) => {
|
|
590
618
|
if (!cursorSelection) return false;
|
|
591
|
-
if (cursorAttemptIndex >= cursorAttempts.length - 1) return false;
|
|
592
619
|
if (hadToolActivity) return false;
|
|
593
620
|
if (!isCursorQuotaError(errorText)) return false;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
promptText,
|
|
602
|
-
nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
|
|
621
|
+
const errorCategory = classifyCursorApiKeyLimitError(errorText);
|
|
622
|
+
const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv);
|
|
623
|
+
markCursorApiKeyLaneBlocked(
|
|
624
|
+
cursorSelection,
|
|
625
|
+
cursorModelSelection.lane,
|
|
626
|
+
cooldownMinutes,
|
|
627
|
+
errorText,
|
|
603
628
|
);
|
|
604
|
-
|
|
629
|
+
const canTryComposer = !hasExplicitModel
|
|
630
|
+
&& cursorModelSelection.lane === "auto"
|
|
631
|
+
&& errorCategory === "explicit_limit"
|
|
632
|
+
&& isCursorAutoFallbackEligible(errorText);
|
|
633
|
+
const hasNextKey = cursorAttemptIndex < cursorAttempts.length - 1;
|
|
634
|
+
if (!canTryComposer && !hasNextKey) return false;
|
|
635
|
+
|
|
636
|
+
const retry = async () => {
|
|
637
|
+
if (canTryComposer) {
|
|
638
|
+
emit({
|
|
639
|
+
type: "status",
|
|
640
|
+
line: `Cursor Auto on API Key ${cursorApiKeyLabel(cursorSelection)} is out of usage; discovering Composer fallback...`,
|
|
641
|
+
});
|
|
642
|
+
const catalog = await discoverCursorModels({
|
|
643
|
+
keyId: cursorSelection.id,
|
|
644
|
+
cwd: ws,
|
|
645
|
+
command: agentCmd,
|
|
646
|
+
env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
|
|
647
|
+
});
|
|
648
|
+
if (catalog.fallbackModel) {
|
|
649
|
+
recordCursorApiKeyFallbackModel(cursorSelection, catalog.fallbackModel);
|
|
650
|
+
const fallbackSelection = {
|
|
651
|
+
lane: "fallback",
|
|
652
|
+
modelId: catalog.fallbackModel.id,
|
|
653
|
+
modelName: catalog.fallbackModel.displayName,
|
|
654
|
+
};
|
|
655
|
+
emit({
|
|
656
|
+
type: "status",
|
|
657
|
+
line: `Switching the same Cursor API Key to ${fallbackSelection.modelName}.`,
|
|
658
|
+
});
|
|
659
|
+
emit({
|
|
660
|
+
type: "raw",
|
|
661
|
+
source: "cursor",
|
|
662
|
+
stream: "runner",
|
|
663
|
+
eventType: "model_fallback",
|
|
664
|
+
text: `auto -> ${fallbackSelection.modelId}`,
|
|
665
|
+
});
|
|
666
|
+
const fallback = runCursorAgentWithPrompt(
|
|
667
|
+
cliWorkspace,
|
|
668
|
+
promptText,
|
|
669
|
+
cursorModelAttemptOptions(options, cursorAttempts, cursorAttemptIndex, fallbackSelection),
|
|
670
|
+
);
|
|
671
|
+
await fallback.finished;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
emit({
|
|
675
|
+
type: "status",
|
|
676
|
+
line: `Composer fallback is unavailable${catalog.error ? `: ${catalog.error}` : "."}`,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (hasNextKey) {
|
|
681
|
+
emit({
|
|
682
|
+
type: "status",
|
|
683
|
+
line: `Cursor API Key ${cursorApiKeyLabel(cursorSelection)} reached its limit; retrying ${cursorAttemptIndex + 2}/${cursorAttempts.length}.`,
|
|
684
|
+
});
|
|
685
|
+
const next = runCursorAgentWithPrompt(
|
|
686
|
+
cliWorkspace,
|
|
687
|
+
promptText,
|
|
688
|
+
nextCursorAttemptOptions(options, cursorAttempts, cursorAttemptIndex),
|
|
689
|
+
);
|
|
690
|
+
await next.finished;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
throw new Error(errorText || "Cursor API Key reached its limit.");
|
|
694
|
+
};
|
|
695
|
+
retry().then(resolve).catch(reject);
|
|
605
696
|
return true;
|
|
606
697
|
};
|
|
607
698
|
if (code !== 0 && lastResult == null) {
|
|
@@ -622,6 +713,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
|
|
|
622
713
|
reject(new Error(msg));
|
|
623
714
|
return;
|
|
624
715
|
}
|
|
716
|
+
if (cursorSelection) clearCursorApiKeyLaneCooldown(cursorSelection, cursorModelSelection.lane);
|
|
625
717
|
resolve();
|
|
626
718
|
});
|
|
627
719
|
});
|
|
@@ -1,71 +1,180 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
let nextCursorApiKeyCursor = 0;
|
|
2
|
-
const
|
|
4
|
+
const cursorApiKeyStates = new Map();
|
|
3
5
|
|
|
4
|
-
export function
|
|
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
|
+
};
|
|
22
|
+
};
|
|
7
23
|
try {
|
|
8
24
|
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
|
-
}
|
|
25
|
+
if (Array.isArray(parsed)) return parsed.map(normalizeRecord).filter(Boolean);
|
|
18
26
|
} catch {
|
|
19
27
|
// Legacy comma-separated format.
|
|
20
28
|
}
|
|
21
|
-
return raw.split(",").map(
|
|
29
|
+
return raw.split(",").map(normalizeRecord).filter(Boolean);
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
export function parseCursorApiKeyPool(value = "") {
|
|
33
|
+
return parseCursorApiKeyRecords(value).map((record) => record.key);
|
|
34
|
+
}
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
36
|
+
export function createCursorApiKeyAttempts(env = {}, now = Date.now()) {
|
|
37
|
+
const records = parseCursorApiKeyRecords(env.CURSOR_API_KEYS);
|
|
38
|
+
if (records.length === 0) return [undefined];
|
|
39
|
+
|
|
40
|
+
const selections = records.map((record, index) => ({
|
|
41
|
+
...record,
|
|
42
|
+
index,
|
|
43
|
+
total: records.length,
|
|
44
|
+
modelSelection: getCursorApiKeyModelSelection(record.id, now),
|
|
45
|
+
}));
|
|
46
|
+
const candidates = selections.filter((selection) => Boolean(selection.modelSelection));
|
|
47
|
+
// Preserve the old pool's last-resort behavior if every key is cooling down.
|
|
48
|
+
// Normal operation always uses candidates and therefore respects lane cooldowns.
|
|
49
|
+
const effective = candidates.length > 0
|
|
50
|
+
? candidates
|
|
51
|
+
: selections.map((selection) => ({
|
|
52
|
+
...selection,
|
|
53
|
+
modelSelection: { lane: "auto", modelId: "auto", modelName: "Auto" },
|
|
54
|
+
}));
|
|
33
55
|
const start = nextCursorApiKeyCursor % effective.length;
|
|
34
56
|
nextCursorApiKeyCursor += 1;
|
|
35
57
|
return [...effective.slice(start), ...effective.slice(0, start)];
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
export function
|
|
39
|
-
|
|
60
|
+
export function getCursorApiKeyModelSelection(keyOrSelection, now = Date.now()) {
|
|
61
|
+
const keyId = selectionId(keyOrSelection);
|
|
62
|
+
const keyState = cursorApiKeyStates.get(keyId);
|
|
63
|
+
const autoBlocked = (keyState?.auto?.blockedUntil || 0) > now;
|
|
64
|
+
if (!autoBlocked) return { lane: "auto", modelId: "auto", modelName: "Auto" };
|
|
65
|
+
|
|
66
|
+
if (keyState?.auto?.errorCategory !== "explicit_limit" || keyState.auto.fallbackEligible !== true) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const fallbackModel = keyState.fallbackModel;
|
|
70
|
+
if (!fallbackModel || (keyState.fallback?.blockedUntil || 0) > now) return undefined;
|
|
71
|
+
return {
|
|
72
|
+
lane: "fallback",
|
|
73
|
+
modelId: fallbackModel.id,
|
|
74
|
+
modelName: fallbackModel.displayName,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recordCursorApiKeyFallbackModel(keyOrSelection, model) {
|
|
79
|
+
if (!model?.id) return;
|
|
80
|
+
const keyId = selectionId(keyOrSelection);
|
|
81
|
+
const keyState = cursorApiKeyStates.get(keyId) || {};
|
|
82
|
+
keyState.fallbackModel = { ...model };
|
|
83
|
+
cursorApiKeyStates.set(keyId, keyState);
|
|
40
84
|
}
|
|
41
85
|
|
|
42
|
-
export function
|
|
43
|
-
|
|
86
|
+
export function markCursorApiKeyLaneBlocked(
|
|
87
|
+
keyOrSelection,
|
|
88
|
+
lane,
|
|
89
|
+
cooldownMinutes = 30,
|
|
90
|
+
errorText = "",
|
|
91
|
+
now = Date.now(),
|
|
92
|
+
) {
|
|
93
|
+
const keyId = selectionId(keyOrSelection);
|
|
94
|
+
if (!keyId || !["auto", "fallback"].includes(lane)) return 0;
|
|
44
95
|
const minutes = Math.max(1, Number(cooldownMinutes) || 30);
|
|
45
|
-
|
|
96
|
+
const keyState = cursorApiKeyStates.get(keyId) || {};
|
|
97
|
+
const currentLane = keyState[lane] || {};
|
|
98
|
+
const blockedUntil = Math.max(currentLane.blockedUntil || 0, now + minutes * 60 * 1000);
|
|
99
|
+
const errorCategory = classifyCursorApiKeyLimitError(errorText);
|
|
100
|
+
keyState[lane] = {
|
|
101
|
+
...currentLane,
|
|
102
|
+
blockedUntil,
|
|
103
|
+
...(errorCategory ? { errorCategory } : {}),
|
|
104
|
+
...(lane === "auto" ? { fallbackEligible: isCursorAutoFallbackEligible(errorText) } : {}),
|
|
105
|
+
};
|
|
106
|
+
cursorApiKeyStates.set(keyId, keyState);
|
|
107
|
+
return blockedUntil;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function clearCursorApiKeyLaneCooldown(keyOrSelection, lane) {
|
|
111
|
+
const keyState = cursorApiKeyStates.get(selectionId(keyOrSelection));
|
|
112
|
+
if (!keyState?.[lane]) return false;
|
|
113
|
+
keyState[lane].blockedUntil = 0;
|
|
114
|
+
delete keyState[lane].errorCategory;
|
|
115
|
+
delete keyState[lane].fallbackEligible;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function cursorApiKeyEnv(selection) {
|
|
120
|
+
return selection?.key ? { CURSOR_API_KEY: selection.key } : {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function markCursorApiKeyQuotaBlocked(selection, cooldownMinutes = 30, errorText = "") {
|
|
124
|
+
return markCursorApiKeyLaneBlocked(selection, "auto", cooldownMinutes, errorText);
|
|
46
125
|
}
|
|
47
126
|
|
|
48
127
|
export function cursorApiKeyLabel(selection) {
|
|
49
128
|
if (!selection) return "default";
|
|
50
|
-
return `${selection.index + 1}/${selection.total}`;
|
|
129
|
+
return selection.name ? `${selection.index + 1}/${selection.total} (${selection.name})` : `${selection.index + 1}/${selection.total}`;
|
|
51
130
|
}
|
|
52
131
|
|
|
53
|
-
export function
|
|
132
|
+
export function isCursorAutoFallbackEligible(error = "") {
|
|
54
133
|
const text = String(error || "");
|
|
55
|
-
|
|
56
|
-
|
|
134
|
+
return [/\bout\s+of\s+usage\b/i, /\busage\s+limit\b/i].some((pattern) => pattern.test(text));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function classifyCursorApiKeyLimitError(error = "") {
|
|
138
|
+
const text = String(error || "");
|
|
139
|
+
if (!text) return undefined;
|
|
140
|
+
const isExplicitLimit = [
|
|
57
141
|
/\b429\b/i,
|
|
58
142
|
/rate[_\s-]*limit/i,
|
|
59
143
|
/too many requests/i,
|
|
60
144
|
/quota/i,
|
|
61
145
|
/usage\s+limit/i,
|
|
146
|
+
/out\s+of\s+usage/i,
|
|
62
147
|
/limit\s+(?:exceeded|reached)/i,
|
|
63
148
|
/exceeded\s+(?:your\s+)?limit/i,
|
|
64
|
-
/
|
|
65
|
-
/
|
|
149
|
+
/insufficient[_\s-]*quota/i,
|
|
150
|
+
/credit[s]?\s+(?:exhausted|limit)/i,
|
|
151
|
+
/request\s+limit/i,
|
|
66
152
|
].some((pattern) => pattern.test(text));
|
|
153
|
+
if (isExplicitLimit) return "explicit_limit";
|
|
154
|
+
if (/resource[_\s-]*exhausted/i.test(text)) return "resource_exhausted";
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isCursorQuotaError(error = "") {
|
|
159
|
+
return classifyCursorApiKeyLimitError(error) !== undefined;
|
|
67
160
|
}
|
|
68
161
|
|
|
69
162
|
export function cursorApiKeyCooldownMinutes(env = {}) {
|
|
70
163
|
return Math.max(1, Number(env.AGENTFLOW_CURSOR_API_KEY_COOLDOWN_MINUTES || env.CURSOR_API_KEY_COOLDOWN_MINUTES || 30) || 30);
|
|
71
164
|
}
|
|
165
|
+
|
|
166
|
+
export function resetCursorApiKeyPoolForTests() {
|
|
167
|
+
nextCursorApiKeyCursor = 0;
|
|
168
|
+
cursorApiKeyStates.clear();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function selectionId(keyOrSelection) {
|
|
172
|
+
if (typeof keyOrSelection === "string") return keyOrSelection;
|
|
173
|
+
if (keyOrSelection?.id) return String(keyOrSelection.id);
|
|
174
|
+
if (keyOrSelection?.key) return legacyKeyId(String(keyOrSelection.key));
|
|
175
|
+
return "default";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function legacyKeyId(key) {
|
|
179
|
+
return `legacy_${createHash("sha256").update(String(key || "")).digest("hex").slice(0, 16)}`;
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -155,8 +155,9 @@ export function marketplaceFlowOriginPath(flowDir) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
export function writeMarketplaceFlowOrigin(flowDir, origin = {}) {
|
|
158
|
+
const kind = safeText(origin.kind, 32) === "project-flow" ? "project-flow" : "flow";
|
|
158
159
|
const value = {
|
|
159
|
-
kind
|
|
160
|
+
kind,
|
|
160
161
|
id: safeText(origin.id),
|
|
161
162
|
version: safeText(origin.version, 80),
|
|
162
163
|
installedAt: safeText(origin.installedAt || new Date().toISOString(), 80),
|
|
@@ -169,8 +170,8 @@ export function writeMarketplaceFlowOrigin(flowDir, origin = {}) {
|
|
|
169
170
|
export function readMarketplaceFlowOrigin(flowDir) {
|
|
170
171
|
try {
|
|
171
172
|
const parsed = JSON.parse(fs.readFileSync(marketplaceFlowOriginPath(flowDir), "utf-8"));
|
|
172
|
-
if (
|
|
173
|
-
return { kind:
|
|
173
|
+
if (!["flow", "project-flow"].includes(parsed?.kind) || !parsed.id || !parsed.version) return null;
|
|
174
|
+
return { kind: parsed.kind, id: String(parsed.id), version: String(parsed.version) };
|
|
174
175
|
} catch {
|
|
175
176
|
return null;
|
|
176
177
|
}
|