w-dispatch-ai 1.0.11 → 1.0.13

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 (51) hide show
  1. package/README.md +33 -5
  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 +15 -3
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/budgetFor.mjs.html +124 -0
  7. package/docs/castPintOr.mjs.html +2 -2
  8. package/docs/dfTimeoutMs.mjs.html +2 -2
  9. package/docs/dispatchAi.mjs.html +2 -2
  10. package/docs/dispatchAiFallback.mjs.html +2 -2
  11. package/docs/dispatchAiWkf.mjs.html +2 -2
  12. package/docs/dispatchAntigravity.mjs.html +2 -2
  13. package/docs/dispatchApiOpenaiCompat.mjs.html +2 -2
  14. package/docs/dispatchClaude.mjs.html +2 -2
  15. package/docs/dispatchCodex.mjs.html +2 -2
  16. package/docs/dispatchOpencode.mjs.html +2 -2
  17. package/docs/getCliArgs.mjs.html +2 -2
  18. package/docs/getErrorResult.mjs.html +2 -2
  19. package/docs/getErrorType.mjs.html +2 -2
  20. package/docs/global.html +1632 -49
  21. package/docs/index.html +2 -2
  22. package/docs/readEnvFile.mjs.html +132 -0
  23. package/docs/resolveProviders.mjs.html +54 -6
  24. package/docs/wkf_callAiWithFallback.mjs.html +7 -22
  25. package/docs/wkf_createFileStore.mjs.html +175 -0
  26. package/docs/wkf_createUsageCounter.mjs.html +213 -0
  27. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  28. package/docs/wkf_noSideEffectPrefix.mjs.html +121 -0
  29. package/docs/wkf_runFanout.mjs.html +2 -2
  30. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  31. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  32. package/docs/wkf_salvageTruncatedArray.mjs.html +167 -0
  33. package/package.json +2 -2
  34. package/src/WDispatchAi.mjs +13 -1
  35. package/src/budgetFor.mjs +52 -0
  36. package/src/providers.mjs +4 -3
  37. package/src/readEnvFile.mjs +60 -0
  38. package/src/resolveProviders.mjs +52 -4
  39. package/src/wkf/callAiWithFallback.mjs +5 -20
  40. package/src/wkf/createFileStore.mjs +103 -0
  41. package/src/wkf/createUsageCounter.mjs +141 -0
  42. package/src/wkf/noSideEffectPrefix.mjs +49 -0
  43. package/src/wkf/salvageTruncatedArray.mjs +95 -0
  44. package/test/unit-WDispatchAi.test.mjs +9 -3
  45. package/test/unit-budgetFor.test.mjs +26 -0
  46. package/test/unit-callAiWithFallback.test.mjs +8 -0
  47. package/test/unit-createFileStore.test.mjs +66 -0
  48. package/test/unit-createUsageCounter.test.mjs +76 -0
  49. package/test/unit-readEnvFile.test.mjs +52 -0
  50. package/test/unit-resolveProviders.test.mjs +49 -0
  51. package/test/unit-salvageTruncatedArray.test.mjs +39 -0
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "w-dispatch-ai",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "main": "dist/w-dispatch-ai.umd.js",
5
5
  "dependencies": {
6
- "wsemi": "^1.8.75"
6
+ "wsemi": "^1.8.76"
7
7
  },
8
8
  "devDependencies": {
9
9
  "w-package-tools": "^1.1.12"
@@ -10,6 +10,12 @@ import dispatchAntigravity from './dispatchAntigravity.mjs'
10
10
  import dispatchApiOpenaiCompat from './dispatchApiOpenaiCompat.mjs'
11
11
  import providers from './providers.mjs'
12
12
  import resolveProviders from './resolveProviders.mjs'
13
+ import readEnvFile from './readEnvFile.mjs'
14
+ import budgetFor from './budgetFor.mjs'
15
+ import createFileStore from './wkf/createFileStore.mjs'
16
+ import createUsageCounter from './wkf/createUsageCounter.mjs'
17
+ import salvageTruncatedArray from './wkf/salvageTruncatedArray.mjs'
18
+ import NO_SIDE_EFFECT from './wkf/noSideEffectPrefix.mjs'
13
19
 
14
20
 
15
21
  // WDispatchAi.mjs — AI供應商分派層
@@ -27,7 +33,7 @@ let KINDS = keys(adapters)
27
33
  /**
28
34
  * AI供應商分派
29
35
  *
30
- * @returns {Object} 回傳物件,其內含KINDS(可用供應商種類字串陣列),dispatchAiWkf之工作流工廠函數,dispatchAi、dispatchAiFallback、dispatchOpencode、dispatchClaude、dispatchCodex、dispatchAntigravity、dispatchApiOpenaiCompat之async函數,以及providers(預設providers定義檔)與resolveProviders(envVar展開器)
36
+ * @returns {Object} 回傳物件,其內含KINDS(可用供應商種類字串陣列),dispatchAiWkf之工作流工廠函數,dispatchAi、dispatchAiFallback、dispatchOpencode、dispatchClaude、dispatchCodex、dispatchAntigravity、dispatchApiOpenaiCompat之async函數,providers(預設providers定義檔)與resolveProviders(envVar展開器),以及readEnvFile(讀.env不污染process.env)、budgetFor(遞補鏈時間預算推導)、createFileStore(store檔案持久化)、createUsageCounter(逐日用量計帳)、salvageTruncatedArray(截斷陣列搶救)、NO_SIDE_EFFECT(防副作用prompt前綴)等工具
31
37
  * @example
32
38
  *
33
39
  * 詳見dispatchAi、dispatchAiFallback、dispatchAiWkf、dispatchOpencode、dispatchClaude、dispatchCodex、dispatchAntigravity、dispatchApiOpenaiCompat、resolveProviders範例
@@ -35,6 +41,7 @@ let KINDS = keys(adapters)
35
41
  */
36
42
  let WDispatchAi = {
37
43
  KINDS,
44
+ NO_SIDE_EFFECT,
38
45
  dispatchAi,
39
46
  dispatchAiFallback,
40
47
  dispatchAiWkf,
@@ -45,6 +52,11 @@ let WDispatchAi = {
45
52
  dispatchApiOpenaiCompat,
46
53
  providers,
47
54
  resolveProviders,
55
+ readEnvFile,
56
+ budgetFor,
57
+ createFileStore,
58
+ createUsageCounter,
59
+ salvageTruncatedArray,
48
60
  }
49
61
 
50
62
 
@@ -0,0 +1,52 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isarr from 'wsemi/src/isarr.mjs'
3
+ import castPintOr from './castPintOr.mjs'
4
+ import dfTimeoutMs from './dfTimeoutMs.mjs'
5
+
6
+
7
+ // budgetFor.mjs — 由供應商鏈推導走滿全鏈之時間預算
8
+ //
9
+ // 【為何屬本套件】「逾時型失敗每組只燒一次timeout即跳組, 故走滿全鏈的上限=各組
10
+ // timeout相加」是dispatchAiFallback失敗分流模型的直接推論。消費端手算此值,
11
+ // 會在改動fallback鏈時忘記同步而再度budget exhausted(使用端殷鑑: 2026-08-14
12
+ // revise名額之預算未跟上鏈變動而失敗); 或於設定檔維護一大段人工反推註解。
13
+ //
14
+ // 【與外部排程上限的關係】本函數給的是「保證能遞補到鏈尾」的值; 呼叫端之外層
15
+ // 常另有硬上限(如Windows排程ExecutionTimeLimit), 兩者取小者交budgetMs即可——
16
+ // 截短的取捨(可能走不完全鏈)由呼叫端自行決定。
17
+
18
+
19
+ /**
20
+ * 計算遞補鏈走滿所需之時間預算(=各條目timeoutMs之總和, 未帶者以套件統一預設計)
21
+ *
22
+ * 特點:
23
+ * 條目timeoutMs取正整數者計入,未帶或無效者以dfTimeoutMs(全套件統一預設300000)計——
24
+ * 與dispatchAiFallback執行時之實際取值一致,故總和即為走滿全鏈之上限;
25
+ * 輸入非陣列回傳0
26
+ *
27
+ * @param {Array} providers 輸入供應商條目物件陣列(dispatchAiFallback之providers)
28
+ * @returns {Number} 回傳時間預算毫秒整數
29
+ * @example
30
+ *
31
+ * import budgetFor from './src/budgetFor.mjs'
32
+ *
33
+ * console.log(budgetFor([{ timeoutMs: 180000 }, { timeoutMs: 240000 }]))
34
+ * // => 420000
35
+ *
36
+ * console.log(budgetFor([{ timeoutMs: 180000 }, {}])) //未帶者以套件統一預設300000計
37
+ * // => 480000
38
+ *
39
+ */
40
+ function budgetFor(providers) {
41
+ if (!isarr(providers)) {
42
+ return 0
43
+ }
44
+ let n = 0
45
+ for (let p of providers) {
46
+ n += castPintOr(get(p, 'timeoutMs', null), dfTimeoutMs)
47
+ }
48
+ return n
49
+ }
50
+
51
+
52
+ export default budgetFor
package/src/providers.mjs CHANGED
@@ -12,9 +12,10 @@
12
12
  // 【使用方式】
13
13
  // import providers from 'w-dispatch-ai/src/providers.mjs'
14
14
  // import resolveProviders from 'w-dispatch-ai/src/resolveProviders.mjs'
15
- // process.loadEnvFile('./.env') //OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把
16
- // let { providers: ps, table, skipped } = resolveProviders(providers) //全取
17
- // let r2 = resolveProviders(providers, { pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] }) //自選, pick順序即遞補優先序
15
+ // import readEnvFile from 'w-dispatch-ai/src/readEnvFile.mjs'
16
+ // let env = readEnvFile('./.env') //OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把; 不污染process.env
17
+ // let { providers: ps, table, skipped } = resolveProviders(providers, { env }) //全取
18
+ // let r2 = resolveProviders(providers, { env, pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] }) //自選, pick順序即遞補優先序
18
19
  //
19
20
  // 【timeout規劃】各條目刻意不帶timeoutMs, 由上層依任務型態統一給定、條目僅於特例覆寫:
20
21
  // 簡單任務(秒級~分鐘級): 沿用套件統一預設即可(全轉接器一律300000=5分鐘, 見dfTimeoutMs.mjs)。
@@ -0,0 +1,60 @@
1
+ import fs from 'fs'
2
+
3
+
4
+ // readEnvFile.mjs — 讀.env為金鑰來源物件(不污染process.env)
5
+ //
6
+ // 【為何需要】resolveProviders之opt.env本為「不用process.env」而設計——多專案並行時
7
+ // 污染全域環境變數會互相覆蓋金鑰——但「env物件從哪來」套件原無答案, README舊教法
8
+ // process.loadEnvFile('./.env')反而把金鑰塞進process.env, 與該設計目的自相矛盾。
9
+ // 兩個消費端專案已各寫一份解析器且行為不一致(2026-08-18使用端調研), 收斂於此。
10
+ //
11
+ // 【為何讀不到回空物件而非拋錯】金鑰缺失由resolveProviders之skipped機制回報
12
+ // (缺金鑰停用該條目、不中斷), 此處拋錯反而讓「部分供應商無金鑰」升級成整體啟動失敗;
13
+ // 要fail-loud的呼叫端自行檢查回傳(如Object.keys(env).length)。
14
+ //
15
+ // 【只解析KEY=value最簡格式】不引入dotenv: 金鑰為單行純文字, 僅剝除成對之首尾引號,
16
+ // 不做跳脫與多行處理——加解析規則只會製造與其他.env工具的行為差異。
17
+
18
+
19
+ /**
20
+ * 讀取.env檔為鍵值物件(金鑰來源, 不寫入process.env)
21
+ *
22
+ * 特點:
23
+ * 只解析`KEY=value`形式(KEY限英數底線),忽略空行、註解與無效行;
24
+ * value修剪空白並剝除成對之首尾引號(單雙引號皆可);
25
+ * 檔案不存在或不可讀一律回空物件不throw(金鑰缺失交由resolveProviders之skipped回報)
26
+ *
27
+ * @param {String} file 輸入.env檔案路徑字串
28
+ * @returns {Object} 回傳鍵值物件(變數名 → 字串值),讀取失敗回空物件
29
+ * @example
30
+ *
31
+ * import readEnvFile from './src/readEnvFile.mjs'
32
+ * import resolveProviders from './src/resolveProviders.mjs'
33
+ * import providersAll from './src/providers.mjs'
34
+ *
35
+ * //金鑰放.env(變數值以逗號分隔多把), 讀成物件交resolveProviders, 不污染process.env
36
+ * let env = readEnvFile('./.env')
37
+ * let { providers, skipped } = resolveProviders(providersAll, { env, pick: ['agnes:agnes-2.5-flash'] })
38
+ *
39
+ */
40
+ function readEnvFile(file) {
41
+ let out = {}
42
+ let text = ''
43
+ try {
44
+ text = fs.readFileSync(file, 'utf8')
45
+ }
46
+ catch (e) {
47
+ return out //檔案不存在或不可讀, 回空由skipped機制回報
48
+ }
49
+ for (let line of text.split(/\r?\n/)) {
50
+ let m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*)$/)
51
+ if (!m) {
52
+ continue
53
+ }
54
+ out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2')
55
+ }
56
+ return out
57
+ }
58
+
59
+
60
+ export default readEnvFile
@@ -15,6 +15,14 @@ import isearr from 'wsemi/src/isearr.mjs'
15
15
  //
16
16
  // 【自選】opt.pick給id陣列即可只取用部分條目, 且依pick之順序回傳
17
17
  // (順序即dispatchAiFallback之優先序); 查無之id列入missing回報。
18
+ //
19
+ // 【後處理單一入口: opt.exes與opt.patch】消費端常需於展開後對條目做兩類覆寫:
20
+ // ①逐kind注入CLI執行檔絕對路徑(Windows排程於session 0執行時PATH可能不含npm
21
+ // 全域目錄, 靠指令名會ENOENT); ②逐id覆寫任意欄位(如各家實測耗時相差近10倍,
22
+ // timeoutMs須逐條指定)。此前只能於呼叫端自行map, 而陣列版(providers)與表格版
23
+ // (table)若各自後處理, 極易只套用其一——使用端殷鑑(2026-08-14): 逐條timeout
24
+ // 只加在陣列版, 工作流走表格版全部落回全域預設而不自知。後處理收進本函數,
25
+ // 兩種輸出同源產出, 結構性杜絕單邊套用。
18
26
 
19
27
 
20
28
  /**
@@ -25,30 +33,46 @@ import isearr from 'wsemi/src/isearr.mjs'
25
33
  * 缺對應環境變數(或值為空)之條目停用並列入skipped,不中斷不throw;
26
34
  * 無envVar之條目(訂閱登入態CLI)原樣通過;條目已自帶有效keys者以keys為準;
27
35
  * opt.pick可依id自選子集並以pick順序回傳(順序即遞補優先序);
36
+ * opt.exes依kind注入CLI執行檔路徑、opt.patch依id淺合併覆寫欄位——皆於展開後施作,
37
+ * providers與table同源產出故必然一致;
28
38
  * 輸入陣列與條目皆不被改動(輸出為淺拷貝)
29
39
  *
30
40
  * @param {Array} providers 輸入providers條目物件陣列(如providers.mjs之預設匯出)
31
41
  * @param {Object} [opt={}] 輸入設定物件,預設{}
32
- * @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env
42
+ * @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env。建議以readEnvFile('./.env')讀成物件傳入,不污染process.env
33
43
  * @param {Array} [opt.pick=null] 輸入自選id字串陣列,依此順序回傳對應條目,查無之id列入missing,預設null代表全取(依原順序)
44
+ * @param {Object} [opt.exes=null] 輸入kind對執行檔絕對路徑之物件(如{claude:'C:/.../claude.exe'}),命中kind之條目補上exe欄位(條目已自帶exe者不覆寫——條目層設定優先),預設null代表不注入
45
+ * @param {Object} [opt.patch=null] 輸入id對部分欄位之物件(如{'claude:sonnet':{timeoutMs:360000}}),命中id之條目淺合併覆寫,於exes之後施作故可覆寫exe,預設null代表不覆寫
34
46
  * @returns {Object} 回傳物件,內含providers(可直接餵dispatchAiFallback之條目陣列)、table(id對條目之物件,可直接餵dispatchAiWkf)、skipped(缺環境變數而停用之{id,envVar}陣列)、missing(pick查無之id字串陣列)
35
47
  * @example
36
48
  *
37
49
  * import resolveProviders from './src/resolveProviders.mjs'
50
+ * import readEnvFile from './src/readEnvFile.mjs'
38
51
  * import providersAll from './src/providers.mjs'
39
52
  *
40
- * process.loadEnvFile('./.env') //金鑰放.env, 變數值以逗號分隔多把
53
+ * //金鑰放.env(變數值以逗號分隔多把), 以readEnvFile讀成物件, 不污染process.env
54
+ * let env = readEnvFile('./.env')
41
55
  *
42
56
  * //全取: envVar展開為keys, 缺環境變數者列入skipped
43
- * let { providers, table, skipped } = resolveProviders(providersAll)
57
+ * let { providers, table, skipped } = resolveProviders(providersAll, { env })
44
58
  * console.log(providers.length, skipped)
45
59
  * // => 10 []
46
60
  *
47
61
  * //自選: 依pick順序回傳(順序即遞補優先序), 可直接餵dispatchAiFallback
48
- * let r2 = resolveProviders(providersAll, { pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] })
62
+ * let r2 = resolveProviders(providersAll, { env, pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] })
49
63
  * console.log(r2.providers.map((p) => p.id))
50
64
  * // => [ 'agnes:agnes-2.5-flash', 'claude:sonnet' ]
51
65
  *
66
+ * //後處理: exes逐kind注入執行檔、patch逐id覆寫欄位, 陣列與table同步生效
67
+ * let r3 = resolveProviders(providersAll, {
68
+ * env,
69
+ * pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'],
70
+ * exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
71
+ * patch: { 'claude:sonnet': { timeoutMs: 360000 } },
72
+ * })
73
+ * console.log(r3.table['claude:sonnet'].exe, r3.table['claude:sonnet'].timeoutMs)
74
+ * // => 'C:/Users/x/.local/bin/claude.exe' 360000
75
+ *
52
76
  * //table可直接餵dispatchAiWkf之providers定義表
53
77
  * //let wkf = dispatchAiWkf({ providers: r2.table, defaults: { timeoutMs: 1200000 } })
54
78
  *
@@ -104,6 +128,30 @@ function resolveProviders(providers, opt = {}) {
104
128
  out.push({ ...omit(entry, ['envVar']), keys })
105
129
  }
106
130
 
131
+ //後處理: exes(逐kind)與patch(逐id), 於table組建「之前」施作——
132
+ //providers與table由同一份out產出, 兩種輸出必然一致(檔頭之單邊套用殷鑑)
133
+ let exes = get(opt, 'exes', null)
134
+ let patch = get(opt, 'patch', null)
135
+ if (isobj(exes) || isobj(patch)) {
136
+ out = out.map((entry) => {
137
+ let e = entry
138
+
139
+ //exes: 條目已自帶exe者不覆寫(條目層設定優先於全域注入)
140
+ let exe = isobj(exes) ? get(exes, get(e, 'kind', ''), null) : null
141
+ if (isestr(exe) && !isestr(get(e, 'exe', null))) {
142
+ e = { ...e, exe }
143
+ }
144
+
145
+ //patch: 依id淺合併, 於exes之後施作故可覆寫exe
146
+ let pt = isobj(patch) ? get(patch, get(e, 'id', ''), null) : null
147
+ if (isobj(pt)) {
148
+ e = { ...e, ...pt }
149
+ }
150
+
151
+ return e
152
+ })
153
+ }
154
+
107
155
  //table, id → 條目, 可直接作dispatchAiWkf之providers定義表
108
156
  let table = {}
109
157
  for (let entry of out) {
@@ -7,6 +7,7 @@ import isobj from 'wsemi/src/isobj.mjs'
7
7
  import dispatchAiFallback from '../dispatchAiFallback.mjs'
8
8
  import dfTimeoutMs from '../dfTimeoutMs.mjs'
9
9
  import extractJsonLoose from './extractJsonLoose.mjs'
10
+ import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
10
11
 
11
12
 
12
13
  // callAiWithFallback.mjs — 工作流的最小呼叫單元: 一個「AI名額」=主模型+自帶遞補鏈
@@ -24,15 +25,9 @@ import extractJsonLoose from './extractJsonLoose.mjs'
24
25
  //
25
26
  // 【防寫檔前綴】agentic CLI對cwd隔離免疫(會自行解析專案根目錄寫檔),
26
27
  // 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
27
- // 殷鑑: 2026-08-10執行任務歷史.md遭AI覆寫、評比腳本繞過前綴又產生根目錄孤兒檔。
28
- //
29
- // 措辭須豁免「唯讀查閱」(2026-08-13 A/B實測): 各CLI取得檔案內容的途徑不同——
30
- // opencode與claude有獨立的read/grep工具, 不受「禁止執行指令」約束;
31
- // 但codex讀檔即是執行shell指令, 舊措辭「禁止執行任何指令」對它等同「禁止讀檔」,
32
- // 凡需讀專案的任務會得到一句「請貼上檔案內容」而非成果, 且該拒答是合法字串,
33
- // 會通過驗證被遞補層當成「成功」, 整條fallback鏈就此停住——兜底地位被靜默廢掉。
34
- // 實測: 舊措辭下codex回「無法讀取該檔案」, 改為下列措辭後正常讀檔作答(11.1s),
35
- // 防副作用(建檔/改檔/刪檔/改動系統狀態)之本旨不變。
28
+ // 措辭已移至獨立模組wkf/noSideEffectPrefix.mjs(單一來源)——不經本層、
29
+ // 直接呼叫dispatchAiFallback的呼叫端亦應引用同一份, 措辭修正時全體同步;
30
+ // 措辭本身的殷鑑(孤兒檔、codex唯讀豁免之A/B實測)詳見該檔檔頭。
36
31
 
37
32
 
38
33
  //本層自用之設定鍵, 其餘鍵(timeoutMs/budgetMs/minAttemptMs/maxRetries/cwd/store/onEvent/
@@ -43,16 +38,6 @@ import extractJsonLoose from './extractJsonLoose.mjs'
43
38
  let OWN_KEYS = ['providers', 'spec', 'check', 'parse', 'rawText', 'promptPrefix', 'meta']
44
39
 
45
40
 
46
- //預設防寫檔前綴(禁副作用, 但豁免唯讀查閱——codex以shell讀檔, 一律禁指令等同禁讀檔)
47
- let NO_SIDE_EFFECT = [
48
- '【執行約束】你只需把結果輸出在回覆內容中。',
49
- '禁止建立、修改或刪除任何檔案,也不要執行任何會改動磁碟或系統狀態的指令——',
50
- '呼叫端只讀取你的回覆文字,任何寫入磁碟的動作都不會被採用,只會製造無人讀取的垃圾檔。',
51
- '唯讀查閱(讀取檔案、搜尋內容、列出目錄)不在此限,需要時請照常使用。',
52
- '', '',
53
- ].join('\n')
54
-
55
-
56
41
  /**
57
42
  * 依providers定義表把「名稱規格」展開成dispatchAiFallback的providers陣列
58
43
  *
@@ -105,7 +90,7 @@ function buildChain(providers, spec) {
105
90
  * @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
106
91
  * @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
107
92
  * @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
108
- * @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束,傳''關閉
93
+ * @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束(見wkf/noSideEffectPrefix.mjs),傳''關閉
109
94
  * @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,全套件統一預設300000
110
95
  * @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
111
96
  * @param {Number} [opt.minAttemptMs=20000] 輸入搭配budgetMs之開工門檻毫秒正整數,剩餘預算低於此值即不再開工,預設20000
@@ -0,0 +1,103 @@
1
+ import path from 'path'
2
+ import get from 'lodash-es/get.js'
3
+ import isobj from 'wsemi/src/isobj.mjs'
4
+ import isestr from 'wsemi/src/isestr.mjs'
5
+ import isfun from 'wsemi/src/isfun.mjs'
6
+ import isearr from 'wsemi/src/isearr.mjs'
7
+ import fsReadJson from 'wsemi/src/fsReadJson.mjs'
8
+ import fsWriteJson from 'wsemi/src/fsWriteJson.mjs'
9
+
10
+
11
+ // createFileStore.mjs — dispatchAiFallback之store的檔案持久化實作
12
+ //
13
+ // 【為何需要】dispatchAiFallback只定義store契約{get,set}, 預設用行程內記憶體——
14
+ // 排程任務每次執行都是獨立行程, 記憶體狀態每次歸零: 金鑰輪替游標永遠從第一把開始,
15
+ // 跨次輪替形同失效、額度無法均攤; 供應商冷卻亦然(上一輪已測得失效者, 本輪照樣先踩)。
16
+ // 每個跨行程消費端都得重寫這份檔案實作, 收斂於此。
17
+ //
18
+ // 【採排除式而非白名單式——本設計最重要的一條】本函數只負責「把套件給的state
19
+ // 原封不動存下、再原封不動拿回來」, 不理解state內有哪些欄位。使用端殷鑑:
20
+ // 曾以白名單只取cursors, 於套件1.0.7新增cooling(供應商冷卻)時被靜默丟棄,
21
+ // cooldownMs形同未啟用——且不會有任何錯誤訊息, 只會「功能沒作用」。
22
+ // 排除式(僅剔除本層自用欄位)使日後state再擴充欄位即自動相容。
23
+ //
24
+ // 【併發假定】與dispatchAiFallback之store文件一致: 假定單行程序列調用,
25
+ // 並行執行同一任務請自行加鎖, 否則兩行程互相覆蓋游標。
26
+ //
27
+ // 【本工廠為同步函數會throw】file與dir皆缺屬設定錯誤, 應於組裝期即失敗(fail fast),
28
+ // 與dispatchAiWkf工廠同一約定; 執行期之get/set則不throw(讀壞回空、寫失敗靜默)。
29
+
30
+
31
+ /**
32
+ * 建立dispatchAiFallback之store的檔案持久化(游標與冷卻跨行程有效)
33
+ *
34
+ * 特點:
35
+ * 採排除式passthrough——state原封存還,僅剔除本層自用欄位(預設只有at:人讀的最後更新時間戳),
36
+ * 日後套件擴充state欄位(如cooling)即自動相容,不需改本函數;
37
+ * 檔案不存在或非法JSON時get回空物件(套件視為全新狀態);
38
+ * stamp可注入時間戳函數(排程環境建議注入時區錨定者),寫入時補進at欄位供人工debug
39
+ *
40
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
41
+ * @param {String} [opt.file=null] 輸入狀態檔完整路徑字串,與dir/name二擇一
42
+ * @param {String} [opt.dir=null] 輸入狀態目錄字串,與name組成檔案路徑
43
+ * @param {String} [opt.name='ai-cursor.json'] 輸入狀態檔名字串,預設'ai-cursor.json'
44
+ * @param {Array} [opt.ownKeys=['at']] 輸入本層自用、不屬於套件狀態之欄位名字串陣列,get時剔除set時補回,預設['at']
45
+ * @param {Function} [opt.stamp=ISO時間戳] 輸入時間戳函數()=>String,寫入at欄位用,預設回傳new Date().toISOString()
46
+ * @returns {Object} 回傳store物件,內含get、set(可直接餵dispatchAiFallback之store)與file(狀態檔路徑)
47
+ * @example
48
+ *
49
+ * import createFileStore from './src/wkf/createFileStore.mjs'
50
+ * import dispatchAiFallback from './src/dispatchAiFallback.mjs'
51
+ *
52
+ * let store = createFileStore({ dir: './state' })
53
+ * //let r = await dispatchAiFallback(prompt, { providers, store, cooldownMs: 3600000 })
54
+ *
55
+ */
56
+ function createFileStore(opt = {}) {
57
+ let file = get(opt, 'file', null)
58
+ if (!isestr(file)) {
59
+ let dir = get(opt, 'dir', null)
60
+ if (!isestr(dir)) {
61
+ throw new Error('createFileStore: file or dir is required')
62
+ }
63
+ let name = get(opt, 'name', null)
64
+ file = path.join(dir, isestr(name) ? name : 'ai-cursor.json')
65
+ }
66
+
67
+ let ownKeys = get(opt, 'ownKeys', null)
68
+ if (!isearr(ownKeys)) {
69
+ ownKeys = ['at']
70
+ }
71
+
72
+ let stamp = get(opt, 'stamp', null)
73
+ if (!isfun(stamp)) {
74
+ stamp = () => new Date().toISOString()
75
+ }
76
+
77
+ return {
78
+
79
+ file,
80
+
81
+ get: () => {
82
+ let r = fsReadJson(file)
83
+ let j = (get(r, 'error', undefined) !== undefined) ? null : get(r, 'success', null)
84
+ if (!isobj(j)) {
85
+ return {}
86
+ }
87
+ let state = { ...j }
88
+ for (let k of ownKeys) {
89
+ delete state[k]
90
+ }
91
+ return state
92
+ },
93
+
94
+ set: (state) => {
95
+ let out = { ...(isobj(state) ? state : {}), at: stamp() }
96
+ fsWriteJson(file, out, { useFormat: true })
97
+ },
98
+
99
+ }
100
+ }
101
+
102
+
103
+ export default createFileStore
@@ -0,0 +1,141 @@
1
+ import path from 'path'
2
+ import get from 'lodash-es/get.js'
3
+ import isobj from 'wsemi/src/isobj.mjs'
4
+ import isestr from 'wsemi/src/isestr.mjs'
5
+ import isfun from 'wsemi/src/isfun.mjs'
6
+ import ispint from 'wsemi/src/ispint.mjs'
7
+ import fsReadJson from 'wsemi/src/fsReadJson.mjs'
8
+ import fsWriteJson from 'wsemi/src/fsWriteJson.mjs'
9
+
10
+
11
+ // createUsageCounter.mjs — 逐日、逐鍵之AI用量計帳(純觀測)
12
+ //
13
+ // 【純觀測, 絕不據以節流或阻擋——本檔最重要的警語】用量門檻是臆測值: 服務端額度視窗
14
+ // 形態多樣(逐日/逐時/5小時滾動/以token計而非以次數計), 呼叫端量得到的只有次數。
15
+ // 以臆測門檻擋自己的呼叫等同拿猜測當事實: 太保守則白白不用已有額度, 太寬鬆則毫無作用。
16
+ // 正確作法是「打下去、失敗了換下一家」(dispatchAiFallback之職責), 本計數器只提供
17
+ // 事後回答「那天各鍵各打了幾次」的本機證據——真要查「某家是不是被打爆了」時,
18
+ // 這是唯一的本機依據。
19
+ //
20
+ // 【於try事件記帳而非事後統計tried】即使行程被外部排程之時限中途砍掉,
21
+ // 已發出的請求仍會留下紀錄; 事後統計則會漏掉被砍那一輪的全部嘗試。
22
+ // 兩個消費端專案已各自寫過此接線(且各寫兩處), 故本函數直接提供onEvent。
23
+ //
24
+ // 【keyOf決定計帳粒度】預設記到金鑰(keyId如'agnes:agnes-2.5-flash#0'),
25
+ // 要記到條目改傳(ev)=>ev.providerId即可。
26
+ //
27
+ // 【本工廠為同步函數會throw】file與dir皆缺屬設定錯誤, 應於組裝期即失敗(fail fast),
28
+ // 與dispatchAiWkf工廠同一約定; 執行期之記帳與查詢則不throw。
29
+
30
+
31
+ /**
32
+ * 建立逐日、逐鍵之用量計數器(純觀測, 不參與任何判斷)
33
+ *
34
+ * 特點:
35
+ * onEvent可直接掛進dispatchAiFallback/dispatchAiWkf,於type為'try'時依keyOf取鍵累加——
36
+ * 嘗試時即記帳,行程被外部時限中途砍掉已發出的請求仍有紀錄;
37
+ * 逐日分桶並僅保留最近keepDays天,避免檔案無限成長;
38
+ * getDate可注入時區錨定之日期函數——預設隨系統時區,排程session之系統時區可能為UTC+0
39
+ * 而使日界錯8小時,排程環境務必注入
40
+ *
41
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
42
+ * @param {String} [opt.file=null] 輸入用量檔完整路徑字串,與dir/name二擇一
43
+ * @param {String} [opt.dir=null] 輸入狀態目錄字串
44
+ * @param {String} [opt.name='ai-usage.json'] 輸入用量檔名字串,預設'ai-usage.json'
45
+ * @param {Function} [opt.getDate=系統日期] 輸入日期函數()=>'YYYY-MM-DD',預設隨系統時區(排程環境建議注入時區錨定者)
46
+ * @param {Number} [opt.keepDays=14] 輸入保留天數正整數,預設14
47
+ * @param {Function} [opt.keyOf=(ev)=>ev.keyId||ev.providerId] 輸入計帳鍵函數(ev)=>String,決定粒度(金鑰或條目),預設優先keyId
48
+ * @returns {Object} 回傳計數器物件,內含onEvent(可直接餵dispatch之onEvent)、bump(手動累加)、today(今日統計)與file(用量檔路徑)
49
+ * @example
50
+ *
51
+ * import createUsageCounter from './src/wkf/createUsageCounter.mjs'
52
+ *
53
+ * let usage = createUsageCounter({ dir: './state' })
54
+ * //let r = await dispatchAiFallback(prompt, { providers, onEvent: usage.onEvent })
55
+ * //console.log(usage.today())
56
+ * // => { today: '2026-08-18', byKey: { 'agnes:agnes-2.5-flash#0': 3 }, total: 3 }
57
+ *
58
+ */
59
+ function createUsageCounter(opt = {}) {
60
+ let file = get(opt, 'file', null)
61
+ if (!isestr(file)) {
62
+ let dir = get(opt, 'dir', null)
63
+ if (!isestr(dir)) {
64
+ throw new Error('createUsageCounter: file or dir is required')
65
+ }
66
+ let name = get(opt, 'name', null)
67
+ file = path.join(dir, isestr(name) ? name : 'ai-usage.json')
68
+ }
69
+
70
+ let getDate = get(opt, 'getDate', null)
71
+ if (!isfun(getDate)) {
72
+ getDate = () => {
73
+ let d = new Date()
74
+ let p2 = (n) => String(n).padStart(2, '0')
75
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`
76
+ }
77
+ }
78
+
79
+ let keepDays = get(opt, 'keepDays', null)
80
+ if (!ispint(keepDays)) {
81
+ keepDays = 14
82
+ }
83
+
84
+ let keyOf = get(opt, 'keyOf', null)
85
+ if (!isfun(keyOf)) {
86
+ keyOf = (ev) => get(ev, 'keyId', null) || get(ev, 'providerId', '')
87
+ }
88
+
89
+ let readAll = () => {
90
+ let r = fsReadJson(file)
91
+ let j = (get(r, 'error', undefined) !== undefined) ? null : get(r, 'success', null)
92
+ return isobj(j) ? j : {}
93
+ }
94
+
95
+ let bump = (key, n = 1) => {
96
+ if (!isestr(key)) {
97
+ return
98
+ }
99
+ let raw = readAll()
100
+ let today = getDate()
101
+ let cur = isobj(raw[today]) ? raw[today] : {}
102
+ cur[key] = (Number(cur[key]) || 0) + n
103
+ raw[today] = cur
104
+
105
+ //只留最近keepDays天
106
+ let days = Object.keys(raw).sort().slice(-keepDays)
107
+ let trimmed = {}
108
+ for (let d of days) {
109
+ trimmed[d] = raw[d]
110
+ }
111
+ fsWriteJson(file, trimmed, { useFormat: true })
112
+ }
113
+
114
+ return {
115
+
116
+ file,
117
+
118
+ //可直接餵dispatch之onEvent: 於type為'try'時依keyOf記帳。要同時掛自有回調, 於自有onEvent內轉呼叫本函數即可
119
+ onEvent: (ev) => {
120
+ if (get(ev, 'type', null) === 'try') {
121
+ bump(keyOf(ev))
122
+ }
123
+ },
124
+
125
+ //手動累加(不經事件之呼叫路徑用)
126
+ bump,
127
+
128
+ //今日各鍵用量統計
129
+ today: () => {
130
+ let raw = readAll()
131
+ let today = getDate()
132
+ let byKey = isobj(raw[today]) ? raw[today] : {}
133
+ let total = Object.values(byKey).reduce((a, b) => a + (Number(b) || 0), 0)
134
+ return { today, byKey, total }
135
+ },
136
+
137
+ }
138
+ }
139
+
140
+
141
+ export default createUsageCounter
@@ -0,0 +1,49 @@
1
+ // noSideEffectPrefix.mjs — 防副作用prompt前綴(單一來源)
2
+ //
3
+ // 【為何獨立成檔】此措辭原內嵌於callAiWithFallback, 但不經該層、直接呼叫
4
+ // dispatchAiFallback的呼叫端也需要同一約束——實例(2026-08-18使用端調研):
5
+ // 某使用端自帶一份「舊措辭」(一律禁止執行任何指令), 而該措辭的codex禁讀bug
6
+ // 本套件早已修正, 形成「套件修了、消費端沒跟上」的分岔。抽成獨立模組後
7
+ // 所有路徑引用同一來源, 措辭再修正時全體同步。
8
+ //
9
+ // 【為何需要此前綴】agentic CLI自帶檔案讀寫工具且對cwd隔離免疫(會自行解析
10
+ // 專案根目錄以絕對路徑寫檔), 實測會「順手」把結果另存孤兒檔,
11
+ // 甚至可能覆蓋呼叫端資料本體(殷鑑: 2026-08-10執行任務歷史.md遭AI覆寫、
12
+ // 評比腳本繞過前綴又產生根目錄孤兒檔); 各家CLI的權限機制彼此不同(codex有sandbox、
13
+ // claude有--disallowedTools、opencode有config.permission),
14
+ // 唯有prompt指示跨provider一致生效。CLI層限制可作第二道防線並存
15
+ // (內建providers.mjs各CLI條目即自帶機械防寫, 對照表見README)。
16
+ //
17
+ // 【措辭須豁免「唯讀查閱」(2026-08-13 A/B實測)】各CLI取得檔案內容的途徑不同——
18
+ // opencode與claude有獨立的read/grep工具, 不受「禁止執行指令」約束;
19
+ // 但codex讀檔即是執行shell指令, 舊措辭「禁止執行任何指令」對它等同「禁止讀檔」,
20
+ // 凡需讀專案的任務會得到一句「請貼上檔案內容」而非成果, 且該拒答是合法字串,
21
+ // 會通過驗證被遞補層當成「成功」, 整條fallback鏈就此停住——兜底地位被靜默廢掉。
22
+ // 實測: 舊措辭下codex回「無法讀取該檔案」, 現行措辭正常讀檔作答(11.1s),
23
+ // 防副作用(建檔/改檔/刪檔/改動系統狀態)之本旨不變。
24
+
25
+
26
+ /**
27
+ * 防副作用prompt前綴:禁止寫入類副作用、豁免唯讀查閱
28
+ *
29
+ * 用法:`NO_SIDE_EFFECT + prompt`後交dispatchAiFallback;
30
+ * callAiWithFallback預設自動掛上(傳promptPrefix:''關閉),
31
+ * 直接呼叫dispatchAiFallback的呼叫端自行前綴
32
+ *
33
+ * @example
34
+ *
35
+ * import NO_SIDE_EFFECT from './src/wkf/noSideEffectPrefix.mjs'
36
+ *
37
+ * //let r = await dispatchAiFallback(NO_SIDE_EFFECT + prompt, { providers })
38
+ *
39
+ */
40
+ let NO_SIDE_EFFECT = [
41
+ '【執行約束】你只需把結果輸出在回覆內容中。',
42
+ '禁止建立、修改或刪除任何檔案,也不要執行任何會改動磁碟或系統狀態的指令——',
43
+ '呼叫端只讀取你的回覆文字,任何寫入磁碟的動作都不會被採用,只會製造無人讀取的垃圾檔。',
44
+ '唯讀查閱(讀取檔案、搜尋內容、列出目錄)不在此限,需要時請照常使用。',
45
+ '', '',
46
+ ].join('\n')
47
+
48
+
49
+ export default NO_SIDE_EFFECT