dowafu 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -66
- package/README_zh-tw.md +216 -66
- package/dist/adapters/anthropic-messages.js +5 -3
- package/dist/adapters/gemini-native.js +13 -5
- package/dist/adapters/responses.js +5 -1
- package/dist/api-token.js +54 -0
- package/dist/cli-args.js +45 -3
- package/dist/cli.js +680 -101
- package/dist/cost.js +24 -0
- package/dist/daemon.js +209 -0
- package/dist/db.js +219 -0
- package/dist/dispatch-home.js +8 -19
- package/dist/doctor.js +105 -45
- package/dist/gate.js +9 -4
- package/dist/job.js +97 -0
- package/dist/liveness.js +39 -0
- package/dist/mcp/http.js +178 -0
- package/dist/mcp/protocol.js +308 -0
- package/dist/mcp/stdio.js +36 -0
- package/dist/messages.js +338 -52
- package/dist/output.js +91 -104
- package/dist/progress.js +36 -0
- package/dist/provider-key.js +84 -0
- package/dist/providers-store.js +165 -0
- package/dist/providers.js +18 -6
- package/dist/report.js +38 -14
- package/dist/runner.js +18 -0
- package/dist/ticket-store.js +195 -0
- package/dist/ticket.js +22 -1
- package/dist/validate.js +58 -21
- package/package.json +1 -2
- package/providers.json +12 -10
package/README_zh-tw.md
CHANGED
|
@@ -4,20 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
**一支唯讀審查用的 spoke harness**:把文件段落派給外部模型,限定它讀什麼、記下它做了什麼、機械稽核它的回報格式。
|
|
6
6
|
|
|
7
|
-
講具體一點——你(與AI agent協同)寫一份工單,`dowafu` 呼叫各家 provider 的 API;每個審查者(*spoke
|
|
7
|
+
講具體一點——你(與AI agent協同)寫一份工單,`dowafu` 呼叫各家 provider 的 API;每個審查者(*spoke*)**只讀你列進白名單的檔案**,回傳帶依據的觀察,做過的事全部記錄下來供你查核。
|
|
8
8
|
|
|
9
9
|
相容於各種支援標準SKILL的AI Agent程式與VS Code擴充套件包含以下,但還有更多:
|
|
10
10
|
|
|
11
11
|
- Claude Code
|
|
12
|
-
- VS Code
|
|
12
|
+
- VS Code Copilot(內建)
|
|
13
|
+
- Codex(擴充套件)
|
|
14
|
+
- Google Antigravity IDE
|
|
13
15
|
|
|
14
16
|
**spoke 產出的是意見,不是裁決。** 那些意見要怎麼處理,仍然由你決定。
|
|
15
17
|
|
|
16
18
|
> ### 英文與繁體中文都是完整支援的語言。
|
|
17
19
|
>
|
|
18
|
-
>
|
|
20
|
+
> 有兩個設定各自決定不同範圍的語言。
|
|
19
21
|
>
|
|
20
|
-
>
|
|
22
|
+
> `--lang en`/`--lang zh-tw` 決定**一次派工**的語言:審查者收到的 Prompt 與報告範本、
|
|
23
|
+
> 稽核用來檢查報告的範本、dry-run 報告,以及隨結果一起存下來的執行摘要。
|
|
24
|
+
>
|
|
25
|
+
> `DISPATCH_LANG` 決定 **CLI 自己的介面**語言——`--help`、`--doctor`、解析期的錯誤訊息,
|
|
26
|
+
> 以及各個子指令(`key`、`providers`、`token`、`approve`、`serve`)。
|
|
27
|
+
> **`--lang` 管不到這些。** 那些訊息在旗標通過驗證之前就要印出來了;而 `--doctor` 更是
|
|
28
|
+
> 「語言設定本身壞掉時你會拿來查的那個工具」。
|
|
29
|
+
>
|
|
30
|
+
> 兩者都沒有時**預設為英文**;任一方填了無法辨識的值都會直接中止,不會猜。
|
|
31
|
+
> Dry run 會針對每個審查者印出判定的語言,送出前就看得到。
|
|
21
32
|
>
|
|
22
33
|
> 工單的段落標題兩種語言都可以寫,與語言選擇無關——它們是欄位名稱,不是語言開關。見[工單格式](#工單格式)。
|
|
23
34
|
>
|
|
@@ -33,39 +44,32 @@ npm install -g dowafu
|
|
|
33
44
|
|
|
34
45
|
## API Keys
|
|
35
46
|
|
|
36
|
-
API Key
|
|
47
|
+
API Key 存在資料庫裡。請在你自己的終端機設定:
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
```bash
|
|
50
|
+
dowafu key
|
|
51
|
+
```
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
它會列出各家 provider,接收你貼上的 key(**不會回顯**),存起來之後只印出末四碼,並詢問要不要立刻對該家 API 驗證一次。
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
- `dowafu key list`:列出哪幾家已設定——只顯示有沒有,永遠不顯示內容
|
|
56
|
+
- `dowafu key rm <provider>`:移除某一家
|
|
57
|
+
- `dowafu key test <provider>`:對真實 API 驗證一次(**這個呼叫會花錢**,執行前會再問一次)
|
|
43
58
|
|
|
44
|
-
|
|
45
|
-
mkdir -p ~/.config/dowafu
|
|
46
|
-
cat > ~/.config/dowafu/.env <<'EOF'
|
|
47
|
-
DEEPSEEK_API_KEY=
|
|
48
|
-
GEMINI_API_KEY=
|
|
49
|
-
OPENAI_API_KEY=
|
|
50
|
-
ANTHROPIC_API_KEY=
|
|
51
|
-
EOF
|
|
52
|
-
chmod 600 ~/.config/dowafu/.env
|
|
53
|
-
```
|
|
59
|
+
`dowafu key` 需要互動式終端機;stdin 被導向管道時它會直接拒絕,不會把管道內容當成 key 讀進去。
|
|
54
60
|
|
|
55
61
|
只需要為實際要執行 Dispatch 的 Provider 設定 Key 即可。
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
**永遠不會讀取目前工作目錄的 `.env`。**
|
|
63
|
+
**本版不讀取任何 `.env` 檔,也不讀取 `*_API_KEY` 環境變數。** 來源只有一個,因此沒有優先序要推敲,也不會有環境裡殘留的舊值悄悄蓋過你剛設好的 key。從 0.4.x 升上來:把 `~/.config/dowafu/.env` 刪掉,改用 `dowafu key` 重設一次。
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
資料庫位於 `$DISPATCH_HOME/dowafu.db`,預設是 `~/.config/dowafu/dowafu.db`,可透過 `DISPATCH_HOME` 或 `XDG_CONFIG_HOME` 覆寫。**Key 在裡面是明文存放**——跟先前那個檔案一樣,它唯一的保護機制就是檔案權限。
|
|
62
66
|
|
|
63
67
|
## 使用方式
|
|
64
68
|
|
|
65
69
|
```bash
|
|
66
|
-
dowafu <ticket-
|
|
67
|
-
dowafu <ticket-
|
|
68
|
-
dowafu --help
|
|
70
|
+
dowafu <ticket-id> --dry-run # 解析、驗證、估算。不呼叫 API,也不產生成本。
|
|
71
|
+
dowafu <ticket-id> --yes # 執行審查。這個操作會產生成本。
|
|
72
|
+
dowafu --help # 查看所有旗標
|
|
69
73
|
```
|
|
70
74
|
|
|
71
75
|
沒有指定 `--yes` 時,指令會要求使用者確認。
|
|
@@ -74,9 +78,43 @@ dowafu --help # 查看所有旗標
|
|
|
74
78
|
|
|
75
79
|
## 工單
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
工單存在資料庫裡,不落到磁碟。用 CLI 建立:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
dowafu ticket create <ticket-id> <shared-file>
|
|
85
|
+
dowafu ticket add-spoke <ticket-id> <agent> <provider> <model> <body-file> [effort]
|
|
86
|
+
dowafu ticket add-allow <ticket-id> <agent> <repo-relative-path> --repo-root .
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`create` 與 `add-spoke` 的本文參數可以是路徑,也可以是 `-`(從 stdin 讀)——所以用 heredoc
|
|
90
|
+
就能把整張工單寫完,不必先落一個暫存檔:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
dowafu ticket create auth-review - <<'EOF'
|
|
94
|
+
# 前提(不受審)
|
|
95
|
+
- 無
|
|
96
|
+
|
|
97
|
+
# 待審段落
|
|
98
|
+
...
|
|
99
|
+
EOF
|
|
100
|
+
|
|
101
|
+
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-v4-flash - <<'EOF'
|
|
102
|
+
# 具體問題
|
|
103
|
+
1. 這裡描述的權限檢查在併發請求下還成立嗎?
|
|
104
|
+
|
|
105
|
+
# 允許讀取
|
|
106
|
+
- lib/auth-guard.ts
|
|
107
|
+
- prisma/schema.prisma
|
|
108
|
+
EOF
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`add-allow` 會立刻驗證路徑、拒絕 `_docs/`,並把該檔案**當下的內容**存起來——派工時不會再讀一次,
|
|
112
|
+
所以審查者看到的是那一刻的快照。用 `dowafu ticket show <ticket-id>` 檢視 Model、允許清單與估算。
|
|
78
113
|
|
|
79
|
-
|
|
114
|
+
### 段落標題
|
|
115
|
+
|
|
116
|
+
上面本文裡的標題是**字面標記(literal markers)**,Parser 會直接比對——必須完全使用以下兩組
|
|
117
|
+
標題之一。
|
|
80
118
|
|
|
81
119
|
| English | 中文 |
|
|
82
120
|
| --- | --- |
|
|
@@ -85,37 +123,19 @@ dowafu --help # 查看所有旗標
|
|
|
85
123
|
| `# Under review` | `# 待審段落` |
|
|
86
124
|
| `# Premises` | `# 前提(不受審)` |
|
|
87
125
|
|
|
88
|
-
|
|
89
|
-
| --- | --- |
|
|
90
|
-
| `_dispatch.md` | 要執行哪些審查者,以及各自使用哪個 Provider 與 Model |
|
|
91
|
-
| `_shared.md` | 前提與正在審查的段落,以原始內容直接貼入 |
|
|
92
|
-
| `<agent>.md` | 每個審查者一份:包含它的問題,以及允許讀取的檔案 |
|
|
126
|
+
兩組標題都收,而且**與審查者使用的語言無關**——語言由 `--lang`/`DISPATCH_LANG` 決定(見上方)。兩組只是同一批欄位的別名,所以英文工單可以用中文跑,反之亦然。
|
|
93
127
|
|
|
94
|
-
|
|
95
|
-
<!-- _dispatch.md -->
|
|
96
|
-
<!-- format: v1 -->
|
|
97
|
-
# dispatch auth-review
|
|
128
|
+
不支援在同一個審查者本文中混用兩組標題——第一個符合的標題會決定用哪一組欄位名,不是決定語言。
|
|
98
129
|
|
|
99
|
-
|
|
100
|
-
| --- | --- | --- | --- |
|
|
101
|
-
| hole-finder-safety | deepseek | deepseek-v4-flash | |
|
|
102
|
-
| hole-finder-feasibility | openai | gpt-5.6-luna | |
|
|
103
|
-
```
|
|
130
|
+
### 匯入舊的工單目錄
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Questions
|
|
108
|
-
1. Does the permission check described here hold under concurrent requests?
|
|
132
|
+
0.5.0 之前,工單是一個目錄,內含三種檔案——`_dispatch.md`、`_shared.md`,以及每個審查者一份的
|
|
133
|
+
`<agent>.md`。那個形態仍然讀得進來,作為一次性的搬遷:
|
|
109
134
|
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
- prisma/schema.prisma
|
|
135
|
+
```bash
|
|
136
|
+
dowafu ticket import <dir> [ticket-id] --repo-root .
|
|
113
137
|
```
|
|
114
138
|
|
|
115
|
-
兩組標題都收,而且**與審查者使用的語言無關**——語言由 `--lang`/`DISPATCH_LANG` 決定(見上方)。兩組只是同一批欄位的別名,所以英文工單可以用中文跑,反之亦然。
|
|
116
|
-
|
|
117
|
-
不支援在同一個審查者檔案中混用兩組標題——第一個符合的標題會決定用哪一組欄位名,不是決定語言。
|
|
118
|
-
|
|
119
139
|
審查者定義放在 Repository 根目錄下的:
|
|
120
140
|
|
|
121
141
|
```text
|
|
@@ -124,25 +144,24 @@ dowafu --help # 查看所有旗標
|
|
|
124
144
|
|
|
125
145
|
它們是每個 spoke 的 System Prompt 來源,CLI 會直接讀取這些檔案。
|
|
126
146
|
|
|
127
|
-
|
|
147
|
+
結果不會落到磁碟上,而是存進 SQLite 資料庫(預設 `$DISPATCH_HOME/dowafu.db`),其中包含:
|
|
128
148
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
- 每個 spoke 的審查報告
|
|
150
|
+
- 稽核表格與該次的實際花費
|
|
151
|
+
- 每一次呼叫實際送出的 Request 與收到的 Response
|
|
132
152
|
|
|
133
|
-
|
|
153
|
+
讀回某一次派工的結果:
|
|
134
154
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
- `raw/`:精確保存實際送出的 Request 與收到的 Response
|
|
155
|
+
```bash
|
|
156
|
+
dowafu result <id>
|
|
157
|
+
```
|
|
139
158
|
|
|
140
159
|
## 工具保證的事項
|
|
141
160
|
|
|
142
161
|
- **每次呼叫的讀取範圍都有白名單限制。** 如果 spoke 要求讀取白名單以外的檔案,會被拒絕,而且拒絕事件會被記錄。
|
|
143
162
|
- **`_docs/` 永遠禁止讀取。** 不論白名單如何設定。
|
|
144
|
-
- **在你確認之前不會產生任何費用。** Dry run 會列出解析後的 repo 根目錄、每個 spoke 的模型與語言、token
|
|
145
|
-
- **Secrets 會被遮罩。**
|
|
163
|
+
- **在你確認之前不會產生任何費用。** Dry run 會列出解析後的 repo 根目錄、每個 spoke 的模型與語言、token 預估值與硬上限金額,而且不會呼叫任何 API。
|
|
164
|
+
- **Secrets 會被遮罩。** 存進資料庫的所有內容以及 stdout 中都會遮罩 Secrets。
|
|
146
165
|
- **發生錯誤就停止整個執行流程。** 例如缺少 API Key、未知的 Model、指定的檔案不存在等,都會在產生任何費用之前中止,並指出造成問題的路徑或名稱。
|
|
147
166
|
|
|
148
167
|
## Models
|
|
@@ -154,9 +173,16 @@ tmp/spoke/<ticket-id>/
|
|
|
154
173
|
| `gemini` | `gemini-3.1-flash-lite`、`gemini-3.5-flash-lite`、`gemini-3.6-flash` |
|
|
155
174
|
| `anthropic` | `claude-opus-5`、`claude-sonnet-5` |
|
|
156
175
|
|
|
157
|
-
這份清單會隨套件以 `providers.json`
|
|
176
|
+
這份清單會隨套件以 `providers.json` 一起提供,並在第一次開啟資料庫時播種進去。
|
|
177
|
+
之後**資料庫就是唯一來源**:
|
|
158
178
|
|
|
159
|
-
|
|
179
|
+
```bash
|
|
180
|
+
dowafu providers list # 白名單,以及哪些 Model 是啟用的
|
|
181
|
+
dowafu providers enable|disable <model> # 開關其中一個
|
|
182
|
+
dowafu providers import <path> # 匯入自己的設定檔
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**沒有 `--providers` 旗標**——0.5.0 把白名單搬進資料庫時已移除,帶了會被當成未知選項拒絕。
|
|
160
186
|
|
|
161
187
|
## 從 Agent 驅動
|
|
162
188
|
|
|
@@ -173,7 +199,7 @@ tmp/spoke/<ticket-id>/
|
|
|
173
199
|
`publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
|
|
174
200
|
|
|
175
201
|
```bash
|
|
176
|
-
npx degit eyesofkids/dowafu/publish/zh-tw#v0.
|
|
202
|
+
npx degit eyesofkids/dowafu/publish/zh-tw#v0.5.0 .claude-pack # 或 publish/en
|
|
177
203
|
|
|
178
204
|
cd .claude-pack
|
|
179
205
|
TARGET=<your project>
|
|
@@ -195,6 +221,130 @@ publish/zh-tw/README.md (或 publish/en/README.md)
|
|
|
195
221
|
|
|
196
222
|
兩份各自以該語言撰寫。
|
|
197
223
|
|
|
224
|
+
## 接成 MCP server
|
|
225
|
+
|
|
226
|
+
Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dispatch_tickets`、
|
|
227
|
+
`dispatch_submit`、`dispatch_status`、`dispatch_result`、`dispatch_approve`。
|
|
228
|
+
|
|
229
|
+
有兩條路,差別在**誰啟動誰**。
|
|
230
|
+
|
|
231
|
+
| | stdio | HTTP |
|
|
232
|
+
| --- | --- | --- |
|
|
233
|
+
| 指令 | `dowafu mcp` | `dowafu serve` |
|
|
234
|
+
| 誰啟動 | client 自己 spawn | 你,在自己的終端機 |
|
|
235
|
+
| Token | 沒有,也不需要 | 必要(`x-api-key`) |
|
|
236
|
+
| 會跑 reviewer 嗎 | **不會** | 會 |
|
|
237
|
+
|
|
238
|
+
### 兩條路都需要 `dowafu serve`
|
|
239
|
+
|
|
240
|
+
`dowafu mcp` 只回應 tool 呼叫,**它不執行任何 reviewer**。真正花錢的 worker 在
|
|
241
|
+
`dowafu serve` 裡。沒有它,核准過的 job 會永遠留在佇列裡,而且沒有任何東西會告訴 agent
|
|
242
|
+
為什麼。
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
stdio: 你的 client ──spawn──→ dowafu mcp ──┐
|
|
246
|
+
├─ 同一個 SQLite 資料庫
|
|
247
|
+
你的終端機 ─────────→ dowafu serve ─┘ (worker 在這邊)
|
|
248
|
+
|
|
249
|
+
HTTP: 你的 client ──x-api-key──→ dowafu serve (端點與 worker 同一個行程)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。這條路的順序很寬鬆
|
|
253
|
+
——核准之前或之後才起都可以,daemon 下一個 tick 就會把它撿走。
|
|
254
|
+
|
|
255
|
+
### stdio
|
|
256
|
+
|
|
257
|
+
把 client 指到那條指令。以 Claude Code 為例:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
claude mcp add dowafu -- dowafu mcp
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
不涉及 token:client 直接 spawn 那個行程,信任邊界是作業系統,中間沒有網路可攔。
|
|
264
|
+
|
|
265
|
+
### HTTP
|
|
266
|
+
|
|
267
|
+
`dowafu serve` 綁 `127.0.0.1:7391/mcp`——**只綁 loopback,沒有 `--host` 旗標**。要不要對外
|
|
268
|
+
是 tunnel 的事,不是 server 的事。改埠用 `--http-port <port>` 或環境變數 `DOWAFU_HTTP_PORT`。
|
|
269
|
+
|
|
270
|
+
每個請求都要帶 token:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
dowafu token issue --label laptop # 明文只印這一次,不會存下來
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
資料庫只存它的 SHA-256 hash,比對是常數時間。把明文填進 client 的 MCP 設定,header 名稱是
|
|
277
|
+
`x-api-key`。`dowafu token list` 看有哪幾把(不顯示明文)、`dowafu token revoke <id>` 撤銷。
|
|
278
|
+
限流是**以 token 計,不是以 IP 計**——tunnel 後面每個請求的來源位址都一樣。
|
|
279
|
+
|
|
280
|
+
> **daemon 要先起,client 才起。** 反過來的話 client 會把 server 標成失敗、那個對話完全沒有
|
|
281
|
+
> tool——**症狀跟設定寫錯一模一樣**。
|
|
282
|
+
|
|
283
|
+
### 讓 daemon 一直跑著
|
|
284
|
+
|
|
285
|
+
沒有東西會在需要時把 `dowafu serve` 喚醒——client 是連上一個**已經在監聽**的 socket,
|
|
286
|
+
沒有行程在聽就沒有東西可被喚醒。交給作業系統讓它保持啟動。
|
|
287
|
+
|
|
288
|
+
**macOS**——`~/Library/LaunchAgents/dowafu.plist`:
|
|
289
|
+
|
|
290
|
+
```xml
|
|
291
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
292
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
293
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
294
|
+
<plist version="1.0">
|
|
295
|
+
<dict>
|
|
296
|
+
<key>Label</key><string>dowafu</string>
|
|
297
|
+
<key>ProgramArguments</key>
|
|
298
|
+
<array>
|
|
299
|
+
<string>/absolute/path/to/dowafu</string>
|
|
300
|
+
<string>serve</string>
|
|
301
|
+
</array>
|
|
302
|
+
<key>RunAtLoad</key><true/>
|
|
303
|
+
<key>KeepAlive</key><true/>
|
|
304
|
+
<key>StandardOutPath</key><string>/Users/you/Library/Logs/dowafu.log</string>
|
|
305
|
+
<key>StandardErrorPath</key><string>/Users/you/Library/Logs/dowafu.err</string>
|
|
306
|
+
</dict>
|
|
307
|
+
</plist>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
用 `launchctl load -w ~/Library/LaunchAgents/dowafu.plist` 載入。
|
|
311
|
+
|
|
312
|
+
**Linux**——`~/.config/systemd/user/dowafu.service`:
|
|
313
|
+
|
|
314
|
+
```ini
|
|
315
|
+
[Unit]
|
|
316
|
+
Description=dowafu dispatch daemon
|
|
317
|
+
|
|
318
|
+
[Service]
|
|
319
|
+
ExecStart=/absolute/path/to/dowafu serve
|
|
320
|
+
Restart=always
|
|
321
|
+
|
|
322
|
+
[Install]
|
|
323
|
+
WantedBy=default.target
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
用 `systemctl --user enable --now dowafu` 啟用。
|
|
327
|
+
|
|
328
|
+
三個會咬人的地方:
|
|
329
|
+
|
|
330
|
+
- **路徑要寫絕對路徑。** launchd 與 systemd 都看不到你 shell 的 `PATH`。跑 `which dowafu`,
|
|
331
|
+
把它印出來的東西原樣貼進去。
|
|
332
|
+
- **環境變數同樣不會繼承。** 如果你的 shell 設了 `DISPATCH_HOME` 或 `XDG_CONFIG_HOME`,
|
|
333
|
+
daemon 看不到它們,會解析到**另一個資料庫**——裡面沒有你的 key,也沒有你的工單。
|
|
334
|
+
要用就寫進 unit 裡。
|
|
335
|
+
- **輸出要導到檔案。** 變成背景服務之後就沒有終端機可以看了,而埠被占用、或某支審查者死掉,
|
|
336
|
+
訊息都出現在那裡。把 stdout 與 stderr 指到你找得到的檔案。
|
|
337
|
+
|
|
338
|
+
daemon 常駐之後,stdio 那條路不需要任何額外動作——`dowafu mcp` 會找到已經在跑的 worker。
|
|
339
|
+
這時自己再打 `dowafu serve` 會被擋下並回報 `daemon already running (pid N)`:
|
|
340
|
+
**那是守衛在運作,不是故障。**
|
|
341
|
+
|
|
342
|
+
### 核准仍然是獨立的一步
|
|
343
|
+
|
|
344
|
+
`dispatch_submit` 只把 job 排進佇列就停下,核准之前不會計費。兩種核准方式:在自己的終端機下
|
|
345
|
+
`dowafu approve <id>`,或用 `dispatch_approve` tool——後者要求帶上這張 job 的硬上限金額,且
|
|
346
|
+
必須與資料庫自己算出來的相符,所以 agent 沒辦法核准一張它沒有把成本攤給你看過的 job。
|
|
347
|
+
|
|
198
348
|
## License
|
|
199
349
|
|
|
200
350
|
MIT
|
|
@@ -19,15 +19,17 @@
|
|
|
19
19
|
// 確認過(tool calling、tool_result 合併、usage 欄位名、effort 生效方向、cache_control
|
|
20
20
|
// 生效、thinking.type:"enabled" 確實 400),結果見 facts_dispatch.md。
|
|
21
21
|
import { normalizeAnthropicUsage, normalizeFinishReason } from "../usage.js";
|
|
22
|
+
import { OUTPUT_TOKEN_CAP } from "../cost.js";
|
|
22
23
|
import { ProviderHttpError } from "../mask.js";
|
|
23
24
|
import { checkRawObjectIntegrity } from "../raw-integrity.js";
|
|
24
25
|
import { readFileToolDescription } from "./read-file-tool-description.js";
|
|
25
26
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
26
27
|
// §29 規格四:max_tokens 是 Anthropic 專屬必要參數,SendOptions 沒有這個概念(openai/gemini
|
|
27
28
|
// 都不需要)。寫死在 adapter,不提升到 SendOptions——那會逼另外兩個 adapter 處理用不到的欄位。
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
29
|
+
// 票 cost-cap §A-1:原本 32,768 是「必填欄位的填充值,不是算過的」(舊註解原話)。現在改引用
|
|
30
|
+
// cost.ts 的 OUTPUT_TOKEN_CAP(65,536)——四家 adapter 共用同一個常數,見 facts_output_cap.md
|
|
31
|
+
// §六:高於三家實測單輪 output max(53,391)約 22% 餘裕,遠高於官方建議 25,000 下限。
|
|
32
|
+
const ANTHROPIC_MAX_TOKENS = OUTPUT_TOKEN_CAP;
|
|
31
33
|
function buildReadFileTool(lang) {
|
|
32
34
|
return {
|
|
33
35
|
name: "read_file",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// 續接時原樣放回 contents(§8)——gemini-3.x 系列的 thoughtSignature 掛在 part 上,
|
|
7
7
|
// 隨 raw 一起帶回,不需另闢欄位承接。
|
|
8
8
|
import { normalizeFinishReason, normalizeGeminiUsage } from "../usage.js";
|
|
9
|
+
import { OUTPUT_TOKEN_CAP } from "../cost.js";
|
|
9
10
|
import { ProviderHttpError } from "../mask.js";
|
|
10
11
|
import { checkRawObjectIntegrity } from "../raw-integrity.js";
|
|
11
12
|
import { readFileToolDescription } from "./read-file-tool-description.js";
|
|
@@ -48,10 +49,17 @@ function turnToContent(turn) {
|
|
|
48
49
|
// plan_dispatch_v1.8.md §5「reasoning.style 的三種轉換」(實測確認):位置與大小寫都關鍵——
|
|
49
50
|
// 頂層 thinking_level 會 400;巢狀內 snake_case(thinking_level)會 200 但 Google 對未知
|
|
50
51
|
// 欄位靜默忽略、不生效。正確路徑是 generationConfig.thinkingConfig.thinkingLevel(camelCase)。
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
//
|
|
53
|
+
// 票 cost-cap §A-1:maxOutputTokens 先前完全沒送(無界),現在與 thinkingConfig 同層送出,
|
|
54
|
+
// 值一律無條件送(不像 thinkingConfig 那樣依 effort 留白與否決定),故 generationConfig
|
|
55
|
+
// 不再可能整個缺席——effort 留白時只是少了 thinkingConfig 這個子欄位。
|
|
56
|
+
function buildGenerationConfig(effort) {
|
|
57
|
+
return {
|
|
58
|
+
generationConfig: {
|
|
59
|
+
maxOutputTokens: OUTPUT_TOKEN_CAP,
|
|
60
|
+
...(effort ? { thinkingConfig: { thinkingLevel: effort } } : {}),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
55
63
|
}
|
|
56
64
|
// 純函式:組出實際會送出的 request body,並執行 §8 的 raw 完整性自我檢查(違反即拋
|
|
57
65
|
// RawIntegrityError,不需打 API 就能測試——見 raw-integrity.test.ts)。
|
|
@@ -62,7 +70,7 @@ export function buildGeminiRequest(conv, opts, lang) {
|
|
|
62
70
|
contents,
|
|
63
71
|
system_instruction: { parts: [{ text: conv.systemPrompt }] },
|
|
64
72
|
...(opts.enableTools === false ? {} : { tools: [buildGeminiTool(lang)] }),
|
|
65
|
-
...
|
|
73
|
+
...buildGenerationConfig(opts.effort),
|
|
66
74
|
};
|
|
67
75
|
return { contents, body };
|
|
68
76
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 不用 toolCalls 重建——這是 v1.3 犯錯、v1.4 §8 明訂修正的地方。
|
|
5
5
|
import OpenAI from "openai";
|
|
6
6
|
import { normalizeFinishReason, normalizeResponsesUsage } from "../usage.js";
|
|
7
|
+
import { OUTPUT_TOKEN_CAP } from "../cost.js";
|
|
7
8
|
import { ProviderHttpError } from "../mask.js";
|
|
8
9
|
import { checkRawArrayIntegrity } from "../raw-integrity.js";
|
|
9
10
|
import { readFileToolDescription } from "./read-file-tool-description.js";
|
|
@@ -57,6 +58,9 @@ export function buildResponsesRequest(conv, opts, config) {
|
|
|
57
58
|
model: opts.model,
|
|
58
59
|
instructions: conv.systemPrompt,
|
|
59
60
|
input,
|
|
61
|
+
// 票 cost-cap §A-1:openai/deepseek 共用此 adapter,先前完全沒送這個參數(無界)。
|
|
62
|
+
// 值的出處見 cost.ts 的 OUTPUT_TOKEN_CAP,三家 adapter 共用同一個常數。
|
|
63
|
+
max_output_tokens: OUTPUT_TOKEN_CAP,
|
|
60
64
|
...(opts.enableTools === false ? {} : { tools: [buildReadFileTool(config.lang)] }),
|
|
61
65
|
...(config.store === false ? { store: false } : {}),
|
|
62
66
|
include: ["reasoning.encrypted_content"],
|
|
@@ -64,7 +68,7 @@ export function buildResponsesRequest(conv, opts, config) {
|
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
export function createResponsesAdapter(config) {
|
|
67
|
-
const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL });
|
|
71
|
+
const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL, ...(config.fetch ? { fetch: config.fetch } : {}) });
|
|
68
72
|
return {
|
|
69
73
|
async send(conv, opts) {
|
|
70
74
|
const params = buildResponsesRequest(conv, opts, config);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 工單 C §B:入站 HTTP 憑證的發/驗/撤銷。DB 只存 hash(見 db.ts 的 tokens 表註解),
|
|
2
|
+
// 明文只在 issueToken 呼叫當下回傳一次,之後任何地方都拿不回來。
|
|
3
|
+
//
|
|
4
|
+
// 與 provider API key(E12,下階段)刻意不共用任何機制:那把是要原樣交給廠商的,
|
|
5
|
+
// 這把只會被比對、不會被重放,兩者的儲存與比對語意相反。
|
|
6
|
+
// 具名匯入的 timingSafeEqual 在測試裡「換成 === 有沒有真的被鎖住」這件事上會失真——
|
|
7
|
+
// ESM 具名綁定在連結時就固定,t.mock.method 對 crypto 這個物件的屬性替換影響不到它。
|
|
8
|
+
// 改用 namespace import、呼叫端每次都經物件屬性查找,F-2 變異的偵測才踩得到。
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { now } from "./db.js";
|
|
11
|
+
import { m } from "./messages.js";
|
|
12
|
+
import { DispatchError } from "./types.js";
|
|
13
|
+
function hashToken(plaintext) {
|
|
14
|
+
return crypto.createHash("sha256").update(plaintext, "utf8").digest("hex");
|
|
15
|
+
}
|
|
16
|
+
// B-1 #3:常數時間比對,長度不同直接回假(timingSafeEqual 對長度不同的 buffer 會拋)。
|
|
17
|
+
// 兩邊都是 sha256 hex(固定 64 字),這裡的長度守衛是防禦性的,不是本函式的主要工作——
|
|
18
|
+
// 主要工作是「用 timingSafeEqual 而不是 ===」,見 F-2 變異。
|
|
19
|
+
export function hashesMatch(a, b) {
|
|
20
|
+
const bufA = Buffer.from(a, "utf8");
|
|
21
|
+
const bufB = Buffer.from(b, "utf8");
|
|
22
|
+
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
|
|
23
|
+
}
|
|
24
|
+
// B-1 #2:明文只在這裡回傳一次,呼叫端(cli.ts 的 `token issue`)印出後即不再持有。
|
|
25
|
+
export function issueToken(db, label) {
|
|
26
|
+
const id = crypto.randomUUID();
|
|
27
|
+
const plaintext = crypto.randomBytes(24).toString("hex");
|
|
28
|
+
db.prepare("INSERT INTO tokens (id, hash, label, created_at) VALUES (?, ?, ?, ?)").run(id, hashToken(plaintext), label ?? null, now());
|
|
29
|
+
return { id, plaintext };
|
|
30
|
+
}
|
|
31
|
+
export function listTokens(db) {
|
|
32
|
+
return db.prepare("SELECT id, label, created_at, revoked_at FROM tokens ORDER BY created_at").all();
|
|
33
|
+
}
|
|
34
|
+
// B-1 #6:HTTP 起來卻一把 token 都沒發,端點活著但沒有任何請求進得來——這個查詢是那個
|
|
35
|
+
// 警告的資料來源,見 daemon.ts 的 serve()。
|
|
36
|
+
export function hasAnyActiveToken(db) {
|
|
37
|
+
return db.prepare("SELECT 1 FROM tokens WHERE revoked_at IS NULL LIMIT 1").get() !== undefined;
|
|
38
|
+
}
|
|
39
|
+
// B-1 #4/#5:撤銷後同一把 token 即 401;撤銷不存在或已撤銷的 id 講人話,不分兩種訊息——
|
|
40
|
+
// 對呼叫者而言「這個 id 現在撤不動」是同一件事,沒有必要在措辭上分岔。
|
|
41
|
+
export function revokeToken(db, id, lang) {
|
|
42
|
+
const result = db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(now(), id);
|
|
43
|
+
if (result.changes === 0)
|
|
44
|
+
throw new DispatchError(m(lang, "tokenNotFoundOrRevoked", id), 2);
|
|
45
|
+
}
|
|
46
|
+
// B-1 #1:比對,不回傳明文、不記值。找不到未撤銷的相符 token 回傳 null。
|
|
47
|
+
export function findTokenIdForPresented(db, presented) {
|
|
48
|
+
if (typeof presented !== "string" || presented.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const presentedHash = hashToken(presented);
|
|
51
|
+
const rows = db.prepare("SELECT id, hash FROM tokens WHERE revoked_at IS NULL").all();
|
|
52
|
+
const match = rows.find((row) => hashesMatch(row.hash, presentedHash));
|
|
53
|
+
return match ? match.id : null;
|
|
54
|
+
}
|
package/dist/cli-args.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// 並收緊兩處:多餘 positional 即中止(原靜默忽略,違反 fail closed);無參數/--help 印
|
|
7
7
|
// 完整用法(原僅一行)。
|
|
8
8
|
import { DispatchError } from "./types.js";
|
|
9
|
+
import { DEFAULT_HTTP_PORT } from "./mcp/http.js";
|
|
9
10
|
import { getCommandName } from "./pkg-info.js";
|
|
11
|
+
import { DEFAULT_MAX_TOOL_CALLS } from "./cost.js";
|
|
10
12
|
import { m } from "./messages.js";
|
|
11
13
|
const DEFAULTS = {
|
|
12
14
|
out: "tmp/spoke",
|
|
@@ -22,10 +24,11 @@ const DEFAULTS = {
|
|
|
22
24
|
retries: 2,
|
|
23
25
|
rateLimitRetries: 5,
|
|
24
26
|
maxRateWaitSec: 30,
|
|
25
|
-
maxToolCalls:
|
|
27
|
+
maxToolCalls: DEFAULT_MAX_TOOL_CALLS,
|
|
26
28
|
charsPerToken: 1.0,
|
|
27
29
|
maxSpokeReasoningTokens: 50_000,
|
|
28
30
|
maxRoundReasoningTokens: null,
|
|
31
|
+
httpPort: DEFAULT_HTTP_PORT,
|
|
29
32
|
json: false,
|
|
30
33
|
dryRun: false,
|
|
31
34
|
yes: false,
|
|
@@ -88,8 +91,20 @@ export function parseArgs(argv, lang) {
|
|
|
88
91
|
if (argv.includes("--version") || argv.includes("-V"))
|
|
89
92
|
return { mode: "version" };
|
|
90
93
|
const options = { ...DEFAULTS };
|
|
94
|
+
// 熱修補(票 C 驗收):`DOWAFU_HTTP_PORT` 原本只寫在 `daemon.ts` 的 `serve()` 參數預設值裡,
|
|
95
|
+
// 而 `cli.ts` 一律傳 `parsed.options.httpPort`(DEFAULTS 給了具體數字),所以那個預設永遠
|
|
96
|
+
// 用不到——環境變數看起來支援、實際是死的。同一行呼叫裡 maxConcurrent 傳的是 `undefined`,
|
|
97
|
+
// 所以 `DOWAFU_MAX_CONCURRENT` 是活的:**兩個環境變數一活一死,而死的那個沒有任何徵兆**。
|
|
98
|
+
// 在這裡讀(而不是在 module-level 的 DEFAULTS 讀)才能每次呼叫重新取值,測試也才注得進去。
|
|
99
|
+
// 優先序:--http-port > DOWAFU_HTTP_PORT > 內建預設。旗標在下方解析,故此處放在它之前。
|
|
100
|
+
const envHttpPort = Number(process.env.DOWAFU_HTTP_PORT);
|
|
101
|
+
if (Number.isInteger(envHttpPort) && envHttpPort > 0 && envHttpPort < 65536)
|
|
102
|
+
options.httpPort = envHttpPort;
|
|
91
103
|
let ticketDir;
|
|
92
104
|
let doctorMode = false;
|
|
105
|
+
let serveMode = false;
|
|
106
|
+
let serveStop = false;
|
|
107
|
+
let mcpMode = false;
|
|
93
108
|
const numFlag = (name, apply) => {
|
|
94
109
|
flagHandlers[name] = (v) => {
|
|
95
110
|
const n = Number(v);
|
|
@@ -101,7 +116,10 @@ export function parseArgs(argv, lang) {
|
|
|
101
116
|
const flagHandlers = {};
|
|
102
117
|
flagHandlers["out"] = (v) => (options.out = v);
|
|
103
118
|
flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
|
|
104
|
-
flagHandlers["
|
|
119
|
+
flagHandlers["db"] = (v) => (options.dbPath = v);
|
|
120
|
+
// Used by the local daemon worker only. It gives each queued run its own
|
|
121
|
+
// result rows without changing the normal D1 ticket-id result key.
|
|
122
|
+
flagHandlers["job-id"] = (v) => (options.jobId = v);
|
|
105
123
|
// 這裡只存原始字串,不驗證格式——格式與 DISPATCH_LANG 的組合判定需要 process.env,
|
|
106
124
|
// parseArgs 是純函式不碰它,交給 cli.ts 呼叫 resolveLang(見下)。重複帶 --lang 時
|
|
107
125
|
// 最後一次覆蓋前一次,與其他旗標一致(v1.3 §二之2 #8)。
|
|
@@ -117,6 +135,7 @@ export function parseArgs(argv, lang) {
|
|
|
117
135
|
numFlag("chars-per-token", (n) => (options.charsPerToken = n));
|
|
118
136
|
numFlag("max-spoke-reasoning-tokens", (n) => (options.maxSpokeReasoningTokens = n));
|
|
119
137
|
numFlag("max-round-reasoning-tokens", (n) => (options.maxRoundReasoningTokens = n));
|
|
138
|
+
numFlag("http-port", (n) => (options.httpPort = n));
|
|
120
139
|
for (let i = 0; i < argv.length; i++) {
|
|
121
140
|
const arg = argv[i];
|
|
122
141
|
if (arg === "--dry-run") {
|
|
@@ -134,6 +153,15 @@ export function parseArgs(argv, lang) {
|
|
|
134
153
|
// (見迴圈後方)——這樣 --doctor 與 --lang 之類的已知旗標仍可同時給、彼此不衝突。
|
|
135
154
|
doctorMode = true;
|
|
136
155
|
}
|
|
156
|
+
else if (arg === "serve") {
|
|
157
|
+
serveMode = true;
|
|
158
|
+
}
|
|
159
|
+
else if (arg === "--stop") {
|
|
160
|
+
serveStop = true;
|
|
161
|
+
}
|
|
162
|
+
else if (arg === "mcp") {
|
|
163
|
+
mcpMode = true;
|
|
164
|
+
}
|
|
137
165
|
else if (arg.startsWith("--")) {
|
|
138
166
|
const name = arg.slice(2);
|
|
139
167
|
const handler = flagHandlers[name];
|
|
@@ -162,7 +190,21 @@ export function parseArgs(argv, lang) {
|
|
|
162
190
|
if (ticketDir !== undefined) {
|
|
163
191
|
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
|
|
164
192
|
}
|
|
165
|
-
|
|
193
|
+
if (serveStop)
|
|
194
|
+
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
195
|
+
return { mode: "doctor", options };
|
|
196
|
+
}
|
|
197
|
+
if (serveMode) {
|
|
198
|
+
if (ticketDir !== undefined)
|
|
199
|
+
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "serve", helpText), 2);
|
|
200
|
+
return { mode: "serve", options, stop: serveStop };
|
|
201
|
+
}
|
|
202
|
+
if (serveStop)
|
|
203
|
+
throw new DispatchError(m(lang, "stopRequiresServe"), 2);
|
|
204
|
+
if (mcpMode) {
|
|
205
|
+
if (ticketDir !== undefined)
|
|
206
|
+
throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "mcp", helpText), 2);
|
|
207
|
+
return { mode: "mcp", options };
|
|
166
208
|
}
|
|
167
209
|
if (!ticketDir) {
|
|
168
210
|
throw new DispatchError(helpText, 2);
|