agent-from-zero 0.35.1 → 0.35.2

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 } from "./config.js"; // the context window sizes the listing budget
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,21 @@ 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"] || "",
35
40
  body,
36
41
  path: filePath,
42
+ dir: filePath ? path.dirname(filePath) : "",
37
43
  };
38
44
  }
39
45
  // Discover skills under the skill dirs (project first, then global). A bad file
@@ -65,64 +71,206 @@ export function loadSkills(dirs = skillDirs()) {
65
71
  }
66
72
  return out;
67
73
  }
74
+ // ---- hot reload -------------------------------------------------------------------
75
+ // Claude Code runs a chokidar watcher over its skill dirs. We get the same
76
+ // effect with no watcher at all: before every model call (and whenever the UI
77
+ // asks), stat the SKILL.md files and compare a fingerprint — every path with its
78
+ // mtime and size. A few readdir + stat calls per round is nothing next to a
79
+ // model call, and there is no timer to leak, debounce, or race.
80
+ let cache;
81
+ function fingerprint(dirs) {
82
+ const parts = [];
83
+ for (const dir of dirs) {
84
+ let entries = [];
85
+ try {
86
+ entries = fs.readdirSync(dir);
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ for (const entry of entries.sort()) {
92
+ try {
93
+ const st = fs.statSync(path.join(dir, entry, "SKILL.md"));
94
+ parts.push(`${dir}/${entry}:${st.mtimeMs}:${st.size}`);
95
+ }
96
+ catch {
97
+ /* no SKILL.md here */
98
+ }
99
+ }
100
+ }
101
+ return parts.join("|");
102
+ }
103
+ // The live skill set: reloaded from disk only when a SKILL.md was added,
104
+ // edited, or removed since the last call. `dirs` is injectable for tests.
105
+ export function currentSkills(dirs = skillDirs()) {
106
+ const key = dirs.join("\n");
107
+ const fp = fingerprint(dirs);
108
+ if (!cache || cache.key !== key || cache.fingerprint !== fp)
109
+ cache = { key, fingerprint: fp, skills: loadSkills(dirs) };
110
+ return cache.skills;
111
+ }
68
112
  export function findSkill(skills, name) {
69
- const n = name.trim().toLowerCase();
113
+ const n = name.trim().replace(/^\//, "").toLowerCase(); // "/deploy" and "deploy" both work, like Claude Code
70
114
  return skills.find((s) => s.name.toLowerCase() === n);
71
115
  }
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);
116
+ // ---- the listing, as a <system-reminder> ------------------------------------------
117
+ export const wrapInSystemReminder = (content) => `<system-reminder>\n${content}\n</system-reminder>`;
118
+ const LISTING_HEADER = "The following skills are available for use with the skill tool:"; // Claude Code's wording, our tool name
119
+ const MAX_ENTRY = 250; // cap each skill's line (Claude Code: MAX_LISTING_DESC_CHARS)
120
+ const MIN_DESC = 20; // below this, a truncated description is noise — list the bare name
121
+ // One line per skill: "- name: description - when to use", as Claude Code formats it.
122
+ function listingEntry(s, maxDesc = MAX_ENTRY) {
123
+ const desc = (s.whenToUse ? `${s.description} - ${s.whenToUse}` : s.description).replace(/\s+/g, " ").trim();
124
+ if (maxDesc < MIN_DESC)
125
+ return `- ${s.name}`;
126
+ return `- ${s.name}: ${desc.length > maxDesc ? desc.slice(0, maxDesc - 1) + "…" : desc}`;
127
+ }
128
+ // The listing lines for a set of skills, fit into a character budget: 1% of the
129
+ // context window at ~4 chars/token (Claude Code's formula). Over budget, every
130
+ // description is shortened evenly rather than dropping skills off the end — a
131
+ // skill the model never hears about can never be used.
132
+ export function skillListing(skills, budget = Math.floor(CONFIG.contextWindow * 4 * 0.01)) {
133
+ let lines = skills.map((s) => listingEntry(s));
134
+ const total = lines.reduce((n, l) => n + l.length + 1, 0);
135
+ if (total > budget && skills.length) {
136
+ const nameCost = skills.reduce((n, s) => n + s.name.length + 5, 0); // "- " + name + ": " + "\n"
137
+ const maxDesc = Math.floor((budget - nameCost) / skills.length);
138
+ lines = skills.map((s) => listingEntry(s, maxDesc));
87
139
  }
88
- return lines.join("\n");
140
+ return lines;
89
141
  }
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) {
142
+ // Which listing lines has THIS conversation already received? Read it back
143
+ // from the history itself instead of keeping a side table: /clear, --resume,
144
+ // and compaction all just work — whatever the model can still see counts as
145
+ // sent, and nothing else does. (Claude Code keeps a per-agent "sent names" set
146
+ // and resets it on reload; deriving it from history gives the same answer and
147
+ // also re-lists after compaction drops the old reminder.)
148
+ function sentListingLines(messages) {
149
+ const sent = new Set();
150
+ const prefix = wrapInSystemReminder(LISTING_HEADER).split("\n").slice(0, 2).join("\n");
151
+ for (const m of messages) {
152
+ if (m.role !== "user" || typeof m.content !== "string" || !m.content.startsWith(prefix))
153
+ continue;
154
+ for (const line of m.content.split("\n"))
155
+ if (line.startsWith("- "))
156
+ sent.add(line);
157
+ }
158
+ return sent;
159
+ }
160
+ // The reminder to send before the next model call, or null if the model is up
161
+ // to date. Only new or changed lines go out (the delta) — the first call of a
162
+ // conversation gets the whole listing, an edited skill gets its fresh line.
163
+ export function skillListingReminder(messages, skills) {
164
+ const sent = sentListingLines(messages);
165
+ const fresh = skillListing(skills.filter((s) => !s.disableModelInvocation)).filter((l) => !sent.has(l));
166
+ return fresh.length ? wrapInSystemReminder(`${LISTING_HEADER}\n\n${fresh.join("\n")}`) : null;
167
+ }
168
+ // ---- the body, as its own user message ----------------------------------------------
169
+ // Argument substitution, Claude Code's order: $ARGUMENTS[n] / $n (shell-style
170
+ // split), then $ARGUMENTS whole. A body with no placeholder still gets the
171
+ // arguments — appended, so they are never silently dropped.
172
+ export function substituteArguments(body, args) {
173
+ const parts = args.trim() ? args.trim().split(/\s+/) : [];
174
+ let out = body
175
+ .replace(/\$ARGUMENTS\[(\d+)\]/g, (_m, i) => parts[Number(i)] ?? "")
176
+ .replace(/\$(\d+)(?!\d)/g, (_m, i) => parts[Number(i)] ?? "")
177
+ .replace(/\$ARGUMENTS/g, args);
178
+ if (out === body && args.trim())
179
+ out += `\n\nARGUMENTS: ${args}`;
180
+ return out;
181
+ }
182
+ // What the model reads once a skill is invoked (by it or by the user):
183
+ // "Base directory for this skill: …" so relative paths in the body resolve,
184
+ // then the body with arguments filled in.
185
+ export function skillBody(s, args = "") {
186
+ 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
187
  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)";
188
+ return `Base directory for this skill: ${s.dir}\n\n${body}${tools}`;
189
+ }
190
+ // The user typed /skill <name> [args]: the two messages Claude Code produces —
191
+ // a <command-name> marker (so the model knows the skill is ALREADY loaded and
192
+ // must not call the tool again), then the body.
193
+ export function userSkillMessages(s, args) {
194
+ const argsTag = args.trim() ? `\n<command-args>${args.trim()}</command-args>` : "";
195
+ return [`<command-message>${s.name}</command-message>\n<command-name>/${s.name}</command-name>${argsTag}`, skillBody(s, args)];
196
+ }
197
+ // ---- the skill tool -----------------------------------------------------------------
198
+ const LAUNCHED = "Launching skill: "; // the tool result that tells the loop a body must follow
199
+ // The tool's manual is static — what skills exist arrives through the listing
200
+ // reminders, so a new skill never changes the tool list.
201
+ export const SKILL_TOOL_DESCRIPTION = `Execute a skill within the main conversation.
202
+
203
+ When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.
204
+
205
+ How to invoke:
206
+ - Use this tool with the skill name and optional arguments
207
+ - Example: skill: "pdf" — invoke the pdf skill
208
+ - Example: skill: "commit", args: "-m 'Fix bug'" — invoke with arguments
209
+
210
+ Important:
211
+ - Available skills are listed in system-reminder messages in the conversation
212
+ - When a skill matches the user's request, invoke it BEFORE generating any other response about the task
213
+ - Do not invoke a skill that is already running
214
+ - 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`;
215
+ export function buildSkillTool(getSkills = currentSkills) {
103
216
  return {
104
217
  definition: {
105
218
  type: "function",
106
219
  function: {
107
220
  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.`,
221
+ description: SKILL_TOOL_DESCRIPTION,
112
222
  parameters: {
113
223
  type: "object",
114
- properties: { name: { type: "string", description: "The exact skill name to load" } },
115
- required: ["name"],
224
+ properties: {
225
+ skill: { type: "string", description: 'The skill name. E.g., "commit", "review-pr", or "pdf"' },
226
+ args: { type: "string", description: "Optional arguments for the skill" },
227
+ },
228
+ required: ["skill"],
116
229
  },
117
230
  },
118
231
  },
119
232
  run: (args) => {
120
- const s = findSkill(skills, args.name ?? "");
233
+ const skills = getSkills();
234
+ const s = findSkill(skills, args.skill ?? "");
121
235
  if (!s)
122
- return `[error] No skill named "${args.name}". Available: ${invokable.map((x) => x.name).join(", ") || "none"}.`;
236
+ return `[error] Unknown skill: ${args.skill}. Available: ${skills.filter((x) => !x.disableModelInvocation).map((x) => x.name).join(", ") || "none"}.`;
123
237
  if (s.disableModelInvocation)
124
- return `[error] Skill "${s.name}" can only be invoked by the user, not by you.`;
125
- return skillInstructions(s);
238
+ return `[error] Skill ${s.name} cannot be used with the skill tool due to disable-model-invocation — only the user can run it.`;
239
+ return `${LAUNCHED}${s.name}`; // the body follows as its own message (skillBodyMessages)
126
240
  },
127
241
  };
128
242
  }
243
+ // After a round's tool results are in: one body message per skill the model
244
+ // launched. They MUST come after all of the round's tool messages — the API
245
+ // requires every tool result of an assistant turn before any other message.
246
+ export function skillBodyMessages(calls, messages, skills = currentSkills()) {
247
+ const out = [];
248
+ for (const call of calls) {
249
+ if (call.name !== "skill")
250
+ continue;
251
+ const result = messages.find((m) => m.role === "tool" && m.tool_call_id === call.id);
252
+ if (!result || typeof result.content !== "string" || !result.content.startsWith(LAUNCHED))
253
+ continue; // refused, errored, or never ran
254
+ let args = {};
255
+ try {
256
+ args = JSON.parse(call.args);
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ const s = findSkill(skills, args.skill ?? "");
262
+ if (s)
263
+ out.push(skillBody(s, args.args ?? ""));
264
+ }
265
+ return out;
266
+ }
267
+ // Keep the tool list in step with the skill set: the `skill` tool exists while
268
+ // at least one model-invocable skill does. Called before every model call, so
269
+ // the first skill dropped into a folder mid-session brings the tool with it.
270
+ export function syncSkillTool(skills) {
271
+ const wanted = skills.some((s) => !s.disableModelInvocation);
272
+ if (wanted && !tools.skill)
273
+ registerExternalTool(buildSkillTool());
274
+ if (!wanted && tools.skill)
275
+ unregisterExternalTool("skill");
276
+ }
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.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mini-agent": "dist/agent.js"