thincoder 0.5.0 → 0.6.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 +83 -45
- package/package.json +2 -2
- package/src/SYSTEM_PROMPT.md +1 -0
- package/src/agent.mjs +27 -1
- package/src/config.mjs +43 -23
- package/src/memory.mjs +43 -68
- package/src/provider.mjs +1 -1
- package/src/tools/bash.md +1 -0
- package/src/tools/glob.md +1 -1
- package/src/tools.mjs +30 -6
- package/src/tui.mjs +92 -19
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Sharp Code, Zero Bloat.**
|
|
4
4
|
|
|
5
|
-
**一个"薄"的 AI 编程 agent
|
|
5
|
+
**一个"薄"的 AI 编程 agent:纯** `.mjs`**、无构建、零 npm 依赖、Node.js 原生。**
|
|
6
6
|
|
|
7
7
|
ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害**——像刀刃。
|
|
8
8
|
在 AI agent 都在卷"全能"的今天,ThinCoder 打的是反面那张牌:**克制、精准、不废话**。
|
|
@@ -12,32 +12,28 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
|
|
|
12
12
|
|
|
13
13
|
## 特性
|
|
14
14
|
|
|
15
|
-
- **Agent 主循环**:LLM ↔
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
- **
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- **流式 TUI**:裸 ANSI 实现(无 UI 库),对话流 / 流式输出 / 权限确认(y/n/a,a=批准并转 AUTO)/ 翻页 / 输入历史 / 斜杠命令 Tab 补全
|
|
29
|
-
- **上下文压缩**:对话超阈值时自动摘要(保留最早 2 条 + 最近 10 条,中间 LLM 摘要)
|
|
30
|
-
- **三层记忆 + 团队共享**(见下)
|
|
31
|
-
- **LLM 调用**:原生 `fetch` 直连 OpenAI 兼容协议(OpenAI / DeepSeek / Moonshot / Ollama),流式 SSE,指数退避重试,支持 `reasoning_content` 思考流
|
|
15
|
+
- **Agent 主循环**:LLM ↔ 工具调用循环,上限 100 轮防失控,完成守卫拦截未验证的改动
|
|
16
|
+
- **代码库理解** ⭐0.5.0:`repo_outline`(依赖大纲,启动自动注入)、`code_search`(源码 FTS5 + 向量 + JSDoc 提取)、`doc_search`(文档按 ## 标题分块检索)——后台索引、写文件自动增量更新、三工具按"结构→意图→细节"引导
|
|
17
|
+
- **模型适配** ⭐:5 家国产大模型内置预设(DeepSeek/Kimi/GLM/Qwen/MiniMax),自动匹配上下文窗口、截断续写协议(prefix/partial)、思考模式 API(thinking.type / reasoning_effort)、输出上限
|
|
18
|
+
- **工具集**:`read` / `write` / `edit` / `bash` / `glob`(支持 `**`) / `grep` / `websearch` / `ls` / `fetch` + 三个检索工具 + MCP,全部零依赖,文件工具目录隔离
|
|
19
|
+
- **记忆系统**:三层(personal/project/team),FTS5 + 向量 RRF 混合检索,markdown 格式 git 友好
|
|
20
|
+
- **两段式工具调度**:权限确认串行,只读工具并行,副作用工具串行
|
|
21
|
+
- **会话持久化** ⭐0.5.0:最多 5 个归档槽位,`/session` 随时切换,恢复时工具结果可见
|
|
22
|
+
- **子 agent 并发**:`explore`/`plan`/`coder` 三种角色,并行派发,流式输出可见,报告进对话区
|
|
23
|
+
- **Plan Mode**:只读探索 + 方案设计,用户确认后实现
|
|
24
|
+
- **AUTO 模式**:`/auto` 完全授权,长任务免确认
|
|
25
|
+
- **任务跟踪**:`task` 工具拆解多步任务,状态栏 ✓n/m 实时进度,自动过滤已完成项
|
|
26
|
+
- **Goal/Verify/Skills**:长目标跟踪、完成验证、可复用技能
|
|
27
|
+
- **流式 TUI**:裸 ANSI,权限预览紧挨输入框,write/edit 自动展示 diff
|
|
32
28
|
|
|
33
29
|
## 记忆系统:一人学到,全队皆知
|
|
34
30
|
|
|
35
31
|
三层记忆,全部"有就查、没有就跳过",统一混合检索:
|
|
36
32
|
|
|
37
|
-
| 层
|
|
38
|
-
|
|
39
|
-
| **Personal**
|
|
40
|
-
| **Project**
|
|
33
|
+
| 层 | 位置 | 同步方式 |
|
|
34
|
+
| ---------------- | --------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
35
|
+
| **Personal** | `~/.thincoder/memory.db`(sqlite) | 不同步,私有 |
|
|
36
|
+
| **Project** | 项目仓库 `.thincoder/memory/*.md` | 随项目 git(ThinCoder **只写文件,绝不替你 commit**) |
|
|
41
37
|
| **Team**(可选) | 独立记忆仓库,clone 到 `~/.thincoder/teams/<name>/` | `thincoder sync`(pull --rebase);写入时自动 commit + push(专用设施,可选启用) |
|
|
42
38
|
|
|
43
39
|
- **混合检索**:FTS5(BM25,中文逐字索引,双字词可命中)+ embedding 向量(暴力余弦)+ RRF(k=60) 融合排序
|
|
@@ -48,7 +44,7 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
|
|
|
48
44
|
|
|
49
45
|
## 要求
|
|
50
46
|
|
|
51
|
-
- Node.js >=
|
|
47
|
+
- Node.js >= 24
|
|
52
48
|
- 一个 OpenAI 兼容端点的 API key
|
|
53
49
|
- 可选:embedding 服务的 key(不配置则退化为纯 FTS 检索)
|
|
54
50
|
|
|
@@ -88,7 +84,7 @@ thincoder upgrade
|
|
|
88
84
|
|
|
89
85
|
从源码运行:把上面的 `thincoder` 换成 `node bin/thincoder.mjs`。
|
|
90
86
|
|
|
91
|
-
TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全部模型;`/model <名称>` 直接切换)、`/provider`(增/删 provider、配 key,支持自定义端点)、`/think`(思维模式开关与推理强度)、`/config`(查看配置、`/config embedkey` 配 embedding key、`/config set` 改参数)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
|
|
87
|
+
TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全部模型;`/model <名称>` 直接切换)、`/provider`(增/删 provider、配 key,支持自定义端点)、`/think`(思维模式开关与推理强度)、`/config`(查看配置、`/config embedkey` 配 embedding key、`/config set` 改参数)、`/session`(列出/切换归档会话)、`/reindex`(重建索引)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
|
|
92
88
|
|
|
93
89
|
环境变量:`THINCODER_API_KEY`(或 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`)、`THINCODER_BASE_URL`、`THINCODER_MODEL`、`SILICONFLOW_API_KEY`。
|
|
94
90
|
|
|
@@ -98,41 +94,45 @@ TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全
|
|
|
98
94
|
|
|
99
95
|
```jsonc
|
|
100
96
|
{
|
|
101
|
-
"providers": [
|
|
97
|
+
"providers": [
|
|
98
|
+
// 可配多个,/model <名称> 切换
|
|
102
99
|
{
|
|
103
100
|
"name": "deepseek",
|
|
104
|
-
"baseURL": "https://api.deepseek.com/v1",
|
|
105
|
-
"apiKey": "sk-...",
|
|
106
|
-
"model": "deepseek-chat"
|
|
107
|
-
}
|
|
101
|
+
"baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
|
|
102
|
+
"apiKey": "sk-...", // 或留空走环境变量
|
|
103
|
+
"model": "deepseek-chat",
|
|
104
|
+
},
|
|
108
105
|
],
|
|
109
|
-
"activeProvider": "deepseek",
|
|
110
|
-
"embedding": {
|
|
106
|
+
"activeProvider": "deepseek", // 当前激活的 provider 名
|
|
107
|
+
"embedding": {
|
|
108
|
+
// 可选:不配则纯 FTS 检索
|
|
111
109
|
"baseURL": "https://api.siliconflow.cn/v1",
|
|
112
|
-
"apiKey": "sk-...",
|
|
113
|
-
"model": "BAAI/bge-m3"
|
|
110
|
+
"apiKey": "sk-...", // 或 SILICONFLOW_API_KEY
|
|
111
|
+
"model": "BAAI/bge-m3",
|
|
114
112
|
},
|
|
115
113
|
"agent": {
|
|
116
|
-
"maxTurns": 100,
|
|
117
|
-
"compactThreshold": 100000
|
|
114
|
+
"maxTurns": 100, // 工具循环上限
|
|
115
|
+
"compactThreshold": 100000, // 上下文压缩阈值(约 token 数)
|
|
118
116
|
},
|
|
119
117
|
"memory": {
|
|
120
|
-
"dbPath": "~/.thincoder/memory.db",
|
|
121
|
-
"projectDir": ".thincoder/memory",
|
|
122
|
-
"team": {
|
|
118
|
+
"dbPath": "~/.thincoder/memory.db", // sqlite 索引库路径
|
|
119
|
+
"projectDir": ".thincoder/memory", // Project 层目录(相对项目根)
|
|
120
|
+
"team": {
|
|
121
|
+
// 可选:不配则 Team 层禁用
|
|
123
122
|
"name": "myteam",
|
|
124
|
-
"repo": "git@github.com:org/team-memory.git"
|
|
125
|
-
}
|
|
123
|
+
"repo": "git@github.com:org/team-memory.git",
|
|
124
|
+
},
|
|
126
125
|
},
|
|
127
|
-
"mcp": {
|
|
126
|
+
"mcp": {
|
|
127
|
+
// 可选:MCP server 列表
|
|
128
128
|
"servers": [
|
|
129
129
|
{
|
|
130
130
|
"name": "filesystem",
|
|
131
131
|
"command": "npx",
|
|
132
|
-
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
133
|
-
}
|
|
134
|
-
]
|
|
135
|
-
}
|
|
132
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
136
|
}
|
|
137
137
|
```
|
|
138
138
|
|
|
@@ -187,6 +187,44 @@ node scripts/verify-team.mjs # 团队记忆 A->git->B 全链路验证(本
|
|
|
187
187
|
- MCP HTTP transport(当前仅 stdio)
|
|
188
188
|
- 更多内置 skills
|
|
189
189
|
|
|
190
|
+
## 更新日志
|
|
191
|
+
|
|
192
|
+
### 0.5.0(2026-07)
|
|
193
|
+
- **代码库理解**:`repo_outline`(依赖大纲,启动自动注入)、`code_search`(FTS5 + 向量 + JSDoc)、`doc_search`(按 ## 标题分块),写文件自动增量索引
|
|
194
|
+
- **模型适配**:5 家内置预设(DeepSeek/Kimi/GLM/Qwen/MiniMax),maxTokens 拉满、截断续写、思考模式 API 自动匹配
|
|
195
|
+
- 会话 5 槽位归档、`/session` 切换、恢复时展示工具结果
|
|
196
|
+
- 子 agent 流式输出可见、最终报告进对话区
|
|
197
|
+
- 文件工具目录隔离、权限预览紧挨输入框
|
|
198
|
+
- write/edit 自动附 git diff、edit 错误信息增强提示
|
|
199
|
+
- task 自动过滤已完成项、全部 done 时主动提醒
|
|
200
|
+
- 提示词引导"查官方文档 → 不一致就存项目记忆"
|
|
201
|
+
|
|
202
|
+
### 0.4.0
|
|
203
|
+
- 权限审批展示文件内容预览(write 内容、edit diff、bash 命令)
|
|
204
|
+
- todo 面板进度可视、状态栏 token 用量与上下文利用率
|
|
205
|
+
- 项目指令双层合并(全局 + 项目 AGENTS.md)
|
|
206
|
+
|
|
207
|
+
### 0.3.0
|
|
208
|
+
- MCP 客户端(JSON-RPC + stdio,零依赖)
|
|
209
|
+
- Skills 系统(`.thincoder/skills/*.md`)
|
|
210
|
+
- Plan/Goal/Question 工具
|
|
211
|
+
- 提示词外部化到 `.md` 文件、子 agent 角色 overlay
|
|
212
|
+
- task 严格纪律(keep ONE in_progress)、完成守卫(改文件未 verify 拦截)
|
|
213
|
+
- DeepSeek thinking 回传、system prompt 前缀缓存
|
|
214
|
+
- checkpoint 存档点 + `/rewind` 回滚
|
|
215
|
+
|
|
216
|
+
### 0.2.0
|
|
217
|
+
- multi-provider 配置(支持多端点切换)
|
|
218
|
+
- 初始配置向导(方向键选模型、配 key)
|
|
219
|
+
- `/think` 思维模式开关与推理强度
|
|
220
|
+
- `/model` 模型选择器
|
|
221
|
+
- bash 流式输出实时透传
|
|
222
|
+
|
|
223
|
+
### 0.1.0
|
|
224
|
+
- Agent 主循环、14 个内置工具、零依赖 TUI
|
|
225
|
+
- 三层记忆(personal/project/team)、FTS5 检索
|
|
226
|
+
- 会话持久化、上下文压缩、流式 SSE
|
|
227
|
+
|
|
190
228
|
## License
|
|
191
229
|
|
|
192
230
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thincoder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"thincoder": "./bin/thincoder.mjs"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=24"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin/",
|
package/src/SYSTEM_PROMPT.md
CHANGED
|
@@ -25,6 +25,7 @@ Coding discipline (rigor over speed—tokens spent on verification are well spen
|
|
|
25
25
|
- Before fixing a bug, find the root cause: read the error output, reproduce it, trace the code path. Don't patch symptoms.
|
|
26
26
|
- Match the surrounding code: comment density, naming, structure. Prefer the project's existing patterns over your own defaults.
|
|
27
27
|
- Before using a library or utility, confirm the project already depends on it (check imports, manifest, lockfile). If it's missing, surface that instead of silently adding a dependency.
|
|
28
|
+
- When you need facts that may be outdated in your training data—API docs, framework versions, language features, npm packages, CLI flags, pricing, CVEs, platform differences—verify with authoritative sources first: read the project's own files (package.json, lockfile), check official docs (websearch/fetch), or test the actual environment. If findings contradict your training data, save the corrected fact to project memory so future sessions benefit.
|
|
28
29
|
- Refactoring: update every caller when an interface changes; never change existing test logic just to make tests pass.
|
|
29
30
|
- Deliver complete changes: no placeholder stubs, no "// rest unchanged", no TODO gaps left for the user to fill in.
|
|
30
31
|
- After changing behavior, sweep comments and docstrings that now describe the old behavior and bring them in line with the code.
|
package/src/agent.mjs
CHANGED
|
@@ -296,6 +296,12 @@ export const subagentTool = {
|
|
|
296
296
|
const relayPrefix = role ? `${role}/` : "sub/"
|
|
297
297
|
const childOpts = {
|
|
298
298
|
onPermissionRequest: childPermission,
|
|
299
|
+
onToken: ctx.callbacks?.onToken
|
|
300
|
+
? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`)
|
|
301
|
+
: null,
|
|
302
|
+
onReasoning: ctx.callbacks?.onReasoning
|
|
303
|
+
? (t) => ctx.callbacks.onReasoning(`${relayPrefix}${t}`)
|
|
304
|
+
: null,
|
|
299
305
|
onToolCall: ctx.callbacks?.onToolCall
|
|
300
306
|
? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
|
|
301
307
|
: null,
|
|
@@ -373,10 +379,14 @@ export const taskTool = {
|
|
|
373
379
|
},
|
|
374
380
|
readonly: true,
|
|
375
381
|
async execute(args, ctx) {
|
|
376
|
-
|
|
382
|
+
// 只保留非 done 项 + 最近完成的 3 项(上下文参考),上限 20 项防堆积
|
|
383
|
+
const raw = (args.items ?? []).map((it) => ({
|
|
377
384
|
title: String(it.title ?? "").slice(0, 200),
|
|
378
385
|
status: VALID_TASK_STATUS.has(it.status) ? it.status : "pending",
|
|
379
386
|
}))
|
|
387
|
+
const pending = raw.filter((t) => t.status !== "done")
|
|
388
|
+
const recentDone = raw.filter((t) => t.status === "done").slice(-3)
|
|
389
|
+
const items = [...pending, ...recentDone].slice(0, 20)
|
|
380
390
|
ctx.agent.tasks = items
|
|
381
391
|
ctx.agent._turnsSinceTaskUpdate = 0
|
|
382
392
|
ctx.agent._onTaskUpdate?.(items)
|
|
@@ -678,6 +688,16 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
678
688
|
if (tree) {
|
|
679
689
|
agent.history.push({ role: "user", content: `[System reminder: working directory snapshot:\n${tree}]`, transient: true })
|
|
680
690
|
}
|
|
691
|
+
// 依赖大纲:模型开局就能看见谁 import 谁,不用盲调 repo_outline
|
|
692
|
+
if (agent.memory) {
|
|
693
|
+
try {
|
|
694
|
+
const { buildOutline } = await import("./repomap.mjs")
|
|
695
|
+
const outline = buildOutline(agent.memory.db, agent.cwd, null)
|
|
696
|
+
if (outline && !outline.startsWith("(no indexed")) {
|
|
697
|
+
agent.history.push({ role: "user", content: `[System reminder: project dependency outline:\n${outline}]`, transient: true })
|
|
698
|
+
}
|
|
699
|
+
} catch { /* 索引未就绪不报错 */ }
|
|
700
|
+
}
|
|
681
701
|
}
|
|
682
702
|
// 相关记忆作为独立 user 上下文消息注入,而不是塞进 system prompt——
|
|
683
703
|
// system prompt 跨 run 逐字节一致,DeepSeek context caching(前缀缓存,命中便宜 ~120x)才能命中
|
|
@@ -922,6 +942,12 @@ function tryCanonicalize(name, args) {
|
|
|
922
942
|
role: "user",
|
|
923
943
|
content: "[System reminder: no task list is being tracked. If the current work is a multi-step task, consider using the task tool to plan and track progress. This is a gentle reminder; ignore it if not applicable. Never mention this reminder to the user.]",
|
|
924
944
|
})
|
|
945
|
+
} else {
|
|
946
|
+
// 全部 done 但面板可能有残留:提示模型要么清掉要么加新任务
|
|
947
|
+
agent.history.push({
|
|
948
|
+
role: "user",
|
|
949
|
+
content: "[System reminder: all tracked tasks are marked done. Use the task tool to clear the list or add new tasks if there's more work. Never mention this reminder to the user.]",
|
|
950
|
+
})
|
|
925
951
|
}
|
|
926
952
|
agent._turnsSinceTaskUpdate = 0
|
|
927
953
|
}
|
package/src/config.mjs
CHANGED
|
@@ -14,10 +14,11 @@ export const configPath = join(configDir, "config.json")
|
|
|
14
14
|
|
|
15
15
|
/** 内置提供商预设:/provider add <预设名>、首次启动向导共用 */
|
|
16
16
|
export const PROVIDER_PRESETS = {
|
|
17
|
-
deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "DeepSeek" },
|
|
18
|
-
kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "
|
|
19
|
-
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "智谱 GLM" },
|
|
20
|
-
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "
|
|
17
|
+
deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
|
|
18
|
+
kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
|
|
19
|
+
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 131072, desc: "智谱 GLM" },
|
|
20
|
+
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", maxTokens: 131072, desc: "通义千问" },
|
|
21
|
+
minimax: { baseURL: "https://api.minimax.chat/v1", chatPath: "/text/chatcompletion_v2", model: "MiniMax-M3", maxTokens: 131072, desc: "MiniMax" },
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// 默认 provider 跟 deepseek 预设保持一致(去掉 desc 展示字段)
|
|
@@ -45,28 +46,47 @@ const DEFAULTS = {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* 已知模型的能力规格表(前缀匹配,长的在前)。
|
|
50
|
+
* 用于压缩阈值推导、截断续写协议选择、能力感知优化。
|
|
51
|
+
*
|
|
52
|
+
* context: 上下文窗口(tokens)
|
|
53
|
+
* maxOutput: 最大输出 tokens(默认 context)
|
|
54
|
+
* thinking: 是否支持思考/推理模式
|
|
55
|
+
* partialMode: Kimi/Qwen Partial Mode 截断续写(assistant 消息带 partial:true)
|
|
56
|
+
* prefixMode: DeepSeek Prefix Completion 截断续写(走 /beta 端点,带 prefix:true)
|
|
57
|
+
* multimodal: 是否多模态(支持图片/视觉输入)
|
|
58
|
+
* cacheMode: 上下文缓存方式:"auto"=自动/"prompt"=需显式/"none"=不支持
|
|
59
|
+
* thinkApi: 思考模式 API 类型:"type"=thinking.type 字段 / "effort"=reasoning_effort 字段
|
|
54
60
|
*/
|
|
55
61
|
const MODEL_SPECS = [
|
|
56
|
-
|
|
57
|
-
["deepseek-v4-
|
|
58
|
-
["deepseek-
|
|
59
|
-
["deepseek-
|
|
60
|
-
["
|
|
61
|
-
|
|
62
|
-
["
|
|
63
|
-
["
|
|
64
|
-
["
|
|
65
|
-
|
|
66
|
-
["
|
|
67
|
-
["
|
|
62
|
+
// DeepSeek V4 系列
|
|
63
|
+
["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
|
|
64
|
+
["deepseek-v4-flash", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
|
|
65
|
+
["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
|
|
66
|
+
["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
|
|
67
|
+
// Kimi 系列
|
|
68
|
+
["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "prompt", thinkApi: "effort" }],
|
|
69
|
+
["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
|
|
70
|
+
["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
|
|
71
|
+
// GLM 系列
|
|
72
|
+
["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
|
|
73
|
+
["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
|
|
74
|
+
["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
|
|
75
|
+
// GPT 系列
|
|
76
|
+
["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
|
|
77
|
+
["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
78
|
+
// Qwen 系列
|
|
79
|
+
["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
|
|
80
|
+
["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
|
|
81
|
+
["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
|
|
82
|
+
["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
|
|
83
|
+
["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
|
|
84
|
+
// MiniMax 系列
|
|
85
|
+
["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
|
|
86
|
+
["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
|
|
87
|
+
["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
|
|
68
88
|
]
|
|
69
|
-
const DEFAULT_SPEC = { context: 128_000 }
|
|
89
|
+
const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
|
|
70
90
|
// 窗口利用率上限:0.8(DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
|
|
71
91
|
// 留 20% 余量给压缩后的尾部增长与输出 token)
|
|
72
92
|
const COMPACT_RATIO = 0.8
|
package/src/memory.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { DatabaseSync } from "node:sqlite"
|
|
14
14
|
import { mkdirSync } from "node:fs"
|
|
15
15
|
import { readFile, readdir, stat, writeFile, mkdir } from "node:fs/promises"
|
|
16
|
-
import { dirname, join } from "node:path"
|
|
16
|
+
import { dirname, join, relative } from "node:path"
|
|
17
17
|
import { parseEntry, serializeEntry, entryFilename } from "./markdown.mjs"
|
|
18
18
|
import { embed, cosine, toBlob, fromBlob } from "./embedding.mjs"
|
|
19
19
|
import { commitAndPush } from "./gitmem.mjs"
|
|
@@ -664,6 +664,33 @@ function extractLeadingDoc(lines, lineNum, ext) {
|
|
|
664
664
|
return text.length > 0 && text.length < 300 ? text : ""
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
+
/** 单文件入索引:删除旧块 → 分块 → 插入新块(codeSync 和 reindexFile 共用) */
|
|
668
|
+
function _upsertCodeFile(memory, rel, lines, lang, mtimeMs) {
|
|
669
|
+
const chunks = chunkCode(lines, rel)
|
|
670
|
+
memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
|
|
671
|
+
const insert = memory.db.prepare(`
|
|
672
|
+
INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
|
|
673
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
674
|
+
`)
|
|
675
|
+
for (const c of chunks) {
|
|
676
|
+
const isFile = c.name === rel
|
|
677
|
+
insert.run(rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function _upsertDocFile(memory, rel, lines, mtimeMs) {
|
|
682
|
+
const chunks = chunkMarkdown(lines, rel)
|
|
683
|
+
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
|
684
|
+
memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
|
|
685
|
+
const insert = memory.db.prepare(`
|
|
686
|
+
INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
|
|
687
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
688
|
+
`)
|
|
689
|
+
for (const c of chunks) {
|
|
690
|
+
insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
667
694
|
/**
|
|
668
695
|
* 同步代码索引:扫描 dir 下所有源文件 → 分块 → upsert 到 code_chunks。
|
|
669
696
|
* 按 mtime 增量——只重建变更过的文件块。
|
|
@@ -713,28 +740,7 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
|
|
|
713
740
|
try { text = await readFile(abs, "utf8") } catch { continue }
|
|
714
741
|
const lines = text.split("\n")
|
|
715
742
|
const lang = detectLanguage(abs)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// 删除该文件的旧块,插入新块
|
|
719
|
-
memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
|
|
720
|
-
const insert = memory.db.prepare(`
|
|
721
|
-
INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
|
|
722
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
723
|
-
`)
|
|
724
|
-
for (const c of chunks) {
|
|
725
|
-
const isFile = c.name === rel
|
|
726
|
-
insert.run(
|
|
727
|
-
rel,
|
|
728
|
-
lang,
|
|
729
|
-
isFile ? "file" : "symbol",
|
|
730
|
-
isFile ? "" : c.name.slice(rel.length + 1), // 去掉 "filepath:" 前缀
|
|
731
|
-
c.content,
|
|
732
|
-
c.line_start,
|
|
733
|
-
c.line_end,
|
|
734
|
-
mtimeMs,
|
|
735
|
-
segmentCJK(c.content),
|
|
736
|
-
)
|
|
737
|
-
}
|
|
743
|
+
_upsertCodeFile(memory, rel, lines, lang, mtimeMs)
|
|
738
744
|
updated++
|
|
739
745
|
|
|
740
746
|
if (onProgress && i % 10 === 0) {
|
|
@@ -851,48 +857,27 @@ export function codeSearchTool(memory) {
|
|
|
851
857
|
*/
|
|
852
858
|
export async function reindexFile(memory, cwd, absPath) {
|
|
853
859
|
const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
|
|
854
|
-
const rel =
|
|
860
|
+
const rel = relative(cwd, absPath).replaceAll("\\", "/")
|
|
861
|
+
if (rel.startsWith("..")) return // 越界路径拒索引
|
|
862
|
+
|
|
863
|
+
let text
|
|
864
|
+
try { text = await readFile(absPath, "utf8") } catch {
|
|
865
|
+
// 文件已删:清理索引
|
|
866
|
+
if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
|
|
867
|
+
else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
const lines = text.split("\n")
|
|
855
871
|
|
|
856
872
|
if (CODE_EXTS.has(ext)) {
|
|
857
|
-
// 文件已删?清理索引
|
|
858
|
-
let text
|
|
859
|
-
try { text = await readFile(absPath, "utf8") } catch {
|
|
860
|
-
memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
|
|
861
|
-
return
|
|
862
|
-
}
|
|
863
|
-
const lines = text.split("\n")
|
|
864
873
|
const lang = detectLanguage(absPath)
|
|
865
|
-
const chunks = chunkCode(lines, rel)
|
|
866
|
-
memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
|
|
867
|
-
const insert = memory.db.prepare(`
|
|
868
|
-
INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
|
|
869
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
870
|
-
`)
|
|
871
874
|
let mtimeMs = 0
|
|
872
875
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
|
|
873
|
-
|
|
874
|
-
const isFile = c.name === rel
|
|
875
|
-
insert.run(rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
876
|
-
}
|
|
876
|
+
_upsertCodeFile(memory, rel, lines, lang, mtimeMs)
|
|
877
877
|
} else if (DOC_EXTS.has(ext)) {
|
|
878
|
-
let text
|
|
879
|
-
try { text = await readFile(absPath, "utf8") } catch {
|
|
880
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
|
|
881
|
-
return
|
|
882
|
-
}
|
|
883
|
-
const lines = text.split("\n")
|
|
884
|
-
const chunks = chunkMarkdown(lines, rel)
|
|
885
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
|
|
886
|
-
const insert = memory.db.prepare(`
|
|
887
|
-
INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
|
|
888
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
889
|
-
`)
|
|
890
|
-
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
|
891
878
|
let mtimeMs = 0
|
|
892
879
|
try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
|
|
893
|
-
|
|
894
|
-
insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
895
|
-
}
|
|
880
|
+
_upsertDocFile(memory, rel, lines, mtimeMs)
|
|
896
881
|
}
|
|
897
882
|
}
|
|
898
883
|
|
|
@@ -968,17 +953,7 @@ export async function docSync(memory, dir, { onProgress } = {}) {
|
|
|
968
953
|
let text
|
|
969
954
|
try { text = await readFile(abs, "utf8") } catch { continue }
|
|
970
955
|
const lines = text.split("\n")
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
|
|
974
|
-
const insert = memory.db.prepare(`
|
|
975
|
-
INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
|
|
976
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
977
|
-
`)
|
|
978
|
-
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
|
979
|
-
for (const c of chunks) {
|
|
980
|
-
insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
981
|
-
}
|
|
956
|
+
_upsertDocFile(memory, rel, lines, mtimeMs)
|
|
982
957
|
updated++
|
|
983
958
|
|
|
984
959
|
if (onProgress && i % 10 === 0) {
|
package/src/provider.mjs
CHANGED
|
@@ -132,7 +132,7 @@ async function requestWithRetry(provider, body, signal) {
|
|
|
132
132
|
|
|
133
133
|
let response
|
|
134
134
|
try {
|
|
135
|
-
response = await fetch(`${provider.baseURL}/chat/completions`, {
|
|
135
|
+
response = await fetch(`${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`, {
|
|
136
136
|
method: "POST",
|
|
137
137
|
headers: {
|
|
138
138
|
"Content-Type": "application/json",
|
package/src/tools/bash.md
CHANGED
|
@@ -12,3 +12,4 @@ Notes:
|
|
|
12
12
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
13
13
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
14
14
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|
|
15
|
+
- Prefer read/glob/grep/ls for file operations inside the project — bash has no directory confinement.
|
package/src/tools/glob.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Find files by glob pattern (e.g.
|
|
1
|
+
Find files by glob pattern. Returns matching paths. Supports `**` for recursive matching (e.g. `src/**/*.mjs` for all .mjs in src/, `**/*.test.mjs` for all test files).
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
4
|
- pattern (required): Glob pattern — supports **, *, ?, and character classes
|
package/src/tools.mjs
CHANGED
|
@@ -48,6 +48,22 @@ function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
|
48
48
|
return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** 对单个文件取 git diff,失败静默返回空 */
|
|
52
|
+
function gitDiffOne(cwd, abs) {
|
|
53
|
+
try {
|
|
54
|
+
const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
|
|
55
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024,
|
|
56
|
+
}).trim()
|
|
57
|
+
if (!diff) return ""
|
|
58
|
+
// 截断超长 diff(超大文件改动 diff 可能几十 KB),只保留前 200 行
|
|
59
|
+
const lines = diff.split("\n")
|
|
60
|
+
if (lines.length <= 200) return diff
|
|
61
|
+
return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
|
|
62
|
+
} catch {
|
|
63
|
+
return ""
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
function resolveInCwd(ctx, p) {
|
|
52
68
|
const resolved = resolve(ctx.cwd, p)
|
|
53
69
|
if (relative(ctx.cwd, resolved).startsWith("..")) throw new Error(`Access denied outside working directory: ${p}`)
|
|
@@ -102,7 +118,8 @@ const writeTool = {
|
|
|
102
118
|
const st = await stat(abs).catch(() => null)
|
|
103
119
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
|
|
104
120
|
await writeFile(abs, args.content, "utf8")
|
|
105
|
-
|
|
121
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
122
|
+
return `Wrote ${args.content.length} chars to ${abs}${diff ? "\n" + diff : ""}`
|
|
106
123
|
},
|
|
107
124
|
}
|
|
108
125
|
|
|
@@ -127,7 +144,13 @@ const editTool = {
|
|
|
127
144
|
const content = await readFile(abs, "utf8")
|
|
128
145
|
const occurrences = content.split(args.old_string).length - 1
|
|
129
146
|
if (occurrences === 0) {
|
|
130
|
-
|
|
147
|
+
// 给出线索帮模型定位:首行预览 + 常见原因
|
|
148
|
+
const preview = args.old_string.slice(0, 100).split("\n")[0]
|
|
149
|
+
throw new Error(
|
|
150
|
+
`old_string not found in ${abs}\n` +
|
|
151
|
+
` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
|
|
152
|
+
` hints: whitespace mismatch? file already changed? try reading the file first`
|
|
153
|
+
)
|
|
131
154
|
}
|
|
132
155
|
if (occurrences > 1 && !args.replace_all) {
|
|
133
156
|
throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
|
|
@@ -136,7 +159,8 @@ const editTool = {
|
|
|
136
159
|
? content.split(args.old_string).join(args.new_string)
|
|
137
160
|
: content.replace(args.old_string, args.new_string)
|
|
138
161
|
await writeFile(abs, updated, "utf8")
|
|
139
|
-
|
|
162
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
163
|
+
return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
|
|
140
164
|
},
|
|
141
165
|
}
|
|
142
166
|
|
|
@@ -272,7 +296,7 @@ const globTool = {
|
|
|
272
296
|
},
|
|
273
297
|
readonly: true,
|
|
274
298
|
async execute(args, ctx) {
|
|
275
|
-
const base =
|
|
299
|
+
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
276
300
|
const regex = globToRegex(args.pattern)
|
|
277
301
|
const results = []
|
|
278
302
|
for await (const relPath of walkFiles(base)) {
|
|
@@ -336,7 +360,7 @@ const grepTool = {
|
|
|
336
360
|
},
|
|
337
361
|
readonly: true,
|
|
338
362
|
async execute(args, ctx) {
|
|
339
|
-
const base =
|
|
363
|
+
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
340
364
|
const regex = new RegExp(args.pattern)
|
|
341
365
|
const fileFilter = args.glob ? globToRegex(args.glob) : null
|
|
342
366
|
const matches = []
|
|
@@ -461,7 +485,7 @@ const lsTool = {
|
|
|
461
485
|
},
|
|
462
486
|
readonly: true,
|
|
463
487
|
async execute(args, ctx) {
|
|
464
|
-
const abs =
|
|
488
|
+
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
465
489
|
const entries = await readdir(abs, { withFileTypes: true })
|
|
466
490
|
const rows = await Promise.all(
|
|
467
491
|
entries.slice(0, 500).map(async (e) => {
|
package/src/tui.mjs
CHANGED
|
@@ -265,20 +265,28 @@ export async function startTUI(agent, opts = {}) {
|
|
|
265
265
|
processing: false,
|
|
266
266
|
controller: null, // AbortController for current agent run
|
|
267
267
|
permission: null, // { name, args, resolve }
|
|
268
|
+
permissionPreview: [], // 权限审批的内容预览行(渲染在输入框上方,不分隔)
|
|
268
269
|
question: null, // { text, options, resolve } — agent 的 question 工具回调
|
|
269
270
|
picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
|
|
270
271
|
wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
|
|
271
|
-
tasks: agent.tasks ?? [], // task
|
|
272
|
+
tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上,全完成自动收起
|
|
272
273
|
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
|
|
273
274
|
ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
|
|
274
275
|
reasoning: "", // 思考流缓冲(暗色展示)
|
|
275
276
|
completion: null, // Tab 补全状态 { candidates, index }
|
|
276
277
|
toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
|
|
278
|
+
subOutput: "", // 子 agent 流式输出(滚动显示,最长保留末尾 300 字符)
|
|
279
|
+
currentSub: null, // 当前活跃的子 agent 角色名
|
|
277
280
|
currentTool: null, // 正在执行的工具名(状态栏显示)
|
|
278
281
|
processingStarted: 0, // 本轮处理开始时间(状态栏计时)
|
|
279
282
|
status: "Ready",
|
|
280
283
|
}
|
|
281
284
|
|
|
285
|
+
// 恢复的会话如果所有任务已完成,自动收起 todo 面板(对齐运行时行为)
|
|
286
|
+
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
287
|
+
state.tasks = []
|
|
288
|
+
}
|
|
289
|
+
|
|
282
290
|
// 输入流先过一道滤网:鼠标序列(滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
|
|
283
291
|
// 防止序列残片(如 "64;72;42M")漏进输入框
|
|
284
292
|
const keyStream = new PassThrough()
|
|
@@ -424,7 +432,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
424
432
|
visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
|
|
425
433
|
}
|
|
426
434
|
const taskPanelH = visibleTasks.length
|
|
427
|
-
|
|
435
|
+
// 子 agent 流式输出占位(显示时占最多 2 行)
|
|
436
|
+
const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
|
|
437
|
+
// 权限预览占位
|
|
438
|
+
const permPreviewLen = state.permission ? 1 + state.permissionPreview.reduce((s, l) => s + wrapText(` ${l}`, W - 1).length, 0) : 0
|
|
439
|
+
const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
|
|
428
440
|
|
|
429
441
|
// 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
|
|
430
442
|
const convLines = []
|
|
@@ -498,6 +510,25 @@ export async function startTUI(agent, opts = {}) {
|
|
|
498
510
|
out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
|
|
499
511
|
}
|
|
500
512
|
|
|
513
|
+
// 子 agent 流式输出(最多 2 行,滚动显示最新内容)
|
|
514
|
+
if (state.subOutput && state.processing) {
|
|
515
|
+
const lines = wrapText(state.subOutput, W - 8)
|
|
516
|
+
const tail = lines.slice(-2)
|
|
517
|
+
for (const l of tail) {
|
|
518
|
+
out.push(`${C.dim}[${state.currentSub}] ${l}${ansi.reset}${ansi.clearLine}`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 权限审批内容预览(黄色,紧挨输入框上方)
|
|
523
|
+
if (state.permission) {
|
|
524
|
+
out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
|
|
525
|
+
for (const line of state.permissionPreview) {
|
|
526
|
+
for (const wrapped of wrapText(` ${line}`, W - 1)) {
|
|
527
|
+
out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
501
532
|
// 输入框(全边框,宽 W)
|
|
502
533
|
let borderColor = C.tool
|
|
503
534
|
let title
|
|
@@ -571,7 +602,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
571
602
|
}
|
|
572
603
|
} else {
|
|
573
604
|
const taskHint = state.tasks.length > 0
|
|
574
|
-
? ` │
|
|
605
|
+
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
575
606
|
: ""
|
|
576
607
|
// token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
|
|
577
608
|
const tk = state.tokens
|
|
@@ -666,6 +697,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
666
697
|
|
|
667
698
|
const callbacks = {
|
|
668
699
|
onToken: (t) => {
|
|
700
|
+
// 子 agent 流式输出:前缀匹配 explore/coder/plan 的 token 进 subOutput
|
|
701
|
+
const subMatch = t.match(/^(explore|coder|plan)\//)
|
|
702
|
+
if (subMatch) {
|
|
703
|
+
state.currentSub = subMatch[1]
|
|
704
|
+
state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
|
|
705
|
+
scheduleRender()
|
|
706
|
+
return
|
|
707
|
+
}
|
|
669
708
|
ensureAssistantLabel()
|
|
670
709
|
state.streaming += t
|
|
671
710
|
scheduleRender()
|
|
@@ -683,14 +722,27 @@ export async function startTUI(agent, opts = {}) {
|
|
|
683
722
|
},
|
|
684
723
|
onToolResult: (name, result) => {
|
|
685
724
|
state.currentTool = null
|
|
725
|
+
// 子 agent 结束:清空流式缓冲,报告进对话区
|
|
726
|
+
const isSubagent = name.startsWith("explore/") || name.startsWith("coder/") || name.startsWith("plan/")
|
|
727
|
+
if (isSubagent) {
|
|
728
|
+
state.subOutput = ""
|
|
729
|
+
state.currentSub = null
|
|
730
|
+
// 子 agent 报告摘要(最多 8 行)直接展示在对话区
|
|
731
|
+
const lines = result.split("\n")
|
|
732
|
+
const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
|
|
733
|
+
if (preview) pushLine(preview, C.dim)
|
|
734
|
+
if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
|
|
735
|
+
}
|
|
686
736
|
const stream = state.toolStreams[name]
|
|
687
737
|
if (stream) {
|
|
688
738
|
const tail = stream.trimEnd().slice(-4000)
|
|
689
739
|
if (tail) pushLine(tail, C.dim)
|
|
690
740
|
delete state.toolStreams[name]
|
|
691
741
|
}
|
|
692
|
-
|
|
693
|
-
|
|
742
|
+
if (!isSubagent) {
|
|
743
|
+
const first = result.split("\n")[0]
|
|
744
|
+
pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
|
|
745
|
+
}
|
|
694
746
|
},
|
|
695
747
|
onToolOutput: (name, chunk) => {
|
|
696
748
|
state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
|
|
@@ -798,10 +850,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
798
850
|
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
799
851
|
return Promise.resolve(true)
|
|
800
852
|
}
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
|
|
804
|
-
for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.warn)
|
|
853
|
+
// 预览内容存到 permissionPreview,渲染在输入框上方紧挨"Allow?"提示
|
|
854
|
+
state.permissionPreview = formatPermission(name, args)
|
|
805
855
|
return new Promise((resolve) => {
|
|
806
856
|
state.permission = { name, args, resolve }
|
|
807
857
|
state.status = `Waiting: ${name}`
|
|
@@ -918,6 +968,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
918
968
|
? data.display.map((l) => ({ text: l.text, color: l.color }))
|
|
919
969
|
: []
|
|
920
970
|
state.tasks = agent.tasks ?? []
|
|
971
|
+
// 切换过来的会话如果任务全完成,自动收起面板
|
|
972
|
+
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
973
|
+
state.tasks = []
|
|
974
|
+
}
|
|
921
975
|
pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
|
|
922
976
|
render()
|
|
923
977
|
}
|
|
@@ -1226,12 +1280,23 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1226
1280
|
// ---- /think on / off ----
|
|
1227
1281
|
if (sub === "on" || sub === "off") {
|
|
1228
1282
|
const enable = sub === "on"
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1283
|
+
// 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
|
|
1284
|
+
const { specForModel } = await import("./config.mjs")
|
|
1285
|
+
const spec = specForModel(cur.model)
|
|
1286
|
+
if (spec.thinkApi === "effort") {
|
|
1287
|
+
// 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
|
|
1288
|
+
if (!enable) delete cur.reasoningEffort
|
|
1289
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
1290
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
1291
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
1292
|
+
} else {
|
|
1293
|
+
cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
|
|
1294
|
+
if (!enable) delete cur.reasoningEffort
|
|
1295
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
1296
|
+
await syncProviderField("thinking", cur.thinking)
|
|
1297
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
1298
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
1299
|
+
}
|
|
1235
1300
|
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
1236
1301
|
pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
|
|
1237
1302
|
if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
|
|
@@ -1864,6 +1929,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
1864
1929
|
if (validKeys.includes(answer) || key.name === "escape") {
|
|
1865
1930
|
const { resolve, name } = state.permission
|
|
1866
1931
|
state.permission = null
|
|
1932
|
+
state.permissionPreview = []
|
|
1867
1933
|
state.status = "Processing..."
|
|
1868
1934
|
if (answer === "a" && !isContinue) {
|
|
1869
1935
|
agent.autoApprove = true
|
|
@@ -2076,8 +2142,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2076
2142
|
}
|
|
2077
2143
|
|
|
2078
2144
|
// 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
|
|
2145
|
+
// \r\n 在 Windows raw mode 下可能漏进来冲乱页面
|
|
2079
2146
|
if (str && !key.ctrl && !key.meta) {
|
|
2080
|
-
const chars = [...str.replace(
|
|
2147
|
+
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
2081
2148
|
state.input.splice(state.cursor, 0, ...chars)
|
|
2082
2149
|
state.cursor += chars.length
|
|
2083
2150
|
render()
|
|
@@ -2099,7 +2166,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2099
2166
|
state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
|
|
2100
2167
|
pushLabel(`── 已恢复上次会话(退出前原样回放);/new 开始新会话 ──`, C.warn)
|
|
2101
2168
|
} else if (opts.restored?.history?.length) {
|
|
2102
|
-
|
|
2169
|
+
// 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
|
|
2170
|
+
for (let i = 0; i < opts.restored.history.length; i++) {
|
|
2171
|
+
const m = opts.restored.history[i]
|
|
2103
2172
|
if (m.role === "user") {
|
|
2104
2173
|
if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
|
|
2105
2174
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
@@ -2108,10 +2177,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
2108
2177
|
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
2109
2178
|
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
2110
2179
|
for (const tc of m.tool_calls ?? []) {
|
|
2111
|
-
|
|
2180
|
+
// 找到下一条对应的 tool 结果,显示首行摘要
|
|
2181
|
+
const toolResult = opts.restored.history[i + 1]
|
|
2182
|
+
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
2183
|
+
const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
|
|
2184
|
+
pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
|
|
2112
2185
|
}
|
|
2113
2186
|
}
|
|
2114
|
-
// tool
|
|
2187
|
+
// tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
|
|
2115
2188
|
}
|
|
2116
2189
|
pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
|
|
2117
2190
|
}
|