dowafu 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/adapters/anthropic-messages.js +145 -0
  4. package/dist/adapters/gemini-native.js +114 -0
  5. package/dist/adapters/responses.js +112 -0
  6. package/dist/audit.js +123 -0
  7. package/dist/cli-args.js +138 -0
  8. package/dist/cli.js +304 -0
  9. package/dist/cost.js +54 -0
  10. package/dist/dispatch-home.js +23 -0
  11. package/dist/dotenv-invariant.js +66 -0
  12. package/dist/error-classify.js +13 -0
  13. package/dist/gate.js +37 -0
  14. package/dist/gitignore-check.js +24 -0
  15. package/dist/json-output.js +80 -0
  16. package/dist/mask.js +83 -0
  17. package/dist/output.js +152 -0
  18. package/dist/pkg-info.js +35 -0
  19. package/dist/prompt.js +105 -0
  20. package/dist/providers.js +151 -0
  21. package/dist/rate-limit.js +27 -0
  22. package/dist/raw-integrity.js +49 -0
  23. package/dist/report.js +101 -0
  24. package/dist/runner.js +328 -0
  25. package/dist/secret-env.js +6 -0
  26. package/dist/semaphore.js +25 -0
  27. package/dist/ticket.js +156 -0
  28. package/dist/tool-call-audit.js +19 -0
  29. package/dist/types.js +11 -0
  30. package/dist/usage.js +224 -0
  31. package/dist/validate.js +100 -0
  32. package/dist/whitelist.js +38 -0
  33. package/package.json +60 -0
  34. package/providers.json +84 -0
  35. package/publish/.agents/skills/find-holes-external/SKILL.md +418 -0
  36. package/publish/.agents/skills/preflight/SKILL.md +126 -0
  37. package/publish/.agents/skills/wrap/SKILL.md +65 -0
  38. package/publish/.claude/agents/explore-haiku.md +8 -0
  39. package/publish/.claude/agents/hole-finder-cost.md +15 -0
  40. package/publish/.claude/agents/hole-finder-feasibility.md +15 -0
  41. package/publish/.claude/agents/hole-finder-safety.md +15 -0
  42. package/publish/.claude/agents/hole-finder.md +14 -0
  43. package/publish/.claude/skills/find-holes/SKILL.md +112 -0
  44. package/publish/.claude/skills/find-holes-external/SKILL.md +434 -0
  45. package/publish/.claude/skills/preflight/SKILL.md +196 -0
  46. package/publish/.claude/skills/wrap/SKILL.md +62 -0
  47. package/publish/README.md +75 -0
  48. package/publish/workflow_spec.md +65 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eddy Chang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # dowafu
2
+
3
+ Send a section of your design doc to external LLMs for review, from the terminal.
4
+
5
+ You write a ticket. `dowafu` calls each provider's API. Each reviewer — a *spoke* —
6
+ reads only the files you whitelisted, and returns observations with the evidence it
7
+ read them from. Everything lands on disk for you to check.
8
+
9
+ **Spokes produce observations, not verdicts.** What to do about them stays with you.
10
+
11
+ > ### Tickets work in English or Chinese. The CLI's own output is Chinese.
12
+ >
13
+ > Write the ticket's section headings in either language and the rest follows: the
14
+ > reviewer is prompted in that language, asked for its report in that language's
15
+ > template, and the audit checks it against the matching one. The language is decided by
16
+ > the headings themselves — there is no flag to forget — and the dry run prints which one
17
+ > it resolved to, per reviewer.
18
+ >
19
+ > What is still Traditional Chinese: `--help`, error messages, the dry-run report and
20
+ > `summary.md`. That is the development language and it has not been translated.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install -g dowafu
26
+ ```
27
+
28
+ The command is `dowafu`.
29
+
30
+ ## API keys
31
+
32
+ Keys are read from `$DISPATCH_HOME/.env`, which defaults to `~/.config/dispatch/.env`
33
+ (`DISPATCH_HOME` or `XDG_CONFIG_HOME` override it). Variables already present in the
34
+ environment win over the file, so CI and one-off overrides need no file at all.
35
+
36
+ ```bash
37
+ mkdir -p ~/.config/dispatch
38
+ cat > ~/.config/dispatch/.env <<'EOF'
39
+ DEEPSEEK_API_KEY=
40
+ GEMINI_API_KEY=
41
+ OPENAI_API_KEY=
42
+ ANTHROPIC_API_KEY=
43
+ EOF
44
+ chmod 600 ~/.config/dispatch/.env
45
+ ```
46
+
47
+ Only the providers you actually dispatch to need a key. The file is plain text — it is
48
+ protected by nothing but its file permissions.
49
+
50
+ **The current directory's `.env` is never read.** That directory is the project under
51
+ review, and its secrets have no business in a process that talks to three external APIs.
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ dowafu <ticket-dir> --dry-run # parse, validate, estimate. No API call, no cost.
57
+ dowafu <ticket-dir> --yes # run it. This is what costs money.
58
+ dowafu --help # every flag
59
+ ```
60
+
61
+ Without `--yes`, the command asks for confirmation. When stdin is not a TTY — which is
62
+ the case whenever an agent runs it for you — there is nobody to answer, so it stops
63
+ before calling anything.
64
+
65
+ ## The ticket
66
+
67
+ A ticket is a directory with three kinds of file. The headings are **literal markers the
68
+ parser matches** — use one of the two sets below, exactly as written.
69
+
70
+ | English | 中文 |
71
+ | --- | --- |
72
+ | `# Questions` | `# 具體問題` |
73
+ | `# Allowed reads` | `# 允許讀取` |
74
+ | `# Under review` | `# 待審段落` |
75
+ | `# Premises` | `# 前提(不受審)` |
76
+
77
+ | File | Contents |
78
+ | --- | --- |
79
+ | `_dispatch.md` | which reviewers to run, and with which provider and model |
80
+ | `_shared.md` | the premises, and the section under review, pasted in verbatim |
81
+ | `<agent>.md` | one per reviewer: its questions, and the files it may read |
82
+
83
+ ```markdown
84
+ <!-- _dispatch.md -->
85
+ <!-- format: v1 -->
86
+ # dispatch auth-review
87
+
88
+ | agent | provider | model | effort |
89
+ | --- | --- | --- | --- |
90
+ | hole-finder-safety | deepseek | deepseek-v4-flash | |
91
+ | hole-finder-feasibility | openai | gpt-5.6-luna | |
92
+ ```
93
+
94
+ ```markdown
95
+ <!-- hole-finder-safety.md -->
96
+ # Questions
97
+ 1. Does the permission check described here hold under concurrent requests?
98
+
99
+ # Allowed reads
100
+ - lib/auth-guard.ts
101
+ - prisma/schema.prisma
102
+ ```
103
+
104
+ The set you use decides the reviewer's language: `# Questions` gets an English prompt and
105
+ an English report template, `# 具體問題` gets the Chinese ones. Mixing the two sets inside
106
+ one reviewer's file is not supported — the first heading that matches wins.
107
+
108
+ Reviewer definitions live in `.claude/agents/<agent>.md` under the repo root — they are
109
+ the source of each spoke's system prompt, and the CLI reads them directly. Results are
110
+ written to `tmp/spoke/<ticket-id>/`: each spoke's report, a `summary.md` with the audit
111
+ table and estimated cost, `run.jsonl` with one line per event, and `raw/` with the exact
112
+ requests and responses.
113
+
114
+ ## What the tool guarantees
115
+
116
+ - **Reads are whitelisted per call.** A spoke asking for a file outside its list is
117
+ refused, and the refusal is recorded.
118
+ - **`_docs/` is off limits**, whatever the whitelist says.
119
+ - **Nothing is billed before you confirm.** The dry run prints the resolved repo root,
120
+ models, token estimates and output path, and calls no API.
121
+ - **Secrets are masked** in `run.jsonl`, `raw/*.json` and stdout.
122
+ - **Failures stop the run.** A missing key, an unknown model, a file that does not
123
+ exist — each aborts with the path or name that caused it, before any spend.
124
+
125
+ ## Models
126
+
127
+ | provider | model |
128
+ | --- | --- |
129
+ | `openai` | `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol` |
130
+ | `deepseek` | `deepseek-v4-flash` |
131
+ | `gemini` | `gemini-3.1-flash-lite`, `gemini-3.5-flash-lite`, `gemini-3.6-flash` |
132
+ | `anthropic` | `claude-opus-5`, `claude-sonnet-5` |
133
+
134
+ The list ships with the package as `providers.json`. Point `--providers` at your own copy
135
+ to use anything else.
136
+
137
+ ## Driving it from an agent
138
+
139
+ `publish/` contains the skills and reviewer definitions to copy into a project, so that
140
+ an agent working there knows how to write a ticket, what to check before spending, and
141
+ how to read the results. Copy both directories — `.claude/` holds the reviewer
142
+ definitions the CLI itself reads, so it is required no matter which agent you use.
143
+
144
+ ```bash
145
+ TARGET=<your project>
146
+ mkdir -p "$TARGET/.claude/skills" "$TARGET/.claude/agents" "$TARGET/.agents/skills"
147
+ cp -R publish/.claude/skills/. "$TARGET/.claude/skills/"
148
+ cp -R publish/.agents/skills/. "$TARGET/.agents/skills/"
149
+ cp publish/.claude/agents/*.md "$TARGET/.claude/agents/"
150
+ cp publish/workflow_spec.md "$TARGET/"
151
+ ```
152
+
153
+ See `publish/README.md` for the details. Those documents are currently written in
154
+ Traditional Chinese.
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,145 @@
1
+ // plan_dispatch_v2.7.md §29:anthropic-messages adapter(Claude Opus 5/Sonnet 5)。
2
+ // 原生 Messages API,不套 OpenAI 相容殼——responses adapter 的具體型別(client.responses.create、
3
+ // function_call_output、response.output)與這裡的 wire format 完全不同,即使 reasoning.style
4
+ // 的 JSON 片段長得像 deepseek 的 output_config.effort(見規格二),也不得重用 responses adapter:
5
+ // 那條路徑走 OpenAI SDK 與 /v1/responses 端點,端點、headers、訊息格式全不對。
6
+ //
7
+ // assistant turn 的 raw 是整個 {role:"assistant", content:[...blocks]} message 物件,續接時
8
+ // 原樣放回 messages 陣列——與 gemini-native 同形,用 checkRawObjectIntegrity(不是
9
+ // checkRawArrayIntegrity)。§29 規格六:Opus 4.5/4.6 以上模型會把前輪 thinking block
10
+ // 併入 input 計費,且 manual 模式下最後一輪 assistant turn 須以 thinking block 開頭——
11
+ // content 陣列必須原封不動塞回,不得過濾掉 thinking block。
12
+ //
13
+ // §29 規格五:Anthropic 的 tool_result block 放在 user role 的 message 裡,且多筆結果應併在
14
+ // 同一則 message——runner.ts 每個 tool 呼叫各自 push 一個 { role:"tool" } turn,故這裡的轉換
15
+ // 需要前瞻(look-ahead)把連續的 tool turn 合併成一則 user message,responses adapter 的
16
+ // turnToInputItems 那種一對多純映射在此不適用(見 turnsToMessages)。
17
+ //
18
+ // §29 規格十一:本檔的所有形態已用 scripts/verify-providers.ts 對真實 API 逐項實測
19
+ // 確認過(tool calling、tool_result 合併、usage 欄位名、effort 生效方向、cache_control
20
+ // 生效、thinking.type:"enabled" 確實 400),結果見 facts_dispatch.md。
21
+ import { normalizeAnthropicUsage, normalizeFinishReason } from "../usage.js";
22
+ import { ProviderHttpError } from "../mask.js";
23
+ import { checkRawObjectIntegrity } from "../raw-integrity.js";
24
+ const ANTHROPIC_VERSION = "2023-06-01";
25
+ // §29 規格四:max_tokens 是 Anthropic 專屬必要參數,SendOptions 沒有這個概念(openai/gemini
26
+ // 都不需要)。寫死在 adapter,不提升到 SendOptions——那會逼另外兩個 adapter 處理用不到的欄位。
27
+ // 初始值推導見規格四:十次派工單支最高 40,970 output token,32k 有 20% 餘裕;本版預設 effort
28
+ // 不會用到官方建議 64k 起跳的 xhigh/max。撞到 stop_reason:"max_tokens" 再調,不在此臆測。
29
+ const ANTHROPIC_MAX_TOKENS = 32768;
30
+ const READ_FILE_TOOL = {
31
+ name: "read_file",
32
+ description: "讀取指定路徑的檔案內容",
33
+ input_schema: {
34
+ type: "object",
35
+ properties: { path: { type: "string" } },
36
+ required: ["path"],
37
+ },
38
+ };
39
+ // §29 規格二:thinking.type:"enabled" 在 Opus 5/Sonnet 5 會 400(實測確認,見
40
+ // facts_dispatch.md)。正確路徑是 adaptive thinking + output_config.effort,兩者一起送——
41
+ // 即使 output_config.effort 的 JSON 片段與 deepseek 的 reasoning.style 完全相同,這是巧合
42
+ // 不是可重用的理由(deepseek 走 responses adapter/OpenAI SDK,端點與訊息格式全不同)。
43
+ function buildReasoningParams(style, effort) {
44
+ if (!effort)
45
+ return {}; // §4:留白則不送任何 reasoning 參數(validate.ts 已解析為 default,實務上不會是 undefined)
46
+ if (style === "anthropic")
47
+ return { thinking: { type: "adaptive" }, output_config: { effort } };
48
+ throw new Error(`anthropic-messages adapter 不支援 reasoning.style=${style}`);
49
+ }
50
+ // §29 規格五:把 conv.turns 轉成 Anthropic 的 messages 陣列。tool turn 需要前瞻合併——
51
+ // runner.ts 對同一輪的每個 tool call 各自 push 一個 { role:"tool" } turn,連續的多個 tool
52
+ // turn 必須合併成一則 user message、內含多個 tool_result block,不能逐筆送出(逐筆送出會
53
+ // 產生連續兩則 user message,Anthropic 不接受這種形態)。
54
+ function turnsToMessages(turns) {
55
+ const messages = [];
56
+ let i = 0;
57
+ while (i < turns.length) {
58
+ const turn = turns[i];
59
+ if (turn.role === "user") {
60
+ messages.push({ role: "user", content: turn.text });
61
+ i++;
62
+ continue;
63
+ }
64
+ if (turn.role === "assistant") {
65
+ // raw 是上一輪原樣的 {role:"assistant", content:[...blocks]} 物件,原樣放回(§8/規格六)
66
+ messages.push(turn.raw);
67
+ i++;
68
+ continue;
69
+ }
70
+ // turn.role === "tool":吃掉所有連續的 tool turn,併成一則 user message
71
+ const toolResults = [];
72
+ while (i < turns.length && turns[i].role === "tool") {
73
+ const toolTurn = turns[i];
74
+ toolResults.push({ type: "tool_result", tool_use_id: toolTurn.callId, content: toolTurn.result });
75
+ i++;
76
+ }
77
+ messages.push({ role: "user", content: toolResults });
78
+ }
79
+ return messages;
80
+ }
81
+ // 純函式:組出實際會送出的請求,並執行 §8 的 raw 完整性自我檢查(違反即拋 RawIntegrityError,
82
+ // 不需打 API 就能測試——見 anthropic-messages.test.ts)。
83
+ export function buildAnthropicRequest(conv, opts, config) {
84
+ const messages = turnsToMessages(conv.turns);
85
+ checkRawObjectIntegrity(conv, messages);
86
+ return {
87
+ model: opts.model,
88
+ max_tokens: ANTHROPIC_MAX_TOKENS,
89
+ system: conv.systemPrompt,
90
+ messages,
91
+ // §29 規格八:top-level 自動快取,由 API 自行管理斷點位置並隨對話推進。低於該模型最低
92
+ // 可快取 token 數時 API 靜默不快取、不報錯,故此欄位可無條件加,不需前置估算。
93
+ cache_control: { type: "ephemeral" },
94
+ ...(opts.enableTools === false ? {} : { tools: [READ_FILE_TOOL] }),
95
+ ...buildReasoningParams(config.reasoning.style, opts.effort),
96
+ };
97
+ }
98
+ export function createAnthropicAdapter(config) {
99
+ return {
100
+ async send(conv, opts) {
101
+ const body = buildAnthropicRequest(conv, opts, config);
102
+ const res = await fetch(`${config.baseURL}/v1/messages`, {
103
+ method: "POST",
104
+ headers: {
105
+ "x-api-key": config.apiKey,
106
+ "anthropic-version": ANTHROPIC_VERSION,
107
+ "content-type": "application/json",
108
+ },
109
+ body: JSON.stringify(body),
110
+ signal: opts.signal,
111
+ });
112
+ const responseBody = await res.json();
113
+ if (!res.ok) {
114
+ throw new ProviderHttpError(`Anthropic Messages API ${res.status}`, res.status, Object.fromEntries(res.headers.entries()), responseBody, body);
115
+ }
116
+ const content = responseBody.content ?? [];
117
+ const toolUseBlocks = content.filter((b) => b.type === "tool_use");
118
+ const toolCalls = toolUseBlocks.map((b) => ({
119
+ id: b.id,
120
+ name: b.name,
121
+ args: b.input ?? {},
122
+ }));
123
+ const text = content
124
+ .filter((b) => b.type === "text")
125
+ .map((b) => b.text)
126
+ .join("");
127
+ const { finishReason, finishReasonRaw } = normalizeFinishReason(responseBody.stop_reason ?? null);
128
+ return {
129
+ turn: { role: "assistant", raw: { role: "assistant", content }, toolCalls },
130
+ usage: normalizeAnthropicUsage(responseBody.usage),
131
+ usageRaw: responseBody.usage ?? null,
132
+ meta: {
133
+ modelReturned: responseBody.model ?? null,
134
+ finishReason,
135
+ finishReasonRaw,
136
+ requestId: responseBody.id ?? null,
137
+ store: "n/a", // §6:Messages API 無 store 概念(規格九)
138
+ text: toolCalls.length === 0 ? text || null : null,
139
+ },
140
+ request: body,
141
+ response: responseBody,
142
+ };
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,114 @@
1
+ // plan_dispatch_v1.4.md §7/§8:gemini-native adapter,原生 generateContent,不套 OpenAI
2
+ // 相容殼(decision「介面策略」;issue_log_v1.md 2026-08-05「作一個專門給 Gemini」裁示)。
3
+ // 金鑰走 x-goog-api-key header,不進 URL query string(§18)。
4
+ //
5
+ // assistant turn 的 raw 就是 candidate.content({role:"model", parts:[...]})整個物件,
6
+ // 續接時原樣放回 contents(§8)——gemini-3.x 系列的 thoughtSignature 掛在 part 上,
7
+ // 隨 raw 一起帶回,不需另闢欄位承接。
8
+ import { normalizeFinishReason, normalizeGeminiUsage } from "../usage.js";
9
+ import { ProviderHttpError } from "../mask.js";
10
+ import { checkRawObjectIntegrity } from "../raw-integrity.js";
11
+ const GEMINI_TOOL = {
12
+ functionDeclarations: [
13
+ {
14
+ name: "read_file",
15
+ description: "讀取指定路徑的檔案內容",
16
+ parameters: {
17
+ type: "object",
18
+ properties: { path: { type: "string" } },
19
+ required: ["path"],
20
+ },
21
+ },
22
+ ],
23
+ };
24
+ function turnToContent(turn) {
25
+ if (turn.role === "user") {
26
+ return { role: "user", parts: [{ text: turn.text }] };
27
+ }
28
+ if (turn.role === "tool") {
29
+ // functionResponse 以 name 對應,不需要 call id(實測確認,見 issue_log)
30
+ let responseObj;
31
+ try {
32
+ responseObj = JSON.parse(turn.result);
33
+ }
34
+ catch {
35
+ responseObj = { result: turn.result };
36
+ }
37
+ return {
38
+ role: "user",
39
+ parts: [{ functionResponse: { name: turn.callId, response: responseObj } }],
40
+ };
41
+ }
42
+ // assistant:raw 就是上一輪 candidate.content,原樣放回
43
+ return turn.raw;
44
+ }
45
+ // plan_dispatch_v1.8.md §5「reasoning.style 的三種轉換」(實測確認):位置與大小寫都關鍵——
46
+ // 頂層 thinking_level 會 400;巢狀內 snake_case(thinking_level)會 200 但 Google 對未知
47
+ // 欄位靜默忽略、不生效。正確路徑是 generationConfig.thinkingConfig.thinkingLevel(camelCase)。
48
+ function buildReasoningParams(effort) {
49
+ if (!effort)
50
+ return {};
51
+ return { generationConfig: { thinkingConfig: { thinkingLevel: effort } } };
52
+ }
53
+ // 純函式:組出實際會送出的 request body,並執行 §8 的 raw 完整性自我檢查(違反即拋
54
+ // RawIntegrityError,不需打 API 就能測試——見 raw-integrity.test.ts)。
55
+ export function buildGeminiRequest(conv, opts) {
56
+ const contents = conv.turns.map(turnToContent);
57
+ checkRawObjectIntegrity(conv, contents);
58
+ const body = {
59
+ contents,
60
+ system_instruction: { parts: [{ text: conv.systemPrompt }] },
61
+ ...(opts.enableTools === false ? {} : { tools: [GEMINI_TOOL] }),
62
+ ...buildReasoningParams(opts.effort),
63
+ };
64
+ return { contents, body };
65
+ }
66
+ export function createGeminiAdapter(config) {
67
+ return {
68
+ async send(conv, opts) {
69
+ const { body } = buildGeminiRequest(conv, opts);
70
+ const res = await fetch(`${config.baseURL}/models/${opts.model}:generateContent`, {
71
+ method: "POST",
72
+ headers: { "x-goog-api-key": config.apiKey, "Content-Type": "application/json" },
73
+ body: JSON.stringify(body),
74
+ signal: opts.signal,
75
+ });
76
+ const responseBody = await res.json();
77
+ if (!res.ok) {
78
+ throw new ProviderHttpError(`Gemini native API ${res.status}`, res.status, Object.fromEntries(res.headers.entries()), responseBody, body);
79
+ }
80
+ const candidate = responseBody.candidates?.[0];
81
+ const parts = candidate?.content?.parts ?? [];
82
+ const callParts = parts.filter((p) => p.functionCall);
83
+ const toolCalls = callParts.map((p, index) => ({
84
+ id: p.functionCall?.id ?? `gemini-call-${index}`,
85
+ name: p.functionCall.name,
86
+ args: p.functionCall.args ?? {},
87
+ }));
88
+ const text = parts
89
+ .filter((p) => typeof p.text === "string")
90
+ .map((p) => p.text)
91
+ .join("");
92
+ const { finishReason, finishReasonRaw } = normalizeFinishReason(candidate?.finishReason ?? null);
93
+ return {
94
+ turn: {
95
+ role: "assistant",
96
+ raw: candidate?.content ?? { role: "model", parts: [] },
97
+ toolCalls,
98
+ },
99
+ usage: normalizeGeminiUsage(responseBody.usageMetadata),
100
+ usageRaw: responseBody.usageMetadata ?? null,
101
+ meta: {
102
+ modelReturned: responseBody.modelVersion ?? null,
103
+ finishReason,
104
+ finishReasonRaw,
105
+ requestId: responseBody.responseId ?? null,
106
+ store: "n/a", // §6:generateContent 無此參數
107
+ text: toolCalls.length === 0 ? text || null : null,
108
+ },
109
+ request: body,
110
+ response: responseBody,
111
+ };
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,112 @@
1
+ // plan_dispatch_v1.4.md §7/§8:openai/deepseek 共用的 `responses` adapter。
2
+ // 全面 stateless(§6):每輪把 conv.turns 整段轉成 input 陣列重送,不用
3
+ // previous_response_id。assistant turn 續接用 raw(該輪 response.output 整包),
4
+ // 不用 toolCalls 重建——這是 v1.3 犯錯、v1.4 §8 明訂修正的地方。
5
+ import OpenAI from "openai";
6
+ import { normalizeFinishReason, normalizeResponsesUsage } from "../usage.js";
7
+ import { ProviderHttpError } from "../mask.js";
8
+ import { checkRawArrayIntegrity } from "../raw-integrity.js";
9
+ const READ_FILE_TOOL = {
10
+ type: "function",
11
+ name: "read_file",
12
+ description: "讀取指定路徑的檔案內容",
13
+ parameters: {
14
+ type: "object",
15
+ properties: { path: { type: "string" } },
16
+ required: ["path"],
17
+ },
18
+ strict: false,
19
+ };
20
+ // plan_dispatch_v1.8.md §5「reasoning.style 的三種轉換」(實測確認,v1.7 兩處填錯已修正):
21
+ // deepseek 正確路徑是 output_config.effort,不是 chat/completions 章節的
22
+ // thinking/reasoning_effort——後者對 /v1/responses 端點回顯欄位但靜默不生效
23
+ // (issue_log_v1.md 2026-08-06)。
24
+ function buildReasoningParams(style, effort) {
25
+ if (!effort)
26
+ return {}; // §4:留白則不送任何 reasoning 參數(v1 起 validate.ts 已解析為 default,實務上不會是 undefined)
27
+ if (style === "openai")
28
+ return { reasoning: { effort } };
29
+ if (style === "deepseek")
30
+ return { output_config: { effort } };
31
+ throw new Error(`responses adapter 不支援 reasoning.style=${style}`);
32
+ }
33
+ function turnToInputItems(turn) {
34
+ if (turn.role === "user") {
35
+ return [{ role: "user", content: [{ type: "input_text", text: turn.text }] }];
36
+ }
37
+ if (turn.role === "tool") {
38
+ return [{ type: "function_call_output", call_id: turn.callId, output: turn.result }];
39
+ }
40
+ // assistant:raw 是上一輪的完整 response.output 陣列,原樣展開塞回(§8)
41
+ return turn.raw;
42
+ }
43
+ // 純函式:組出實際會送出的請求,並執行 §8 的 raw 完整性自我檢查(違反即拋
44
+ // RawIntegrityError,不需打 API 就能測試——見 raw-integrity.test.ts)。
45
+ export function buildResponsesRequest(conv, opts, config) {
46
+ const input = conv.turns.flatMap(turnToInputItems);
47
+ checkRawArrayIntegrity(conv, input);
48
+ return {
49
+ model: opts.model,
50
+ instructions: conv.systemPrompt,
51
+ input,
52
+ ...(opts.enableTools === false ? {} : { tools: [READ_FILE_TOOL] }),
53
+ ...(config.store === false ? { store: false } : {}),
54
+ include: ["reasoning.encrypted_content"],
55
+ ...buildReasoningParams(config.reasoning.style, opts.effort),
56
+ };
57
+ }
58
+ export function createResponsesAdapter(config) {
59
+ const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL });
60
+ return {
61
+ async send(conv, opts) {
62
+ const params = buildResponsesRequest(conv, opts, config);
63
+ let response;
64
+ try {
65
+ response = await client.responses.create(params, { signal: opts.signal });
66
+ }
67
+ catch (err) {
68
+ if (err && typeof err === "object" && "status" in err) {
69
+ const anyErr = err;
70
+ throw new ProviderHttpError(anyErr.message ?? "responses adapter 呼叫失敗", anyErr.status ?? 0, anyErr.headers ?? {}, anyErr.error, params);
71
+ }
72
+ throw err;
73
+ }
74
+ const functionCalls = response.output.filter((item) => item.type === "function_call");
75
+ const toolCalls = functionCalls.map((call) => ({
76
+ id: call.call_id,
77
+ name: call.name,
78
+ args: safeParseArgs(call.arguments),
79
+ }));
80
+ const messageItem = response.output.find((item) => item.type === "message");
81
+ const text = messageItem?.content
82
+ .filter((c) => c.type === "output_text")
83
+ .map((c) => c.text)
84
+ .join("") ?? null;
85
+ const { finishReason, finishReasonRaw } = normalizeFinishReason(response.status);
86
+ return {
87
+ turn: { role: "assistant", raw: response.output, toolCalls },
88
+ usage: normalizeResponsesUsage(response.usage),
89
+ usageRaw: response.usage ?? null,
90
+ meta: {
91
+ modelReturned: response.model ?? null,
92
+ finishReason,
93
+ finishReasonRaw,
94
+ requestId: response.id ?? null,
95
+ store: config.store === false ? "false" : "n/a",
96
+ text: functionCalls.length === 0 ? text : null,
97
+ },
98
+ request: params,
99
+ response,
100
+ };
101
+ },
102
+ };
103
+ }
104
+ function safeParseArgs(raw) {
105
+ try {
106
+ const parsed = JSON.parse(raw);
107
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }