agent-from-zero 0.35.1 → 0.35.3

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 CHANGED
@@ -1 +1,142 @@
1
1
  # mini-agent
2
+
3
+ 一个照着 Claude Code 做出来的命令行 coding agent,TypeScript 实现,默认接 DeepSeek(任何支持 function calling 的 OpenAI 兼容模型都能换上)。
4
+
5
+ ## 这是什么
6
+
7
+ mini-agent 是我模仿 Claude Code 自己搓的。从终端 UI 的样子,到背后的各种机制,基本都是照着 Claude Code 的做法来实现的:
8
+
9
+ - **交互界面**:Ink 终端 UI、方向键审批菜单、多问题表单、`@file` 引用、diff 预览、推理过程流式展示
10
+ - **Skill**:`SKILL.md` 按需加载,项目级放 `.mini-agent/skills/`,全局放 `~/.config/mini-agent/skills/`。和 Claude Code 一样,skill 列表通过带 `<system-reminder>` 的 user message 告诉模型,新增或修改 skill 不用重启;可以用 `/skill <名字> [参数]` 或 `/<名字> [参数]` 手动运行
11
+ - **MCP**:stdio 和 HTTP 两种传输都支持,远程服务器走 OAuth
12
+ - **权限**:allow / ask / deny 三档,deny 永远优先;另外有 plan 模式、auto 模式(交给模型审核)、`--dangerously-skip-permissions`
13
+ - **其他**:上下文压缩、长期记忆、hooks、子 agent、后台任务、todo、撤销、会话续接、成本统计、非交互的 `-p` 模式
14
+
15
+ 这个版本是我自己一直在 dogfooding、边用边改出来的,到现在已经挺顺手了。我把自己工作用的 **Slack** 和 **Linear** MCP 都接了进来,底层模型用的是 DeepSeek。平时主要让它干这些活:
16
+
17
+ - 查 bug、看日志、定位问题
18
+ - 回 Slack 消息
19
+ - 跑数据 backfill 之类的脏活
20
+
21
+ ## 用下来的感受
22
+
23
+ 老实说,跟 Claude Code 比体验还是差一截。但跟我直接用 DeepSeek 自带的 harness 相比,mini-agent 好用太多了。
24
+
25
+ 说句题外话:我对 A 社这家公司的人品不太认可,但产品确实做得好。至少从 Claude Code 的体验来看,我觉得它远远好过 Codex 和 DeepSeek 的 harness。所以与其从头去想一个 agent 该长什么样,不如直接照着最好的那个来做。
26
+
27
+ ## 安装与配置
28
+
29
+ ### 安装
30
+
31
+ 需要 Node >= 20.6。不用 clone 仓库,直接用 npx 运行 npm 包(包名是 `agent-from-zero`):
32
+
33
+ ```bash
34
+ npx agent-from-zero
35
+ ```
36
+
37
+ ### 配置 API key
38
+
39
+ 推荐把 API key 写进 **shell 的启动配置文件**(zsh 是 `~/.zshrc`,bash 是 `~/.bashrc`),这样在任何目录启动 mini-agent 都能用。默认用 DeepSeek,只需要一个 key(在 [platform.deepseek.com](https://platform.deepseek.com) 获取),变量名是 `DEEPSEEK_API_KEY`:
40
+
41
+ ```bash
42
+ # ~/.zshrc 或 ~/.bashrc
43
+ export DEEPSEEK_API_KEY=sk-你的key
44
+ ```
45
+
46
+ 改完运行 `source ~/.zshrc`(或 `source ~/.bashrc`),或者新开一个终端。
47
+
48
+ 如果某个项目想单独用一个 key,也可以在那个目录下放一个 `.env` 文件,写 `DEEPSEEK_API_KEY=sk-...`,不带 `export`。mini-agent 启动时会读当前目录的 `.env`,但**环境变量优先**:shell 配置文件里已经导出的变量,`.env` 里的同名值会被忽略。
49
+
50
+ ### 配置审核模型 judge.model
51
+
52
+ 开启 auto 模式(会话里输入 `/auto`,或启动时加 `--auto`)后,每个需要审批的工具调用会先交给一个审核模型判断:安全的直接执行,有风险或拿不准的仍然问你。审核模型只有两种选择:
53
+
54
+ 1. **主 agent 用的模型**(默认):和主 agent 共用同一个 API key 和 base URL。没配 `judge.model` 就直接用主 agent 当前的模型。
55
+ 2. **Jev**:配好 Jev 的 key 后自动优先用它(见下一节)。
56
+
57
+ `judge.model` 写在 `~/.config/mini-agent/settings.json`(全局)或 `.mini-agent/settings.json`(项目级):
58
+
59
+ ```json
60
+ {
61
+ "judge": { "model": "deepseek-flash" }
62
+ }
63
+ ```
64
+
65
+ 注意:`judge.model` 走的是主 agent 的 API 端点,所以只能填主 agent 那个服务商下的模型,不能填别家的模型,也不能在这里填 Jev。不确定的话就不要配,默认用主 agent 的模型最稳妥。
66
+
67
+ ### 配置 Jev(可选)
68
+
69
+ **Jev 不是必须的**,不配也能正常使用 auto 模式,审核会用上面说的主 agent 模型。
70
+
71
+ Jev 是 TypeSafe 提供的审核模型。想用的话,和 DeepSeek 的 key 放在同一个地方,加上 Jev 的 key(两个变量名都可以,二选一):
72
+
73
+ ```bash
74
+ # ~/.zshrc 或 ~/.bashrc
75
+ export JEV_API_KEY=你的key
76
+ # 或者
77
+ export TYPESAFE_API_KEY=你的key
78
+ ```
79
+
80
+ 配了 key 之后,下次启动会自动优先用 Jev 审核;Jev 额度用完或出错时,会自动退回主 agent 的模型。Jev 的模型版本默认是 `jev-1.13.0`,要换的话在 settings.json 里设置 `autoMode.model`:
81
+
82
+ ```json
83
+ {
84
+ "autoMode": { "model": "jev-1.13.0" }
85
+ }
86
+ ```
87
+
88
+ ### 换别的模型
89
+
90
+ 和 API key 放在同一个地方,设置这三个变量就行,各家的 base URL 写在 [.env.example](.env.example) 里:
91
+
92
+ ```bash
93
+ export MINI_AGENT_API_KEY=sk-...
94
+ export MINI_AGENT_BASE_URL=https://api.openai.com/v1
95
+ export MINI_AGENT_MODEL=gpt-4.1-mini
96
+ ```
97
+
98
+ 模型必须支持 function calling。如果它的上下文窗口比 DeepSeek 小(默认按约 1M 算),在 settings.json 里按模型配置窗口大小,`/model` 切换模型时会跟着变:
99
+
100
+ ```json
101
+ {
102
+ "contextWindows": { "gpt-4.1-mini": 1000000, "deepseek-v4-pro": 1000000 }
103
+ }
104
+ ```
105
+
106
+ 也可以用环境变量 `MINI_AGENT_CONTEXT_WINDOW` 统一覆盖所有模型。
107
+
108
+ ### 上下文和自动压缩
109
+
110
+ 上下文大小用的是 API 每次返回的真实 `prompt_tokens`,再加上之后新增消息的估算值,所以基本是准的。快满时自动压缩:触发点是窗口减去给回复预留的 20k 和 13k 安全余量(约 1M 的窗口大概在 97% 左右)。状态栏的 `ctx N%` 表示离自动压缩还有多远,到 100% 时下一次调用模型前就会先压缩。也可以随时手动 `/compact`。
111
+
112
+ ### 配置 MCP
113
+
114
+ 在 `~/.config/mini-agent/settings.json`(全局)或 `.mini-agent/settings.json`(项目级)的 `mcpServers` 里添加服务器,写法和 Claude Code 一样。保存后会话里自动生效,不用重启:新加的服务器会连上,删掉的会断开,改过的会重连。也可以手动运行 `/mcp reload`。服务器自己的工具有增减时(MCP 的 `tools/list_changed` 通知),工具列表也会自动更新。启动时 MCP 在后台并行连接,不会卡住输入框,哪个连上了它的工具就马上可用;有连接失败的,状态栏右下角会用红色标出是哪个(需要登录的是黄色)。
115
+
116
+ 输入 `/mcp` 选一个服务器,会看到和 Claude Code 一样的详情(状态、登录、协议版本、URL、配置文件位置、能力、工具数)和操作:View tools、Re-authenticate、Clear authentication、Reconnect、Disable。也可以直接敲 `/mcp tools|auth|clear-auth|reconnect|disable|enable <名字>`。
117
+
118
+ ### 非交互模式
119
+
120
+ ```bash
121
+ npx agent-from-zero -p "总结当前改动"
122
+ cat error.log | npx agent-from-zero -p "分析这份日志"
123
+ npx agent-from-zero -p "总结当前改动" --output-format json
124
+ ```
125
+
126
+ ## 常用命令
127
+
128
+ 在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
129
+
130
+ ## 开发
131
+
132
+ ```bash
133
+ git clone https://github.com/ftfw0621/mini-agent.git && cd mini-agent
134
+ npm install
135
+ cp .env.example .env # 填入 DEEPSEEK_API_KEY
136
+ npm start # 从源码运行
137
+ npm run typecheck # 类型检查
138
+ npm test # 单元测试
139
+ npm run eval # 回归评测
140
+ ```
141
+
142
+ 各个功能的实现思路见 [docs/](docs/)。
package/dist/agent.js CHANGED
@@ -8,12 +8,12 @@ import { CONFIG, requireApiKey, saveGlobalSetting, PROJECT_SETTINGS_PATH, GLOBAL
8
8
  import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.js"; // the state machine
9
9
  import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // the constitution + optional AGENT.md project memory
10
10
  import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
11
- import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
+ import { compactHistory, compactThreshold, contextPercent, contextWindowFor } from "./context.js"; // /compact, /model info, the ctx % in the status line
12
12
  import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
13
13
  import { generateSessionTitle, setTerminalTitle } from "./title.js"; // concise session name, generated after the first message + the terminal tab that shows it
14
14
  import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
15
15
  import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
16
- import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
16
+ import { connectMcpServers, listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpServerDetails, reloadMcpServers, runMcpAction, watchMcpConfig } from "./mcp.js"; // external tool servers (MCP) + /mcp
17
17
  import { Judge } from "./judge.js"; // optional LLM permission classifier
18
18
  import { AutoMode } from "./auto.js";
19
19
  import { effortMenu, setEffort, selectedEffort } from "./effort.js";
@@ -33,7 +33,7 @@ import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu
33
33
  import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
34
34
  import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
35
35
  import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
36
- import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
36
+ import { allSkills, currentSkills, findSkill, recordSkillUsage, skillLocked, skillMode, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
37
37
  import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
38
38
  import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
39
39
  import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
@@ -96,7 +96,8 @@ const SESSION_HELP = `commands:
96
96
  /memory show the durable facts the agent remembers about this project
97
97
  /status session usage and vendor account balance/cost
98
98
  /cost tokens, cache hit rate and estimated spend this session (local)
99
- /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
99
+ /mcp list configured MCP servers + status; select one to view tools / authenticate / clear auth / reconnect / disable
100
+ /mcp reload re-read mcpServers from settings.json (saves are also picked up automatically)
100
101
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
101
102
  /effort [level] list/select the current model’s supported reasoning effort
102
103
  /auto toggle auto mode — risky or uncertain actions still require approval
@@ -109,8 +110,8 @@ const SESSION_HELP = `commands:
109
110
  /undo revert the most recent file write (write_file / edit_file) this session
110
111
  /diff show every file changed this session, as a diff from where it started
111
112
  /resume list recent sessions in this project and continue one of them
112
- /skills list the reusable skills available in this project
113
- /skill <name> run a skill yourself (works even for user-only skills)
113
+ /skills manage skills: turn each on, off, or user-only (search with /, sort with t)
114
+ /skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
114
115
  exit leave (Ctrl+C at the prompt does the same)
115
116
 
116
117
  keys (at the prompt):
@@ -197,6 +198,8 @@ async function main() {
197
198
  disconnectMcp();
198
199
  };
199
200
  process.on("exit", disconnectMcp); // best-effort cleanup of server subprocesses
201
+ if (!cli.print)
202
+ process.on("exit", watchMcpConfig()); // hot reload: a settings.json save adds/removes/reconnects servers, no restart (a one-shot -p run doesn't need it)
200
203
  process.on("exit", killAllBackground); // Day 37: SIGKILL any background job (dev server, slow install) so it never outlives the agent as an orphan
201
204
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed so they don't leave pending promises
202
205
  process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
@@ -219,14 +222,12 @@ async function main() {
219
222
  const memCount = readMemory().length;
220
223
  if (memCount)
221
224
  console.log(chalk.dim(`(long-term memory: ${memCount} facts loaded)`));
222
- // Skills: reusable Markdown procedures. The model-invocable ones are exposed
223
- // through a single `skill` tool whose description lists them (progressive
224
- // disclosure); user-only skills are reachable via /skill <name>.
225
- const skills = loadSkills();
226
- if (skills.some((s) => !s.disableModelInvocation))
227
- registerExternalTool(buildSkillTool(skills));
228
- if (skills.length)
229
- console.log(chalk.dim(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`));
225
+ // Skills: reusable Markdown procedures, re-read from disk before every model
226
+ // call (so a new SKILL.md needs no restart). The loop registers the `skill`
227
+ // tool and sends the listing as a <system-reminder>; here we just announce them.
228
+ const startupSkills = currentSkills();
229
+ if (startupSkills.length)
230
+ console.log(chalk.dim(`(skills: ${startupSkills.length} loaded — ${startupSkills.map((s) => s.name).join(", ")})`));
230
231
  // Token/cost meter for this session. Prices come from settings, falling back
231
232
  // to the defaults; the loop records usage into it from every stream.
232
233
  const costMeter = initCostMeter({
@@ -476,7 +477,7 @@ async function main() {
476
477
  // No name: show the current model and a picker.
477
478
  console.log(chalk.dim(`model: ${CONFIG.model}\n` +
478
479
  (CONFIG.subAgentModel ? `sub-agent model: ${CONFIG.subAgentModel} (delegated task work)\n` : "") +
479
- `endpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
480
+ `endpoint: ${CONFIG.baseURL}\ncontext window: ${contextWindowFor()} tokens (auto-compact at ~${compactThreshold()})`));
480
481
  if (!process.stdin.isTTY)
481
482
  return; // no picker without a TTY — switch with: /model <name>
482
483
  const models = await listModels();
@@ -493,12 +494,18 @@ async function main() {
493
494
  apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
494
495
  };
495
496
  // /mcp — list configured MCP servers + status, then manage one (like Claude Code).
496
- const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : chalk.dim("disabled");
497
+ const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : status === "pending" ? chalk.dim("connecting…") : chalk.dim("disabled");
497
498
  const handleMcpCommand = async (arg) => {
498
499
  const parts = arg.trim().split(/\s+/).filter(Boolean);
499
- if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
500
+ // /mcp reload — re-read settings.json now (the file watcher normally does this on save).
501
+ if (parts[0] === "reload") {
502
+ const changes = await reloadMcpServers();
503
+ console.log(chalk.dim(changes.length ? `(mcp: ${changes.join(", ")})` : "(mcp: no changes in settings.json)"));
504
+ return;
505
+ }
506
+ if (parts.length >= 2 && MCP_SUBCOMMANDS[parts[0]]) {
500
507
  const name = parts.slice(1).join(" ");
501
- const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
508
+ const action = MCP_SUBCOMMANDS[parts[0]];
502
509
  try {
503
510
  console.log(chalk.dim(await runMcpAction(name, action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
504
511
  }
@@ -524,6 +531,7 @@ async function main() {
524
531
  return;
525
532
  }
526
533
  const server = servers[choice];
534
+ console.log(`\n${chalk.bold(`${server.name} MCP Server`)}\n\n${mcpServerDetails(server)}\n`); // Claude Code's server screen
527
535
  const actions = mcpActionsFor(server);
528
536
  const actionChoice = await select(actions.map((a) => a.label));
529
537
  if (actionChoice < 0) {
@@ -580,10 +588,13 @@ async function main() {
580
588
  await handleModelCommand(line.slice("/model".length).trim());
581
589
  return true;
582
590
  }
583
- // /skill <name> runs a skill on the user's behalf — inject its instructions
584
- // as a turn so the model follows them. Works for user-only skills too.
591
+ // /skill <name> [args] runs a skill on the user's behalf. Like Claude Code it
592
+ // sends two user messages: a <command-name> marker (the model then knows the
593
+ // skill is already loaded) and the body. Works for user-only skills too.
585
594
  if (line === "/skill" || line.startsWith("/skill ")) {
586
- const name = line.slice("/skill".length).trim();
595
+ const skills = currentSkills(); // live — a SKILL.md added mid-session is found
596
+ const [name = "", ...rest] = line.slice("/skill".length).trim().split(/\s+/);
597
+ const args = rest.join(" ");
587
598
  if (!name) {
588
599
  console.log(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
589
600
  return true;
@@ -593,8 +604,10 @@ async function main() {
593
604
  console.log(chalk.yellow(`(no skill named "${name}")`));
594
605
  return true;
595
606
  }
596
- messages.push({ role: "user", content: `Run the "${s.name}" skill.\n\n${skillInstructions(s)}` });
597
- autoMode.recordRequest(`Run the "${s.name}" skill.`);
607
+ for (const content of userSkillMessages(s, args))
608
+ messages.push({ role: "user", content });
609
+ autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
610
+ recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
598
611
  console.log(chalk.dim(`(running skill: ${s.name})`));
599
612
  running = true;
600
613
  interrupted = false;
@@ -606,20 +619,22 @@ async function main() {
606
619
  console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
607
620
  return true;
608
621
  }
609
- // /mcp [reconnect|auth|enable|disable <name>]
622
+ // /mcp [reload | reconnect|auth|enable|disable <name>]
610
623
  if (line === "/mcp" || line.startsWith("/mcp ")) {
611
624
  await handleMcpCommand(line.slice("/mcp".length).trim());
612
625
  return true;
613
626
  }
614
627
  switch (line) {
615
628
  case "/skills": {
629
+ const skills = allSkills(); // every skill, with its on / user-only / off state (toggle them in the Ink UI's /skills)
616
630
  if (!skills.length) {
617
631
  console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
618
632
  return true;
619
633
  }
620
634
  console.log(chalk.dim(`skills in this project:`));
621
635
  for (const s of skills) {
622
- const who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
636
+ const mode = skillMode(s);
637
+ const who = mode === "on" ? chalk.green("on") : mode === "off" ? chalk.dim("off") : chalk.yellow(skillLocked(s) ? "user-only (locked)" : "user-only");
623
638
  console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
624
639
  }
625
640
  return true;
@@ -819,6 +834,8 @@ async function main() {
819
834
  return true;
820
835
  }
821
836
  default:
837
+ if (line.startsWith("/") && findSkill(currentSkills(), line.slice(1).split(/\s+/)[0]))
838
+ return handleCommand(`/skill ${line.slice(1)}`); // /<skill-name> [args], like Claude Code
822
839
  if (line.startsWith("/")) {
823
840
  console.log(chalk.dim(`unknown command: ${line} — try /help`)); // typo guard
824
841
  return true; // still consumed — don't send typos to the model
@@ -857,7 +874,7 @@ async function main() {
857
874
  // input stays clean.
858
875
  let footer;
859
876
  if (process.stdin.isTTY) {
860
- const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
877
+ const ctxPct = contextPercent(messages); // % of the way to auto-compaction
861
878
  console.log(""); // one blank line separates the previous answer from the prompt
862
879
  footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
863
880
  }
@@ -1,8 +1,9 @@
1
+ import Fuse from "fuse.js"; // the fuzzy matcher Claude Code's "/" typeahead uses
1
2
  import { effortMenu } from "./effort.js";
2
3
  export const COMMANDS = [
3
4
  { value: "/model", description: "Switch the model for this session", children: true },
4
5
  { value: "/effort", description: "Choose this model's reasoning effort", children: true },
5
- { value: "/skills", description: "Browse and run available skills", children: true },
6
+ { value: "/skills", description: "Turn skills on, off, or user-only" },
6
7
  { value: "/skill", description: "Run a skill by name", children: true },
7
8
  { value: "/help", description: "Show commands and keyboard shortcuts" },
8
9
  { value: "/clear", description: "Start a fresh conversation" },
@@ -23,6 +24,86 @@ export const COMMANDS = [
23
24
  { value: "/diff", description: "Show file changes this session" },
24
25
  { value: "/resume", description: "Choose a previous session" },
25
26
  ];
27
+ // Every "/<command>" token in the text that names a real command or skill, as
28
+ // [start, end) code-unit ranges — the input box paints these. Claude Code's
29
+ // rule (findSlashCommandPositions): a "/" at the start or after whitespace,
30
+ // then a letter and [A-Za-z0-9:_-]*, anywhere in the text; it lights up only
31
+ // if that name exists. So "/grill-me," lights up, "/grill" and "a/grill-me" don't.
32
+ export function commandTokenRanges(text, names) {
33
+ const known = new Set(names.map((n) => n.toLowerCase()));
34
+ const out = [];
35
+ for (const m of text.matchAll(/(^|\s)(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g)) {
36
+ const start = (m.index ?? 0) + m[1].length;
37
+ if (known.has(m[2].slice(1).toLowerCase()))
38
+ out.push([start, start + m[2].length]);
39
+ }
40
+ return out;
41
+ }
42
+ // Names a "/" can reach: built-in commands, then skills (a built-in wins a clash — it is what runs).
43
+ export function commandNames(ctx) {
44
+ const builtins = COMMANDS.map((c) => c.value.slice(1));
45
+ return [...builtins, ...ctx.skills.map((s) => s.name).filter((n) => !builtins.includes(n))];
46
+ }
47
+ function typeaheadItems(ctx) {
48
+ const builtins = new Set(COMMANDS.map((c) => c.value));
49
+ const skills = ctx.skills
50
+ .filter((s) => !builtins.has(`/${s.name}`))
51
+ .map((s) => ({
52
+ value: `/${s.name}`,
53
+ name: s.name,
54
+ // Claude Code's formatDescriptionWithSource: "desc (User)" / "desc (Project)", plus the argument names.
55
+ description: `${s.description || s.whenToUse}${s.source ? ` (${s.source === "project" ? "Project" : "User"})` : ""}${s.argNames?.length ? ` (arguments: ${s.argNames.join(", ")})` : ""}`,
56
+ children: Boolean(s.argNames?.length), // takes arguments → Enter fills it in; none → Enter runs it
57
+ group: s.source ?? "user",
58
+ usage: ctx.usage?.(s.name) ?? 0,
59
+ }));
60
+ return [...COMMANDS.map((c) => ({ ...c, name: c.value.slice(1), group: "builtin", usage: 0 })), ...skills];
61
+ }
62
+ // The "/" typeahead, Claude Code's generateCommandSuggestions:
63
+ // bare "/" → your 5 most-used skills, then built-ins, then user skills, then
64
+ // project skills, each group alphabetical
65
+ // "/query" → Fuse.js over name (weight 3), name parts split on :_- (2) and
66
+ // description words (0.5), threshold 0.3; then re-sorted: exact
67
+ // name, then name prefix (shorter first), then fuzzy score (when
68
+ // it differs by > 0.1), then usage
69
+ function commandTypeahead(input, ctx) {
70
+ const items = typeaheadItems(ctx);
71
+ const q = input.slice(1).toLowerCase();
72
+ const alpha = (a, b) => a.name.localeCompare(b.name);
73
+ if (!q) {
74
+ const top = items.filter((i) => i.usage > 0).sort((a, b) => b.usage - a.usage).slice(0, 5);
75
+ const rest = (group) => items.filter((i) => i.group === group && !top.includes(i)).sort(alpha);
76
+ return [...top, ...rest("builtin"), ...rest("user"), ...rest("project")];
77
+ }
78
+ const docs = items.map((item) => {
79
+ const parts = item.name.split(/[:_-]/);
80
+ return { item, commandName: item.name.toLowerCase(), partKey: parts.length > 1 ? parts.map((p) => p.toLowerCase()) : undefined, descriptionKey: item.description.toLowerCase().split(/\s+/).map((w) => w.replace(/[^a-z0-9]/g, "")).filter(Boolean) };
81
+ });
82
+ const fuse = new Fuse(docs, { includeScore: true, threshold: 0.3, location: 0, distance: 100, keys: [{ name: "commandName", weight: 3 }, { name: "partKey", weight: 2 }, { name: "descriptionKey", weight: 0.5 }] });
83
+ const rank = (name) => (name === q ? 0 : name.startsWith(q) ? 1 : 2);
84
+ return fuse.search(q).sort((a, b) => {
85
+ const an = a.item.commandName, bn = b.item.commandName;
86
+ if (rank(an) !== rank(bn))
87
+ return rank(an) - rank(bn);
88
+ if (rank(an) === 1 && an.length !== bn.length)
89
+ return an.length - bn.length;
90
+ const ds = (a.score ?? 0) - (b.score ?? 0);
91
+ if (Math.abs(ds) > 0.1)
92
+ return ds;
93
+ return b.item.item.usage - a.item.item.usage;
94
+ }).map((r) => r.item.item);
95
+ }
96
+ // A "/" in the middle of the text gets no list — just ghost text: the rest of
97
+ // the best prefix match, dim, after the cursor; Tab accepts it as "/name ".
98
+ // (Claude Code: getBestCommandMatch on /\s\/([a-zA-Z0-9_:-]*)$/ before the cursor.)
99
+ export function inlineCommandGhost(beforeCursor, ctx) {
100
+ const m = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]+)$/);
101
+ if (!m)
102
+ return null;
103
+ const partial = m[1].toLowerCase();
104
+ const best = commandNames(ctx).filter((n) => n.toLowerCase().startsWith(partial) && n.length > partial.length).sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
105
+ return best ? { start: beforeCursor.length - m[1].length - 1, suffix: best.slice(partial.length), full: best } : null;
106
+ }
26
107
  export function slashCompletions(input, ctx) {
27
108
  if (!input.startsWith("/") || /[\r\n]/.test(input))
28
109
  return null;
@@ -33,7 +114,7 @@ export function slashCompletions(input, ctx) {
33
114
  const menu = effortMenu(ctx.model);
34
115
  return { items: filter(menu.values.map((value, i) => ({ value: `/effort ${value}`, description: menu.labels[i] }))), hint: menu.header };
35
116
  }
36
- if (command === "/skills" || command === "/skill") {
117
+ if ((command === "/skills" && parts.length) || command === "/skill") { // bare "/skills" opens the manager; "/skills <name>" still runs one
37
118
  return { items: ctx.skills.filter((s) => s.name.toLowerCase().startsWith(arg)).map((s) => ({ value: `/skill ${s.name}`, description: s.whenToUse || s.description })), hint: ctx.skills.length ? "Choose a skill to run" : "No skills found in project or user skill directories" };
38
119
  }
39
120
  if (command === "/model") {
@@ -58,8 +139,17 @@ export function slashCompletions(input, ctx) {
58
139
  ], input.trimEnd()) };
59
140
  }
60
141
  if (command === "/mcp") {
61
- const items = [{ value: "/mcp", description: "Open MCP server menu" }, ...["reconnect", "auth", "enable", "disable"].flatMap((action) => ctx.servers.map((name) => ({ value: `/mcp ${action} ${name}`, description: `${action} ${name}` })))];
142
+ const items = [{ value: "/mcp", description: "Open MCP server menu" }, { value: "/mcp reload", description: "Re-read MCP servers from settings.json" }, ...["tools", "auth", "clear-auth", "reconnect", "enable", "disable"].flatMap((action) => ctx.servers.map((name) => ({ value: `/mcp ${action} ${name}`, description: `${action} ${name}` })))];
62
143
  return { items: filter(items, input.trimEnd()) };
63
144
  }
145
+ if (!/\s/.test(input))
146
+ return { items: commandTypeahead(input, ctx), hint: "No matching commands or skills" };
147
+ // "/<skill> args…": the user is typing arguments now — no list, just the
148
+ // skill's argument hint while it has one (Claude Code's argumentHint).
149
+ const skill = ctx.skills.find((s) => `/${s.name.toLowerCase()}` === command.toLowerCase());
150
+ if (skill) {
151
+ const hint = skill.argumentHint || (skill.argNames?.length ? skill.argNames.map((a) => `<${a}>`).join(" ") : "");
152
+ return hint && !arg ? { items: [], hint } : null;
153
+ }
64
154
  return { items: filter(COMMANDS), hint: "Commands" };
65
155
  }
package/dist/config.js CHANGED
@@ -40,6 +40,39 @@ function readSettings(p) {
40
40
  }
41
41
  const globalSettings = readSettings(GLOBAL_SETTINGS_PATH); // the user's defaults
42
42
  const projectSettings = readSettings(PROJECT_SETTINGS_PATH); // this project's rules
43
+ // Re-read ONLY the mcpServers map from both layers, mid-session (the MCP hot
44
+ // reload in mcp.ts). Unlike startup, a file that won't parse is NOT fatal here:
45
+ // an editor saving in two steps can leave half-written JSON for a moment, and
46
+ // killing a live session over that would be absurd. Return null instead — the
47
+ // caller keeps the servers it already has and waits for the next save.
48
+ export function readMcpServers() {
49
+ const layers = [];
50
+ for (const p of [GLOBAL_SETTINGS_PATH, PROJECT_SETTINGS_PATH]) {
51
+ if (!fs.existsSync(p)) {
52
+ layers.push({});
53
+ continue;
54
+ }
55
+ try {
56
+ layers.push(JSON.parse(fs.readFileSync(p, "utf8")));
57
+ }
58
+ catch {
59
+ return null; // unparseable right now — don't guess, keep what is running
60
+ }
61
+ }
62
+ return { ...(layers[0].mcpServers ?? {}), ...(layers[1].mcpServers ?? {}) }; // same merge as startup: project wins
63
+ }
64
+ // Which settings file defines this MCP server (the /mcp panel's "Config
65
+ // location"). Project entries override global ones, so check the project first.
66
+ export function mcpConfigPath(name) {
67
+ try {
68
+ if (JSON.parse(fs.readFileSync(PROJECT_SETTINGS_PATH, "utf8")).mcpServers?.[name])
69
+ return PROJECT_SETTINGS_PATH;
70
+ }
71
+ catch {
72
+ /* no project settings (or unreadable) — it's global */
73
+ }
74
+ return GLOBAL_SETTINGS_PATH;
75
+ }
43
76
  // Provider-agnostic configuration. Resolution order, most specific wins:
44
77
  // env MINI_AGENT_* > project settings > global settings > legacy DEEPSEEK_* env > defaults
45
78
  export const CONFIG = {
@@ -51,12 +84,16 @@ export const CONFIG = {
51
84
  apiKey: process.env.MINI_AGENT_API_KEY || // generic key wins...
52
85
  process.env.DEEPSEEK_API_KEY || // ...over the back-compat one
53
86
  "", // empty = missing; requireApiKey() turns that into a helpful error
54
- model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-chat", // must support function calling
87
+ model: process.env.MINI_AGENT_MODEL || projectSettings.model || globalSettings.model || "deepseek-flash", // must support function calling
55
88
  effortProfiles: { ...(globalSettings.effortProfiles ?? {}), ...(projectSettings.effortProfiles ?? {}) },
56
89
  // Sub-agents (the task tool) can run on a DIFFERENT model than the orchestrator:
57
90
  // a cheap/fast one for grunt work (reading many files, broad search), or a
58
91
  // strong one as an "advisor" to double-check. undefined → use the main model.
59
92
  subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
93
+ // /skills on/off/user-only choices. Project entries override global ones.
94
+ skillOverrides: { ...(globalSettings.skillOverrides ?? {}), ...(projectSettings.skillOverrides ?? {}) },
95
+ // Per-model windows (context.ts contextWindowFor): project entries override global ones.
96
+ contextWindows: { ...(globalSettings.contextWindows ?? {}), ...(projectSettings.contextWindows ?? {}) },
60
97
  contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
61
98
  projectSettings.contextWindow || // then files
62
99
  globalSettings.contextWindow ||
package/dist/context.js CHANGED
@@ -2,14 +2,33 @@ import { effortMessages } from "./effort.js";
2
2
  import fs from "node:fs"; // re-reading files from disk during recovery
3
3
  import chalk from "chalk"; // status lines
4
4
  import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
5
- import { recentFiles, forgetFilesExcept } from "./tools.js"; // session file state (what was read/edited, and when)
5
+ import { recentFiles, forgetFilesExcept, toolDefinitions } from "./tools.js"; // session file state (what was read/edited, and when) + the tool manuals we send
6
6
  // ---- The context window ------------------------------------------------------
7
7
  // The default (1,048,565) came from a real API error message, not from docs.
8
8
  // Trust what the API tells you over what you remember reading.
9
- export const CONTEXT_WINDOW = CONFIG.contextWindow; // resolved in config.ts: env > project file > global file > default
10
- // Compact well before the hard limit: estimation is approximate and the summary
11
- // itself needs room. 80% is deliberately conservative.
12
- export const COMPACT_AT = Number(process.env.MINI_AGENT_COMPACT_AT || Math.floor(CONTEXT_WINDOW * 0.8)); // env override kept so tests can trigger compaction cheaply
9
+ //
10
+ // The window belongs to a MODEL, not to the session: /model can switch to one
11
+ // with a smaller window mid-conversation. Resolution, most specific first:
12
+ // env MINI_AGENT_CONTEXT_WINDOW > settings contextWindows[<model>] > settings contextWindow > default
13
+ export function contextWindowFor(model = CONFIG.model) {
14
+ return Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || CONFIG.contextWindows[model] || CONFIG.contextWindow;
15
+ }
16
+ // When to compact, the Claude Code way: not at a fixed percentage, but at
17
+ // "window minus what we must keep free". Two reservations:
18
+ // - room for the model's reply (the compaction call itself writes a summary)
19
+ // - a safety buffer for what we can't count exactly (the tail estimate)
20
+ // On a ~1M window that's ~97% full; on a 128k window it's ~74%, because the
21
+ // same 33k is a bigger slice of a smaller window. The floor keeps a tiny window
22
+ // from getting a threshold at or below zero.
23
+ export const RESERVED_OUTPUT_TOKENS = 20_000; // Claude Code reserves min(max output, 20k)
24
+ export const AUTOCOMPACT_BUFFER_TOKENS = 13_000; // Claude Code's AUTOCOMPACT_BUFFER_TOKENS
25
+ export function compactThreshold(model = CONFIG.model) {
26
+ const env = Number(process.env.MINI_AGENT_COMPACT_AT); // explicit override (tests use it to trigger compaction cheaply)
27
+ if (env)
28
+ return env;
29
+ const window = contextWindowFor(model);
30
+ return Math.max(Math.floor(window * 0.6), window - RESERVED_OUTPUT_TOKENS - AUTOCOMPACT_BUFFER_TOKENS);
31
+ }
13
32
  // How many times one query may compact / fail to compact before we give up.
14
33
  export const MAX_COMPACTIONS_PER_QUERY = 4; // a query that needs more is too big — stop, don't loop
15
34
  export const MAX_COMPACT_FAILURES = 3; // the compaction circuit breaker
@@ -37,6 +56,27 @@ export function estimateHistoryTokens(messages) {
37
56
  }
38
57
  return total;
39
58
  }
59
+ const usageAnchors = new WeakMap();
60
+ export function recordContextUsage(messages, sentCount, usage) {
61
+ const promptTokens = Number(usage?.prompt_tokens ?? 0);
62
+ if (!sentCount || !promptTokens)
63
+ return; // nothing sent, or a provider that doesn't report input tokens
64
+ usageAnchors.set(messages, { last: messages[sentCount - 1], count: sentCount, promptTokens });
65
+ }
66
+ // The tool manuals ride along on every request but aren't messages — a
67
+ // history-only estimate misses them (dozens of MCP tools can be tens of thousands of tokens).
68
+ export const estimateToolTokens = () => estimateTokens(JSON.stringify(toolDefinitions()), true);
69
+ export function contextTokens(messages) {
70
+ const a = usageAnchors.get(messages);
71
+ if (a && messages.length >= a.count && messages[a.count - 1] === a.last)
72
+ return a.promptTokens + estimateHistoryTokens(messages.slice(a.count));
73
+ return estimateHistoryTokens(messages) + estimateToolTokens(); // no (valid) measurement yet — estimate it all
74
+ }
75
+ // For the status bar: how close we are to AUTO-COMPACTION (not to the hard
76
+ // limit). 100% means the next model call compacts first.
77
+ export function contextPercent(messages, model = CONFIG.model) {
78
+ return Math.min(100, Math.round((contextTokens(messages) / compactThreshold(model)) * 100));
79
+ }
40
80
  // ---- The summary prompt ---------------------------------------------------------
41
81
  // Six numbered sections. Each exists because losing that piece of information
42
82
  // causes a specific, real failure mode after compaction (e.g. losing section 4
@@ -88,7 +128,7 @@ client, // the same client the loop uses
88
128
  model, // the same model summarizes its own conversation
89
129
  signal, // Ctrl+C must abort compaction too
90
130
  log = (s) => console.log(s)) {
91
- const before = estimateHistoryTokens(messages); // for the log line
131
+ const before = contextTokens(messages); // for the log line — measured, not just estimated
92
132
  log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
93
133
  const res = await client.chat.completions.create({
94
134
  model, // same model — no need for a fancier one to summarize