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/dist/skills.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs"; // discover and read SKILL.md files
2
2
  import os from "node:os"; // the global skills dir
3
3
  import path from "node:path"; // path resolution
4
+ import { CONFIG, saveGlobalSetting } from "./config.js"; // the listing budget + /skills on/off choices
5
+ import { registerExternalTool, tools, unregisterExternalTool } from "./tools.js"; // a skill is exposed to the model as a tool
4
6
  // Project skills win over global ones with the same name.
5
7
  function skillDirs() {
6
8
  return [path.resolve(".mini-agent", "skills"), path.join(os.homedir(), ".config", "mini-agent", "skills")];
@@ -23,17 +25,22 @@ export function parseSkill(raw, fallbackName, filePath = "") {
23
25
  fm[k] = v;
24
26
  }
25
27
  }
28
+ // No description? Claude Code falls back to the body's first line — something beats nothing in the listing.
29
+ const firstLine = body.split("\n").find((l) => l.trim())?.replace(/^#+\s*/, "").trim() ?? "";
26
30
  return {
27
31
  name: fm.name || fallbackName,
28
- description: fm.description || "",
29
- whenToUse: fm.when_to_use || fm.whenToUse || fm.description || "",
32
+ description: fm.description || firstLine,
33
+ whenToUse: fm.when_to_use || fm.whenToUse || "",
30
34
  allowedTools: (fm["allowed-tools"] || fm.allowedTools || "")
31
35
  .split(",")
32
36
  .map((s) => s.trim())
33
37
  .filter(Boolean),
34
38
  disableModelInvocation: /^true$/i.test(fm.disableModelInvocation || fm["disable-model-invocation"] || ""),
39
+ argumentHint: fm["argument-hint"] || "",
40
+ argNames: (fm.arguments || "").replace(/^\[|\]$/g, "").split(/[\s,]+/).map((a) => a.replace(/^["']|["']$/g, "")).filter(Boolean), // "a, b" or "[a, b]"
35
41
  body,
36
42
  path: filePath,
43
+ dir: filePath ? path.dirname(filePath) : "",
37
44
  };
38
45
  }
39
46
  // Discover skills under the skill dirs (project first, then global). A bad file
@@ -65,64 +72,298 @@ export function loadSkills(dirs = skillDirs()) {
65
72
  }
66
73
  return out;
67
74
  }
75
+ // ---- hot reload -------------------------------------------------------------------
76
+ // Claude Code runs a chokidar watcher over its skill dirs. We get the same
77
+ // effect with no watcher at all: before every model call (and whenever the UI
78
+ // asks), stat the SKILL.md files and compare a fingerprint — every path with its
79
+ // mtime and size. A few readdir + stat calls per round is nothing next to a
80
+ // model call, and there is no timer to leak, debounce, or race.
81
+ let cache;
82
+ function fingerprint(dirs) {
83
+ const parts = [];
84
+ for (const dir of dirs) {
85
+ let entries = [];
86
+ try {
87
+ entries = fs.readdirSync(dir);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ for (const entry of entries.sort()) {
93
+ try {
94
+ const st = fs.statSync(path.join(dir, entry, "SKILL.md"));
95
+ parts.push(`${dir}/${entry}:${st.mtimeMs}:${st.size}`);
96
+ }
97
+ catch {
98
+ /* no SKILL.md here */
99
+ }
100
+ }
101
+ }
102
+ return parts.join("|");
103
+ }
104
+ // Every skill on disk, whatever its /skills setting: reloaded only when a
105
+ // SKILL.md was added, edited, or removed since the last call. `dirs` is
106
+ // injectable for tests. The /skills panel lists these.
107
+ export function allSkills(dirs = skillDirs()) {
108
+ const key = dirs.join("\n");
109
+ const fp = fingerprint(dirs);
110
+ if (!cache || cache.key !== key || cache.fingerprint !== fp)
111
+ cache = { key, fingerprint: fp, skills: loadSkills(dirs) };
112
+ return cache.skills;
113
+ }
114
+ // The skills in effect — what the listing, the skill tool and /skill see.
115
+ // The /skills choices apply here: "off" skills vanish entirely, "user-only"
116
+ // ones stay runnable by the user but are hidden from the model.
117
+ export function currentSkills(dirs = skillDirs()) {
118
+ return allSkills(dirs)
119
+ .filter((s) => skillMode(s) !== "off")
120
+ .map((s) => (skillMode(s) === "user-only" && !s.disableModelInvocation ? { ...s, disableModelInvocation: true } : s));
121
+ }
122
+ export const skillLocked = (s) => s.disableModelInvocation;
123
+ export function skillMode(s) {
124
+ if (skillLocked(s))
125
+ return "user-only";
126
+ return CONFIG.skillOverrides[s.name] ?? "on";
127
+ }
128
+ // Enter/space in the panel: on → user-only → off → on.
129
+ export const nextSkillMode = (m) => (m === "on" ? "user-only" : m === "user-only" ? "off" : "on");
130
+ // Apply and persist a choice. "on" is the default, so it removes the entry
131
+ // rather than storing it — the settings file only lists exceptions.
132
+ export function setSkillMode(name, mode, persist = true) {
133
+ const next = { ...CONFIG.skillOverrides };
134
+ if (mode === "on")
135
+ delete next[name];
136
+ else
137
+ next[name] = mode;
138
+ CONFIG.skillOverrides = next;
139
+ if (persist)
140
+ saveGlobalSetting("skillOverrides", next);
141
+ }
142
+ // One panel row's facts: where it lives and what it costs the model to know
143
+ // about it (its listing line, ~4 bytes per token — the same estimate as context.ts).
144
+ export function skillSource(s) {
145
+ return s.path.startsWith(path.resolve(".mini-agent", "skills")) ? "project" : "user";
146
+ }
147
+ export const skillListingTokens = (s) => Math.ceil(Buffer.byteLength(listingEntry(s)) / 4);
148
+ export const nextSkillSort = (s) => (s === "name" ? "tokens" : s === "tokens" ? "source" : "name");
149
+ // The panel's list: filtered by the search box, sorted.
150
+ // Matching is about the NAME: any part of it ("pul" finds co_dev_pulse). The
151
+ // description only counts for a real word — 3+ characters that start one of
152
+ // its words ("standup", "pulse") — and those rows come after the name matches.
153
+ // A plain substring over descriptions matched everything: a single "a" is in
154
+ // almost every sentence.
155
+ export function skillPanelRows(skills, query, sort) {
156
+ const q = query.trim().toLowerCase();
157
+ const byName = (a, b) => a.name.localeCompare(b.name);
158
+ const order = (rows) => sort === "tokens" ? rows.sort((a, b) => skillListingTokens(b) - skillListingTokens(a) || byName(a, b))
159
+ : sort === "source" ? rows.sort((a, b) => skillSource(a).localeCompare(skillSource(b)) || byName(a, b))
160
+ : rows.sort(byName);
161
+ if (!q)
162
+ return order([...skills]);
163
+ const inName = skills.filter((s) => s.name.toLowerCase().includes(q));
164
+ const inWords = q.length >= 3 ? skills.filter((s) => !inName.includes(s) && s.description.toLowerCase().split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) : [];
165
+ return [...order(inName), ...order(inWords)];
166
+ }
68
167
  export function findSkill(skills, name) {
69
- const n = name.trim().toLowerCase();
168
+ const n = name.trim().replace(/^\//, "").toLowerCase(); // "/deploy" and "deploy" both work, like Claude Code
70
169
  return skills.find((s) => s.name.toLowerCase() === n);
71
170
  }
72
- const MAX_DESC = 250; // cap each skill's listing line (handbook MAX_LISTING_DESC_CHARS)
73
- const LIST_BUDGET = 8000; // cap the whole listing (handbook DEFAULT_CHAR_BUDGET)
74
- // The one-line-per-skill listing the model sees (name + when-to-use), budget
75
- // capped. This is the "progressive disclosure" surface: enough to decide whether
76
- // to invoke, not the full body.
77
- export function skillListing(skills) {
78
- const lines = [];
79
- let used = 0;
80
- for (const s of skills) {
81
- const desc = (s.whenToUse || s.description).replace(/\s+/g, " ").slice(0, MAX_DESC);
82
- const line = `- ${s.name}: ${desc}`;
83
- if (used + line.length > LIST_BUDGET)
84
- break; // out of budget — stop listing
85
- used += line.length;
86
- lines.push(line);
171
+ // ---- usage: what the "/" typeahead puts first -----------------------------------------
172
+ // Claude Code ranks a bare "/" by how much you use each skill: every run you
173
+ // start yourself (/name or /skill name) counts, and old use fades — the score
174
+ // halves every 7 days, never below a tenth. Kept in its own small file (not in
175
+ // settings.json, which is for choices, not counters).
176
+ // MINI_AGENT_SKILL_USAGE relocates it (tests point it at a scratch file).
177
+ const usagePath = () => process.env.MINI_AGENT_SKILL_USAGE || path.join(os.homedir(), ".config", "mini-agent", "skill-usage.json");
178
+ function readUsage() {
179
+ try {
180
+ return JSON.parse(fs.readFileSync(usagePath(), "utf8"));
181
+ }
182
+ catch {
183
+ return {}; // none yet, or unreadable — ranking just falls back to groups + names
87
184
  }
88
- return lines.join("\n");
89
185
  }
90
- // What gets handed back when a skill is invoked: the body, plus a reminder of
91
- // its allowed tools (least-privilege is advisory here — the permission gate
92
- // still governs every actual call).
93
- export function skillInstructions(s) {
186
+ export function recordSkillUsage(name, now = Date.now()) {
187
+ const usage = readUsage();
188
+ usage[name] = { usageCount: (usage[name]?.usageCount ?? 0) + 1, lastUsedAt: now };
189
+ try {
190
+ fs.mkdirSync(path.dirname(usagePath()), { recursive: true });
191
+ fs.writeFileSync(usagePath(), JSON.stringify(usage, null, 2) + "\n");
192
+ }
193
+ catch {
194
+ /* a counter is never worth failing a skill run over */
195
+ }
196
+ }
197
+ // One read of the usage file, scored per name — for the typeahead, which asks about every skill per keystroke.
198
+ export function skillUsageScores(now = Date.now()) {
199
+ const usage = readUsage();
200
+ return (name) => skillUsageScore(name, now, usage);
201
+ }
202
+ export function skillUsageScore(name, now = Date.now(), usage = readUsage()) {
203
+ const u = usage[name];
204
+ if (!u)
205
+ return 0;
206
+ const days = (now - u.lastUsedAt) / 86_400_000;
207
+ return u.usageCount * Math.max(0.5 ** (days / 7), 0.1);
208
+ }
209
+ // ---- the listing, as a <system-reminder> ------------------------------------------
210
+ export const wrapInSystemReminder = (content) => `<system-reminder>\n${content}\n</system-reminder>`;
211
+ const LISTING_HEADER = "The following skills are available for use with the skill tool:"; // Claude Code's wording, our tool name
212
+ const MAX_ENTRY = 250; // cap each skill's line (Claude Code: MAX_LISTING_DESC_CHARS)
213
+ const MIN_DESC = 20; // below this, a truncated description is noise — list the bare name
214
+ // One line per skill: "- name: description - when to use", as Claude Code formats it.
215
+ function listingEntry(s, maxDesc = MAX_ENTRY) {
216
+ const desc = (s.whenToUse ? `${s.description} - ${s.whenToUse}` : s.description).replace(/\s+/g, " ").trim();
217
+ if (maxDesc < MIN_DESC)
218
+ return `- ${s.name}`;
219
+ return `- ${s.name}: ${desc.length > maxDesc ? desc.slice(0, maxDesc - 1) + "…" : desc}`;
220
+ }
221
+ // The listing lines for a set of skills, fit into a character budget: 1% of the
222
+ // context window at ~4 chars/token (Claude Code's formula). Over budget, every
223
+ // description is shortened evenly rather than dropping skills off the end — a
224
+ // skill the model never hears about can never be used.
225
+ export function skillListing(skills, budget = Math.floor(CONFIG.contextWindow * 4 * 0.01)) {
226
+ let lines = skills.map((s) => listingEntry(s));
227
+ const total = lines.reduce((n, l) => n + l.length + 1, 0);
228
+ if (total > budget && skills.length) {
229
+ const nameCost = skills.reduce((n, s) => n + s.name.length + 5, 0); // "- " + name + ": " + "\n"
230
+ const maxDesc = Math.floor((budget - nameCost) / skills.length);
231
+ lines = skills.map((s) => listingEntry(s, maxDesc));
232
+ }
233
+ return lines;
234
+ }
235
+ // Which listing lines has THIS conversation already received? Read it back
236
+ // from the history itself instead of keeping a side table: /clear, --resume,
237
+ // and compaction all just work — whatever the model can still see counts as
238
+ // sent, and nothing else does. (Claude Code keeps a per-agent "sent names" set
239
+ // and resets it on reload; deriving it from history gives the same answer and
240
+ // also re-lists after compaction drops the old reminder.)
241
+ function sentListingLines(messages) {
242
+ const sent = new Set();
243
+ const prefix = wrapInSystemReminder(LISTING_HEADER).split("\n").slice(0, 2).join("\n");
244
+ for (const m of messages) {
245
+ if (m.role !== "user" || typeof m.content !== "string" || !m.content.startsWith(prefix))
246
+ continue;
247
+ for (const line of m.content.split("\n"))
248
+ if (line.startsWith("- "))
249
+ sent.add(line);
250
+ }
251
+ return sent;
252
+ }
253
+ // The reminder to send before the next model call, or null if the model is up
254
+ // to date. Only new or changed lines go out (the delta) — the first call of a
255
+ // conversation gets the whole listing, an edited skill gets its fresh line.
256
+ export function skillListingReminder(messages, skills) {
257
+ const sent = sentListingLines(messages);
258
+ const fresh = skillListing(skills.filter((s) => !s.disableModelInvocation)).filter((l) => !sent.has(l));
259
+ return fresh.length ? wrapInSystemReminder(`${LISTING_HEADER}\n\n${fresh.join("\n")}`) : null;
260
+ }
261
+ // ---- the body, as its own user message ----------------------------------------------
262
+ // Argument substitution, Claude Code's order: $ARGUMENTS[n] / $n (shell-style
263
+ // split), then $ARGUMENTS whole. A body with no placeholder still gets the
264
+ // arguments — appended, so they are never silently dropped.
265
+ export function substituteArguments(body, args) {
266
+ const parts = args.trim() ? args.trim().split(/\s+/) : [];
267
+ let out = body
268
+ .replace(/\$ARGUMENTS\[(\d+)\]/g, (_m, i) => parts[Number(i)] ?? "")
269
+ .replace(/\$(\d+)(?!\d)/g, (_m, i) => parts[Number(i)] ?? "")
270
+ .replace(/\$ARGUMENTS/g, args);
271
+ if (out === body && args.trim())
272
+ out += `\n\nARGUMENTS: ${args}`;
273
+ return out;
274
+ }
275
+ // What the model reads once a skill is invoked (by it or by the user):
276
+ // "Base directory for this skill: …" so relative paths in the body resolve,
277
+ // then the body with arguments filled in.
278
+ export function skillBody(s, args = "") {
279
+ const body = substituteArguments(s.body, args).replaceAll("${CLAUDE_SKILL_DIR}", s.dir).replaceAll("${SKILL_DIR}", s.dir); // a copied Claude Code skill keeps working
94
280
  const tools = s.allowedTools.length ? `\n\n[This skill should use only these tools: ${s.allowedTools.join(", ")}. Other actions still go through the normal permission gate.]` : "";
95
- return `# Skill: ${s.name}\n\nFollow these steps:\n\n${s.body}${tools}`;
96
- }
97
- // Build the `skill` tool from the model-invocable skills. The tool's description
98
- // IS the listing, so the model learns what skills exist and when to use them;
99
- // calling it with a name returns that skill's full instructions.
100
- export function buildSkillTool(skills) {
101
- const invokable = skills.filter((s) => !s.disableModelInvocation);
102
- const listing = skillListing(invokable) || "(none)";
281
+ return `Base directory for this skill: ${s.dir}\n\n${body}${tools}`;
282
+ }
283
+ // The user typed /skill <name> [args]: the two messages Claude Code produces —
284
+ // a <command-name> marker (so the model knows the skill is ALREADY loaded and
285
+ // must not call the tool again), then the body.
286
+ export function userSkillMessages(s, args) {
287
+ const argsTag = args.trim() ? `\n<command-args>${args.trim()}</command-args>` : "";
288
+ return [`<command-message>${s.name}</command-message>\n<command-name>/${s.name}</command-name>${argsTag}`, skillBody(s, args)];
289
+ }
290
+ // ---- the skill tool -----------------------------------------------------------------
291
+ const LAUNCHED = "Launching skill: "; // the tool result that tells the loop a body must follow
292
+ // The tool's manual is static — what skills exist arrives through the listing
293
+ // reminders, so a new skill never changes the tool list.
294
+ export const SKILL_TOOL_DESCRIPTION = `Execute a skill within the main conversation.
295
+
296
+ When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.
297
+
298
+ How to invoke:
299
+ - Use this tool with the skill name and optional arguments
300
+ - Example: skill: "pdf" — invoke the pdf skill
301
+ - Example: skill: "commit", args: "-m 'Fix bug'" — invoke with arguments
302
+
303
+ Important:
304
+ - Available skills are listed in system-reminder messages in the conversation
305
+ - When a skill matches the user's request, invoke it BEFORE generating any other response about the task
306
+ - Do not invoke a skill that is already running
307
+ - If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded — follow the instructions directly instead of calling this tool again`;
308
+ export function buildSkillTool(getSkills = currentSkills) {
103
309
  return {
104
310
  definition: {
105
311
  type: "function",
106
312
  function: {
107
313
  name: "skill",
108
- description: `Load and follow a reusable skill — a saved, named procedure. Call this when the user's request matches one of the skills below; it returns step-by-step instructions you then carry out yourself.
109
- Available skills:
110
- ${listing}
111
- Pass the exact skill name. If nothing matches, do not call this — just answer normally.`,
314
+ description: SKILL_TOOL_DESCRIPTION,
112
315
  parameters: {
113
316
  type: "object",
114
- properties: { name: { type: "string", description: "The exact skill name to load" } },
115
- required: ["name"],
317
+ properties: {
318
+ skill: { type: "string", description: 'The skill name. E.g., "commit", "review-pr", or "pdf"' },
319
+ args: { type: "string", description: "Optional arguments for the skill" },
320
+ },
321
+ required: ["skill"],
116
322
  },
117
323
  },
118
324
  },
119
325
  run: (args) => {
120
- const s = findSkill(skills, args.name ?? "");
326
+ const skills = getSkills();
327
+ const s = findSkill(skills, args.skill ?? "");
121
328
  if (!s)
122
- return `[error] No skill named "${args.name}". Available: ${invokable.map((x) => x.name).join(", ") || "none"}.`;
329
+ return `[error] Unknown skill: ${args.skill}. Available: ${skills.filter((x) => !x.disableModelInvocation).map((x) => x.name).join(", ") || "none"}.`;
123
330
  if (s.disableModelInvocation)
124
- return `[error] Skill "${s.name}" can only be invoked by the user, not by you.`;
125
- return skillInstructions(s);
331
+ return `[error] Skill ${s.name} cannot be used with the skill tool due to disable-model-invocation — only the user can run it.`;
332
+ return `${LAUNCHED}${s.name}`; // the body follows as its own message (skillBodyMessages)
126
333
  },
127
334
  };
128
335
  }
336
+ // After a round's tool results are in: one body message per skill the model
337
+ // launched. They MUST come after all of the round's tool messages — the API
338
+ // requires every tool result of an assistant turn before any other message.
339
+ export function skillBodyMessages(calls, messages, skills = currentSkills()) {
340
+ const out = [];
341
+ for (const call of calls) {
342
+ if (call.name !== "skill")
343
+ continue;
344
+ const result = messages.find((m) => m.role === "tool" && m.tool_call_id === call.id);
345
+ if (!result || typeof result.content !== "string" || !result.content.startsWith(LAUNCHED))
346
+ continue; // refused, errored, or never ran
347
+ let args = {};
348
+ try {
349
+ args = JSON.parse(call.args);
350
+ }
351
+ catch {
352
+ continue;
353
+ }
354
+ const s = findSkill(skills, args.skill ?? "");
355
+ if (s)
356
+ out.push(skillBody(s, args.args ?? ""));
357
+ }
358
+ return out;
359
+ }
360
+ // Keep the tool list in step with the skill set: the `skill` tool exists while
361
+ // at least one model-invocable skill does. Called before every model call, so
362
+ // the first skill dropped into a folder mid-session brings the tool with it.
363
+ export function syncSkillTool(skills) {
364
+ const wanted = skills.some((s) => !s.disableModelInvocation);
365
+ if (wanted && !tools.skill)
366
+ registerExternalTool(buildSkillTool());
367
+ if (!wanted && tools.skill)
368
+ unregisterExternalTool("skill");
369
+ }
package/dist/ui.js CHANGED
@@ -102,11 +102,18 @@ export function formatElapsed(ms) {
102
102
  }
103
103
  // Live activity, elapsed time and streamed tokens. The persistent status line
104
104
  // owns the model name, so each animation need not repeat it.
105
- export function spinnerText(word, elapsedSec, subAgent, tokens) {
105
+ //
106
+ // Everything that changes lives INSIDE the parentheses, after the verb. The verb
107
+ // must never move: a prefix that comes and goes (the old "💭 " while reasoning)
108
+ // shoves the whole line sideways every time it toggles. Reasoning is shown the
109
+ // way Claude Code does it — "thinking" while it streams, "thought for Ns" after.
110
+ export function spinnerText(word, elapsedSec, subAgent, tokens, thinking) {
106
111
  const head = subAgent ? "sub-agent" : word;
107
112
  const bits = [`${elapsedSec}s`];
108
113
  if (tokens && tokens > 0)
109
114
  bits.push(`↓ ${formatTokens(tokens)} tokens`);
115
+ if (thinking)
116
+ bits.push(thinking.active ? "thinking" : `thought for ${Math.max(1, Math.round(thinking.ms / 1000))}s`);
110
117
  bits.push("Ctrl+C to interrupt");
111
118
  return `${head}… ${chalk.dim(`(${bits.join(" · ")})`)}`;
112
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"
@@ -26,6 +26,7 @@
26
26
  "dependencies": {
27
27
  "chalk": "^5.6.2",
28
28
  "cli-table3": "^0.6.5",
29
+ "fuse.js": "^7.5.0",
29
30
  "ink": "^5.2.1",
30
31
  "marked": "^15.0.12",
31
32
  "marked-terminal": "^7.3.0",