agent-from-zero 0.35.2 → 0.35.4
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 +14 -2
- package/dist/agent.js +46 -6
- package/dist/completion.js +94 -2
- package/dist/config.js +2 -0
- package/dist/context.js +57 -10
- package/dist/ink/app.js +218 -22
- package/dist/ink/commands.js +3 -1
- package/dist/ink/completion.js +13 -5
- package/dist/ink/launch.js +5 -0
- package/dist/ink/setup.js +10 -1
- package/dist/ink/skills-panel.js +86 -0
- package/dist/loop.js +41 -5
- package/dist/peers.js +332 -0
- package/dist/permissions.js +4 -0
- package/dist/skills.js +97 -4
- package/dist/title.js +41 -1
- package/dist/ui.js +15 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
107
107
|
|
|
108
108
|
### 上下文和自动压缩
|
|
109
109
|
|
|
110
|
-
上下文大小用的是 API 每次返回的真实 `prompt_tokens`,再加上之后新增消息的估算值,所以基本是准的。快满时自动压缩:触发点是窗口减去给回复预留的 20k 和 13k 安全余量(约 1M 的窗口大概在 97% 左右)。状态栏的 `ctx N%` 表示离自动压缩还有多远,到 100% 时下一次调用模型前就会先压缩。也可以随时手动 `/compact
|
|
110
|
+
上下文大小用的是 API 每次返回的真实 `prompt_tokens`,再加上之后新增消息的估算值,所以基本是准的。快满时自动压缩:触发点是窗口减去给回复预留的 20k 和 13k 安全余量(约 1M 的窗口大概在 97% 左右)。状态栏的 `ctx N%` 表示离自动压缩还有多远,到 100% 时下一次调用模型前就会先压缩。也可以随时手动 `/compact`。不管是手动还是自动,压缩时都会像 Claude Code 一样显示 `Compacting conversation…` 和进度条。
|
|
111
111
|
|
|
112
112
|
### 配置 MCP
|
|
113
113
|
|
|
@@ -115,6 +115,18 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
115
115
|
|
|
116
116
|
输入 `/mcp` 选一个服务器,会看到和 Claude Code 一样的详情(状态、登录、协议版本、URL、配置文件位置、能力、工具数)和操作:View tools、Re-authenticate、Clear authentication、Reconnect、Disable。也可以直接敲 `/mcp tools|auth|clear-auth|reconnect|disable|enable <名字>`。
|
|
117
117
|
|
|
118
|
+
### 多开实例互相通信
|
|
119
|
+
|
|
120
|
+
在几个终端里各开一个 mini-agent(比如一个在后端仓库、一个在前端仓库),它们会自动互相发现,不用配置。
|
|
121
|
+
|
|
122
|
+
- `/peers` 列出本机其他实例:名字、空闲/忙、目录和分支、在哪个终端(比如 `iTerm2 · ttys003`,tmux 里会显示 pane)、正在做什么。选一个之后可以:
|
|
123
|
+
- **Talk to**:输入框变成 `→ web ❯`,你打的字直接发给那个实例,它处理完会把回答发回你这个窗口。按 Esc 回到当前 agent。
|
|
124
|
+
- **Identify**:让那个实例响铃、刷一条横幅、让标签页标题闪几秒,用来确认它是哪个窗口。
|
|
125
|
+
- 实例名跟着它在做的事走:发出第一条消息后会生成会话标题(比如 “Fix login bug”),实例名随之变成 `fix-login-bug`,标签页标题就是 `✳ Fix login bug`,和 `/peers` 里看到的一致。还没有标题时用目录名。`/rename <名字>` 可以手动指定名字,指定后标签页显示 `✳ 名字 · 标题`,之后不会再跟着标题变。
|
|
126
|
+
- agent 之间也能自己沟通:模型有 `list_peers` 工具,`send_message` 可以直接发给其他实例。对方空闲时会自动开一轮处理;忙的话,在它当前这一步工具调用结束后插进对话。你在 Talk 模式里打的字则会等对方这一轮结束再处理,因为回答要发回给你。另一个 agent 发来的消息不算用户的授权。为了防止两个 agent 互相回复停不下来,连续自动处理 5 轮之后会暂停,等你输入任何内容再继续。
|
|
127
|
+
|
|
128
|
+
所有数据都存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。
|
|
129
|
+
|
|
118
130
|
### 非交互模式
|
|
119
131
|
|
|
120
132
|
```bash
|
|
@@ -125,7 +137,7 @@ npx agent-from-zero -p "总结当前改动" --output-format json
|
|
|
125
137
|
|
|
126
138
|
## 常用命令
|
|
127
139
|
|
|
128
|
-
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
140
|
+
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
129
141
|
|
|
130
142
|
## 开发
|
|
131
143
|
|
package/dist/agent.js
CHANGED
|
@@ -10,7 +10,7 @@ import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // th
|
|
|
10
10
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
11
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
|
-
import { generateSessionTitle, setTerminalTitle } from "./title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
13
|
+
import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle } 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
16
|
import { connectMcpServers, listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpServerDetails, reloadMcpServers, runMcpAction, watchMcpConfig } from "./mcp.js"; // external tool servers (MCP) + /mcp
|
|
@@ -28,12 +28,14 @@ import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff
|
|
|
28
28
|
import path from "node:path"; // shorten paths for the /diff summary
|
|
29
29
|
import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
|
|
30
30
|
import { banner, framedPrompt, formatModelChoices, statusLine, sentMessage } from "./ui.js"; // welcome box, prompt, model labels, status line, sent-message echo
|
|
31
|
+
import { SCREEN_KINDS, listPeers, peerInboxPending, peerMessageContent, peersCommand, readPeerInbox, registerSession, setSessionState, unregisterSession } from "./peers.js"; // peer sessions: other mini-agent processes on this machine
|
|
32
|
+
import { STDOUT_OUTPUT } from "./output.js"; // /compact draws its progress bar with the same ora spinner the loop uses
|
|
31
33
|
import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, revealToolCalls, clearToolCalls, cleanup as tuiCleanup } from "./tui.js"; // git branch + collapsible output + collapsed reasoning (Ctrl+R) + folded tool-call trace (Ctrl+T)
|
|
32
34
|
import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
|
|
33
35
|
import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
|
|
34
36
|
import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
|
|
35
37
|
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
36
|
-
import { currentSkills, findSkill, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
|
|
38
|
+
import { allSkills, currentSkills, findSkill, recordSkillUsage, skillLocked, skillMode, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
|
|
37
39
|
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
38
40
|
import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
|
|
39
41
|
import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
|
|
@@ -107,10 +109,12 @@ const SESSION_HELP = `commands:
|
|
|
107
109
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
108
110
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
109
111
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
112
|
+
/peers list other mini-agent sessions on this machine (the agent can message them with send_message)
|
|
113
|
+
/rename <name> rename this session — the name other sessions use to reach it
|
|
110
114
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
111
115
|
/diff show every file changed this session, as a diff from where it started
|
|
112
116
|
/resume list recent sessions in this project and continue one of them
|
|
113
|
-
/skills
|
|
117
|
+
/skills manage skills: turn each on, off, or user-only (search with /, sort with t)
|
|
114
118
|
/skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
|
|
115
119
|
exit leave (Ctrl+C at the prompt does the same)
|
|
116
120
|
|
|
@@ -297,6 +301,14 @@ async function main() {
|
|
|
297
301
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
298
302
|
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
299
303
|
console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
|
|
304
|
+
// Peer sessions: join the machine-wide registry (interactive sessions only —
|
|
305
|
+
// a one-shot -p run is gone before anyone could answer it).
|
|
306
|
+
const me = registerSession({ cwd: process.cwd(), branch: gitBranch(), model: CONFIG.model });
|
|
307
|
+
process.on("exit", unregisterSession);
|
|
308
|
+
const otherSessions = listPeers().length;
|
|
309
|
+
refreshTerminalTitle(); // the tab now carries this session's name
|
|
310
|
+
if (otherSessions)
|
|
311
|
+
console.log(chalk.dim(`(peers: this session is "${me.name}"; ${otherSessions} other session${otherSessions === 1 ? "" : "s"} online — /peers)`));
|
|
300
312
|
// ONE readline interface for the whole session — the task prompt and the
|
|
301
313
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
302
314
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -547,6 +559,12 @@ async function main() {
|
|
|
547
559
|
};
|
|
548
560
|
// Handle a /slash command. Returns true if the line was a command.
|
|
549
561
|
const handleCommand = async (line) => {
|
|
562
|
+
const peers = peersCommand(line); // /peers, /rename <name>
|
|
563
|
+
if (peers !== null) {
|
|
564
|
+
console.log(chalk.dim(peers));
|
|
565
|
+
refreshTerminalTitle();
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
550
568
|
if (line === "/status" || line.startsWith("/status ")) {
|
|
551
569
|
console.log(await statusCommand(line, costMeter));
|
|
552
570
|
return true;
|
|
@@ -607,6 +625,7 @@ async function main() {
|
|
|
607
625
|
for (const content of userSkillMessages(s, args))
|
|
608
626
|
messages.push({ role: "user", content });
|
|
609
627
|
autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
|
|
628
|
+
recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
|
|
610
629
|
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
611
630
|
running = true;
|
|
612
631
|
interrupted = false;
|
|
@@ -625,14 +644,15 @@ async function main() {
|
|
|
625
644
|
}
|
|
626
645
|
switch (line) {
|
|
627
646
|
case "/skills": {
|
|
628
|
-
const skills =
|
|
647
|
+
const skills = allSkills(); // every skill, with its on / user-only / off state (toggle them in the Ink UI's /skills)
|
|
629
648
|
if (!skills.length) {
|
|
630
649
|
console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
|
|
631
650
|
return true;
|
|
632
651
|
}
|
|
633
652
|
console.log(chalk.dim(`skills in this project:`));
|
|
634
653
|
for (const s of skills) {
|
|
635
|
-
const
|
|
654
|
+
const mode = skillMode(s);
|
|
655
|
+
const who = mode === "on" ? chalk.green("on") : mode === "off" ? chalk.dim("off") : chalk.yellow(skillLocked(s) ? "user-only (locked)" : "user-only");
|
|
636
656
|
console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
|
|
637
657
|
}
|
|
638
658
|
return true;
|
|
@@ -824,7 +844,7 @@ async function main() {
|
|
|
824
844
|
return true;
|
|
825
845
|
}
|
|
826
846
|
try {
|
|
827
|
-
await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
|
|
847
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, undefined, STDOUT_OUTPUT); // same machinery (and progress bar) as automatic compaction
|
|
828
848
|
}
|
|
829
849
|
catch (err) {
|
|
830
850
|
console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
|
|
@@ -923,6 +943,25 @@ async function main() {
|
|
|
923
943
|
.catch(() => { });
|
|
924
944
|
}
|
|
925
945
|
}
|
|
946
|
+
// The readline prompt blocks on your input, so it cannot start a turn by
|
|
947
|
+
// itself: peer messages that arrived while you were typing ride along now.
|
|
948
|
+
for (const m of readPeerInbox(SCREEN_KINDS)) {
|
|
949
|
+
// Replies and "which window?" pings are for you, not the model.
|
|
950
|
+
if (m.kind === "identify") {
|
|
951
|
+
process.stdout.write("\x07");
|
|
952
|
+
console.log(chalk.bgMagenta.white.bold(` 👋 This window is session "${m.text}" — the "${m.from.name}" window was looking for it `));
|
|
953
|
+
}
|
|
954
|
+
else
|
|
955
|
+
console.log(chalk.magenta(`↩ ${m.from.name} replied:\n`) + m.text);
|
|
956
|
+
}
|
|
957
|
+
if (peerInboxPending()) {
|
|
958
|
+
const got = readPeerInbox();
|
|
959
|
+
if (got.length) {
|
|
960
|
+
messages.push({ role: "user", content: peerMessageContent(got) });
|
|
961
|
+
console.log(chalk.magenta(`✉ ${got.length} message${got.length > 1 ? "s" : ""} from ${[...new Set(got.map((m) => m.from.name))].join(", ")}`));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
setSessionState("busy", CONFIG.model);
|
|
926
965
|
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
927
966
|
autoMode.recordRequest(line); // original human text, before file/hook attachments
|
|
928
967
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
|
|
@@ -945,6 +984,7 @@ async function main() {
|
|
|
945
984
|
canPrompt: !!process.stdin.isTTY,
|
|
946
985
|
});
|
|
947
986
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
987
|
+
setSessionState("idle"); // other sessions see this one as free again
|
|
948
988
|
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
949
989
|
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
950
990
|
// that saves durable facts — especially user corrections — so memory
|
package/dist/completion.js
CHANGED
|
@@ -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: "
|
|
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" },
|
|
@@ -19,10 +20,92 @@ export const COMMANDS = [
|
|
|
19
20
|
{ value: "/team", description: "Show teammates and status" },
|
|
20
21
|
{ value: "/tasks", description: "Show the shared task board" },
|
|
21
22
|
{ value: "/cron", description: "Show scheduled jobs" },
|
|
23
|
+
{ value: "/peers", description: "Talk to another mini-agent session, or find its window" },
|
|
24
|
+
{ value: "/rename", description: "Rename this session for other sessions", children: true },
|
|
22
25
|
{ value: "/undo", description: "Revert the most recent file write" },
|
|
23
26
|
{ value: "/diff", description: "Show file changes this session" },
|
|
24
27
|
{ value: "/resume", description: "Choose a previous session" },
|
|
25
28
|
];
|
|
29
|
+
// Every "/<command>" token in the text that names a real command or skill, as
|
|
30
|
+
// [start, end) code-unit ranges — the input box paints these. Claude Code's
|
|
31
|
+
// rule (findSlashCommandPositions): a "/" at the start or after whitespace,
|
|
32
|
+
// then a letter and [A-Za-z0-9:_-]*, anywhere in the text; it lights up only
|
|
33
|
+
// if that name exists. So "/grill-me," lights up, "/grill" and "a/grill-me" don't.
|
|
34
|
+
export function commandTokenRanges(text, names) {
|
|
35
|
+
const known = new Set(names.map((n) => n.toLowerCase()));
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const m of text.matchAll(/(^|\s)(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g)) {
|
|
38
|
+
const start = (m.index ?? 0) + m[1].length;
|
|
39
|
+
if (known.has(m[2].slice(1).toLowerCase()))
|
|
40
|
+
out.push([start, start + m[2].length]);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
// Names a "/" can reach: built-in commands, then skills (a built-in wins a clash — it is what runs).
|
|
45
|
+
export function commandNames(ctx) {
|
|
46
|
+
const builtins = COMMANDS.map((c) => c.value.slice(1));
|
|
47
|
+
return [...builtins, ...ctx.skills.map((s) => s.name).filter((n) => !builtins.includes(n))];
|
|
48
|
+
}
|
|
49
|
+
function typeaheadItems(ctx) {
|
|
50
|
+
const builtins = new Set(COMMANDS.map((c) => c.value));
|
|
51
|
+
const skills = ctx.skills
|
|
52
|
+
.filter((s) => !builtins.has(`/${s.name}`))
|
|
53
|
+
.map((s) => ({
|
|
54
|
+
value: `/${s.name}`,
|
|
55
|
+
name: s.name,
|
|
56
|
+
// Claude Code's formatDescriptionWithSource: "desc (User)" / "desc (Project)", plus the argument names.
|
|
57
|
+
description: `${s.description || s.whenToUse}${s.source ? ` (${s.source === "project" ? "Project" : "User"})` : ""}${s.argNames?.length ? ` (arguments: ${s.argNames.join(", ")})` : ""}`,
|
|
58
|
+
children: Boolean(s.argNames?.length), // takes arguments → Enter fills it in; none → Enter runs it
|
|
59
|
+
group: s.source ?? "user",
|
|
60
|
+
usage: ctx.usage?.(s.name) ?? 0,
|
|
61
|
+
}));
|
|
62
|
+
return [...COMMANDS.map((c) => ({ ...c, name: c.value.slice(1), group: "builtin", usage: 0 })), ...skills];
|
|
63
|
+
}
|
|
64
|
+
// The "/" typeahead, Claude Code's generateCommandSuggestions:
|
|
65
|
+
// bare "/" → your 5 most-used skills, then built-ins, then user skills, then
|
|
66
|
+
// project skills, each group alphabetical
|
|
67
|
+
// "/query" → Fuse.js over name (weight 3), name parts split on :_- (2) and
|
|
68
|
+
// description words (0.5), threshold 0.3; then re-sorted: exact
|
|
69
|
+
// name, then name prefix (shorter first), then fuzzy score (when
|
|
70
|
+
// it differs by > 0.1), then usage
|
|
71
|
+
function commandTypeahead(input, ctx) {
|
|
72
|
+
const items = typeaheadItems(ctx);
|
|
73
|
+
const q = input.slice(1).toLowerCase();
|
|
74
|
+
const alpha = (a, b) => a.name.localeCompare(b.name);
|
|
75
|
+
if (!q) {
|
|
76
|
+
const top = items.filter((i) => i.usage > 0).sort((a, b) => b.usage - a.usage).slice(0, 5);
|
|
77
|
+
const rest = (group) => items.filter((i) => i.group === group && !top.includes(i)).sort(alpha);
|
|
78
|
+
return [...top, ...rest("builtin"), ...rest("user"), ...rest("project")];
|
|
79
|
+
}
|
|
80
|
+
const docs = items.map((item) => {
|
|
81
|
+
const parts = item.name.split(/[:_-]/);
|
|
82
|
+
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) };
|
|
83
|
+
});
|
|
84
|
+
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 }] });
|
|
85
|
+
const rank = (name) => (name === q ? 0 : name.startsWith(q) ? 1 : 2);
|
|
86
|
+
return fuse.search(q).sort((a, b) => {
|
|
87
|
+
const an = a.item.commandName, bn = b.item.commandName;
|
|
88
|
+
if (rank(an) !== rank(bn))
|
|
89
|
+
return rank(an) - rank(bn);
|
|
90
|
+
if (rank(an) === 1 && an.length !== bn.length)
|
|
91
|
+
return an.length - bn.length;
|
|
92
|
+
const ds = (a.score ?? 0) - (b.score ?? 0);
|
|
93
|
+
if (Math.abs(ds) > 0.1)
|
|
94
|
+
return ds;
|
|
95
|
+
return b.item.item.usage - a.item.item.usage;
|
|
96
|
+
}).map((r) => r.item.item);
|
|
97
|
+
}
|
|
98
|
+
// A "/" in the middle of the text gets no list — just ghost text: the rest of
|
|
99
|
+
// the best prefix match, dim, after the cursor; Tab accepts it as "/name ".
|
|
100
|
+
// (Claude Code: getBestCommandMatch on /\s\/([a-zA-Z0-9_:-]*)$/ before the cursor.)
|
|
101
|
+
export function inlineCommandGhost(beforeCursor, ctx) {
|
|
102
|
+
const m = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]+)$/);
|
|
103
|
+
if (!m)
|
|
104
|
+
return null;
|
|
105
|
+
const partial = m[1].toLowerCase();
|
|
106
|
+
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];
|
|
107
|
+
return best ? { start: beforeCursor.length - m[1].length - 1, suffix: best.slice(partial.length), full: best } : null;
|
|
108
|
+
}
|
|
26
109
|
export function slashCompletions(input, ctx) {
|
|
27
110
|
if (!input.startsWith("/") || /[\r\n]/.test(input))
|
|
28
111
|
return null;
|
|
@@ -33,7 +116,7 @@ export function slashCompletions(input, ctx) {
|
|
|
33
116
|
const menu = effortMenu(ctx.model);
|
|
34
117
|
return { items: filter(menu.values.map((value, i) => ({ value: `/effort ${value}`, description: menu.labels[i] }))), hint: menu.header };
|
|
35
118
|
}
|
|
36
|
-
if (command === "/skills" || command === "/skill") {
|
|
119
|
+
if ((command === "/skills" && parts.length) || command === "/skill") { // bare "/skills" opens the manager; "/skills <name>" still runs one
|
|
37
120
|
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
121
|
}
|
|
39
122
|
if (command === "/model") {
|
|
@@ -61,5 +144,14 @@ export function slashCompletions(input, ctx) {
|
|
|
61
144
|
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
145
|
return { items: filter(items, input.trimEnd()) };
|
|
63
146
|
}
|
|
147
|
+
if (!/\s/.test(input))
|
|
148
|
+
return { items: commandTypeahead(input, ctx), hint: "No matching commands or skills" };
|
|
149
|
+
// "/<skill> args…": the user is typing arguments now — no list, just the
|
|
150
|
+
// skill's argument hint while it has one (Claude Code's argumentHint).
|
|
151
|
+
const skill = ctx.skills.find((s) => `/${s.name.toLowerCase()}` === command.toLowerCase());
|
|
152
|
+
if (skill) {
|
|
153
|
+
const hint = skill.argumentHint || (skill.argNames?.length ? skill.argNames.map((a) => `<${a}>`).join(" ") : "");
|
|
154
|
+
return hint && !arg ? { items: [], hint } : null;
|
|
155
|
+
}
|
|
64
156
|
return { items: filter(COMMANDS), hint: "Commands" };
|
|
65
157
|
}
|
package/dist/config.js
CHANGED
|
@@ -90,6 +90,8 @@ export const CONFIG = {
|
|
|
90
90
|
// a cheap/fast one for grunt work (reading many files, broad search), or a
|
|
91
91
|
// strong one as an "advisor" to double-check. undefined → use the main model.
|
|
92
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 ?? {}) },
|
|
93
95
|
// Per-model windows (context.ts contextWindowFor): project entries override global ones.
|
|
94
96
|
contextWindows: { ...(globalSettings.contextWindows ?? {}), ...(projectSettings.contextWindows ?? {}) },
|
|
95
97
|
contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { compactingText } from "./ui.js"; // the progress bar
|
|
4
5
|
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
5
6
|
import { recentFiles, forgetFilesExcept, toolDefinitions } from "./tools.js"; // session file state (what was read/edited, and when) + the tool manuals we send
|
|
6
7
|
// ---- The context window ------------------------------------------------------
|
|
@@ -120,6 +121,22 @@ export function recoverFileState() {
|
|
|
120
121
|
return null; // nothing worth recovering
|
|
121
122
|
return `[Recovered file state after compaction — most recently used files, re-read fresh from disk:]\n\n${blocks.join("\n\n")}`;
|
|
122
123
|
}
|
|
124
|
+
// ---- Compaction progress ----------------------------------------------------------
|
|
125
|
+
// A summary call takes tens of seconds, and a frozen screen reads as a hang. We
|
|
126
|
+
// cannot know the true progress (nobody knows the summary's length in advance),
|
|
127
|
+
// so we estimate it from two signals and never let it go backwards:
|
|
128
|
+
// - time: before the first token (the model reading a huge prompt, or
|
|
129
|
+
// thinking) the bar creeps toward 15%, so it visibly moves;
|
|
130
|
+
// - output: streamed tokens against an expected summary size. The curve is
|
|
131
|
+
// asymptotic and capped at 95% — only the finished summary says 100%.
|
|
132
|
+
export function expectedSummaryTokens(historyTokens) {
|
|
133
|
+
return Math.max(800, Math.min(4000, Math.round(historyTokens * 0.05))); // the 6-section summary grows with the history, within bounds
|
|
134
|
+
}
|
|
135
|
+
export function compactProgress(elapsedMs, outTokens, expected) {
|
|
136
|
+
const waiting = 15 * (1 - Math.exp(-elapsedMs / 10_000));
|
|
137
|
+
const writing = 95 * (1 - Math.exp(-(outTokens / expected) * 1.6));
|
|
138
|
+
return Math.min(95, Math.max(waiting, writing));
|
|
139
|
+
}
|
|
123
140
|
// ---- Compaction itself -----------------------------------------------------------
|
|
124
141
|
// Replace the whole history with a structured summary + recovered file state.
|
|
125
142
|
// Throws on failure — the loop decides what a failure means.
|
|
@@ -127,17 +144,47 @@ export async function compactHistory(messages, // the history, mutated in place
|
|
|
127
144
|
client, // the same client the loop uses
|
|
128
145
|
model, // the same model summarizes its own conversation
|
|
129
146
|
signal, // Ctrl+C must abort compaction too
|
|
130
|
-
log = (s) => console.log(s)
|
|
147
|
+
log = (s) => console.log(s), // where the result line goes — stdout by default; the Ink REPL routes it through its sink so it doesn't corrupt the live region
|
|
148
|
+
output) {
|
|
131
149
|
const before = contextTokens(messages); // for the log line — measured, not just estimated
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
const spin = output?.spinner(compactingText(0));
|
|
151
|
+
if (!spin)
|
|
152
|
+
log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
153
|
+
const expected = expectedSummaryTokens(before);
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
let outChars = 0; // content + reasoning streamed so far (~4 chars a token)
|
|
156
|
+
let shown = 0; // the bar only moves forward
|
|
157
|
+
const paint = () => {
|
|
158
|
+
shown = Math.max(shown, compactProgress(Date.now() - started, outChars / 4, expected));
|
|
159
|
+
spin?.set(compactingText(shown));
|
|
160
|
+
};
|
|
161
|
+
const timer = spin ? setInterval(paint, 200) : null; // time alone moves the bar while no token arrives
|
|
162
|
+
let summary = "";
|
|
163
|
+
try {
|
|
164
|
+
// Streamed only so the bar has something to measure; the result is the same text.
|
|
165
|
+
const res = (await client.chat.completions.create({
|
|
166
|
+
model, // same model — no need for a fancier one to summarize
|
|
167
|
+
messages: [...effortMessages(model, messages), { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
168
|
+
stream: true,
|
|
169
|
+
// Deliberately NO `tools` parameter: with no tools declared, the API
|
|
170
|
+
// cannot accept a tool call — that is the hard guarantee. The CRITICAL
|
|
171
|
+
// lines in the prompt are the soft second layer of the same defense.
|
|
172
|
+
}, { signal }));
|
|
173
|
+
if ("choices" in res)
|
|
174
|
+
summary = res.choices[0]?.message.content ?? ""; // a provider (or test double) that ignored stream: true
|
|
175
|
+
else {
|
|
176
|
+
for await (const chunk of res) {
|
|
177
|
+
const delta = chunk.choices[0]?.delta;
|
|
178
|
+
summary += delta?.content ?? "";
|
|
179
|
+
outChars += (delta?.content?.length ?? 0) + (delta?.reasoning_content?.length ?? 0); // thinking is work too
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (timer)
|
|
185
|
+
clearInterval(timer);
|
|
186
|
+
spin?.stop();
|
|
187
|
+
}
|
|
141
188
|
if (!summary.trim())
|
|
142
189
|
throw new Error("compaction returned an empty summary"); // empty summary = failed compaction
|
|
143
190
|
// The constitution survives compaction: keep the leading system message and
|