@thincoder/core 0.10.10 → 0.10.11
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/CHANGELOG.md +28 -2
- package/agent/completion.mjs +4 -1
- package/agent/family-tools.mjs +3 -3
- package/agent/helpers.mjs +13 -11
- package/agent/light-round.mjs +1 -4
- package/agent/record-results.mjs +6 -1
- package/agent/setup-reminders.mjs +1 -1
- package/agent/unbooked-pen.mjs +62 -0
- package/agent-tools/advisor.mjs +15 -31
- package/agent-tools/audit-block.mjs +16 -62
- package/agent-tools/batch-gate.mjs +10 -18
- package/agent-tools/context.mjs +1 -1
- package/agent-tools/design-token.mjs +9 -2
- package/agent-tools/digest-budget.mjs +2 -0
- package/agent-tools/doc-check.mjs +3 -14
- package/agent-tools/manifest.mjs +3 -4
- package/agent-tools/review-facts.mjs +35 -81
- package/agent-tools/settings.mjs +65 -5
- package/agent-tools/spawn-gates.mjs +68 -31
- package/agent-tools/subagent-async.mjs +6 -3
- package/agent-tools/subagent-spawn.mjs +42 -61
- package/agent-tools/subagent.mjs +15 -15
- package/browser/launch.mjs +14 -3
- package/browser/queue.mjs +31 -3
- package/browser/session.mjs +4 -0
- package/config-defaults.mjs +8 -0
- package/config-presets.mjs +1 -1
- package/config.mjs +60 -11
- package/doc-check/anchors.mjs +10 -9
- package/doc-check/index.mjs +27 -23
- package/doc-check/paragraph.mjs +119 -0
- package/fts-text.mjs +4 -1
- package/ledger-cmd.mjs +17 -4
- package/ledger-db.mjs +14 -8
- package/ledger.mjs +1 -1
- package/manifest-discovery.mjs +19 -10
- package/manifest.mjs +17 -5
- package/model-context.mjs +98 -0
- package/model-specs.mjs +110 -37
- package/package.json +2 -2
- package/permission.mjs +3 -1
- package/prompts/advisor-design.md +15 -0
- package/prompts/common.md +1 -0
- package/prompts/discipline-engineering.md +27 -6
- package/prompts/discipline-normal.md +7 -7
- package/prompts/persona-eng-designer.md +16 -4
- package/prompts/persona-engineering.md +16 -27
- package/provider/anthropic.mjs +127 -4
- package/provider/core.mjs +16 -6
- package/provider/google.mjs +8 -8
- package/provider/list-models.mjs +37 -5
- package/provider/normalize.mjs +2 -2
- package/provider/responses.mjs +7 -7
- package/provider/sse.mjs +6 -5
- package/proxy-transport.mjs +5 -3
- package/proxy.mjs +2 -1
- package/session-guard.mjs +7 -1
- package/session-lifecycle.mjs +23 -8
- package/session-segments.mjs +1 -0
- package/session-slot-claims.mjs +4 -0
- package/session-slot-write.mjs +6 -0
- package/session-slots-manifest.mjs +4 -0
- package/token-ttl.mjs +10 -0
- package/tool-docs/advisor.md +24 -5
- package/tool-docs/doc_check.md +23 -16
- package/tool-docs/ledger.md +2 -2
- package/tool-docs/subagent.md +41 -16
- package/tools/web-deepseek.mjs +26 -11
- package/doc-check/version.mjs +0 -84
- package/doc-check/width.mjs +0 -87
package/session-lifecycle.mjs
CHANGED
|
@@ -39,7 +39,9 @@ import { mergeAdjacentAssistantEchoes } from "./context.mjs"
|
|
|
39
39
|
// 打开态读数公式(§6.24 判据句 2——与 CLI 状态行 / VSC 同式;零新公式,只组合)。
|
|
40
40
|
import { historyPercent } from "./token-window.mjs"
|
|
41
41
|
import { scheduleSessionGC } from "./session-gc.mjs"
|
|
42
|
-
import { restoreEngTokens } from "./token-ttl.mjs"
|
|
42
|
+
import { restoreEngTokens, tokenExpired } from "./token-ttl.mjs"
|
|
43
|
+
// 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):`slot:new` 走核日志单一通道。
|
|
44
|
+
import { logEvent } from "./log.mjs"
|
|
43
45
|
import { clearPlanMode } from "./agent-tools/plan.mjs"
|
|
44
46
|
import { bindRecordStore, unbindRecordStore, RECORD_WINDOW_MESSAGES } from "./session-store.mjs"
|
|
45
47
|
// 会话级档位(§6.21 判据句 4)判据单源:off 形族(`think-off.mjs`)+ 逐模型枚举
|
|
@@ -73,8 +75,9 @@ function stripTruncatedToolArgs(m) {
|
|
|
73
75
|
return changed ? { ...m, tool_calls } : m
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/** Apply loaded session data onto an agent object; returns
|
|
77
|
-
*
|
|
78
|
+
/** Apply loaded session data onto an agent object; returns `{ switched, droppedDesignTokens }`
|
|
79
|
+
* (`switched` = 运行态渠道是否被切——原布尔值;`droppedDesignTokens` = 离开本会话的未过期
|
|
80
|
+
* 设计凭证数,设计档 §3.2)。2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领。
|
|
78
81
|
*
|
|
79
82
|
* MODEL-MERGE-SESSION(F-4)applySession 重写为两支 + 删旧支(评审 #7/#9 措辞——防
|
|
80
83
|
* "三支"误读):
|
|
@@ -121,8 +124,8 @@ export function applySession(agent, data, opts = {}) {
|
|
|
121
124
|
agent.goal = data.goal ?? null
|
|
122
125
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
123
126
|
agent._sessionStart = data.sessionStart ?? null
|
|
124
|
-
// R16 token 字段恢复(过期过滤——见 token-ttl.mjs restoreEngTokens)
|
|
125
|
-
restoreEngTokens(agent, data)
|
|
127
|
+
// R16 token 字段恢复(过期过滤——见 token-ttl.mjs restoreEngTokens);返回值 = 离开集计数(§3.2)
|
|
128
|
+
const restored = restoreEngTokens(agent, data)
|
|
126
129
|
// engineering is session-level (2026-08-29): the slot value is the CLI session's authority
|
|
127
130
|
// — config.json is only the initial default / cross-end mirror. A legacy slot without the
|
|
128
131
|
// field keeps whatever config.json seeded (unchanged behavior).
|
|
@@ -207,11 +210,11 @@ export function applySession(agent, data, opts = {}) {
|
|
|
207
210
|
agent.provider.reasoningEffort = data.effort
|
|
208
211
|
}
|
|
209
212
|
}
|
|
210
|
-
return switched
|
|
213
|
+
return { switched, droppedDesignTokens: restored.droppedDesignTokens }
|
|
211
214
|
}
|
|
212
215
|
// ② 槽无 key ∥ 不在册 → D-S3 保留——静默保持现状(落 config 链:config 统一解析产物已是
|
|
213
216
|
// `agent.provider`;不报错不纠正——两方都无效由调用侧复验 validateProvider 弹重选)
|
|
214
|
-
return false
|
|
217
|
+
return { switched: false, droppedDesignTokens: restored.droppedDesignTokens }
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
|
@@ -266,6 +269,7 @@ export async function newSession(cwd, opts = {}) {
|
|
|
266
269
|
writeSessionFile(slotPath(cwd, slot), data)
|
|
267
270
|
// 摘要 `ts` 地板入参(#503 同族随动——保存面写后取 mtime;`slotMtime` 读失败 ⇒ null ⇒ 裸墙钟)
|
|
268
271
|
m.slots[slot] = slotDigest(data, slotMtime(cwd, slot))
|
|
272
|
+
const prevSlot = Number.isInteger(m.active) ? m.active : null // 覆盖前 active(无则 null)
|
|
269
273
|
m.active = slot
|
|
270
274
|
// 2026-08-31 advisor round1 🟡:与 ensureActive 认领模型一致——立即记录新 slot 所有权,
|
|
271
275
|
// 否则 /new 后到首次保存之间并发方(VS Code/另一 CLI)会把新 active 槽当"空闲可恢复"
|
|
@@ -287,19 +291,29 @@ export async function newSession(cwd, opts = {}) {
|
|
|
287
291
|
saveManifest(cwd, m, deletions, opts.releaseStale ? { setActive: true, release: [slot] } : { setActive: true })
|
|
288
292
|
// 2026-09-05 §6.10 D-4:/new 落点写本端记录(显式切换跟随——T-M8)
|
|
289
293
|
writeEndMarker(cwd, slot)
|
|
294
|
+
// 槽事件(设计档 §3.1):落定后一行——字段闭集,不带标题 / 路径。
|
|
295
|
+
logEvent("slot:new", { slot, prevSlot, reason: "new-session" })
|
|
290
296
|
return slot
|
|
291
297
|
}
|
|
292
298
|
|
|
293
299
|
/** 清空会话运行态(/new 用,2026-08-31 会诊 F3):_fullHistory/title/_sessionStart 等
|
|
294
300
|
* 全部会话级状态 + 一次性注入标志必须全清——否则新会话首次落盘把旧会话完整人类线 +
|
|
295
301
|
* 旧标题写进新槽(实锤 .19/.3 双副本);注入标志不清则 /new 后新会话永不注入
|
|
296
|
-
* OS/cwd reminder(2026-09-01 会诊 glm 🟡)。autoApprove 是用户偏好,跨会话保留(有意)。
|
|
302
|
+
* OS/cwd reminder(2026-09-01 会诊 glm 🟡)。autoApprove 是用户偏好,跨会话保留(有意)。
|
|
303
|
+
* 返回 `{ droppedDesignTokens }`(设计档 §3.2)——重建 Map 前数一遍的未过期凭证数。 */
|
|
297
304
|
export function resetSessionState(agent) {
|
|
298
305
|
agent._fullHistory = []
|
|
299
306
|
agent.history = []
|
|
300
307
|
agent.title = ""
|
|
301
308
|
agent.tasks = []
|
|
302
309
|
agent._sessionStart = null
|
|
310
|
+
// 设计档 §3.2(R2):重建 Map 前数一遍「未过期」条目——返回值 = 端壳报明口径(离开集)
|
|
311
|
+
let droppedDesignTokens = 0
|
|
312
|
+
if (agent._engDesignTokens instanceof Map) {
|
|
313
|
+
for (const t of agent._engDesignTokens.values()) {
|
|
314
|
+
if (!(typeof t === "string" && tokenExpired(t))) droppedDesignTokens++
|
|
315
|
+
}
|
|
316
|
+
}
|
|
303
317
|
// DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——不再维护
|
|
304
318
|
agent._engDesignTokens = new Map() // multi-design slots die with the session (2026-09-01 fix #2)
|
|
305
319
|
agent._compressFailures = 0
|
|
@@ -322,6 +336,7 @@ export function resetSessionState(agent) {
|
|
|
322
336
|
agent._lastEngState = false
|
|
323
337
|
// /new 或切换:记录存储解绑(新槽由 cmd-new / 首保存补绑重新挂载——§6.14 绑定与对账)
|
|
324
338
|
unbindRecordStore(agent)
|
|
339
|
+
return { droppedDesignTokens }
|
|
325
340
|
}
|
|
326
341
|
|
|
327
342
|
/** Switch the manifest active pointer to a slot. Returns the slot's session data
|
package/session-segments.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export function slimForDisplay(m) {
|
|
|
41
41
|
if (m && m.role === "assistant" && Array.isArray(m.tool_calls)) {
|
|
42
42
|
let changed = false
|
|
43
43
|
const tool_calls = m.tool_calls.map((tc) => {
|
|
44
|
+
if (!tc || typeof tc !== "object") return tc // 空元素原样带走:读取侧不许崩(#31)
|
|
44
45
|
const args = tc.function?.arguments
|
|
45
46
|
if (typeof args === "string" && args.length > 300) {
|
|
46
47
|
changed = true
|
package/session-slot-claims.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { probeOwnersSync, ownerState, filterDeadOwners } from "./process-probe.m
|
|
|
22
22
|
import { getSessionId, manifestPath, slotPath } from "./session-slots.mjs"
|
|
23
23
|
// 清单读写(落盘单源住原档——静态环见头注;反向 re-export 保 import 面)。
|
|
24
24
|
import { loadManifest, saveManifest } from "./session-slots-manifest.mjs"
|
|
25
|
+
// 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):分到全新号时落 `slot:allocate`。
|
|
26
|
+
import { logEvent } from "./log.mjs"
|
|
25
27
|
|
|
26
28
|
/** 本进程**残留认领集**(F-CR1 释放谓词 —— **判据单源** · SESSION.md §6.2 / §6.16):
|
|
27
29
|
* 「释放 A ⟺ `slotSessions[A]` 为本进程 ∧ `A ∉ 保留集`」。`keep` = 保留集 = 落点槽 ∪ 本进程
|
|
@@ -177,9 +179,11 @@ export function allocateFresh(cwd, m, deadParam = null, bundle = null) {
|
|
|
177
179
|
}
|
|
178
180
|
let newSlot = allSlots.length > 0 ? allSlots[allSlots.length - 1] + 1 : 1
|
|
179
181
|
while (liveClaimed(newSlot) || existsSync(slotPath(cwd, newSlot))) newSlot++
|
|
182
|
+
const prevSlot = Number.isInteger(m.active) ? m.active : null // 分配前 active(无则 null)
|
|
180
183
|
m.active = newSlot
|
|
181
184
|
m.slotSessions[newSlot] = mySessionId
|
|
182
185
|
saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
|
|
186
|
+
logEvent("slot:allocate", { slot: newSlot, prevSlot, reason: "all-slots-live" })
|
|
183
187
|
return newSlot
|
|
184
188
|
}
|
|
185
189
|
|
package/session-slot-write.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
|
|
|
26
26
|
|
|
27
27
|
import { getSessionId, loadManifest, saveManifest, slotDigest, slotPath, writeSessionFile, sessionEnd } from "./session-slots.mjs"
|
|
28
28
|
import { loadSlotFile, isLegacyTransient } from "./session.mjs"
|
|
29
|
+
// 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):走核日志单一通道 `logEvent`。
|
|
30
|
+
import { logEvent } from "./log.mjs"
|
|
29
31
|
// token 形态单源(格式校验 + 到期时刻)——本档的保存面合并规则复用同一判定。
|
|
30
32
|
import { tokenExpiryMs } from "./token-ttl.mjs"
|
|
31
33
|
// 会话级偏好写面(§6.21 判据句 3)的判据单源——档位值域(`specForModel(...).reasoningEffortEnum`)
|
|
@@ -77,9 +79,11 @@ function rotateIfForeign(p, slot, data) {
|
|
|
77
79
|
const diskLonger = Array.isArray(disk?.history) && Array.isArray(data?.history) &&
|
|
78
80
|
disk.history.filter((m) => !isLegacyTransient(m)).length > data.history.length
|
|
79
81
|
if (!(diskIsNewer || diskForeign || (diskStart && diskStart !== myStart) || diskLonger)) return null
|
|
82
|
+
const reason = diskIsNewer ? "newer-version" : diskForeign ? "foreign-cwd" : diskLonger ? "longer-record" : "other-session"
|
|
80
83
|
const bak = `${p}.bak-${Date.now()}`
|
|
81
84
|
renameSync(p, bak)
|
|
82
85
|
console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : diskLonger ? `a longer record (${disk.history.length} > ${data.history.length} messages — concurrent append)` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${bak}`)
|
|
86
|
+
logEvent("slot:rotate", { slot, reason, bak: true, via: "write" })
|
|
83
87
|
return bak
|
|
84
88
|
} catch (e) {
|
|
85
89
|
// 文件存在但不可读(损坏 / 半写):改名保留现场,不覆盖
|
|
@@ -89,6 +93,8 @@ function rotateIfForeign(p, slot, data) {
|
|
|
89
93
|
}
|
|
90
94
|
// 排障面:区分「现场不可读」与「轮转失败」——静默会让半写/占用现场无从定位。
|
|
91
95
|
console.error(`[session] slot ${slot}: pre-write rotation check failed (${e?.message ?? e}) —${preserved ? " preserved as .corrupted" : " the scene could NOT be preserved"}`)
|
|
96
|
+
// 判别式(设计档 §3.1):已保全 ⇒ unreadable;未保全 ⇒ rotate-failed(同一个 catch 的两个现场)。
|
|
97
|
+
logEvent("slot:rotate", { slot, reason: preserved ? "unreadable" : "rotate-failed", bak: false, via: "write" })
|
|
92
98
|
return null
|
|
93
99
|
}
|
|
94
100
|
}
|
|
@@ -37,6 +37,8 @@ import { getSessionId, manifestPath, slotPath, writeSessionFile } from "./sessio
|
|
|
37
37
|
// 认领面(2026-09-28 拆分外提——见头注):本档 import `staleClaims` 供 dropStaleClaims 取用,
|
|
38
38
|
// 并在下方 re-export 全族保既有 import 面。
|
|
39
39
|
import { staleClaims } from "./session-slot-claims.mjs"
|
|
40
|
+
// 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):拒写 / 读回失败两处写时点。
|
|
41
|
+
import { logEvent } from "./log.mjs"
|
|
40
42
|
|
|
41
43
|
/** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
|
|
42
44
|
export function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
|
|
@@ -145,6 +147,7 @@ export function ledgerHealth(cwd) {
|
|
|
145
147
|
function refuseManifestWrite(p, reason, preserved = null) {
|
|
146
148
|
noteRefusal(reason, p)
|
|
147
149
|
console.error(`[session] saveManifest: write refused (reason=${reason}) path=${p}${preserved ? ` preserved=${preserved}` : ""}`)
|
|
150
|
+
logEvent("slot:refuse", { reason })
|
|
148
151
|
return false
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -162,6 +165,7 @@ function writeManifestBase(p, m) {
|
|
|
162
165
|
if (trusted) return true
|
|
163
166
|
noteRefusal("readback-failed", p)
|
|
164
167
|
console.error(`[session] saveManifest: readback-failed path=${p} preserved=${preserveScene(p)}`)
|
|
168
|
+
logEvent("slot:refuse", { reason: "readback-failed" })
|
|
165
169
|
return false
|
|
166
170
|
}
|
|
167
171
|
|
package/token-ttl.mjs
CHANGED
|
@@ -129,6 +129,13 @@ export function engTokenSlotFields(agent) {
|
|
|
129
129
|
* 残留——一次性语义。镜像若已过期/畸形 → 不迁移(drop——与 Map 过期项同处理,
|
|
130
130
|
* 清盘闭环)。永不写 agent._engDesignToken。 */
|
|
131
131
|
export function restoreEngTokens(agent, data) {
|
|
132
|
+
// 设计档 §3.2(R2):整体替换前快照旧 Map 的「未过期 id 集」,落定后求差 = 离开集(非集合大小差)。
|
|
133
|
+
const before = new Set()
|
|
134
|
+
if (agent._engDesignTokens instanceof Map) {
|
|
135
|
+
for (const [id, t] of agent._engDesignTokens) {
|
|
136
|
+
if (!(typeof t === "string" && tokenExpired(t))) before.add(id)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
132
139
|
const map = new Map()
|
|
133
140
|
if (data.engDesignTokens && typeof data.engDesignTokens === "object" && !Array.isArray(data.engDesignTokens)) {
|
|
134
141
|
for (const [id, t] of Object.entries(data.engDesignTokens)) {
|
|
@@ -144,6 +151,9 @@ export function restoreEngTokens(agent, data) {
|
|
|
144
151
|
}
|
|
145
152
|
if (map.size > 0) agent._engDesignTokens = map
|
|
146
153
|
else delete agent._engDesignTokens
|
|
154
|
+
let droppedDesignTokens = 0
|
|
155
|
+
for (const id of before) if (!map.has(id)) droppedDesignTokens++
|
|
156
|
+
return { droppedDesignTokens }
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
// ── 会话槽权威台账 I/O(DESIGN-TOKEN-SETTLEMENT D1/D2/D3——槽文件 = 权威结算台账) ──
|
package/tool-docs/advisor.md
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
Run an independent review of your work. `type` is REQUIRED — one of exactly two values
|
|
1
|
+
Run an independent review of your work. `type` is REQUIRED — one of exactly two values, no default and no silent fallback.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- `type="design"` — design review: reviews design / requirement documents BEFORE implementation. Pass `documents=[...]` (the explicit list), plus `batchDoc` when a batch record is in flight.
|
|
4
|
+
- `type="code"` — code review: reviews the code you changed AFTER implementation. Pass `paths=[...]` to scope files/directories; `documents=[...]` adds acceptance-criteria context.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
`documents` — one rule: a relative path resolves against the session project root (e.g. `docs/core/design/ADVISOR-GUARDS.md`); an absolute path is used as-is.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
The relative form needs the session anchor to be inside a project. When the anchor is a workspace root (several projects below it, no manifest of its own) there is no single project root to resolve against, and a relative path is refused with `no-project-root` — write the absolute path there.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Accepted = an existing `.md` file anywhere inside a project — a doc sitting at the repo root counts. Anything else is refused with the resolved path, the reason and the two expected forms.
|
|
11
|
+
|
|
12
|
+
A call without a type (or with a conflicting `object.type`) is refused.
|
|
13
|
+
|
|
14
|
+
The advisor is an independent read-only sub-agent (explores the codebase, reads files, traces callers via grep/lsp).
|
|
15
|
+
|
|
16
|
+
- Code review converges: round 1 full review → round 2 verifies the agent's fix claims → round 3+ re-checks only the fix claims.
|
|
17
|
+
- Design review is a single pass against methodology and requirements.
|
|
18
|
+
- Criteria come from `.thincoder/advisor.md` (if present) or sensible defaults.
|
|
19
|
+
- After the review you MUST produce a response table (format = discipline rules); on all-clear, call `verify`.
|
|
20
|
+
|
|
21
|
+
`object={type,target,status,reason,exclude}` optionally anchors the review target — mechanically injected at the start of the review message; absent = no injection.
|
|
22
|
+
|
|
23
|
+
ASYNC (depth 0 only): the review runs in the BACKGROUND by default — ack immediately, the report arrives automatically in a digest turn.
|
|
24
|
+
|
|
25
|
+
- One shared pool: `agent.poolLimits.advisor`, default 4 — configurable via `/config` 并发池 or config.json. Pool-full and same-scope launches are refused stating the current limit; launch reviews one at a time.
|
|
26
|
+
- Inside a child (depth>0) reviews are always synchronous (`async:true` rejected).
|
|
27
|
+
|
|
28
|
+
Returns the verdict: all-clear (call `verify`) or a findings list to fix.
|
package/tool-docs/doc_check.md
CHANGED
|
@@ -1,53 +1,59 @@
|
|
|
1
1
|
Run a built-in documentation-discipline check on a project and report its findings.
|
|
2
2
|
Read-only. In the default `report` mode it is non-gating: it reports, it never blocks, never sets an exit code, and its output is never a reason to refuse a task or a user instruction.
|
|
3
|
-
When the engine files are newer than this session's start time (or unreadable), `report` prefixes its output with one
|
|
4
|
-
`[doc_check note]` line naming the stale files — that reading came from an engine loaded at startup and is not usable as
|
|
5
|
-
delivery evidence. Nothing else changes: `report` still produces no verdict and no existing line is altered (no stale files ⇒
|
|
6
|
-
the report is byte-identical).
|
|
7
3
|
|
|
8
4
|
What it checks — exactly two criteria, both built into the engine:
|
|
9
5
|
- Anchors — every documentation pointer (a `*.md` token, optionally carrying directory segments, optionally followed by a section
|
|
10
6
|
indicator `§N` / `§N.M` / `§N.M.K`) must resolve: the file must exist on disk (`档缺` when it does not), and when a section
|
|
11
7
|
indicator is given the target file must carry a heading numbered with it (`节缺` when it does not). Line-number coordinates
|
|
8
|
+
|
|
12
9
|
(`<doc>:12`) are not section references and are not judged as such. Files inside fenced code blocks and executable lines
|
|
13
10
|
(command / pattern code spans) are skipped whole.
|
|
14
11
|
|
|
15
12
|
~~~text
|
|
16
|
-
**Operand names.**
|
|
17
|
-
the prompt face (`thincoder-core/prompts/**`) they are stripped from a line before extraction. On other faces four of the
|
|
13
|
+
**Operand names.** Seven names count as operands rather than documents: the model writes them, or reads a file by that name. On
|
|
14
|
+
the prompt face (`thincoder-core/prompts/**`) they are stripped from a line before extraction. On other faces four of the seven are
|
|
15
|
+
|
|
18
16
|
exempt as file-name classes — `AGENTS.md`, `SKILL.md`, `.thincoder/advisor.md`, `project_rules.md` — while `README.md` and
|
|
19
|
-
`MANIFEST.md` are still judged as pointers. The
|
|
17
|
+
`MANIFEST.md` are still judged as pointers. `RTM.md` is stripped on the prompt face; on other faces it is judged as a pointer — write it path-qualified (e.g. `test/RTM.md`) so it resolves. The seven:
|
|
20
18
|
|
|
21
19
|
```text
|
|
22
|
-
AGENTS.md SKILL.md README.md MANIFEST.md .thincoder/advisor.md project_rules.md
|
|
20
|
+
AGENTS.md SKILL.md README.md MANIFEST.md .thincoder/advisor.md project_rules.md RTM.md
|
|
23
21
|
```
|
|
24
22
|
~~~
|
|
25
23
|
|
|
26
|
-
-
|
|
27
|
-
|
|
24
|
+
- Paragraph length — on a document face, adjacent non-blank body lines merge into one paragraph whose total character
|
|
25
|
+
count (CJK counts as 1) is judged against the built-in threshold; blank lines, table rows, headings, list items,
|
|
26
|
+
quote-block lines and fenced code blocks do not count into a paragraph and break it, and lines inside an exempt zone
|
|
27
|
+
|
|
28
|
+
are excluded. Breaking a paragraph into more lines changes nothing — only splitting it into more paragraphs or
|
|
29
|
+
writing it shorter passes.
|
|
30
|
+
- Line length — a prompt-face file (`thincoder-core/prompts/**`) is judged line by line: the model reads raw text, so
|
|
31
|
+
one line is one block, and any single line over the threshold counts with no structural exemption.
|
|
28
32
|
|
|
29
33
|
It cannot — in `report` mode — write anything, gate anything, or turn a finding into a refusal.
|
|
30
34
|
|
|
31
35
|
Arguments — `target` (optional): the project root to check; defaults to the session anchor, relative paths resolve against it.
|
|
32
36
|
A workspace target (several projects below it) is reported with the full candidate list — pass the project root you mean.
|
|
37
|
+
|
|
33
38
|
`mode` (optional) — `report` (default: a non-gating report over the documents you list) or `gate` (the delivery gate below).
|
|
34
39
|
`docs` (required) — the documents to judge, as paths relative to the project root or absolute; each is judged whole and nothing
|
|
40
|
+
|
|
35
41
|
else is read except the targets those documents point at. Omitted in `gate` mode ⇒ the Markdown documents this session wrote.
|
|
36
42
|
There is no default range in `report` mode: omitting `docs`, or passing an empty list, is refused rather than widened — nothing
|
|
43
|
+
|
|
37
44
|
is scanned unless you name it. A path that is not on disk or is not a Markdown document is an error in `report` mode and nothing
|
|
38
45
|
is judged; in `gate` mode such paths are listed as skipped instead. A pointer resolves against the project root (full path) or
|
|
46
|
+
|
|
39
47
|
against the pointing document's own directory — bare names are not resolved across other directories.
|
|
40
48
|
|
|
41
49
|
Delivery gate — `mode: "gate"`, for the end of a design or development round. Every listed doc is judged **whole**: a
|
|
42
|
-
pre-existing dangling pointer or overlong line anywhere in it counts, not only this round's edited lines. The first line is the
|
|
50
|
+
pre-existing dangling pointer or overlong paragraph / line anywhere in it counts, not only this round's edited lines. The first line is the
|
|
51
|
+
|
|
43
52
|
machine-readable verdict — `DOC-CHECK GATE: PASS` / `DOC-CHECK GATE: REFUSED(命中 K 条)` / `DOC-CHECK GATE: ERROR(reason)` —
|
|
44
53
|
and consumers judge by that prefix; the hits are fixed in the same round and are never booked to the ledger. The gate refuses
|
|
54
|
+
|
|
45
55
|
**one delivery**, not a user instruction: an explicit user go-ahead is followed, with the hits written into the delivery.
|
|
46
|
-
|
|
47
|
-
`doc-check/anchors.mjs` / `doc-check/width.mjs` / `doc-check/version.mjs` / `agent-tools/doc-check.mjs` / this tool description) each have their mtime
|
|
48
|
-
compared with this process's start time — newer, or unreadable, ⇒ `DOC-CHECK GATE: ERROR(引擎已更新…)` instead of a PASS,
|
|
49
|
-
because a session keeps the engine modules it loaded at startup. `ERROR` is fail-closed (no PASS means not passed): restart the
|
|
50
|
-
session and re-run; a multi-project workspace ⇒ name the project root, per the stated cause.
|
|
56
|
+
`ERROR` is fail-closed (no PASS means not passed): a multi-project workspace ⇒ name the project root, per the stated cause.
|
|
51
57
|
|
|
52
58
|
Do not wire it as a gate or CI step in `report` mode, and do not treat a finding as a reason to refuse an instruction — a
|
|
53
59
|
machine check never outranks a user instruction.
|
|
@@ -59,6 +65,7 @@ Side effects and permissions — none. The tool is read-only: no writes, no appr
|
|
|
59
65
|
|
|
60
66
|
Parameters and scope — both criteria run on built-in constants (`DOC_CHECK_BUILTINS`: threshold 300 characters, no exempt zones,
|
|
61
67
|
no exemptions), and the judged set is the document list the caller passes — the judged set and the resolution set are the same
|
|
68
|
+
|
|
62
69
|
documents, so there is no scan-dir / domain / root / exclude surface and no whole-repository scan. Changing a criterion or a
|
|
63
70
|
threshold is a code change and goes through review; no project declaration changes these readings.
|
|
64
71
|
|
package/tool-docs/ledger.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
台账统一入口——action = add / update / close / query / count。query / count 只读全角色;add / update / close 写仅主 agent。
|
|
2
2
|
- add:新增条目入待讨论。kind = requirement(需求池)/ tech_todo(技术待办);trigger = 归批 / 条件 / 认账不排期(可空);resume_when = 复启条件句(可空;值非空——空串 / 全空白拒;句内可带锚 `@YYYY-MM-DD`(机判到期)或 `@事件件名`(机器不判))。task_book 写门(add ∥ update 同判,判本笔写入面):结果行 在途 / 待核销 ∥ trigger = 归批 ⇒ task_book 必填(缺 / 空串 / 全空白拒);本笔新写 task_book(非空值)∥ 本笔新写 trigger = 归批 ⇒ 该指针的文件部分(首个 `§` 前子串)须可解析且指出的档在盘,否则拒。本笔不动指针 ⇒ 零判零阻——旧指针所指档已被清理不阻 status / evidence / 其它字段更新(「档在不在盘」是读数 taskBookOnDisk,不是门)。
|
|
3
|
-
- update:按六态允许表迁移:待讨论→待设计 / 待设计→在途 / 在途→待核销 / 待核销→已核销 /
|
|
3
|
+
- update:按六态允许表迁移:待讨论→待设计 / 待设计→在途 / 在途→待核销 / 待核销→已核销 / 任意态→已废弃;表外拒;迁移入 已核销 时结果行标题携「收尾链待跑」同拒(同笔携清标记 title 放行)。id ∥ batch 恰携其一。status 缺省 = 仅更新字段;executor 缺省 = 进入待设计 / 在途自动写本会话,出在途 / 撤回清空;resume_when 同 update 可空字段(缺省 = 不变)。evidence 传入 = 追加(旧值非空 ⇒ 拼接 `旧|新`——「|」零空格;旧空直写);`evidenceReplace: true` 与 evidence 同传 = 整段替换;旗独传(无新文)∥ 新值全空白 ⇒ 拒。
|
|
4
4
|
- update --batch:批级批量迁移(batch = 批档指针,与该档 task_book 同址的行一次处理)——携 status ⇒ 逐行按迁移表重判;不携 status ⇒ 纯字段补丁(status 零写)。回执 { batch, status, applied, skipped };被跳过的行逐行点名 { id, from, why }(why = 逐行拒因原文),其余行照落;事务级失败整批回滚。batch 值须可解析且批档在盘,否则拒。
|
|
5
|
-
- close:勾销(待核销 → 已核销);追认核销(待讨论 / 待设计 → 已核销,须有 evidence——可随本调用传入(一跳核销),判结果值:缺 / 全空白拒);在途 → 已核销
|
|
5
|
+
- close:勾销(待核销 → 已核销);追认核销(待讨论 / 待设计 → 已核销,须有 evidence——可随本调用传入(一跳核销),判结果值:缺 / 全空白拒);在途 → 已核销 不可跳;核销标记门:结果行标题携「收尾链待跑」⇒ 拒(收尾链未跑完,不许核销——先跑收尾全链,再由父侧清标记);撤回(任意态 → 已废弃)不拦。软删除(写 closed_at,行保留)。id ∥ batch 恰携其一。evidence 传入 = 追加(旧值非空 ⇒ 拼接 `旧|新`;旧空直写);`evidenceReplace: true` 同传 = 整段替换;旗独传 / 新值全空白 ⇒ 拒;缺省 = 判行值。
|
|
6
6
|
- close --batch:批级批量结算——只销已到 待核销 的行(勾销);待讨论 / 待设计 逐行点名跳过(追认核销不入批量面,转单行 close),在途 逐行点名跳过(不可跳);已核销 / 已废弃 行静默不计。status 可不给(缺省 = 已核销),给了只能是 已核销。
|
|
7
7
|
- query:SQLite 行集,等值过滤(status / kind / board / trigger / task_book)缺省 = 全部行;行集逐行携 aged(未决四态 ∧ 行龄 > 30 天)/ evidenceOversize(evidence 超软上限 300 字——读数非拒写)/ taskBookOnDisk(task_book 文件部分解析成立 ∧ 该档在盘 ⇒ true;缺指针 / 不可解析 / 不在盘 ⇒ false——读数非门,不拦任何写入)/ overdue(出口态字串 ∥ null:resume_due / dangling / unlabeled / aged,首中优先)。六态 = 待讨论 / 待设计 / 在途 / 待核销 / 已核销 / 已废弃。
|
|
8
8
|
- count:回报 { count, overdue, evidenceOversize }——count = 未决四态(待讨论 / 待设计 / 在途 / 待核销)行数;overdue = 其中命中出口态的行数;evidenceOversize = 其中 evidence 超限行数。task_book 等值过滤。
|
package/tool-docs/subagent.md
CHANGED
|
@@ -1,36 +1,61 @@
|
|
|
1
1
|
ONE tool, NINE actions — pick by what you need:
|
|
2
2
|
|
|
3
3
|
- action:'spawn' (DEFAULT): run a sub-agent in its own isolated context — its reads, searches and tool calls never reach your history; only the final report comes back. Spawn MULTIPLE subagents in the SAME response for parallel work — they run concurrently.
|
|
4
|
-
- action:'status': NON-BLOCKING progress query — returns immediately, consumes nothing, never fetches. Pass the spawn id for one child (role, state: running|queued|done, model, elapsed, turn, position, plus a touched-files summary — first 5 + a count) or omit it for a pool overview — see what a
|
|
5
|
-
|
|
6
|
-
- action:'
|
|
7
|
-
|
|
8
|
-
- action:'
|
|
9
|
-
|
|
10
|
-
- action:'
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
- action:'status': NON-BLOCKING progress query — returns immediately, consumes nothing, never fetches. Pass the spawn id for one child (role, state: running|queued|done, model, elapsed, turn, position, plus a touched-files summary — first 5 + a count) or omit it for a pool overview — see what a
|
|
5
|
+
running child has changed BEFORE cancelling it. Background advisor reviews share this surface (pass the review id — role:'advisor', reviewType/round/elapsedSec, same shape).
|
|
6
|
+
- action:'observe': SEE what a running async subagent is DOING right now (progress vs stuck) — pass the id; returns state, turn, the tool(s) executing right now (omitted when none), and the last N one-line turn summaries, newest first (default 5, via recent). Readonly; running/queued/done all answer
|
|
7
|
+
— queued returns a placeholder, done gives the activity summary only, NOT the report (it rides the auto channel). Judge stuck vs progressing BEFORE cancelling or steering.
|
|
8
|
+
- action:'send': STEER a running async subagent mid-flight — id + message (a direction, e.g. "check X, don't fixate on Y"). The child consumes it at its next turn boundary as an ORDINARY user instruction (non-interrupting; its discipline is unchanged — guidance, not a deviation waiver); returns {id,
|
|
9
|
+
status:'delivered', queued}. Only RUNNING async children are targetable — sync/queued/settled/unknown error clearly; if the child settles first, its report carries an 'undelivered' note.
|
|
10
|
+
- action:'escalate' (飞刀): hand an implementation task to a STRONGER model from your consult models; it gets WRITE access and does the work itself, then returns a post-op report (what changed, why, verification). Escalate EARLY, when the task calls for stronger hands (complex refactors, intractable
|
|
11
|
+
bugs, intricate algorithms — or beyond your comfortable ability). Not available in engineering mode (implementation goes through eng-coder). Default-async at depth 0: ack {id, role:'escalate', status:'running'} returns immediately; the report arrives automatically with its mutations merged into
|
|
12
|
+
your bookkeeping (pooled with the other role-domain spawns).
|
|
13
|
+
- action:'cancel': STOP ONE background subagent — the id from the spawn return is REQUIRED (a blanket cancel is unsupported; Ctrl+C stops everything). Running aborts immediately ({id, status:'cancelled'}); queued is removed ({id, status:'cancelled', was:'queued'}; later positions shift). Other
|
|
14
|
+
children and the session keep running. Cancel is a last resort: verify alarming signals with reliable checks (git/node — not guesses) first, and prefer scoped recovery — a running child's in-flight work dies with it, partial changes stay unmerged and unaudited. Background advisor reviews cancel
|
|
15
|
+
the same way (pass the review id — no token is issued for a cancelled review).
|
|
16
|
+
- action:'consume-design' (engineering mode, parent side): chain-terminal token consumption — after the delivery is verified and the chain closes out, consume this design's token slot. Selectors — pass exactly ONE: designId (a single slot; optional for a single-design session), designIds (an
|
|
17
|
+
explicit non-empty list), expired:true (every expired slot), or olderThanDays:N (every slot older than N days). A consumed slot mechanically rejects further spawns for the same designId — new work (deviation fixes included) needs a fresh design review and token. Idempotent
|
|
18
|
+
(unknown/already-consumed = a no-op notice); batch calls report per-slot lines + a `consumed N` count line. Do NOT call while the chain is still open — fix rounds reuse the same slot.
|
|
19
|
+
- action:'design-slots' (engineering mode, parent side): READ-ONLY inventory of this session's approved design token slots — one line per slot (designId, live|expired, ageDays, expiresAt, batch, designToken), oldest first, ending with a total/live/expired count line. Nothing is written. Use it to
|
|
20
|
+
see what still holds a slot before consuming; the expired / olderThanDays selectors act on the same set.
|
|
21
|
+
Every row carries its designToken value (expired ones included — status marks them; a malformed record shows designToken=—). Pass designId to filter to a single slot (trimmed exact match; unknown = refused with the held id list; empty string = no filter). Take designId + designToken verbatim onto
|
|
22
|
+
the eng-coder spawn — the gate checks exact equality.
|
|
23
|
+
- action:'panel': DIAGNOSE + fix the subagent panel (the collapsible blocks the user sees; headless/VS Code degrade to a 'no panel' pool view). view (default): the live blocks (key, role, status: running|done|awaitingDigest) exactly as the user sees them — digested-stuck entries carry digested:true.
|
|
24
|
+
freeze: pass a digested-stuck block key (e.g. 'eng-coder#9' — {action:'panel', freeze:'role#N'}) to reclaim it into the conversation — passes ONLY for awaitingDigest blocks with no live pool entry and no pending report (a still-pending report would break the digestion order — refused).
|
|
13
25
|
|
|
14
26
|
Why delegate? The child's noise never reaches your window — delegation keeps your context lean and the child focused; parallel children save wall-clock time, and coder/eng-coder children self-review (verify + advisor) before their reports arrive.
|
|
15
27
|
|
|
16
28
|
Available roles (exposed per mode; the role is REQUIRED for action:'spawn' — a missing or misspelled role is refused):
|
|
17
29
|
- explore — read-only search & analysis (read/search family); no git context — evidence comes from reads and the task book; its report must list what it searched and what it did NOT find. Say quick / medium / thorough (default medium).
|
|
18
30
|
- plan — read-only planning; NEVER edits; returns a step-by-step plan.
|
|
19
|
-
- coder — full implementation; the parent's complete read/write/execute toolset plus verify and advisor self-review. Its report carries a delivery table, one row per requirement (Done / Simplified / Not done — no deferred column). Normal mode: may optionally carry `batchDoc` — binds this spawn's §5
|
|
20
|
-
-
|
|
21
|
-
- eng-
|
|
31
|
+
- coder — full implementation; the parent's complete read/write/execute toolset plus verify and advisor self-review. Its report carries a delivery table, one row per requirement (Done / Simplified / Not done — no deferred column). Normal mode: may optionally carry `batchDoc` — binds this spawn's §5
|
|
32
|
+
record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file.
|
|
33
|
+
- eng-coder — engineering mode only (replaces coder): same toolset plus the design-driven methodology overlay; its delivery protocol (implementation → audit → self-fix → advisor re-review → converged delivery) runs fully inside the child. REQUIRES a valid designToken from a passed
|
|
34
|
+
advisor(type='design') review — pass the exact value from that review, or copy it verbatim from the action:'design-slots' row (the text after designToken=): missing or mismatched = the spawn is refused, and an expired token is refused with expired named (its reply also echoes a designId — pass it:
|
|
35
|
+
required to pick between concurrent designs, optional for one; echo it back in the delivery report). REQUIRES a batchDoc — the batch record path, the task book this spawn implements (refused without it or when it does not resolve to a readable file).
|
|
36
|
+
- eng-designer — engineering mode only: the SOLE author of the design documents and of the batch record's design section (requirement docs stay with the main agent). Writes no implementation code, does not edit prompts, does not fire reviews, needs NO designToken. Surveys on its own; may only spawn
|
|
37
|
+
read-only explore children (sync, ≤6 per batch). REQUIRES a batchDoc (same gate as eng-coder).
|
|
22
38
|
|
|
23
|
-
Task book (
|
|
39
|
+
Task book (normal-mode roles: explore / plan / coder): four core structured parameters — goal (goal & why), knownFacts (the facts you already verified), acceptance (machine-verifiable criteria), reportFormat (delivery-report format)
|
|
40
|
+
— plus scope (scope & forbidden scope — what to read / what not to touch).
|
|
41
|
+
All five are required: a missing, blank or non-string value is refused with the parameter named.
|
|
42
|
+
A free-text task is an optional supplement, never the whole brief.
|
|
43
|
+
Engineering roles (eng-coder / eng-designer) take their task book from the batch record: batchDoc (the record path) and round (initial|fix), plus files / dependsOn for scheduling; the child reads §2 of that record itself — no other task text is passed.
|
|
44
|
+
An eng-coder spawn also carries designToken — the write credential (the exact value from the review, or copied verbatim from the action:'design-slots' row, the text after designToken=), and designId when several designs are open: missing or mismatched = the spawn is refused.
|
|
24
45
|
|
|
25
46
|
Pass one parameter per field — contents only (the runtime adds the labels):
|
|
26
|
-
subagent(role="eng-coder", round="initial",
|
|
47
|
+
subagent(role="eng-coder", round="initial", batchDoc="<batch record>", designToken="…", files=[…])
|
|
27
48
|
subagent(role="explore", goal="…", knownFacts="…", scope="…", acceptance="…", reportFormat="…")
|
|
28
49
|
|
|
29
50
|
Mode filtering: normal mode exposes explore/plan/coder; engineering mode exposes explore/eng-designer/eng-coder — off-mode roles are refused mechanically (enums filtered at assembly; the spawn gate re-checks).
|
|
30
51
|
|
|
31
|
-
Async spawn: top-level spawns are async by default — never pass async:false at depth 0 (the spawn returns {id, status:"running"} immediately; the report arrives automatically; if your next step needs it, end the turn and let the digest deliver it — do not poll; the explicit async:false is a
|
|
52
|
+
Async spawn: top-level spawns are async by default — never pass async:false at depth 0 (the spawn returns {id, status:"running"} immediately; the report arrives automatically; if your next step needs it, end the turn and let the digest deliver it — do not poll; the explicit async:false is a
|
|
53
|
+
mechanism parameter, not for model use). Inside subagents (depth>0) spawns are always synchronous. Async spawns are pooled per role domain — 4 concurrent eng-coders plus 4 concurrent other-role spawns by default (agent.poolLimits overrides both); a full domain queues further spawns with a position
|
|
54
|
+
while the other keeps starting.
|
|
32
55
|
|
|
33
|
-
Task scheduling: declare files (the file paths this task will modify) and dependsOn (ids from prior async returns whose outcome this task needs) to let the scheduler order your spawns. Overlapping-file tasks serialize and dependents start in order: a conflicting/dependent spawn queues
|
|
56
|
+
Task scheduling: declare files (the file paths this task will modify) and dependsOn (ids from prior async returns whose outcome this task needs) to let the scheduler order your spawns. Overlapping-file tasks serialize and dependents start in order: a conflicting/dependent spawn queues
|
|
57
|
+
(position/reason) and auto-starts when the conflict clears / the dependency settles (cancel to drop). A cancelled/failed dependency leaves it queued marked "dependency cancelled" until you decide (AUTO sessions auto-start). Unknown ids error; a consumed id counts as satisfied. Omit both for a plain
|
|
58
|
+
immediate spawn (no scheduler involvement).
|
|
34
59
|
|
|
35
60
|
Writing the prompt:
|
|
36
61
|
- The child starts with zero context — brief it like a colleague who just walked in: goal, what you already know, the specifics.
|
package/tools/web-deepseek.mjs
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The tools never fail because of this backend: every export returns a pure value
|
|
11
11
|
* (`{ status: "ok" | "empty" | "failed" | "unavailable" }`) and never throws.
|
|
12
|
-
* 401/402/403
|
|
13
|
-
*
|
|
12
|
+
* 401/402/403 trip a per-agent breaker for `BREAKER_TTL_MS` (10 min — auto-unseals, no restart);
|
|
13
|
+
* 404 does NOT trip (the target page is missing — not a credential failure, D-WB3); 408/429/5xx
|
|
14
|
+
* retry once (500 ms); everything else falls back to the caller's existing chain.
|
|
14
15
|
*/
|
|
15
16
|
import { URL } from "node:url"
|
|
16
17
|
import { truncate, isPrivateHost } from "./shared.mjs"
|
|
@@ -27,7 +28,8 @@ const DEEPSEEK_HOST = "api.deepseek.com"
|
|
|
27
28
|
const TIMEOUT_MS = 15_000
|
|
28
29
|
const RETRY_DELAY_MS = 500 // D-WB5: fixed single backoff (no Retry-After parsing)
|
|
29
30
|
const COST_PER_CALL = 0.00075 // $0.75 / 1000 successful requests — `count` does not change the price
|
|
30
|
-
const TRIP_STATUS = new Set([401, 402, 403
|
|
31
|
+
const TRIP_STATUS = new Set([401, 402, 403]) // 404 = 目标页面的状态,不是凭据失效(D-WB3)
|
|
32
|
+
const BREAKER_TTL_MS = 600_000 // 熔断限时 10 分钟——到期自动放行(改完 key 无需重启会话)
|
|
31
33
|
const RETRY_STATUS = (() => {
|
|
32
34
|
const codes = new Set([408, 429])
|
|
33
35
|
for (let code = 500; code <= 599; code++) codes.add(code)
|
|
@@ -35,11 +37,16 @@ const RETRY_STATUS = (() => {
|
|
|
35
37
|
})()
|
|
36
38
|
|
|
37
39
|
let _transport = proxyFetch
|
|
38
|
-
let
|
|
40
|
+
let _now = Date.now
|
|
41
|
+
let _breaker = new WeakMap() // agent → expiresAtMs(单域——search ∥ fetch 共享,D-WB3)
|
|
39
42
|
|
|
40
|
-
/** Test seam (CONFIG.md §6.2 ③ — paired): swap the transport; reset also clears the breaker. */
|
|
43
|
+
/** Test seam (CONFIG.md §6.2 ③ — paired): swap the transport ∥ the clock; reset also clears the breaker. */
|
|
41
44
|
export function _setDeepseekTransportForTest(fn) { _transport = typeof fn === "function" ? fn : proxyFetch }
|
|
42
|
-
export function
|
|
45
|
+
export function _setDeepseekClockForTest(fn) { _now = typeof fn === "function" ? fn : Date.now }
|
|
46
|
+
export function _resetDeepseekTransportForTest() { _transport = proxyFetch; _now = Date.now; _breaker = new WeakMap() }
|
|
47
|
+
|
|
48
|
+
/** 熔断名单只读面(用例断言 404 零命中——D-WB3)。 */
|
|
49
|
+
export const _TRIP_STATUS_FOR_TEST = Object.freeze([...TRIP_STATUS])
|
|
43
50
|
|
|
44
51
|
/** `local` skips the DeepSeek backend; anything else (missing ∥ misspelled ∥ non-string) = `auto` (fail-open). */
|
|
45
52
|
export function backendMode(cfg, key) {
|
|
@@ -66,11 +73,19 @@ function deepseekChannel(cfg) {
|
|
|
66
73
|
return null
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
/** 熔断未到期?到期 ⇒ 删条目并放行(判活读时钟——改完 key 不必重启会话)。 */
|
|
77
|
+
function tripped(agent) {
|
|
78
|
+
if (agent === null || typeof agent !== "object") return false
|
|
79
|
+
const expiresAt = _breaker.get(agent)
|
|
80
|
+
if (typeof expiresAt !== "number") return false
|
|
81
|
+
if (expiresAt > _now()) return true
|
|
82
|
+
_breaker.delete(agent)
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
70
85
|
|
|
71
|
-
/** Break the backend for this agent identity (single domain — search ∥ fetch share it, D-WB3). */
|
|
86
|
+
/** Break the backend for this agent identity for `BREAKER_TTL_MS` (single domain — search ∥ fetch share it, D-WB3). */
|
|
72
87
|
function trip(agent) {
|
|
73
|
-
if (agent !== null && typeof agent === "object") _breaker.
|
|
88
|
+
if (agent !== null && typeof agent === "object") _breaker.set(agent, _now() + BREAKER_TTL_MS)
|
|
74
89
|
}
|
|
75
90
|
|
|
76
91
|
/** One POST. Returns `{ kind: "ok", data }` | `{ kind: "http", status }` | `{ kind: "net", timeout }` | `{ kind: "json" }`. */
|
|
@@ -95,7 +110,7 @@ async function postOnce(url, body, apiKey, proxyUri) {
|
|
|
95
110
|
} finally { clearTimeout(timer) }
|
|
96
111
|
}
|
|
97
112
|
|
|
98
|
-
/** One logical call: retry once (408/429/5xx), trip on 401/402/403
|
|
113
|
+
/** One logical call: retry once (408/429/5xx), trip on 401/402/403, otherwise fall back. */
|
|
99
114
|
async function request(url, body, apiKey, proxyUri, tool, agent) {
|
|
100
115
|
const started = Date.now()
|
|
101
116
|
let r = await postOnce(url, body, apiKey, proxyUri)
|
|
@@ -107,7 +122,7 @@ async function request(url, body, apiKey, proxyUri, tool, agent) {
|
|
|
107
122
|
if (r.kind === "http" && TRIP_STATUS.has(r.status) && agent !== null && typeof agent === "object") {
|
|
108
123
|
trip(agent)
|
|
109
124
|
// Tripping line only (transient fallbacks stay quiet — ⑤).
|
|
110
|
-
logEvent("web:deepseek", { tool, status: r.status, tripped: true, ms: Date.now() - started })
|
|
125
|
+
logEvent("web:deepseek", { tool, status: r.status, tripped: true, ttlMs: BREAKER_TTL_MS, ms: Date.now() - started })
|
|
111
126
|
}
|
|
112
127
|
const reason = r.kind === "http" ? `http ${r.status}`
|
|
113
128
|
: r.kind === "net" ? (r.timeout ? "timeout" : "network")
|