dowafu 0.1.0 → 0.3.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 +44 -25
- package/README_zh-tw.md +197 -0
- package/dist/adapters/anthropic-messages.js +18 -11
- package/dist/adapters/gemini-native.js +19 -16
- package/dist/adapters/read-file-tool-description.js +5 -0
- package/dist/adapters/responses.js +22 -14
- package/dist/audit.js +17 -1
- package/dist/cli-args.js +88 -38
- package/dist/cli.js +60 -43
- package/dist/dispatch-home.js +24 -6
- package/dist/gate.js +3 -2
- package/dist/mask.js +16 -3
- package/dist/messages.js +342 -0
- package/dist/output.js +60 -37
- package/dist/prompt.js +5 -3
- package/dist/providers.js +46 -34
- package/dist/raw-integrity.js +6 -5
- package/dist/report.js +76 -22
- package/dist/runner.js +21 -10
- package/dist/ticket.js +27 -25
- package/dist/validate.js +21 -20
- package/dist/whitelist.js +9 -1
- package/package.json +3 -2
- package/publish/en/.agents/skills/find-holes-external/SKILL.md +394 -0
- package/publish/en/.agents/skills/preflight/SKILL.md +120 -0
- package/publish/en/.agents/skills/wrap/SKILL.md +64 -0
- package/publish/en/.claude/agents/explore-haiku.md +8 -0
- package/publish/en/.claude/agents/hole-finder-cost.md +15 -0
- package/publish/en/.claude/agents/hole-finder-feasibility.md +15 -0
- package/publish/en/.claude/agents/hole-finder-safety.md +15 -0
- package/publish/en/.claude/agents/hole-finder.md +14 -0
- package/publish/en/.claude/skills/find-holes/SKILL.md +109 -0
- package/publish/en/.claude/skills/find-holes-external/SKILL.md +407 -0
- package/publish/en/.claude/skills/preflight/SKILL.md +179 -0
- package/publish/en/.claude/skills/wrap/SKILL.md +61 -0
- package/publish/en/README.md +106 -0
- package/publish/en/workflow_spec.md +71 -0
- package/publish/{.agents → zh-tw/.agents}/skills/find-holes-external/SKILL.md +109 -18
- package/publish/{.agents → zh-tw/.agents}/skills/preflight/SKILL.md +22 -5
- package/publish/{.claude → zh-tw/.claude}/skills/find-holes/SKILL.md +21 -4
- package/publish/{.claude → zh-tw/.claude}/skills/find-holes-external/SKILL.md +108 -17
- package/publish/{.claude → zh-tw/.claude}/skills/preflight/SKILL.md +21 -4
- package/publish/{README.md → zh-tw/README.md} +16 -0
- /package/publish/{.agents → zh-tw/.agents}/skills/wrap/SKILL.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/explore-haiku.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-cost.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-feasibility.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-safety.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/skills/wrap/SKILL.md +0 -0
- /package/publish/{workflow_spec.md → zh-tw/workflow_spec.md} +0 -0
package/dist/providers.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// plan_dispatch_v1.4.md §5:providers.json 載入與驗證。fail closed——api 欄缺失或
|
|
2
2
|
// store:true 即中止(exit 2),不套用預設值繼續跑(§10 步驟 3)。
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { DispatchError } from "./types.js";
|
|
5
|
-
|
|
4
|
+
import { DispatchError, } from "./types.js";
|
|
5
|
+
import { m } from "./messages.js";
|
|
6
|
+
function parseReasoning(raw, providerName, lang) {
|
|
6
7
|
if (!raw || typeof raw !== "object") {
|
|
7
8
|
return { style: null, allowed: [] };
|
|
8
9
|
}
|
|
9
10
|
const r = raw;
|
|
10
11
|
const style = r.style;
|
|
11
12
|
if (style !== undefined && style !== "openai" && style !== "deepseek" && style !== "gemini" && style !== "anthropic") {
|
|
12
|
-
throw new DispatchError(
|
|
13
|
+
throw new DispatchError(m(lang, "reasoningStyleInvalid", providerName, String(style)), 2);
|
|
13
14
|
}
|
|
14
15
|
const allowed = Array.isArray(r.allowed) ? r.allowed.filter((v) => typeof v === "string") : [];
|
|
15
16
|
const modelOverrides = r.modelOverrides && typeof r.modelOverrides === "object"
|
|
@@ -21,75 +22,86 @@ function parseReasoning(raw, providerName) {
|
|
|
21
22
|
let def;
|
|
22
23
|
if (r.default !== undefined) {
|
|
23
24
|
if (typeof r.default !== "string") {
|
|
24
|
-
throw new DispatchError(
|
|
25
|
+
throw new DispatchError(m(lang, "reasoningDefaultNotString", providerName), 2);
|
|
25
26
|
}
|
|
26
27
|
def = r.default;
|
|
27
28
|
}
|
|
28
29
|
if (allowed.length > 0 && def === undefined) {
|
|
29
|
-
throw new DispatchError(
|
|
30
|
+
throw new DispatchError(m(lang, "reasoningDefaultMissing", providerName), 2);
|
|
30
31
|
}
|
|
31
32
|
if (def !== undefined && !allowed.includes(def)) {
|
|
32
|
-
|
|
33
|
+
const list = allowed.length > 0 ? allowed.join(", ") : m(lang, "emptyList");
|
|
34
|
+
throw new DispatchError(m(lang, "reasoningDefaultNotAllowed", providerName, def, list), 2);
|
|
33
35
|
}
|
|
34
36
|
return { style: style ?? null, allowed, default: def, modelOverrides };
|
|
35
37
|
}
|
|
36
38
|
// plan_fixes_v1.0.md §4:pricing 為選填——缺席的 provider/模型無法估算成本(cost.ts
|
|
37
39
|
// 回傳 null,不是 0),不強制每個 provider 都要有價目。有填就驗完整,格式錯即中止
|
|
38
40
|
// (fail closed,與本檔其餘欄位一致),不悄悄忽略壞掉的價目導致成本估算安靜地錯。
|
|
39
|
-
function parsePositiveNumber(v, label) {
|
|
41
|
+
function parsePositiveNumber(v, label, lang) {
|
|
40
42
|
if (typeof v !== "number" || !(v > 0)) {
|
|
41
|
-
throw new DispatchError(
|
|
43
|
+
throw new DispatchError(m(lang, "positiveNumberRequired", label, JSON.stringify(v)), 2);
|
|
42
44
|
}
|
|
43
45
|
return v;
|
|
44
46
|
}
|
|
45
|
-
function parsePricing(raw, providerName) {
|
|
47
|
+
function parsePricing(raw, providerName, lang) {
|
|
46
48
|
if (raw === undefined)
|
|
47
49
|
return undefined;
|
|
48
50
|
if (!raw || typeof raw !== "object") {
|
|
49
|
-
throw new DispatchError(
|
|
51
|
+
throw new DispatchError(m(lang, "pricingNotObject", providerName), 2);
|
|
50
52
|
}
|
|
51
53
|
const out = {};
|
|
52
54
|
for (const [model, entry] of Object.entries(raw)) {
|
|
53
55
|
if (!entry || typeof entry !== "object") {
|
|
54
|
-
throw new DispatchError(
|
|
56
|
+
throw new DispatchError(m(lang, "pricingModelNotObject", providerName, model), 2);
|
|
55
57
|
}
|
|
56
58
|
const e = entry;
|
|
57
59
|
out[model] = {
|
|
58
|
-
inputPerM: parsePositiveNumber(e.inputPerM, `${providerName}.pricing.${model}.inputPerM
|
|
59
|
-
outputPerM: parsePositiveNumber(e.outputPerM, `${providerName}.pricing.${model}.outputPerM
|
|
60
|
+
inputPerM: parsePositiveNumber(e.inputPerM, `${providerName}.pricing.${model}.inputPerM`, lang),
|
|
61
|
+
outputPerM: parsePositiveNumber(e.outputPerM, `${providerName}.pricing.${model}.outputPerM`, lang),
|
|
60
62
|
...(e.cachedInputPerM !== undefined
|
|
61
|
-
? { cachedInputPerM: parsePositiveNumber(e.cachedInputPerM, `${providerName}.pricing.${model}.cachedInputPerM
|
|
63
|
+
? { cachedInputPerM: parsePositiveNumber(e.cachedInputPerM, `${providerName}.pricing.${model}.cachedInputPerM`, lang) }
|
|
62
64
|
: {}),
|
|
63
65
|
...(e.cacheWritePerM !== undefined
|
|
64
|
-
? { cacheWritePerM: parsePositiveNumber(e.cacheWritePerM, `${providerName}.pricing.${model}.cacheWritePerM
|
|
66
|
+
? { cacheWritePerM: parsePositiveNumber(e.cacheWritePerM, `${providerName}.pricing.${model}.cacheWritePerM`, lang) }
|
|
65
67
|
: {}),
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
return out;
|
|
69
71
|
}
|
|
70
|
-
|
|
72
|
+
// v0.2.0:只取 `pricingSource.asOf`,而且**刻意寬鬆**——與上面的 `parsePricing` 相反。
|
|
73
|
+
// 理由是兩者性質不同:`pricing` 是計費輸入,錯了成本會安靜地算錯,所以 fail closed;
|
|
74
|
+
// `asOf` 只是報表上的一個查證日期,壞掉的後果是「少印一個日期」。對它 fail closed 會讓
|
|
75
|
+
// 一份價目完全正確的 `--providers` 檔,因為 metadata 打錯字而整個跑不起來。
|
|
76
|
+
function parsePricingAsOf(raw) {
|
|
77
|
+
if (!raw || typeof raw !== "object")
|
|
78
|
+
return undefined;
|
|
79
|
+
const asOf = raw.asOf;
|
|
80
|
+
return typeof asOf === "string" && asOf.trim().length > 0 ? asOf.trim() : undefined;
|
|
81
|
+
}
|
|
82
|
+
function parseProviderConfig(name, raw, lang) {
|
|
71
83
|
if (!raw || typeof raw !== "object") {
|
|
72
|
-
throw new DispatchError(
|
|
84
|
+
throw new DispatchError(m(lang, "providerConfigNotObject", name), 2);
|
|
73
85
|
}
|
|
74
86
|
const r = raw;
|
|
75
87
|
// §5:api 欄無預設值,介面選擇不容猜測——缺失即中止。
|
|
76
88
|
if (r.api !== "responses" && r.api !== "gemini-native" && r.api !== "anthropic-messages") {
|
|
77
|
-
throw new DispatchError(
|
|
89
|
+
throw new DispatchError(m(lang, "providerApiInvalid", name, JSON.stringify(r.api)), 2);
|
|
78
90
|
}
|
|
79
91
|
// §5:store 不可為 true——違反設計原則 6(零留存)即中止。
|
|
80
92
|
if (r.store === true) {
|
|
81
|
-
throw new DispatchError(
|
|
93
|
+
throw new DispatchError(m(lang, "providerStoreTrue", name), 2);
|
|
82
94
|
}
|
|
83
95
|
const store = r.store === null ? null : false; // 未填視為 false
|
|
84
96
|
if (typeof r.baseURL !== "string" || r.baseURL.length === 0) {
|
|
85
|
-
throw new DispatchError(
|
|
97
|
+
throw new DispatchError(m(lang, "providerBaseUrlMissing", name), 2);
|
|
86
98
|
}
|
|
87
99
|
// §5:models 白名單。未填(或非陣列)視為空陣列 = 不做型號檢查。
|
|
88
100
|
const models = Array.isArray(r.models) ? r.models.filter((v) => typeof v === "string") : [];
|
|
89
101
|
// §5:charsPerToken。未填時用 CLI 全域值——保留 null,不在此處套 1.0(CLI 端才知道
|
|
90
102
|
// 使用者傳入的 --chars-per-token 是多少)。
|
|
91
103
|
if (r.charsPerToken !== undefined && (typeof r.charsPerToken !== "number" || !(r.charsPerToken > 0))) {
|
|
92
|
-
throw new DispatchError(
|
|
104
|
+
throw new DispatchError(m(lang, "providerCharsPerTokenInvalid", name), 2);
|
|
93
105
|
}
|
|
94
106
|
const charsPerToken = typeof r.charsPerToken === "number" ? r.charsPerToken : null;
|
|
95
107
|
return {
|
|
@@ -97,12 +109,13 @@ function parseProviderConfig(name, raw) {
|
|
|
97
109
|
api: r.api,
|
|
98
110
|
store,
|
|
99
111
|
toolCalling: r.toolCalling === true,
|
|
100
|
-
reasoning: parseReasoning(r.reasoning, name),
|
|
112
|
+
reasoning: parseReasoning(r.reasoning, name, lang),
|
|
101
113
|
models,
|
|
102
114
|
charsPerToken,
|
|
103
115
|
tpmLimit: typeof r.tpmLimit === "number" ? r.tpmLimit : null,
|
|
104
116
|
maxSpokeTokens: typeof r.maxSpokeTokens === "number" ? r.maxSpokeTokens : null,
|
|
105
|
-
pricing: parsePricing(r.pricing, name),
|
|
117
|
+
pricing: parsePricing(r.pricing, name, lang),
|
|
118
|
+
pricingAsOf: parsePricingAsOf(r.pricingSource),
|
|
106
119
|
};
|
|
107
120
|
}
|
|
108
121
|
// plan_dispatch_v1.10.md §24.3:providers.json 隨工具出貨,不可覆寫(方案 D)。
|
|
@@ -110,42 +123,41 @@ function parseProviderConfig(name, raw) {
|
|
|
110
123
|
// 在載入時做,不符即中止,防跨版本漂移(一份舊版 providers.json 寫著已證實不生效的
|
|
111
124
|
// 參數路徑,可能回顯、不報錯,行為完全不受控,facts_dispatch.md 2026-08-06 已有實例)。
|
|
112
125
|
export const PROVIDERS_FORMAT_VERSION = 1;
|
|
113
|
-
export function parseProvidersFile(json) {
|
|
126
|
+
export function parseProvidersFile(json, lang) {
|
|
114
127
|
if (!json || typeof json !== "object") {
|
|
115
|
-
throw new DispatchError(
|
|
128
|
+
throw new DispatchError(m(lang, "providersFileNotObject"), 2);
|
|
116
129
|
}
|
|
117
130
|
const { formatVersion, ...providerEntries } = json;
|
|
118
131
|
if (formatVersion !== PROVIDERS_FORMAT_VERSION) {
|
|
119
|
-
throw new DispatchError(
|
|
120
|
-
`這通常代表 --providers 指向了舊版或不相容的檔案。`, 2);
|
|
132
|
+
throw new DispatchError(m(lang, "providersFormatVersionMismatch", PROVIDERS_FORMAT_VERSION, JSON.stringify(formatVersion)), 2);
|
|
121
133
|
}
|
|
122
134
|
const out = {};
|
|
123
135
|
for (const [name, raw] of Object.entries(providerEntries)) {
|
|
124
|
-
out[name] = parseProviderConfig(name, raw);
|
|
136
|
+
out[name] = parseProviderConfig(name, raw, lang);
|
|
125
137
|
}
|
|
126
138
|
return out;
|
|
127
139
|
}
|
|
128
|
-
export async function loadProviders(filePath) {
|
|
140
|
+
export async function loadProviders(filePath, lang) {
|
|
129
141
|
let text;
|
|
130
142
|
try {
|
|
131
143
|
text = await readFile(filePath, "utf8");
|
|
132
144
|
}
|
|
133
145
|
catch {
|
|
134
|
-
throw new DispatchError(
|
|
146
|
+
throw new DispatchError(m(lang, "missingProviders", filePath), 2);
|
|
135
147
|
}
|
|
136
148
|
let json;
|
|
137
149
|
try {
|
|
138
150
|
json = JSON.parse(text);
|
|
139
151
|
}
|
|
140
152
|
catch (err) {
|
|
141
|
-
throw new DispatchError(
|
|
153
|
+
throw new DispatchError(m(lang, "providersFileInvalidJson", err.message), 2);
|
|
142
154
|
}
|
|
143
|
-
return parseProvidersFile(json);
|
|
155
|
+
return parseProvidersFile(json, lang);
|
|
144
156
|
}
|
|
145
|
-
export function getProviderConfig(providers, name) {
|
|
157
|
+
export function getProviderConfig(providers, name, lang) {
|
|
146
158
|
const config = providers[name];
|
|
147
159
|
if (!config) {
|
|
148
|
-
throw new DispatchError(
|
|
160
|
+
throw new DispatchError(m(lang, "providerUndefined", name), 2);
|
|
149
161
|
}
|
|
150
162
|
return config;
|
|
151
163
|
}
|
package/dist/raw-integrity.js
CHANGED
|
@@ -17,33 +17,34 @@
|
|
|
17
17
|
// 物件被傳過去」,而非「長得像的東西存在」,後者會掩蓋掉真正的 bug(例如重新建構出一個
|
|
18
18
|
// 內容相似但簽章欄位被序列化過程改寫的複製品)。若未來 adapter 需要對 raw 做任何正規化,
|
|
19
19
|
// 這個判準本身就需要重新設計——那是規格問題,不是這裡的實作可以自行決定的。
|
|
20
|
+
import { m } from "./messages.js";
|
|
20
21
|
export class RawIntegrityError extends Error {
|
|
21
22
|
constructor(message) {
|
|
22
23
|
super(message);
|
|
23
24
|
this.name = "RawIntegrityError";
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
export function checkRawArrayIntegrity(conv, builtInput) {
|
|
27
|
+
export function checkRawArrayIntegrity(conv, builtInput, lang) {
|
|
27
28
|
for (const turn of conv.turns) {
|
|
28
29
|
if (turn.role !== "assistant")
|
|
29
30
|
continue;
|
|
30
31
|
if (!Array.isArray(turn.raw)) {
|
|
31
|
-
throw new RawIntegrityError(
|
|
32
|
+
throw new RawIntegrityError(m(lang, "rawIntegrityNotArray"));
|
|
32
33
|
}
|
|
33
34
|
for (const item of turn.raw) {
|
|
34
35
|
if (!builtInput.includes(item)) {
|
|
35
36
|
const type = item?.type ?? "?";
|
|
36
|
-
throw new RawIntegrityError(
|
|
37
|
+
throw new RawIntegrityError(m(lang, "rawIntegrityItemNotFound", type));
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
|
-
export function checkRawObjectIntegrity(conv, builtContents) {
|
|
42
|
+
export function checkRawObjectIntegrity(conv, builtContents, lang) {
|
|
42
43
|
for (const turn of conv.turns) {
|
|
43
44
|
if (turn.role !== "assistant")
|
|
44
45
|
continue;
|
|
45
46
|
if (!builtContents.includes(turn.raw)) {
|
|
46
|
-
throw new RawIntegrityError(
|
|
47
|
+
throw new RawIntegrityError(m(lang, "rawIntegrityObjectNotFound"));
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
package/dist/report.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// plan_dispatch_v1.4.md §11:派工報表(呼叫前)。模型錯了、effort 不如預期、介面走錯、
|
|
2
2
|
// 成本量級不對,在送出前就看得到。不使用 ✓/✗ 標記——TPM 是滑動窗,429 等待會動態改變
|
|
3
3
|
// 實際並行數,靜態估算無法精確預測。
|
|
4
|
+
import { stripFrontmatter } from "./ticket.js";
|
|
5
|
+
import { FIXED_CLOSING_LINE, FIXED_CLOSING_LINE_EN } from "./audit.js";
|
|
6
|
+
import { m } from "./messages.js";
|
|
4
7
|
export function effectiveCap(spoke, cli) {
|
|
5
8
|
return spoke.providerConfig.maxSpokeTokens ?? cli.maxSpokeTokens;
|
|
6
9
|
}
|
|
7
10
|
export function effectiveCharsPerToken(spoke, cli) {
|
|
8
11
|
return spoke.providerConfig.charsPerToken ?? cli.charsPerToken;
|
|
9
12
|
}
|
|
10
|
-
function formatProvidersSource(src) {
|
|
13
|
+
function formatProvidersSource(src, lang) {
|
|
11
14
|
if (src.kind === "bundled")
|
|
12
|
-
return
|
|
13
|
-
return
|
|
15
|
+
return m(lang, "providersBundled", src.formatVersion);
|
|
16
|
+
return m(lang, "providersExplicit", src.path ?? "", src.formatVersion);
|
|
14
17
|
}
|
|
15
18
|
// v1.10 §10:三態不得合併——「未忽略」是需要處理的狀態,「無法判定」不是,措辭須區分。
|
|
16
19
|
//
|
|
@@ -18,22 +21,64 @@ function formatProvidersSource(src) {
|
|
|
18
21
|
// 已訂輸出目錄屬呼叫端 cwd,checkGitignore 檢查的也是 cwd 所屬的 repo——cwd 與
|
|
19
22
|
// --repo-root 指的目標專案是兩回事(v1.11 §10 修的正是這個基準不一致)。「目標專案」
|
|
20
23
|
// 這個詞在 cwd ≠ repoRoot 時會指錯對象,故改為不預設兩者相同的措辭。
|
|
21
|
-
function formatGitignoreWarning(status, outDir) {
|
|
24
|
+
function formatGitignoreWarning(status, outDir, lang) {
|
|
22
25
|
if (status === "ignored")
|
|
23
26
|
return null;
|
|
24
27
|
if (status === "not_ignored") {
|
|
25
|
-
return
|
|
28
|
+
return m(lang, "gitignoreNotIgnored", outDir);
|
|
26
29
|
}
|
|
27
|
-
return
|
|
30
|
+
return m(lang, "gitignoreUnknown", outDir);
|
|
28
31
|
}
|
|
29
|
-
|
|
32
|
+
// plan_i18n_v1.2.md §6.3:呼叫前提示 lens 收尾句落在哪一個 FIXED_CLOSING_LINE*——只提示不擋
|
|
33
|
+
// (同既有 gitignore 警告),讓 lens 檔案本身的收尾句指示(如「回報最後一行固定為:...」)
|
|
34
|
+
// 在真正付費呼叫之前先被人看到有沒有漂移。agentBody 已在 resolveSpokes 剝過 frontmatter,
|
|
35
|
+
// 這裡再跑一次 stripFrontmatter 是冪等的(不再以 --- 開頭,regex 不命中),不需重讀檔。
|
|
36
|
+
function lensClosingLineStatus(agentBody) {
|
|
37
|
+
const lines = stripFrontmatter(agentBody).split(/\r?\n/).map((l) => l.trim());
|
|
38
|
+
const lastNonEmpty = [...lines].reverse().find((l) => l.length > 0) ?? "";
|
|
39
|
+
if (lastNonEmpty.includes(FIXED_CLOSING_LINE))
|
|
40
|
+
return "zh";
|
|
41
|
+
if (lastNonEmpty.includes(FIXED_CLOSING_LINE_EN))
|
|
42
|
+
return "en";
|
|
43
|
+
return "none";
|
|
44
|
+
}
|
|
45
|
+
// 沒有收尾句的 lens(如 explore-haiku.md,不是 hole-finder 系列、不受這份收尾句約定拘束)
|
|
46
|
+
// 回傳 null、不印任何提示——只對「有收尾句、值得比對」的 lens 發聲,才不會對每一支
|
|
47
|
+
// 不相干的 lens 都噴一行「未偵測到」的常駐噪音。
|
|
48
|
+
function formatLensClosingLineNotice(spoke, lang) {
|
|
49
|
+
const status = lensClosingLineStatus(spoke.agentBody);
|
|
50
|
+
if (status === "zh")
|
|
51
|
+
return m(lang, "lensClosingLineZh", spoke.agent);
|
|
52
|
+
if (status === "en")
|
|
53
|
+
return m(lang, "lensClosingLineEn", spoke.agent);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// v0.2.0:把本次型號的單價印進乾跑報表。起因是 /publish-check 判斷清單第 4 項
|
|
57
|
+
// (「指向的機制在外部專案存在嗎」):skill 改成「價目查 providers.json,不要查官網」之後,
|
|
58
|
+
// 外部專案的 hub 其實找不到那個檔——它隨套件出貨,躺在 node_modules 或全域安裝目錄底下,
|
|
59
|
+
// 路徑帶版本號與相依雜湊,猜不到。找不到就會退回舊行為去查官網,等於那條規則只修了一半。
|
|
60
|
+
//
|
|
61
|
+
// **這一行讓那個檔不必被找到。** 順帶也解決 `--providers` 覆寫之後 hub 讀到錯的那一份。
|
|
62
|
+
// 缺價目時明講「無價目資料」而不是印 0——與 cost.ts 的 null 語意一致(§4:「無法估算」
|
|
63
|
+
// 與「估算出來是零」不是同一件事)。
|
|
64
|
+
function formatModelPricing(spoke, lang) {
|
|
65
|
+
const pricing = spoke.providerConfig.pricing?.[spoke.model];
|
|
66
|
+
if (!pricing)
|
|
67
|
+
return m(lang, "modelPricingMissing", spoke.model);
|
|
68
|
+
const cached = pricing.cachedInputPerM !== undefined ? m(lang, "modelPricingCachedSuffix", pricing.cachedInputPerM) : "";
|
|
69
|
+
const asOf = spoke.providerConfig.pricingAsOf
|
|
70
|
+
? m(lang, "modelPricingAsOfSuffix", spoke.providerConfig.pricingAsOf)
|
|
71
|
+
: "";
|
|
72
|
+
return m(lang, "modelPricing", pricing.inputPerM, pricing.outputPerM, cached, asOf);
|
|
73
|
+
}
|
|
74
|
+
export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli, outDir, meta, lang) {
|
|
30
75
|
const estByAgent = new Map(estimates.map((e) => [e.agent, e.estimatedTokens]));
|
|
31
|
-
const lines = [
|
|
76
|
+
const lines = [m(lang, "aboutToDispatch", ticketId)];
|
|
32
77
|
// v1.10 §11:repoRoot 明列於報表,與 store 同性質的可見性保證——白名單是整套隔離的
|
|
33
78
|
// 唯一支點(§7),其邊界由「從哪個目錄下指令」決定,這件事在報表上必須看得見,否則
|
|
34
79
|
// 使用者無從發現自己在錯的目錄下跑。
|
|
35
80
|
lines.push(` repoRoot ${meta.repoRoot}`);
|
|
36
|
-
lines.push(` providers ${formatProvidersSource(meta.providersSource)}`);
|
|
81
|
+
lines.push(` providers ${formatProvidersSource(meta.providersSource, lang)}`);
|
|
37
82
|
for (const spoke of spokes) {
|
|
38
83
|
const est = estByAgent.get(spoke.agent) ?? 0;
|
|
39
84
|
const cap = effectiveCap(spoke, cli);
|
|
@@ -41,6 +86,10 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
|
|
|
41
86
|
`[${spoke.providerConfig.api}] effort=${spoke.effort ?? "—"} lang=${spoke.lang} ` +
|
|
42
87
|
`store=${spoke.providerConfig.store === false ? "false" : "n/a"} ` +
|
|
43
88
|
`est. ${est.toLocaleString()} cap ${cap.toLocaleString()}`);
|
|
89
|
+
lines.push(` ${formatModelPricing(spoke, lang)}`);
|
|
90
|
+
const closingLineNotice = formatLensClosingLineNotice(spoke, lang);
|
|
91
|
+
if (closingLineNotice)
|
|
92
|
+
lines.push(` ${closingLineNotice}`);
|
|
44
93
|
}
|
|
45
94
|
const totalEst = estimates.reduce((s, e) => s + e.estimatedTokens, 0);
|
|
46
95
|
const worstTotal = spokes.reduce((s, spoke) => s + effectiveCap(spoke, cli), 0);
|
|
@@ -48,37 +97,42 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
|
|
|
48
97
|
// 這個數字只涵蓋 system prompt + buildFirstUserText() 產出的第一則訊息,不含工單與
|
|
49
98
|
// 允許清單本身(那些是 spoke 自己用 tool call 讀進去的)。實測差距 27 倍(2,593 vs
|
|
50
99
|
// 71,482)。標籤明講範圍,並緊接印出允許清單總量,讓兩個數字一起出現、不必自己去找。
|
|
51
|
-
lines.push(
|
|
100
|
+
lines.push(m(lang, "initialPromptEstimate", totalEst.toLocaleString(), cli.maxTokens.toLocaleString()));
|
|
52
101
|
// §14:獨立於閘門一之外,只呈現不設閘門——閘門一不含允許清單與工單內容(issue_log_v2.0.md
|
|
53
102
|
// 2026-08-07),此數字才是「若整個允許清單都被讀完」的量級參考,門檻待樣本累積後再定。
|
|
54
103
|
const totalAllowlistTokens = allowlistEstimates.reduce((s, e) => s + e.estimatedTokens, 0);
|
|
55
104
|
const totalAllowlistFiles = allowlistEstimates.reduce((s, e) => s + e.fileCount, 0);
|
|
56
|
-
lines.push(
|
|
105
|
+
lines.push(m(lang, "allowlistTotalEstimate", totalAllowlistTokens.toLocaleString(), totalAllowlistFiles));
|
|
57
106
|
// plan_dispatch_v2.4.md §14:口徑說明——避免此數字被誤讀為預期消耗。三件事:
|
|
58
107
|
// (1) 字元數的上限估計,不是預期消耗;(2) 不去重,同一檔出現在多支清單會重複計入
|
|
59
108
|
// (正確行為:兩支各讀一次就是兩份成本);(3) 實測程式碼素材約 3.5 字元/token,
|
|
60
109
|
// 故實際消耗通常遠低於此數(charsPerToken 假設 1.0,係數本身刻意不動,見 §14)。
|
|
61
|
-
lines.push(
|
|
110
|
+
lines.push(m(lang, "allowlistEstimateCaveat"));
|
|
62
111
|
// issue_log_v2.1.md(第 8、9 次):多數模型一輪只叫一個檔,每輪重送全部歷史,故清單靠前
|
|
63
112
|
// 的檔會被重複計費多次。這一行是「順序造成的放大量」,也是唯一 hub 改得動的成本槓桿——
|
|
64
113
|
// 只印排序能省的部分,不印預估總量:總量還受「批次讀 vs 逐個讀」影響(模型決定,我們
|
|
65
114
|
// 控制不了),印出來會被當成預期值。省下的百分比則不受 charsPerToken 偏差影響(分子分母
|
|
66
115
|
// 抵銷),是這裡唯一可信的絕對數字。
|
|
116
|
+
//
|
|
117
|
+
// real-run-same-lens §九之6 要求這一行標明「此為逐個讀假設下的上限」,訊息文字已補。
|
|
118
|
+
// real-run-i18n-lang(2026-08-12)另加一條:讀檔形態**不只隨型號變,也隨 prompt 語言變**
|
|
119
|
+
// ——同型號同 effort,只把語言從中文換成英文,luna 的 feasibility 就從 9 輪(逐個)
|
|
120
|
+
// 變 4 輪(批次)。所以「這個廠牌會不會批次」在乾跑階段更不可能預先判定。
|
|
67
121
|
const seqAsListed = allowlistEstimates.reduce((s, e) => s + e.sequential.asListed, 0);
|
|
68
122
|
const seqSorted = allowlistEstimates.reduce((s, e) => s + e.sequential.sorted, 0);
|
|
69
123
|
if (seqAsListed > 0) {
|
|
70
124
|
const savedPct = Math.round(((seqAsListed - seqSorted) / seqAsListed) * 100);
|
|
71
|
-
lines.push(
|
|
125
|
+
lines.push(m(lang, "sequentialReadAmplification", seqAsListed.toLocaleString()));
|
|
72
126
|
if (savedPct >= 5) {
|
|
73
|
-
lines.push(
|
|
127
|
+
lines.push(m(lang, "sequentialReadCanReduce", seqSorted.toLocaleString(), savedPct));
|
|
74
128
|
}
|
|
75
129
|
else {
|
|
76
|
-
lines.push(
|
|
130
|
+
lines.push(m(lang, "sequentialReadNearOptimal", savedPct));
|
|
77
131
|
}
|
|
78
|
-
lines.push(
|
|
132
|
+
lines.push(m(lang, "sequentialReadCostNote"));
|
|
79
133
|
}
|
|
80
|
-
lines.push(
|
|
81
|
-
lines.push(
|
|
134
|
+
lines.push(m(lang, "worstCaseTotal", worstTotal.toLocaleString()));
|
|
135
|
+
lines.push(m(lang, "concurrencyLine", cli.concurrency));
|
|
82
136
|
const byProvider = new Map();
|
|
83
137
|
for (const spoke of spokes) {
|
|
84
138
|
const est = estByAgent.get(spoke.agent) ?? 0;
|
|
@@ -89,12 +143,12 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
|
|
|
89
143
|
for (const [provider, { tpmLimit, peak }] of byProvider) {
|
|
90
144
|
if (tpmLimit === null)
|
|
91
145
|
continue;
|
|
92
|
-
lines.push(
|
|
93
|
-
lines.push(
|
|
146
|
+
lines.push(m(lang, "tpmPeakLine", provider, tpmLimit.toLocaleString(), peak.toLocaleString()));
|
|
147
|
+
lines.push(m(lang, "tpmPeakCaveat"));
|
|
94
148
|
}
|
|
95
149
|
const allowedCount = spokes.reduce((s, spoke) => s + spoke.allowedReadsResolved.length, 0);
|
|
96
|
-
lines.push(
|
|
97
|
-
const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir);
|
|
150
|
+
lines.push(m(lang, "allowedReadsSummary", allowedCount, outDir));
|
|
151
|
+
const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir, lang);
|
|
98
152
|
if (gitignoreWarning)
|
|
99
153
|
lines.push(gitignoreWarning);
|
|
100
154
|
return lines.join("\n");
|
package/dist/runner.js
CHANGED
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { buildFinalizeUserText, buildFirstUserText, buildSystemPrompt } from "./prompt.js";
|
|
6
|
-
import {
|
|
6
|
+
import { allowlistRejectMessage, checkAllowlist } from "./whitelist.js";
|
|
7
7
|
import { findUnknownUsageKeys, sumUsage, usageProviderKeyFor } from "./usage.js";
|
|
8
8
|
import { estimateCostUsd } from "./cost.js";
|
|
9
9
|
import { describeError, ProviderHttpError } from "./mask.js";
|
|
10
10
|
import { parseRetryAfter } from "./rate-limit.js";
|
|
11
11
|
import { RawIntegrityError } from "./raw-integrity.js";
|
|
12
12
|
import { classifyError } from "./error-classify.js";
|
|
13
|
+
import { m } from "./messages.js";
|
|
13
14
|
const MAX_FILE_BYTES = 200 * 1024; // §13:單檔 200KB 上限,防「單輪讀入巨檔」異常
|
|
14
15
|
const MAX_ROUNDS = 60; // 安全上限,遠高於實務上的 --max-tool-calls,純防無窮迴圈 bug
|
|
16
|
+
// i18n_classification_t2.md §三之1:這兩則都成為 executeToolCall/runSpoke 的
|
|
17
|
+
// resultText,被 push 進 conv.turns 送往外部 API——消費者是 spoke 不是人類,歸 C 類,
|
|
18
|
+
// 不進 messages.ts,由 spoke.lang 直接選用(同 whitelist.ts 的 allowlistRejectMessage)。
|
|
19
|
+
const FILE_TRUNCATED_SUFFIX = "\n...(檔案超過 200KB 上限,內容已截斷)";
|
|
20
|
+
const FILE_TRUNCATED_SUFFIX_EN = "\n...(file exceeds the 200KB limit; content truncated)";
|
|
21
|
+
const TOOL_LIMIT_REACHED_MESSAGE = "已達 --max-tool-calls 上限,未執行";
|
|
22
|
+
const TOOL_LIMIT_REACHED_MESSAGE_EN = "The --max-tool-calls limit has been reached; not executed.";
|
|
15
23
|
function sleep(ms) {
|
|
16
24
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
25
|
}
|
|
@@ -21,7 +29,7 @@ async function executeToolCall(call, spoke, repoRoot) {
|
|
|
21
29
|
const check = checkAllowlist(spoke.allowSet, requestedPath, repoRoot);
|
|
22
30
|
if (!check.allowed) {
|
|
23
31
|
return {
|
|
24
|
-
resultText:
|
|
32
|
+
resultText: allowlistRejectMessage(spoke.lang),
|
|
25
33
|
log: { path: requestedPath, allowed: false, reason: check.reason, startedAt, durationMs: Date.now() - startedAt },
|
|
26
34
|
};
|
|
27
35
|
}
|
|
@@ -29,7 +37,8 @@ async function executeToolCall(call, spoke, repoRoot) {
|
|
|
29
37
|
const buf = await readFile(check.realPath);
|
|
30
38
|
let content = buf.toString("utf8");
|
|
31
39
|
if (buf.byteLength > MAX_FILE_BYTES) {
|
|
32
|
-
|
|
40
|
+
const suffix = spoke.lang === "en" ? FILE_TRUNCATED_SUFFIX_EN : FILE_TRUNCATED_SUFFIX;
|
|
41
|
+
content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + suffix;
|
|
33
42
|
}
|
|
34
43
|
return {
|
|
35
44
|
resultText: content,
|
|
@@ -38,7 +47,7 @@ async function executeToolCall(call, spoke, repoRoot) {
|
|
|
38
47
|
}
|
|
39
48
|
catch {
|
|
40
49
|
return {
|
|
41
|
-
resultText:
|
|
50
|
+
resultText: allowlistRejectMessage(spoke.lang),
|
|
42
51
|
log: { path: requestedPath, allowed: false, reason: "not_found", startedAt, durationMs: Date.now() - startedAt },
|
|
43
52
|
};
|
|
44
53
|
}
|
|
@@ -68,19 +77,19 @@ async function sendWithResilience(adapter, conv, sendOpts, cfg, ctx) {
|
|
|
68
77
|
// §8:raw 完整性違反是實作缺陷,不是 API 錯誤——重試只會確定性地再次觸發同一個
|
|
69
78
|
// bug(下一輪的 conv 結構仍帶著同樣的漏洞),不消耗任何網路呼叫也不可能成功。
|
|
70
79
|
// 立即判定失敗,不進入一般失敗的重試計數。
|
|
71
|
-
recordError(err,
|
|
80
|
+
recordError(err, m(ctx.lang, "rawIntegrityCheckFailed", err.message), null);
|
|
72
81
|
return { ok: false, kind: "failed" };
|
|
73
82
|
}
|
|
74
83
|
const info = describeError(err);
|
|
75
84
|
if (info.is429) {
|
|
76
85
|
rateLimitAttempt++;
|
|
77
86
|
if (rateLimitAttempt > cfg.rateLimitRetries) {
|
|
78
|
-
recordError(err,
|
|
87
|
+
recordError(err, m(ctx.lang, "rateLimitRetriesExceeded", cfg.rateLimitRetries), info.status ?? 429, info.errorBody);
|
|
79
88
|
return { ok: false, kind: "rate_limited" };
|
|
80
89
|
}
|
|
81
90
|
const { seconds, source } = parseRetryAfter(info.retryAfterHeader, info.message, rateLimitAttempt - 1);
|
|
82
91
|
if (seconds > cfg.maxRateWaitSec) {
|
|
83
|
-
recordError(err,
|
|
92
|
+
recordError(err, m(ctx.lang, "rateLimitWaitExceeded", seconds, cfg.maxRateWaitSec), info.status ?? 429, info.errorBody);
|
|
84
93
|
return { ok: false, kind: "rate_limited" };
|
|
85
94
|
}
|
|
86
95
|
ctx.rateLimitHits.push({ at: Date.now(), waitSeconds: seconds, source });
|
|
@@ -157,6 +166,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
|
157
166
|
addWaitedMs: (ms) => {
|
|
158
167
|
waitedMs += ms;
|
|
159
168
|
},
|
|
169
|
+
lang: spoke.lang,
|
|
160
170
|
});
|
|
161
171
|
if (!outcome.ok) {
|
|
162
172
|
const hasContent = rawResponses.length > 0;
|
|
@@ -206,7 +216,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
|
206
216
|
conv.turns.push(result.turn);
|
|
207
217
|
if (!result.usage.available) {
|
|
208
218
|
status = "truncated:usage_unavailable";
|
|
209
|
-
errors.push(
|
|
219
|
+
errors.push(m(spoke.lang, "usageUnavailableRound", round));
|
|
210
220
|
finalText = result.meta.text ?? finalText;
|
|
211
221
|
if (toolCallsThisRound.length === 0 || finalizeMode)
|
|
212
222
|
break;
|
|
@@ -220,7 +230,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
|
220
230
|
}
|
|
221
231
|
if (finalizeMode) {
|
|
222
232
|
// 收束呼叫理論上不帶 tool,仍收到 tool call 屬異常,防禦性丟棄不執行
|
|
223
|
-
errors.push(
|
|
233
|
+
errors.push(m(spoke.lang, "finalizeToolCallIgnored", round));
|
|
224
234
|
finalText = result.meta.text;
|
|
225
235
|
break;
|
|
226
236
|
}
|
|
@@ -267,7 +277,8 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
|
267
277
|
};
|
|
268
278
|
toolCalls.push(log);
|
|
269
279
|
options.onEvent({ type: "tool_call", agent: spoke.agent, path: log.path, allowed: false, reason: log.reason });
|
|
270
|
-
|
|
280
|
+
const limitMessage = spoke.lang === "en" ? TOOL_LIMIT_REACHED_MESSAGE_EN : TOOL_LIMIT_REACHED_MESSAGE;
|
|
281
|
+
conv.turns.push({ role: "tool", callId: call.id, result: limitMessage });
|
|
271
282
|
continue;
|
|
272
283
|
}
|
|
273
284
|
const { resultText, log } = await executeToolCall(call, spoke, options.repoRoot);
|