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 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 list the reusable skills available in this project
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 = currentSkills();
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 who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
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
@@ -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" },
@@ -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
- log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
133
- const res = await client.chat.completions.create({
134
- model, // same model — no need for a fancier one to summarize
135
- messages: [...effortMessages(model, messages), { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
136
- // Deliberately NO `tools` parameter: with no tools declared, the API
137
- // cannot accept a tool call — that is the hard guarantee. The CRITICAL
138
- // lines in the prompt are the soft second layer of the same defense.
139
- }, { signal });
140
- const summary = res.choices[0].message.content ?? ""; // the structured summary text
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