w-dispatch-ai 1.0.22 → 1.0.23

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.
Files changed (70) hide show
  1. package/README.md +627 -581
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +10 -4
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/budgetFor.mjs.html +2 -2
  7. package/docs/buildValidator.mjs.html +2 -2
  8. package/docs/castPintOr.mjs.html +2 -2
  9. package/docs/dfTimeoutMs.mjs.html +2 -2
  10. package/docs/dispatchAi.mjs.html +2 -2
  11. package/docs/dispatchAiFallback.mjs.html +2 -2
  12. package/docs/dispatchAiWkf.mjs.html +2 -2
  13. package/docs/dispatchAntigravity.mjs.html +2 -2
  14. package/docs/dispatchApiOpenaiCompat.mjs.html +2 -2
  15. package/docs/dispatchApiOpenaiResponses.mjs.html +2 -2
  16. package/docs/dispatchClaude.mjs.html +2 -2
  17. package/docs/dispatchCodex.mjs.html +2 -2
  18. package/docs/dispatchOpencode.mjs.html +2 -2
  19. package/docs/getCliArgs.mjs.html +2 -2
  20. package/docs/getErrorResult.mjs.html +2 -2
  21. package/docs/getErrorType.mjs.html +2 -2
  22. package/docs/global.html +10615 -3368
  23. package/docs/index.html +2 -2
  24. package/docs/quota_dfQuotaTimeoutMs.mjs.html +99 -0
  25. package/docs/quota_fetchQuotaJson.mjs.html +348 -0
  26. package/docs/quota_fromCodexUsageHttp.mjs.html +335 -0
  27. package/docs/quota_getQuotaAntigravity.mjs.html +463 -0
  28. package/docs/quota_getQuotaClaude.mjs.html +457 -0
  29. package/docs/quota_getQuotaCodex.mjs.html +515 -0
  30. package/docs/quota_readJsonOrNull.mjs.html +113 -0
  31. package/docs/quota_toQuotaLabel.mjs.html +146 -0
  32. package/docs/quota_toQuotaResult.mjs.html +214 -0
  33. package/docs/quota_toQuotaScopedLabel.mjs.html +117 -0
  34. package/docs/quota_toQuotaWindow.mjs.html +239 -0
  35. package/docs/readEnvFile.mjs.html +2 -2
  36. package/docs/resolveProviders.mjs.html +2 -2
  37. package/docs/wkf_callAiWithFallback.mjs.html +2 -2
  38. package/docs/wkf_createFileStore.mjs.html +2 -2
  39. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  40. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  41. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  42. package/docs/wkf_runFanout.mjs.html +2 -2
  43. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  44. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  45. package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
  46. package/package.json +2 -2
  47. package/src/WDispatchAi.mjs +8 -2
  48. package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
  49. package/src/quota/fetchQuotaJson.mjs +276 -0
  50. package/src/quota/fromCodexUsageHttp.mjs +263 -0
  51. package/src/quota/getQuotaAntigravity.mjs +391 -0
  52. package/src/quota/getQuotaClaude.mjs +385 -0
  53. package/src/quota/getQuotaCodex.mjs +443 -0
  54. package/src/quota/readJsonOrNull.mjs +41 -0
  55. package/src/quota/toQuotaLabel.mjs +74 -0
  56. package/src/quota/toQuotaResult.mjs +142 -0
  57. package/src/quota/toQuotaScopedLabel.mjs +45 -0
  58. package/src/quota/toQuotaWindow.mjs +167 -0
  59. package/test/tools/fakeCliForTest.mjs +4 -3
  60. package/test/tools/fakeServerForQuotaTest.mjs +171 -0
  61. package/test/unit-WDispatchAi.test.mjs +11 -2
  62. package/test/unit-fetchQuotaJson.test.mjs +70 -0
  63. package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
  64. package/test/unit-getQuotaAntigravity.test.mjs +123 -0
  65. package/test/unit-getQuotaClaude.test.mjs +137 -0
  66. package/test/unit-getQuotaCodex.test.mjs +191 -0
  67. package/test/unit-readJsonOrNull.test.mjs +44 -0
  68. package/test/unit-toQuotaLabel.test.mjs +43 -0
  69. package/test/unit-toQuotaResult.test.mjs +53 -0
  70. package/test/unit-toQuotaWindow.test.mjs +73 -0
@@ -0,0 +1,263 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import isearr from 'wsemi/src/isearr.mjs'
4
+ import iseobj from 'wsemi/src/iseobj.mjs'
5
+ import isnum from 'wsemi/src/isnum.mjs'
6
+ import cdbl from 'wsemi/src/cdbl.mjs'
7
+ import toQuotaScopedLabel from './toQuotaScopedLabel.mjs'
8
+ import toQuotaWindow from './toQuotaWindow.mjs'
9
+
10
+
11
+ // fromCodexUsageHttp.mjs — 將chatgpt.com之/wham/usage回應對映為統一結構
12
+ //
13
+ // 【為何獨立成檔】此端點為ChatGPT前端自用之內部端點, 欄位形狀已知會變(尤其additional_rate_limits),
14
+ // 對映邏輯獨立後可用離線fixture斷言, 不必每次都靠真帳號才能驗證。
15
+ //
16
+ // 【additional_rate_limits之寬容解析】本機帳號此欄實測為null, 形狀取自同類工具codex-reset-checker
17
+ // 之測試fixture(其作者觀察到的真實回應): 集合可為陣列或以名稱為鍵之物件; 單筆之識別可為
18
+ // limit_name / metered_limit_name / name / id; 顯示名可為display_name / title; 窗口可在單筆頂層、
19
+ // rate_limit之下(snake或camel), 或單筆本身就是一個窗口(直接帶used_percent, 或置於window鍵);
20
+ // 直接窗口不分primary/secondary時, 以名稱含weekly/secondary/reserve或窗口長度≥6天判為週限額。
21
+ // 已知名稱: codex-spark(GPT-5.3-Codex-Spark)、gpt-reserve(Luna reserve之週限額)。
22
+ // 任何欄位缺漏皆「該窗口不列入」而不拋錯, 原始回應由呼叫端保留於raw。
23
+
24
+
25
+ //直接窗口判為週限額之窗口長度門檻(秒)
26
+ let SEC_WEEKLY_MIN = 6 * 86400
27
+
28
+
29
+ /**
30
+ * 取第一個為非空字串之鍵值
31
+ *
32
+ * @param {Object} o 輸入物件
33
+ * @param {Array} keys 輸入鍵陣列
34
+ * @returns {String} 回傳字串, 皆無回傳''
35
+ */
36
+ function firstStr(o, keys) {
37
+ for (let k of keys) {
38
+ let v = get(o, k, '')
39
+ if (isestr(v)) {
40
+ return v
41
+ }
42
+ }
43
+ return ''
44
+ }
45
+
46
+
47
+ /**
48
+ * 將單一窗口{used_percent, limit_window_seconds, reset_at, reset_after_seconds}正規化
49
+ *
50
+ * @param {Object} o 輸入窗口物件
51
+ * @param {String} key 輸入窗口鍵字串
52
+ * @param {String} scope 輸入範圍字串
53
+ * @returns {Object|null} 回傳統一窗口物件, 輸入非物件回傳null
54
+ */
55
+ function fromWindow(o, key, scope) {
56
+ if (!iseobj(o)) {
57
+ return null
58
+ }
59
+ let ws = get(o, 'limit_window_seconds', null)
60
+ let windowSeconds = isnum(ws) ? cdbl(ws) : null
61
+ return toQuotaWindow({
62
+ key,
63
+ label: toQuotaScopedLabel(windowSeconds, scope),
64
+ windowSeconds,
65
+ usedPercent: get(o, 'used_percent', null),
66
+ resetAt: get(o, 'reset_at', ''),
67
+ resetAfterSeconds: get(o, 'reset_after_seconds', null),
68
+ scope,
69
+ })
70
+ }
71
+
72
+
73
+ /**
74
+ * 取出additional_rate_limits之集合並統一為陣列
75
+ *
76
+ * @param {Object} data 輸入回應物件
77
+ * @returns {Array} 回傳單筆物件陣列, 無則[]
78
+ */
79
+ function collectAdditional(data) {
80
+ let cands = [
81
+ get(data, 'additional_rate_limits', null),
82
+ get(data, 'rate_limit.additional_rate_limits', null),
83
+ ]
84
+ for (let c of cands) {
85
+ if (isearr(c)) {
86
+ return c
87
+ }
88
+ if (iseobj(c)) {
89
+ return Object.keys(c).map((k) => {
90
+ let v = c[k]
91
+ if (!iseobj(v)) {
92
+ return v
93
+ }
94
+ let nm = firstStr(v, ['limit_name', 'name', 'id'])
95
+ return { ...v, limit_name: nm === '' ? k : nm }
96
+ })
97
+ }
98
+ }
99
+ return []
100
+ }
101
+
102
+
103
+ /**
104
+ * 自單筆取出指定窗口(頂層、rate_limit、rateLimit之下, snake或camel)
105
+ *
106
+ * @param {Object} it 輸入單筆物件
107
+ * @param {String} snake 輸入snake鍵, 例如'primary_window'
108
+ * @param {String} camel 輸入camel鍵, 例如'primaryWindow'
109
+ * @returns {Object|null} 回傳窗口物件, 無回傳null
110
+ */
111
+ function pickWindow(it, snake, camel) {
112
+ for (let c of [it, get(it, 'rate_limit', null), get(it, 'rateLimit', null)]) {
113
+ if (!iseobj(c)) {
114
+ continue
115
+ }
116
+ let w = get(c, snake, null)
117
+ if (!iseobj(w)) {
118
+ w = get(c, camel, null)
119
+ }
120
+ if (iseobj(w)) {
121
+ return w
122
+ }
123
+ }
124
+ return null
125
+ }
126
+
127
+
128
+ /**
129
+ * 單筆本身即為窗口時取出之
130
+ *
131
+ * @param {Object} it 輸入單筆物件
132
+ * @returns {Object|null} 回傳窗口物件, 無回傳null
133
+ */
134
+ function directWindow(it) {
135
+ if (!iseobj(it)) {
136
+ return null
137
+ }
138
+ if (get(it, 'used_percent', undefined) !== undefined) {
139
+ return it
140
+ }
141
+ let w = get(it, 'window', null)
142
+ if (iseobj(w)) {
143
+ return w
144
+ }
145
+ let rl = get(it, 'rate_limit', null)
146
+ if (iseobj(rl) && get(rl, 'used_percent', undefined) !== undefined) {
147
+ return rl
148
+ }
149
+ return null
150
+ }
151
+
152
+
153
+ /**
154
+ * 將chatgpt.com之/wham/usage回應對映為統一結構
155
+ *
156
+ * 主窗口(rate_limit.primary_window / secondary_window)、程式碼審查限額(code_review_rate_limit)、
157
+ * 以及寬容解析之additional_rate_limits皆納入windows;額外限額之窗口鍵為「<識別>:primary|secondary」,
158
+ * scope為其顯示名或識別。任何欄位缺漏皆略過該窗口而不拋錯
159
+ *
160
+ * @param {Object} data 輸入/wham/usage之回應物件
161
+ * @returns {Object} 回傳物件,內含email(字串,無則'')、plan(字串,無則'')、windows(統一窗口陣列)、credits(點數與重置券資訊物件)
162
+ * @example
163
+ *
164
+ * import fromCodexUsageHttp from './src/quota/fromCodexUsageHttp.mjs'
165
+ *
166
+ * let m = fromCodexUsageHttp({
167
+ * email: 'a@b.c',
168
+ * plan_type: 'plus',
169
+ * rate_limit: { primary_window: { used_percent: 42, limit_window_seconds: 18000, reset_after_seconds: 100 } },
170
+ * additional_rate_limits: [{ limit_name: 'codex-spark', primary_window: { used_percent: 12, limit_window_seconds: 18000 } }],
171
+ * })
172
+ * console.log(m.windows.map((w) => `${w.key}=${w.label}`))
173
+ * // => [ 'primary=5小時', 'codex-spark:primary=5小時(codex-spark)' ]
174
+ *
175
+ */
176
+ function fromCodexUsageHttp(data) {
177
+
178
+ //email與plan
179
+ let email = firstStr(data, ['email'])
180
+ let plan = firstStr(data, ['plan_type'])
181
+
182
+ //windows, 主窗口
183
+ let windows = []
184
+ for (let k of ['primary', 'secondary']) {
185
+ let w = fromWindow(get(data, `rate_limit.${k}_window`, null), k, '')
186
+ if (w !== null) {
187
+ windows.push(w)
188
+ }
189
+ }
190
+
191
+ //code review限額, 與對話額度分開計算
192
+ for (let k of ['primary', 'secondary']) {
193
+ let w = fromWindow(get(data, `code_review_rate_limit.${k}_window`, null), `code_review:${k}`, 'code review')
194
+ if (w !== null) {
195
+ windows.push(w)
196
+ }
197
+ }
198
+
199
+ //additional_rate_limits, 寬容解析
200
+ let addi = collectAdditional(data)
201
+ for (let i = 0; i < addi.length; i++) {
202
+ let it = addi[i]
203
+ if (!iseobj(it)) {
204
+ continue
205
+ }
206
+
207
+ //id與scope
208
+ let id = firstStr(it, ['limit_name', 'metered_limit_name', 'name', 'id'])
209
+ if (id === '') {
210
+ id = `additional_${i + 1}`
211
+ }
212
+ let display = firstStr(it, ['display_name', 'title', 'name', 'limit_name', 'metered_limit_name'])
213
+ let scope = display === '' ? id : display
214
+ let identity = `${id} ${display}`.toLowerCase()
215
+
216
+ //窗口, 先找明確之primary/secondary, 皆無時把單筆本身當窗口並以名稱或長度判別週限額
217
+ let pri = pickWindow(it, 'primary_window', 'primaryWindow')
218
+ let sec = pickWindow(it, 'secondary_window', 'secondaryWindow')
219
+ if (pri === null && sec === null) {
220
+ let d = directWindow(it)
221
+ if (d !== null) {
222
+ let ws = get(d, 'limit_window_seconds', null)
223
+ let weekly = /weekly|secondary|reserve/.test(identity) || (isnum(ws) && cdbl(ws) >= SEC_WEEKLY_MIN)
224
+ if (weekly) {
225
+ sec = d
226
+ }
227
+ else {
228
+ pri = d
229
+ }
230
+ }
231
+ }
232
+
233
+ let wp = fromWindow(pri, `${id}:primary`, scope)
234
+ if (wp !== null) {
235
+ windows.push(wp)
236
+ }
237
+ let ws2 = fromWindow(sec, `${id}:secondary`, scope)
238
+ if (ws2 !== null) {
239
+ windows.push(ws2)
240
+ }
241
+ }
242
+
243
+ //credits, 點數餘額與「額度重置券」(可提前重置窗口)與限額狀態
244
+ let credits = {
245
+ hasCredits: get(data, 'credits.has_credits', false),
246
+ unlimited: get(data, 'credits.unlimited', false),
247
+ balance: get(data, 'credits.balance', ''),
248
+ resetCreditsAvailable: get(data, 'rate_limit_reset_credits.available_count', null),
249
+ spendControlReached: get(data, 'spend_control.reached', false),
250
+ rateLimitReachedType: get(data, 'rate_limit_reached_type', null),
251
+ individualLimit: get(data, 'spend_control.individual_limit', null),
252
+ }
253
+
254
+ return {
255
+ email,
256
+ plan,
257
+ windows,
258
+ credits,
259
+ }
260
+ }
261
+
262
+
263
+ export default fromCodexUsageHttp
@@ -0,0 +1,391 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import crypto from 'crypto'
5
+ import get from 'lodash-es/get.js'
6
+ import isestr from 'wsemi/src/isestr.mjs'
7
+ import isearr from 'wsemi/src/isearr.mjs'
8
+ import isbol from 'wsemi/src/isbol.mjs'
9
+ import isnum from 'wsemi/src/isnum.mjs'
10
+ import cdbl from 'wsemi/src/cdbl.mjs'
11
+ import execCli from 'wsemi/src/execCli.mjs'
12
+ import strCompareForVersion from 'wsemi/src/strCompareForVersion.mjs'
13
+ import castPintOr from '../castPintOr.mjs'
14
+ import toQuotaLabel from './toQuotaLabel.mjs'
15
+ import toQuotaWindow from './toQuotaWindow.mjs'
16
+ import toQuotaResult from './toQuotaResult.mjs'
17
+
18
+
19
+ // getQuotaAntigravity.mjs — 查詢Antigravity(Google側, agy CLI)帳號之當前額度
20
+ //
21
+ // 【資料來源: agy自身之print模式(官方、無頭、不碰憑證), 2026-09-05本機實測(agy 1.1.27)】
22
+ // `agy -p "/usage" --output-format json` 回 {status:'SUCCESS', num_turns:0, usage:{全零},
23
+ // command:{name:'usage', data:{groups:[{name:'Gemini Models', buckets:[
24
+ // {id:'gemini-weekly', window:'weekly', remaining_fraction, reset_time, description}, {id:'gemini-5h', window:'5h', ...}]},
25
+ // {name:'Claude and GPT models', buckets:[{id:'3p-weekly',...},{id:'3p-5h',...}]}]}}}
26
+ // agy CHANGELOG 1.1.11原文: 「-p "/usage", /quota, /credits ... emit a structured payload under
27
+ // --output-format json ... without starting an agent turn, spending quota, or leaving a conversation behind」。
28
+ // 帳號email: 同一次spawn加`--log-file <暫存檔>`, log內有`applyAuthResult: email=..., authMethod=consumer`。
29
+ // 耗時實測5~8秒(agy啟動、認證、loadCodeAssist與fetchAvailableModels兩個後端往返), 故預設逾時較寬。
30
+ //
31
+ // 【為何不走Gemini CLI憑證或Code Assist端點】Google已於2026-06-18終止Gemini CLI對個人層之服務;
32
+ // ~/.gemini/google_accounts.json與oauth_creds.json為舊Gemini CLI所留, 實測其帳號(firsemisphere6)
33
+ // 與agy實際登入帳號(firsemisphere)不同, 且打Code Assist端點回403; 同類工具為此各自借token
34
+ // (讀IDE之state.vscdb、讀Windows認證管理員、掃agy二進位取client secret、自建OAuth登入),
35
+ // 全屬未公開之內部格式與灰色地帶。agy既已提供官方無頭介面, 上述路徑一概不做。
36
+ //
37
+ // 【版本閘門】1.1.11之前的agy會把「/usage」當一般prompt起一個agent turn(耗額度、留對話),
38
+ // 故先以`agy --version`把關(實測<1秒), 低於門檻即回unsupported而非冒險執行。
39
+ //
40
+ // 【Git Bash陷阱(僅影響手動測試)】MSYS會把以斜線開頭之參數「/usage」轉譯成Windows路徑
41
+ // (C:/Program Files/Git/usage), agy收到路徑字串就當成prompt執行; Node之spawn無此轉譯,
42
+ // 本函數不受影響, 但在Git Bash手動驗證須加MSYS_NO_PATHCONV=1。
43
+
44
+
45
+ //預設值
46
+ let DEFAULT_EXE = 'agy'
47
+ let DEFAULT_TIMEOUT_MS = 60000
48
+ let MIN_VERSION = '1.1.11'
49
+
50
+
51
+ //各窗口字串對應之秒數; 未列者以正則解析Nh/Nd, 皆不成則null(標籤退回桶名)
52
+ let WINDOW_SECONDS = {
53
+ '5h': 18000,
54
+ 'five_hour': 18000,
55
+ 'five-hour': 18000,
56
+ 'weekly': 604800,
57
+ 'week': 604800,
58
+ 'daily': 86400,
59
+ 'day': 86400,
60
+ }
61
+
62
+
63
+ /**
64
+ * 由桶之window字串推得窗口秒數
65
+ *
66
+ * @param {String} w 輸入window字串, 例如'5h'、'weekly'
67
+ * @returns {Number|null} 回傳秒數, 無法推得回傳null
68
+ */
69
+ function windowToSeconds(w) {
70
+ if (!isestr(w)) {
71
+ return null
72
+ }
73
+ let k = w.trim().toLowerCase()
74
+ if (WINDOW_SECONDS[k] !== undefined) {
75
+ return WINDOW_SECONDS[k]
76
+ }
77
+ let m = k.match(/^(\d+)\s*(h|hr|hour|hours|d|day|days|m|min|minutes)$/)
78
+ if (!m) {
79
+ return null
80
+ }
81
+ let n = Number(m[1])
82
+ if (/^h/.test(m[2])) {
83
+ return n * 3600
84
+ }
85
+ if (/^d/.test(m[2])) {
86
+ return n * 86400
87
+ }
88
+ return n * 60
89
+ }
90
+
91
+
92
+ /**
93
+ * 將群組內之單一桶正規化為統一窗口物件
94
+ *
95
+ * @param {Object} g 輸入群組物件
96
+ * @param {Object} b 輸入桶物件
97
+ * @returns {Object|null} 回傳統一窗口物件, 桶無id或無剩餘比例回傳null
98
+ */
99
+ function fromBucket(g, b) {
100
+
101
+ let key = get(b, 'id', '')
102
+ if (!isestr(key)) {
103
+ return null
104
+ }
105
+
106
+ //usedPercent, 由剩餘比例反推; 文件載明可能為數值或字串故一律轉數值
107
+ let rf = get(b, 'remaining_fraction', null)
108
+ let usedPercent = null
109
+ if (isnum(rf)) {
110
+ usedPercent = Math.round((1 - cdbl(rf)) * 100 * 100) / 100
111
+ }
112
+
113
+ //scope與label, 群組名為範圍; 窗口秒數可推得時標籤為「5小時(Gemini Models)」, 否則退回桶名
114
+ let scope = get(g, 'name', '')
115
+ if (!isestr(scope)) {
116
+ scope = ''
117
+ }
118
+ let windowSeconds = windowToSeconds(get(b, 'window', ''))
119
+ let base = toQuotaLabel(windowSeconds)
120
+ if (base === '') {
121
+ base = get(b, 'name', '')
122
+ if (!isestr(base)) {
123
+ base = key
124
+ }
125
+ }
126
+ let label = scope === '' ? base : `${base}(${scope})`
127
+
128
+ //severity, 用罄與停用須明示; 其餘normal
129
+ let severity = 'normal'
130
+ if (get(b, 'disabled', false) === true) {
131
+ severity = 'disabled'
132
+ }
133
+ else if (usedPercent !== null && usedPercent >= 100) {
134
+ severity = 'exhausted'
135
+ }
136
+
137
+ return toQuotaWindow({
138
+ key,
139
+ label,
140
+ windowSeconds,
141
+ usedPercent,
142
+ resetAt: get(b, 'reset_time', ''),
143
+ scope,
144
+ active: false,
145
+ severity,
146
+ })
147
+ }
148
+
149
+
150
+ /**
151
+ * 執行agy之print模式讀取型slash指令並解析其JSON回應
152
+ *
153
+ * @param {String} exe 輸入agy執行檔
154
+ * @param {String} cmd 輸入slash指令字串, 例如'/usage'
155
+ * @param {Object} o 輸入設定物件{timeoutMs, cwd, logFile}
156
+ * @returns {Promise} 回傳物件{ok, data, response, stderr, error, errorType, code}
157
+ */
158
+ async function agyPrint(exe, cmd, o) {
159
+
160
+ let args = []
161
+ if (isestr(o.logFile)) {
162
+ args.push('--log-file', o.logFile)
163
+ }
164
+ args.push('-p', cmd, '--output-format', 'json', '--print-timeout', `${Math.max(5, Math.ceil(o.timeoutMs / 1000))}s`)
165
+
166
+ let r = await execCli(exe, args, { timeoutMs: o.timeoutMs, cwd: o.cwd })
167
+
168
+ //stdout最後一行非空者為JSON
169
+ let last = String(get(r, 'stdout', '')).trim().split('\n').map((s) => s.trim()).filter((s) => s !== '').pop() || ''
170
+ let j = null
171
+ try {
172
+ j = JSON.parse(last)
173
+ }
174
+ catch (err) {}
175
+
176
+ if (!r.ok) {
177
+ let tail = String(get(r, 'stderr', '')).trim().slice(-300)
178
+ let errorType = /timeout/i.test(get(r, 'error', '')) ? 'timeout' : 'exit'
179
+ if (/not logged in|login|sign in|authenticate/i.test(tail)) {
180
+ errorType = 'auth'
181
+ }
182
+ return { ok: false, data: null, response: '', stderr: tail, error: `agy ${cmd} failed: ${get(r, 'error', '')}${tail ? ` | stderr: ${tail}` : ''}`, errorType, code: get(r, 'code', null) }
183
+ }
184
+
185
+ if (j === null) {
186
+ return { ok: false, data: null, response: '', stderr: '', error: `agy ${cmd} stdout is not JSON: ${last.slice(0, 200)}`, errorType: 'parse', code: r.code }
187
+ }
188
+
189
+ //防呆: 若agy把指令當成prompt執行(num_turns>0或無command), 代表版本過舊或slash展開被停用
190
+ let numTurns = get(j, 'num_turns', 0)
191
+ let cname = get(j, 'command.name', '')
192
+ if (!isestr(cname) || (isnum(numTurns) && cdbl(numTurns) > 0)) {
193
+ return { ok: false, data: null, response: get(j, 'response', ''), stderr: '', error: `agy did not handle ${cmd} as a slash command (num_turns=${numTurns}); version may be below ${MIN_VERSION} or --disable-slash-commands is set; this call may have consumed quota`, errorType: 'unsupported', code: r.code }
194
+ }
195
+
196
+ if (get(j, 'status', '') !== 'SUCCESS') {
197
+ return { ok: false, data: get(j, 'command.data', null), response: get(j, 'response', ''), stderr: '', error: `agy ${cmd} reported status=${get(j, 'status', '')}: ${String(get(j, 'response', '')).slice(0, 200)}`, errorType: 'exit', code: r.code }
198
+ }
199
+
200
+ return { ok: true, data: get(j, 'command.data', null), response: get(j, 'response', ''), stderr: '', error: '', errorType: '', code: r.code, raw: j }
201
+ }
202
+
203
+
204
+ /**
205
+ * 查詢Antigravity(Google側, agy CLI)帳號之當前額度
206
+ *
207
+ * 以agy自身之print模式取得額度(官方、無頭、不碰憑證):`agy -p "/usage" --output-format json`,
208
+ * 並以同一次執行之`--log-file`取得登入帳號email。
209
+ * 額度綁定本機agy之登入憑證,email參數之作用為比對——不符時ok為false且matched為false;
210
+ * 未給email時不比對。本函數不會reject,一律以結果物件之ok與error欄位回報成敗
211
+ *
212
+ * @param {String} [email=''] 輸入欲查詢之帳號email字串,預設''代表不比對而直接回報本機當前帳號
213
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
214
+ * @param {String} [opt.exe='agy'] 輸入agy執行檔名稱或路徑字串,預設'agy'
215
+ * @param {Boolean} [opt.checkVersion=true] 輸入是否先以`agy --version`把關版本布林值,低於1.1.11即回unsupported而不執行(舊版會把/usage當prompt跑而耗額度),預設true
216
+ * @param {Boolean} [opt.withCredits=false] 輸入是否另執行`/credits`取得G1點數餘額布林值,會多一次約5秒之啟動,預設false
217
+ * @param {String} [opt.cwd=os.tmpdir()] 輸入子進程工作目錄字串,預設系統暫存目錄以免在專案目錄留下痕跡
218
+ * @param {Number} [opt.timeoutMs=60000] 輸入單次agy執行之逾時毫秒正整數,預設60000(agy啟動加兩個後端往返實測5~8秒)
219
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok、provider('antigravity')、email(agy登入帳號)、matched、plan(空字串,/usage不提供方案別)、planTier(空字串)、source('agy-print-usage')、windows(每群組之5小時與7天窗口,scope為群組名)、credits(withCredits時為{remainingCredits, upgradeUri},否則null)、raw(含agy版本、authMethod、原始回應)、error、errorType、durationMs,本函數不會reject
220
+ * @example
221
+ * //need agy cli (>=1.1.11) logged in
222
+ *
223
+ * import getQuotaAntigravity from './src/quota/getQuotaAntigravity.mjs'
224
+ *
225
+ * let test = async () => {
226
+ * let r = await getQuotaAntigravity('firsemisphere@gmail.com')
227
+ * console.log(r.ok, r.email)
228
+ * // => true firsemisphere@gmail.com
229
+ * console.log(r.windows.map((w) => `${w.label}:${w.remainingPercent}%`).join(' '))
230
+ * // => 7天(Gemini Models):85% 5小時(Gemini Models):90% 7天(Claude and GPT models):100% 5小時(Claude and GPT models):100% (即時值)
231
+ * }
232
+ * test()
233
+ *
234
+ */
235
+ async function getQuotaAntigravity(email = '', opt = {}) {
236
+
237
+ let t0 = Date.now()
238
+ let source = 'agy-print-usage'
239
+
240
+ //emailWant
241
+ let emailWant = isestr(email) ? email : ''
242
+
243
+ //exe
244
+ let exe = get(opt, 'exe', null)
245
+ if (!isestr(exe)) {
246
+ exe = DEFAULT_EXE
247
+ }
248
+
249
+ //timeoutMs
250
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
251
+
252
+ //cwd
253
+ let cwd = get(opt, 'cwd', null)
254
+ if (!isestr(cwd)) {
255
+ cwd = os.tmpdir()
256
+ }
257
+
258
+ //checkVersion與withCredits
259
+ let checkVersion = get(opt, 'checkVersion', true) !== false
260
+ let withCredits = get(opt, 'withCredits', null)
261
+ if (!isbol(withCredits)) {
262
+ withCredits = false
263
+ }
264
+
265
+ //fin
266
+ let fin = (o) => {
267
+ return toQuotaResult('antigravity', {
268
+ emailWant,
269
+ source,
270
+ durationMs: Date.now() - t0,
271
+ ...o,
272
+ })
273
+ }
274
+
275
+ //version gate
276
+ let version = ''
277
+ if (checkVersion) {
278
+ let rv = await execCli(exe, ['--version'], { timeoutMs: Math.min(timeoutMs, 15000), cwd })
279
+ if (!rv.ok) {
280
+ return fin({
281
+ error: `agy not found or not executable (${exe}): ${get(rv, 'error', '')}${String(get(rv, 'stderr', '')).trim() ? ` | ${String(rv.stderr).trim().slice(-200)}` : ''}; see https://antigravity.google/docs/cli/ for install and login`,
282
+ errorType: 'notfound',
283
+ })
284
+ }
285
+ version = String(get(rv, 'stdout', '')).trim().split('\n')[0]
286
+
287
+ //strCompareForVersion可直接餵--version原始輸出; 解析不出三段版本號回null, 一律視為不達門檻
288
+ let cmp = strCompareForVersion(version, MIN_VERSION)
289
+ if (cmp === null || cmp < 0) {
290
+ return fin({
291
+ error: `agy version [${version || 'unknown'}] is below ${MIN_VERSION}; its print mode cannot run /usage non-interactively (it would treat it as a prompt and consume quota); run [agy update]`,
292
+ errorType: 'unsupported',
293
+ raw: { version },
294
+ })
295
+ }
296
+ }
297
+
298
+ //logFile, 供取得登入帳號; 落系統暫存目錄, 結束必刪
299
+ let logFile = path.join(os.tmpdir(), `w-dispatch-ai-agy-${process.pid}-${crypto.randomBytes(4).toString('hex')}.log`)
300
+
301
+ let ru = null
302
+ let rc = null
303
+ let emailHave = ''
304
+ let authMethod = ''
305
+ try {
306
+
307
+ //usage
308
+ ru = await agyPrint(exe, '/usage', { timeoutMs, cwd, logFile })
309
+
310
+ //email, 自log取得(agy於認證後寫入applyAuthResult行)
311
+ let log = ''
312
+ try {
313
+ log = fs.readFileSync(logFile, 'utf8')
314
+ }
315
+ catch (err) {}
316
+ let m = log.match(/applyAuthResult: email=([^,\s]+), authMethod=(\w+)/)
317
+ if (m) {
318
+ emailHave = m[1]
319
+ authMethod = m[2]
320
+ }
321
+ else {
322
+ let m2 = log.match(/authenticated successfully as (\S+)/)
323
+ if (m2) {
324
+ emailHave = m2[1]
325
+ }
326
+ }
327
+
328
+ //credits, 選配
329
+ if (ru.ok && withCredits) {
330
+ rc = await agyPrint(exe, '/credits', { timeoutMs, cwd, logFile: '' })
331
+ }
332
+
333
+ }
334
+ finally {
335
+ try {
336
+ fs.unlinkSync(logFile)
337
+ }
338
+ catch (err) {}
339
+ }
340
+
341
+ if (!ru.ok) {
342
+ return fin({
343
+ email: emailHave,
344
+ error: ru.error,
345
+ errorType: ru.errorType,
346
+ raw: { version, authMethod, response: ru.response, stderr: ru.stderr },
347
+ })
348
+ }
349
+
350
+ //windows
351
+ let windows = []
352
+ let groups = get(ru.data, 'groups', null)
353
+ if (isearr(groups)) {
354
+ for (let g of groups) {
355
+ let buckets = get(g, 'buckets', null)
356
+ if (!isearr(buckets)) {
357
+ continue
358
+ }
359
+ for (let b of buckets) {
360
+ let w = fromBucket(g, b)
361
+ if (w !== null) {
362
+ windows.push(w)
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ //credits
369
+ let credits = null
370
+ if (rc !== null) {
371
+ credits = rc.ok
372
+ ? { remainingCredits: get(rc.data, 'remaining_credits', null), upgradeUri: get(rc.data, 'upgrade_uri', '') }
373
+ : { error: rc.error }
374
+ }
375
+
376
+ return fin({
377
+ email: emailHave,
378
+ windows,
379
+ credits,
380
+ raw: {
381
+ version,
382
+ authMethod,
383
+ description: get(ru.data, 'description', ''),
384
+ usage: ru.raw,
385
+ credits: rc !== null ? get(rc, 'raw', null) : null,
386
+ },
387
+ })
388
+ }
389
+
390
+
391
+ export default getQuotaAntigravity