thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
package/src/escape.mjs
CHANGED
|
@@ -1,38 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* escape.mjs — 中和 OpenAI 兼容服务端在 message content
|
|
2
|
+
* escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析 + 孤立代理净化。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 遇到 "\x" 后不足 2 个 hex(或 "\u" 后不足 4 个 hex)时服务端报
|
|
6
|
-
* "unexpected end of hex escape" → 400(首次观察于 2026-08-06,见 advisor.mjs 历史)。
|
|
4
|
+
* 两个独立毒源(均真机实证):
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* ① 字面 hex 转义二次解析(2026-08-06 Kimi 首观察):Kimi/deepseek 等网关会把 content 里的字面
|
|
7
|
+
* "\x5Cx" / "\x5Cu" 当作 hex escape 再解释一遍,不足位时 400("unexpected end of hex escape")。
|
|
8
|
+
* 对策:不足位序列前 double 反斜杠(\\x5CxNN 形态还原为字面量);合法完整序列放行。
|
|
9
|
+
* Known limitation(2026-09-01 v3 修复):反斜杠 run ≥3 时(如 "\\\x5Cu" 三反斜杠+u)v1 的
|
|
10
|
+
* lookbehind 只看前 1 字符会整体放行,但二次解析按配对消费后尾部的 \x5Cu 仍裸露 → 炸。
|
|
11
|
+
* 修复:按 run 奇偶判断——run 为奇数时尾部的 \x5Cu/\x5Cx 裸露(需 double),偶数已配对(放行)。
|
|
12
|
+
*
|
|
13
|
+
* ② 孤立 UTF-16 代理对(2026-09-02 deepseek 真机实锤):content 里的**真实孤立代理字符**
|
|
14
|
+
* (高代理 U+D800-DBFF 无低代理跟随,或低代理 U+DC00-DFFF 无高代理前置)——JSON.stringify
|
|
15
|
+
* 输出 \ud83d(合法 JSON),但 deepseek 解析器严格 UTF-16 解码,孤立代理 → 400
|
|
16
|
+
* ("unexpected end of hex escape" / "lone leading surrogate in hex escape")。
|
|
17
|
+
* 来源实证:doc_search 结果预览 `slice(0, N)` 按 UTF-16 码元截断,emoji 🔴(代理对)恰在
|
|
18
|
+
* 截断边界被切成孤立高代理 → 注入 system reminder → 每轮发送 → deepseek 400。
|
|
19
|
+
* 对策:发送前把孤立代理替换为 U+FFFD(任何来源安全兜底);源头截断点另修 UTF-16 安全切。
|
|
11
20
|
*/
|
|
12
21
|
|
|
13
|
-
/**
|
|
14
|
-
* Known limitation (documented, accepted, VS Code port parity): an ODD backslash
|
|
15
|
-
* run of 3+ (e.g. "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare
|
|
16
|
-
* in real conversation text (no such hit in the 2026-08-31 repro session). */
|
|
22
|
+
/** 中和非法字面 hex 转义序列(毒源①)。 */
|
|
17
23
|
export function escapeLiteralEscapes(text) {
|
|
18
24
|
text = String(text ?? "")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
let out = ""
|
|
26
|
+
let i = 0
|
|
27
|
+
const n = text.length
|
|
28
|
+
while (i < n) {
|
|
29
|
+
const ch = text[i]
|
|
30
|
+
if (ch !== "\\") { out += ch; i++; continue }
|
|
31
|
+
// 数反斜杠 run 长度
|
|
32
|
+
let run = 0
|
|
33
|
+
while (i + run < n && text[i + run] === "\\") run++
|
|
34
|
+
const next = text[i + run]
|
|
35
|
+
if ((next === "x" || next === "u") && run % 2 === 1) {
|
|
36
|
+
// run 奇数 → 二次解析配对消费后尾部 \x5Cx/\x5Cu 裸露——hex 不足则网关炸 → 前插反斜杠 double
|
|
37
|
+
const need = next === "u" ? 4 : 2
|
|
38
|
+
const after = text.slice(i + run + 1, i + run + 1 + need)
|
|
39
|
+
if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
|
|
40
|
+
out += "\\".repeat(run + 1) + next
|
|
41
|
+
i += run + 1
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
// 合法完整:输出全序列并跳过(hex 尾不重新扫描)
|
|
45
|
+
out += text.slice(i, i + run + 1 + need)
|
|
46
|
+
i += run + 1 + need
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
out += "\\".repeat(run)
|
|
50
|
+
i += run
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 净化孤立 UTF-16 代理对(毒源②):高代理无低代理跟随 / 低代理无高代理前置 → 替换为 。 */
|
|
56
|
+
export function sanitizeLoneSurrogates(text) {
|
|
57
|
+
text = String(text ?? "")
|
|
58
|
+
let out = ""
|
|
59
|
+
let i = 0
|
|
60
|
+
const n = text.length
|
|
61
|
+
while (i < n) {
|
|
62
|
+
const cp = text.charCodeAt(i)
|
|
63
|
+
if (cp >= 0xd800 && cp <= 0xdbff) {
|
|
64
|
+
const next = text.charCodeAt(i + 1)
|
|
65
|
+
if (next >= 0xdc00 && next <= 0xdfff) { out += text[i] + text[i + 1]; i += 2; continue }
|
|
66
|
+
out += ""; i++; continue // 孤立高代理
|
|
67
|
+
}
|
|
68
|
+
if (cp >= 0xdc00 && cp <= 0xdfff) { out += ""; i++; continue } // 孤立低代理
|
|
69
|
+
out += text[i]; i++
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
24
72
|
}
|
|
25
73
|
|
|
26
|
-
/**
|
|
74
|
+
/** 发送前文本净化总入口:hex 转义中和 + 孤立代理净化。 */
|
|
75
|
+
export function sanitizeText(text) {
|
|
76
|
+
return sanitizeLoneSurrogates(escapeLiteralEscapes(text))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 对单条消息的 content 应用 sanitizeText(支持字符串或 OpenAI 多模态 part 数组)。
|
|
27
80
|
* 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
|
|
28
|
-
* reasoning_content 做同样的非标二次转义解析(字面
|
|
81
|
+
* reasoning_content 做同样的非标二次转义解析(字面 \\x5Cx/\\x5Cu 经工具参数/思考回传 → 400,
|
|
29
82
|
* 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
|
|
30
83
|
export function escapeMessageContent(message) {
|
|
31
84
|
const content = message?.content
|
|
32
85
|
let changed = false
|
|
33
86
|
let next = message
|
|
34
87
|
if (typeof content === "string") {
|
|
35
|
-
const escaped =
|
|
88
|
+
const escaped = sanitizeText(content)
|
|
36
89
|
if (escaped !== content) {
|
|
37
90
|
next = { ...next, content: escaped }
|
|
38
91
|
changed = true
|
|
@@ -40,7 +93,7 @@ export function escapeMessageContent(message) {
|
|
|
40
93
|
} else if (Array.isArray(content)) {
|
|
41
94
|
const parts = content.map((p) => {
|
|
42
95
|
if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
|
|
43
|
-
const escaped =
|
|
96
|
+
const escaped = sanitizeText(p.text)
|
|
44
97
|
if (escaped !== p.text) {
|
|
45
98
|
changed = true
|
|
46
99
|
return { ...p, text: escaped }
|
|
@@ -55,7 +108,7 @@ export function escapeMessageContent(message) {
|
|
|
55
108
|
const tool_calls = next.tool_calls.map((tc) => {
|
|
56
109
|
const args = tc?.function?.arguments
|
|
57
110
|
if (typeof args === "string") {
|
|
58
|
-
const escaped =
|
|
111
|
+
const escaped = sanitizeText(args)
|
|
59
112
|
if (escaped !== args) {
|
|
60
113
|
tcChanged = true
|
|
61
114
|
return { ...tc, function: { ...tc.function, arguments: escaped } }
|
|
@@ -69,7 +122,7 @@ export function escapeMessageContent(message) {
|
|
|
69
122
|
}
|
|
70
123
|
}
|
|
71
124
|
if (typeof next.reasoning_content === "string") {
|
|
72
|
-
const escaped =
|
|
125
|
+
const escaped = sanitizeText(next.reasoning_content)
|
|
73
126
|
if (escaped !== next.reasoning_content) {
|
|
74
127
|
next = { ...next, reasoning_content: escaped }
|
|
75
128
|
changed = true
|
|
@@ -78,13 +131,15 @@ export function escapeMessageContent(message) {
|
|
|
78
131
|
return changed ? next : message
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
/** IKBGX4
|
|
134
|
+
/** IKBGX4 + SESSION.md §9 D-S1:剥离仅本地使用的整消息标记字段(transient/ts)——发送给 provider 前移除。
|
|
82
135
|
* 严格 OpenAI 兼容服务端(opencode/LiteLLM 等)会拒绝消息级未知 key
|
|
83
|
-
* ("Extra inputs are not permitted, field: 'messages[i].transient'"
|
|
136
|
+
* ("Extra inputs are not permitted, field: 'messages[i].transient'");ts 同理
|
|
137
|
+
* (消息时间戳是本地取证字段,不进任何 provider 请求——T-S3)。copy-on-write:
|
|
138
|
+
* 历史里的原对象不动(read_history 仍能读到 ts)。 */
|
|
84
139
|
export function stripLocalMessageFields(messages) {
|
|
85
140
|
return messages.map((m) => {
|
|
86
|
-
if (m && typeof m === "object" && "transient" in m) {
|
|
87
|
-
const { transient, ...rest } = m
|
|
141
|
+
if (m && typeof m === "object" && ("transient" in m || "ts" in m)) {
|
|
142
|
+
const { transient, ts, ...rest } = m
|
|
88
143
|
return rest
|
|
89
144
|
}
|
|
90
145
|
return m
|
|
@@ -94,4 +149,4 @@ export function stripLocalMessageFields(messages) {
|
|
|
94
149
|
/** 对整个 messages 数组逐条应用 escapeMessageContent(先剥离本地字段,再转义)。 */
|
|
95
150
|
export function escapeMessages(messages) {
|
|
96
151
|
return stripLocalMessageFields(messages).map(escapeMessageContent)
|
|
97
|
-
}
|
|
152
|
+
}
|
package/src/log.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log.mjs — 诊断事件日志(docs/design/LOGGING.md 权威规格)。
|
|
3
|
+
* 双端同构:thincoder/src/log.mjs 与 thincoder-vscode/src/log.mjs 同一实现语义
|
|
4
|
+
* (共享 ~/.thincoder/logs/、同格式、同事件面——F-L6)。
|
|
5
|
+
*
|
|
6
|
+
* 常驻骨架日志:回合/LLM/工具/子代理/挂起的关键事件——每行一个 JSON 事件,按天轮转
|
|
7
|
+
* (agent-YYYY-MM-DD.log),保留 1 天——用户零手动维护(F-L3/NF-L4)。问题发生时
|
|
8
|
+
* 直接 tail/grep 定位断点(根治"临时插桩再删"循环)。
|
|
9
|
+
*
|
|
10
|
+
* 纪律(实现侧逐条落实):
|
|
11
|
+
* - fire-and-forget:logEvent 失败静默降级(NF-L1)——主流程零影响。进程内首次写失败
|
|
12
|
+
* 即置死(_dead),当日不再尝试(磁盘满/权限错不逐事件空转)。
|
|
13
|
+
* - 单事件行 <512 字符(NF-L2):head(LLM ≤300 / 工具 ≤200)、err ≤200 由调用方截断,
|
|
14
|
+
* 本模块对任意字符串字段做上限兜底(head 300 / err 200 / 其余 120)+ 超长时丢可选字段。
|
|
15
|
+
* - 敏感字段零落盘(NF-L3/§2.4,2026-09-03 评审 refinement #7 匹配语义定稿):
|
|
16
|
+
* ① 字段名精确匹配黑名单(apiKey/designToken/password/secret/token,大小写不敏感;
|
|
17
|
+
* 另加防御性名称 authorization/proxy/proxyUri——防凭据型 URL/头字段泄漏——§2.5)
|
|
18
|
+
* → 整个字段丢弃;
|
|
19
|
+
* ② 内容只扫密钥形态(sk-xxx / Bearer xxx / key=… 等)→ 截断到形态之前
|
|
20
|
+
* ——宁可丢信息不漏密钥。
|
|
21
|
+
* 工具事件不记 args;URL 不入事件(llm/tool 事件从不携带 URL;err 文本经形态扫描)。
|
|
22
|
+
* - 摘要截断(B 方案,2026-09-03 用户裁定):截断处带 "…" 标记(截后仍 ≤上限)。
|
|
23
|
+
* - 测试隔离:node --test 进程(NODE_TEST_CONTEXT)默认不写盘——防测试事件污染真实
|
|
24
|
+
* 诊断日志(两端测试套件都会跑真实 agent 管线);显式设置 THINCODER_LOG_DIR 强制
|
|
25
|
+
* 写入该目录(log.test.mjs 用它隔离临时目录——refinement #6)。
|
|
26
|
+
* - 轮转清理(refinement #3——长驻进程覆盖):非仅启动时——每进程每日**首次写事件**
|
|
27
|
+
* 时顺带清理 >1 天的 agent-*.log(extension host 可长驻数月,启动清理覆盖不到)。
|
|
28
|
+
* - seq:每进程单调计数器。双端同写一个文件时 seq 会各自重复——定位同文件时序以
|
|
29
|
+
* ts 为准,seq 仅进程内参照(refinement #8)。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, existsSync, statSync } from "node:fs"
|
|
33
|
+
import { homedir } from "node:os"
|
|
34
|
+
import { join } from "node:path"
|
|
35
|
+
|
|
36
|
+
/** 单行长度硬上限(NF-L2) */
|
|
37
|
+
export const MAX_LINE = 512
|
|
38
|
+
/** 字段名精确黑名单(NF-L3/§2.4——2026-09-03 评审 refinement #7:与 §2.4 词表一致含 token) */
|
|
39
|
+
const BLACKLIST_FIELDS = new Set(["apikey", "designtoken", "password", "secret", "token", "authorization", "proxyuri", "proxy"])
|
|
40
|
+
/** 内容密钥形态扫描(大小写不敏感)——命中即截断到形态前 */
|
|
41
|
+
const SECRET_FORM = /(\bsk-[A-Za-z0-9_-]{6,}|\bBearer\s+[A-Za-z0-9._~+/=-]{6,}|\b(?:api[_-]?key|key|token|secret|password|pwd|passwd)\s*[=:]\s*['"]?[A-Za-z0-9._~+/=-]{6,})/i
|
|
42
|
+
/** 字符串字段长度兜底:head ≤300 / err ≤200 / 其余 ≤120 */
|
|
43
|
+
const FIELD_CAPS = { head: 300, err: 200 }
|
|
44
|
+
|
|
45
|
+
let _seq = 0
|
|
46
|
+
let _dead = false // 进程内写失败即死(NF-L1 静默降级——不逐事件重复空转)
|
|
47
|
+
let _cleanupDate = null // 本进程已执行过清理的日期(每日首次写时清一次)
|
|
48
|
+
|
|
49
|
+
/** 日志目录:THINCODER_LOG_DIR(测试隔离/override)> ~/.thincoder/logs(与 sessions/ 同域) */
|
|
50
|
+
export function logsDir() {
|
|
51
|
+
return process.env.THINCODER_LOG_DIR ?? join(homedir(), ".thincoder", "logs")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 今日日志文件路径(agent-YYYY-MM-DD.log) */
|
|
55
|
+
export function todayLogPath(now = new Date()) {
|
|
56
|
+
const ymd = now.toISOString().slice(0, 10)
|
|
57
|
+
return join(logsDir(), `agent-${ymd}.log`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 写门(测试隔离):test runner 进程(NODE_TEST_CONTEXT)默认跳过——除显式
|
|
61
|
+
* THINCODER_LOG_DIR override(log.test.mjs 隔离临时目录——refinement #6)。 */
|
|
62
|
+
function writeEnabled() {
|
|
63
|
+
if (_dead) return false
|
|
64
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.THINCODER_LOG_DIR) return false
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 清理 >1 天的 agent-*.log(NF-L4——refinement #3:机会式,非仅启动时)。
|
|
69
|
+
* 保留窗口(2026-09-03 code review #1 修正):以文件名日期的**当日结束**为龄基准——
|
|
70
|
+
* 今天+昨天的文件保留(任何事件至少留存 24h、最多 48h),早于昨天的删除。
|
|
71
|
+
* 无法按名解析日期的 agent-*.log 按 mtime 兜底(>1 天未写的删除)。
|
|
72
|
+
* 导出供测试直呼(T-L7/T-L7b)。静默:任何失败都不影响主流程。 */
|
|
73
|
+
export const LOG_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
74
|
+
export function cleanupOldLogs(now = new Date()) {
|
|
75
|
+
const dir = logsDir()
|
|
76
|
+
if (!existsSync(dir)) return
|
|
77
|
+
let names
|
|
78
|
+
try {
|
|
79
|
+
names = readdirSync(dir)
|
|
80
|
+
} catch {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (!name.startsWith("agent-") || !name.endsWith(".log")) continue
|
|
85
|
+
const m = name.match(/^agent-(\d{4}-\d{2}-\d{2})\.log$/)
|
|
86
|
+
const ts = m ? Date.parse(m[1] + "T00:00:00Z") : NaN
|
|
87
|
+
// 文件名日期 → 龄以该日结束(ts+24h)计:事件在删除时至少已留存 24h
|
|
88
|
+
const ageMs = Number.isFinite(ts) ? now - (ts + LOG_RETENTION_MS) : (tryStatAge(dir, name, now) ?? Infinity)
|
|
89
|
+
if (ageMs > LOG_RETENTION_MS) {
|
|
90
|
+
try { rmSync(join(dir, name), { force: true }) } catch { /* 静默 */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tryStatAge(dir, name, now) {
|
|
96
|
+
try {
|
|
97
|
+
return now - statSync(join(dir, name)).mtimeMs
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 写入一条事件(fire-and-forget)。fields 内所有字符串都经黑名单/截断处理。
|
|
105
|
+
* 事件行结构:{"ts":ISO,"ev":kind,"seq":N,...fields}——单行 JSON <512 字符。
|
|
106
|
+
*/
|
|
107
|
+
export function logEvent(kind, fields = {}) {
|
|
108
|
+
if (!writeEnabled()) return
|
|
109
|
+
const now = new Date()
|
|
110
|
+
// 机会式轮转清理:每进程每日首次写事件时执行一次(refinement #3——长驻进程覆盖)
|
|
111
|
+
const ymd = now.toISOString().slice(0, 10)
|
|
112
|
+
if (_cleanupDate !== ymd) {
|
|
113
|
+
_cleanupDate = ymd
|
|
114
|
+
cleanupOldLogs(now)
|
|
115
|
+
}
|
|
116
|
+
const entry = { ts: now.toISOString(), ev: kind, seq: ++_seq }
|
|
117
|
+
for (const [k, v] of Object.entries(fields ?? {})) {
|
|
118
|
+
if (v === undefined || v === null) continue
|
|
119
|
+
// ① 字段名精确黑名单 → 丢弃(token 族永不落盘)
|
|
120
|
+
if (BLACKLIST_FIELDS.has(String(k).toLowerCase())) continue
|
|
121
|
+
if (typeof v === "string") entry[k] = sanitizeString(k, v)
|
|
122
|
+
else if (typeof v === "number" || typeof v === "boolean") entry[k] = v
|
|
123
|
+
}
|
|
124
|
+
let line = JSON.stringify(entry)
|
|
125
|
+
// ② 防御性收尾(NF-L2):字段上限后仍超长 → 丢可选字段(保留 ts/ev/seq)直至 <512
|
|
126
|
+
while (line.length > MAX_LINE && Object.keys(entry).length > 3) {
|
|
127
|
+
for (const k of Object.keys(entry)) {
|
|
128
|
+
if (k !== "ts" && k !== "ev" && k !== "seq") { delete entry[k]; break }
|
|
129
|
+
}
|
|
130
|
+
line = JSON.stringify(entry)
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const dir = logsDir()
|
|
134
|
+
mkdirSync(dir, { recursive: true })
|
|
135
|
+
appendFileSync(join(dir, `agent-${ymd}.log`), line + "\n", "utf8")
|
|
136
|
+
} catch {
|
|
137
|
+
_dead = true // NF-L1:写失败静默降级——主流程零影响
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 共享脱敏(§18.6 D-TR2——trace-store 复用;logEvent 自身保持"丢弃字段"语义,
|
|
142
|
+
* 本函数提供"遮蔽标记"语义——同一字段名黑名单 + 同一 SECRET_FORM 形态扫描,
|
|
143
|
+
* 不发明新遮蔽模式)。字段名命中黑名单 → "[REDACTED](整个字段遮蔽);内容命中
|
|
144
|
+
* 密钥形态 → 截断到形态前 + "[redacted]" 标记(宁可丢信息不漏密钥——§2.4)。 */
|
|
145
|
+
export function redactSecret(fieldKey, value) {
|
|
146
|
+
const s = String(value)
|
|
147
|
+
if (BLACKLIST_FIELDS.has(String(fieldKey).toLowerCase())) return "[REDACTED]"
|
|
148
|
+
const hit = s.match(SECRET_FORM)
|
|
149
|
+
return hit ? s.slice(0, hit.index) + "[redacted]" : s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 内容级净化:密钥形态截断到形态前(②);超长截断 + "…" 标记。 */
|
|
153
|
+
export function sanitizeString(key, value) {
|
|
154
|
+
let s = String(value)
|
|
155
|
+
// 密钥形态 → 截断到形态前(宁可丢信息不漏密钥——§2.4)
|
|
156
|
+
const hit = s.match(SECRET_FORM)
|
|
157
|
+
if (hit) s = s.slice(0, hit.index)
|
|
158
|
+
const cap = FIELD_CAPS[key] ?? 120
|
|
159
|
+
if (s.length > cap) s = s.slice(0, cap - 1) + "…"
|
|
160
|
+
return s
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 错误文本提取:message + cause 链首条、压成单行、≤max(默认 200——NF-L2/§2.2) */
|
|
164
|
+
export function errText(err, max = 200) {
|
|
165
|
+
let msg = err?.message ?? String(err ?? "")
|
|
166
|
+
if (!msg && err?.cause) msg = String(err.cause)
|
|
167
|
+
if (err?.cause?.message && !/^LLM API error/.test(msg)) msg += ` (${err.cause.message})`
|
|
168
|
+
msg = msg.replace(/\s*\n\s*/g, " ").trim()
|
|
169
|
+
if (msg.length > max) msg = msg.slice(0, max - 1) + "…"
|
|
170
|
+
return msg
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 错误 kind 分类(llm:error 字段):timeout(网关超时/超时信号)/ abort(用户中止)/
|
|
174
|
+
* error(其余——API/网络/未分类)。signal 为请求中止信号(chat 调用点直传)。 */
|
|
175
|
+
export function classifyErr(err, signal) {
|
|
176
|
+
const msg = String(err?.message ?? "").toLowerCase() + " " + String(err?.cause?.message ?? "").toLowerCase()
|
|
177
|
+
if (/timeout|timed ?out|etimedout|time limit exceeded/i.test(msg)) return "timeout"
|
|
178
|
+
if (err?.name === "AbortError" || signal?.aborted) {
|
|
179
|
+
return signal?.reason?.name === "TimeoutError" ? "timeout" : "abort"
|
|
180
|
+
}
|
|
181
|
+
return "error"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 文本头截取:前 max 字符、单行化(JSON 单行约束)、截断带 "…" 标记。
|
|
185
|
+
* paragraph=true(llm:done 响应头):取首段(首个空行前)——B 方案"首段文本"语义。 */
|
|
186
|
+
export function headText(text, max, { paragraph = false } = {}) {
|
|
187
|
+
let s = String(text ?? "")
|
|
188
|
+
if (paragraph) {
|
|
189
|
+
const p = s.split(/\n\s*\n/, 1)[0]
|
|
190
|
+
if (p.length < s.length) s = p
|
|
191
|
+
}
|
|
192
|
+
s = s.replace(/\s*\n+\s*/g, " ").trim()
|
|
193
|
+
if (s.length > max) s = s.slice(0, max - 1) + "…"
|
|
194
|
+
return s
|
|
195
|
+
}
|
|
@@ -9,6 +9,9 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
9
9
|
let sessionId = null
|
|
10
10
|
let closed = false
|
|
11
11
|
let eventSource = null
|
|
12
|
+
// 2026-09-01 MCP.md §4 D-1:GET SSE 不可用(405/不支持)降级后的纯 POST 模式标记。
|
|
13
|
+
// POST-only server(如 glm-websearch)无流可断:isAlive 不得因 eventSource == null 判死。
|
|
14
|
+
let postOnly = false
|
|
12
15
|
let abortController = null
|
|
13
16
|
let postUrl = url
|
|
14
17
|
let legacySSE = false
|
|
@@ -232,5 +235,14 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
232
235
|
pending.clear()
|
|
233
236
|
}
|
|
234
237
|
|
|
235
|
-
|
|
238
|
+
/** D-1:openSSE 降级(GET 405/不支持)后由 connect 链调用——标记纯 Streamable POST 模式 */
|
|
239
|
+
const markPostOnly = () => { postOnly = true }
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
send, notify, close, openSSE, url, headers: extraHeaders,
|
|
243
|
+
// F1:POST-only 降级(postOnly)与 legacy SSE 流(eventSource)都算活连接——
|
|
244
|
+
// 不得因 eventSource == null 误判死(glm-websearch "reconnect failed" 根因)。
|
|
245
|
+
isAlive: () => !closed && (eventSource != null || postOnly),
|
|
246
|
+
onDead, markPostOnly,
|
|
247
|
+
}
|
|
236
248
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -7,6 +7,12 @@ import { stdioTransport } from "./mcp/transport-stdio.mjs"
|
|
|
7
7
|
import { httpTransport } from "./mcp/transport-http.mjs"
|
|
8
8
|
import { wsTransport } from "./mcp/transport-ws.mjs"
|
|
9
9
|
|
|
10
|
+
/** F6/D-5:config.token → `Authorization: Bearer <token>` 合成(仅当 headers 未显式给
|
|
11
|
+
* Authorization——显式优先,向后兼容)。合成发生在传给 transport 前,不写回 config。 */
|
|
12
|
+
export function withBearerToken(config) {
|
|
13
|
+
if (!config?.token || config.headers?.Authorization) return config
|
|
14
|
+
return { ...config, headers: { ...config.headers, Authorization: `Bearer ${config.token}` } }
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/** Race a pending MCP request against an abort signal — a hung MCP server must not
|
|
12
18
|
* hold the turn hostage. signal absent → passthrough. */
|
|
@@ -31,8 +37,9 @@ async function sendWithSignal(promise, signal) {
|
|
|
31
37
|
|
|
32
38
|
/** 2026-08-31 MCP 会诊 P5:CLI session 注册表(serverName → session)。
|
|
33
39
|
* session.state.transport 可变(重连替换);buildTools 的 execute 动态取
|
|
34
|
-
* session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。
|
|
35
|
-
|
|
40
|
+
* session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。
|
|
41
|
+
* 2026-09-01 MCP.md §4:导出给 /mcp test 的零副作用断言用(probe 后 _sessions 不增)。 */
|
|
42
|
+
export const _sessions = new Map()
|
|
36
43
|
/** 退避重连进行中(serverName → promise)——与 vscode 语义对齐;延迟表见 _mcpHooks。 */
|
|
37
44
|
const _reconnecting = new Map()
|
|
38
45
|
|
|
@@ -44,7 +51,8 @@ export const _mcpHooks = {
|
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/** 按 config 创建并完成握手的 transport(findTransportConfig 与 vscode 对齐)。 */
|
|
47
|
-
async function createConnectedTransport(
|
|
54
|
+
async function createConnectedTransport(rawConfig, serverName) {
|
|
55
|
+
const config = withBearerToken(rawConfig) // F6/D-5:token 合成(不写回原 config)
|
|
48
56
|
let transport
|
|
49
57
|
if (config.wsUrl) {
|
|
50
58
|
transport = wsTransport(config.wsUrl, config.headers ?? {})
|
|
@@ -54,7 +62,10 @@ async function createConnectedTransport(config, serverName) {
|
|
|
54
62
|
try {
|
|
55
63
|
await transport.openSSE()
|
|
56
64
|
} catch {
|
|
57
|
-
// Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode
|
|
65
|
+
// Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode.
|
|
66
|
+
// MCP.md §4 D-1:显式标记 postOnly——POST-only server(glm-websearch 类)isAlive
|
|
67
|
+
// 不得因 eventSource == null 误判死(否则 ensureAlive 触发无意义重连循环)。
|
|
68
|
+
transport.markPostOnly()
|
|
58
69
|
}
|
|
59
70
|
} else {
|
|
60
71
|
transport = stdioTransport(config.command, config.args ?? [], config.env)
|
|
@@ -63,6 +74,25 @@ async function createConnectedTransport(config, serverName) {
|
|
|
63
74
|
return { transport, mcpTools }
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/** F4/D-2:一次性探活——createConnectedTransport(initialize + tools/list)+ 计时。
|
|
78
|
+
* 零副作用:不进 _sessions、不动 agent.tools、无 onDead 挂钩;finally close
|
|
79
|
+
* (closed=true 使 onDead 重连不会触发)。initialize 与 tools/list 同受
|
|
80
|
+
* INIT_TIMEOUT_MS 约束(见 doInitialize)。不复用 connectMcpServer(避免污染
|
|
81
|
+
* session 幂等表)。 */
|
|
82
|
+
export async function probeMcpServer(config) {
|
|
83
|
+
const start = Date.now()
|
|
84
|
+
let transport
|
|
85
|
+
let mcpTools
|
|
86
|
+
try {
|
|
87
|
+
;({ transport, mcpTools } = await createConnectedTransport(config, config.name ?? config.command ?? config.url ?? config.wsUrl))
|
|
88
|
+
return { ok: true, toolCount: mcpTools.length, latencyMs: Date.now() - start }
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, error: error?.message ?? String(error) }
|
|
91
|
+
} finally {
|
|
92
|
+
try { transport?.close() } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
/** onDead → 后台退避重连;成功替换 session.state.transport(tools 闭包动态引用,
|
|
67
97
|
* agent.tools 无需重建);失败静默(下次 execute 前置检查再试)。 */
|
|
68
98
|
function scheduleReconnect(name, config) {
|
|
@@ -173,10 +203,14 @@ async function doInitialize(transport, _name) {
|
|
|
173
203
|
|
|
174
204
|
// 2026-08-31 MCP 会诊 P6:tools/list 分页被忽略(nextCursor 多页工具静默丢失)——
|
|
175
205
|
// 循环跟随 cursor 直到 server 不再返回(上限 20 页防死循环)。
|
|
206
|
+
// MCP.md §4 评审 #8:每页同受 INIT_TIMEOUT_MS 约束(否则 probe 的延迟统计无界)。
|
|
176
207
|
const tools = []
|
|
177
208
|
let cursor
|
|
178
209
|
for (let page = 0; page < 20; page++) {
|
|
179
|
-
const toolsResp = await
|
|
210
|
+
const toolsResp = await withTimeout(
|
|
211
|
+
transport.send("tools/list", cursor ? { cursor } : {}),
|
|
212
|
+
INIT_TIMEOUT_MS,
|
|
213
|
+
)
|
|
180
214
|
if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
|
|
181
215
|
tools.push(...(toolsResp.result?.tools ?? []))
|
|
182
216
|
cursor = toolsResp.result?.nextCursor
|
|
@@ -192,7 +226,9 @@ export async function connectMcpServer(config) {
|
|
|
192
226
|
if (!config || (!config.command && !config.url && !config.wsUrl))
|
|
193
227
|
throw new Error(`MCP server "${config?.name ?? ""}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
|
|
194
228
|
const name = config.name ?? config.command ?? config.url ?? config.wsUrl
|
|
195
|
-
|
|
229
|
+
// MCP.md §4 D-5:fingerprint 计入 token 字段——/mcp edit 改 token → 指纹变更 →
|
|
230
|
+
// 旧连接主动关闭重建(T13)。
|
|
231
|
+
const configFingerprint = JSON.stringify([config.command ?? null, config.args ?? null, config.url ?? null, config.wsUrl ?? null, config.env ?? null, config.headers ?? null, config.token ?? null])
|
|
196
232
|
|
|
197
233
|
const existing = _sessions.get(name)
|
|
198
234
|
if (existing && !existing.closed) {
|
|
@@ -208,7 +244,16 @@ export async function connectMcpServer(config) {
|
|
|
208
244
|
}
|
|
209
245
|
}
|
|
210
246
|
|
|
211
|
-
|
|
247
|
+
let transport
|
|
248
|
+
let mcpTools
|
|
249
|
+
try {
|
|
250
|
+
;({ transport, mcpTools } = await createConnectedTransport(config, name))
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// MCP.md §4 评审 #7:握手失败不泄漏 transport(GET SSE 降级成功但 POST initialize
|
|
253
|
+
// 失败时,openSSE 若开了流会留一个悬挂的 reader/请求)。
|
|
254
|
+
try { transport?.close() } catch { /* ignore */ }
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
212
257
|
const session = {
|
|
213
258
|
config, configFingerprint,
|
|
214
259
|
state: { transport, tools: null },
|
package/src/memory/code-sync.mjs
CHANGED
|
@@ -313,7 +313,7 @@ export function codeSearchTool(memory) {
|
|
|
313
313
|
return {
|
|
314
314
|
name: "code_search",
|
|
315
315
|
description:
|
|
316
|
-
"Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers.",
|
|
316
|
+
"Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers. Prefer doc_search for the intended design (design docs, conventions); code_search for the implementation as written.",
|
|
317
317
|
parameters: {
|
|
318
318
|
type: "object",
|
|
319
319
|
properties: {
|