min-agent 0.2.0 → 0.3.0

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.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
package/dist/skills.js CHANGED
@@ -1,79 +1,410 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFileSync, existsSync, readdirSync, statSync } from "fs";
2
+ import { readFileSync, existsSync, readdirSync, realpathSync } from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
5
  import { globSync } from "glob";
6
- import { loadConfig } from "./config.js";
6
+ import { loadConfig, saveConfig, loadProjectConfig, saveProjectConfig } from "./config.js";
7
+ import { truncateToolOutput } from "./tool-output.js";
8
+ /** Coerce a config skill list to an array of names, tolerating hand-edited configs. */
9
+ function skillNames(value) {
10
+ return Array.isArray(value) ? value.filter((n) => typeof n === "string") : [];
11
+ }
12
+ /**
13
+ * Effective enable state: project `.min-agent/config.json` wins over the global
14
+ * `~/.min-agent/config.json`, so disabling a repo-local skill in one project
15
+ * does not silently disable a same-named skill everywhere.
16
+ */
17
+ function resolveSkillState() {
18
+ const project = loadProjectConfig();
19
+ const forced = new Set(skillNames(project.enabledSkills));
20
+ const disabled = new Set();
21
+ const scopeOf = new Map();
22
+ for (const name of skillNames(loadConfig().disabledSkills)) {
23
+ if (forced.has(name))
24
+ continue;
25
+ disabled.add(name);
26
+ scopeOf.set(name, "global");
27
+ }
28
+ for (const name of skillNames(project.disabledSkills)) {
29
+ disabled.add(name);
30
+ scopeOf.set(name, "project");
31
+ }
32
+ return { disabled, scopeOf };
33
+ }
34
+ function applyState(skill, state) {
35
+ const disabled = state.disabled.has(skill.name);
36
+ return {
37
+ ...skill,
38
+ enabled: !disabled,
39
+ ...(disabled ? { disabledScope: state.scopeOf.get(skill.name) } : {}),
40
+ };
41
+ }
42
+ /** Max SKILL.md depth below a skills root: `<skill>/SKILL.md` plus one nesting level. */
43
+ const SKILL_SCAN_MAX_DEPTH = 3;
44
+ /** Guard against pathological skill trees blowing up the system prompt. */
45
+ const MAX_SKILL_FILES = 20;
46
+ const SKILL_CONTENT_MAX_BYTES = 32 * 1024;
7
47
  /**
8
48
  * Skill scan order: later entries win on duplicate `name` in frontmatter.
9
49
  * Global user skills first, then project-local dirs so repo skills override ~/.agents.
50
+ * Computed lazily so process.cwd() changes are respected.
51
+ * `MIN_AGENT_SKILLS_DIRS` (path-delimiter separated) replaces the default list.
10
52
  */
11
- const SKILL_DIRS = [
12
- path.join(os.homedir(), ".agents", "skills"),
13
- path.join(process.cwd(), ".min-agent", "skills"),
14
- path.join(process.cwd(), ".agent-demo", "skills"),
15
- path.join(process.cwd(), ".opencode", "skills"),
16
- path.join(process.cwd(), ".claude", "skills"),
17
- ];
53
+ function getSkillDirs() {
54
+ const override = process.env.MIN_AGENT_SKILLS_DIRS?.trim();
55
+ if (override) {
56
+ return override
57
+ .split(path.delimiter)
58
+ .map((dir) => dir.trim())
59
+ .filter(Boolean)
60
+ .map((dir) => path.resolve(dir));
61
+ }
62
+ return [
63
+ path.join(os.homedir(), ".agents", "skills"),
64
+ path.join(process.cwd(), ".min-agent", "skills"),
65
+ path.join(process.cwd(), ".agents", "skills"),
66
+ path.join(process.cwd(), ".opencode", "skills"),
67
+ path.join(process.cwd(), ".claude", "skills"),
68
+ ];
69
+ }
18
70
  let loadedSkills = {};
19
- export function discoverSkills(opts) {
20
- loadedSkills = {};
21
- for (const dir of SKILL_DIRS) {
22
- if (!existsSync(dir))
71
+ const stderrWarn = (line) => process.stderr.write(`${line}\n`);
72
+ /** Same underlying file? Symlinked skill libraries surface under several roots. */
73
+ function sameFile(a, b) {
74
+ if (a === b)
75
+ return true;
76
+ try {
77
+ return realpathSync(a) === realpathSync(b);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ /**
84
+ * Scan a skills root for SKILL.md files.
85
+ * Symlinked skill dirs are followed — sharing one skill library across
86
+ * `.claude/skills`, `.agents/skills`, … via symlink is a common setup.
87
+ * Within one root the shallowest SKILL.md wins for a given name, so the
88
+ * `<skill>/<skill>/SKILL.md` layout some installers produce doesn't shadow
89
+ * the canonical `<skill>/SKILL.md`.
90
+ */
91
+ export function scanSkillDir(dir, warn = stderrWarn) {
92
+ if (!existsSync(dir))
93
+ return [];
94
+ const matches = globSync("**/SKILL.md", {
95
+ cwd: dir,
96
+ absolute: true,
97
+ follow: true,
98
+ maxDepth: SKILL_SCAN_MAX_DEPTH,
99
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"],
100
+ }).sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
101
+ const byName = new Map();
102
+ for (const match of matches) {
103
+ const skill = parseSkillFile(match, warn);
104
+ if (!skill || byName.has(skill.name))
23
105
  continue;
24
- const matches = globSync("**/SKILL.md", { cwd: dir, absolute: true });
25
- for (const match of matches) {
26
- const skill = parseSkillFile(match);
27
- if (skill) {
28
- if (loadedSkills[skill.name]) {
29
- console.warn(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${match} overrides ${loadedSkills[skill.name].location}\x1b[0m`);
30
- }
31
- loadedSkills[skill.name] = skill;
106
+ byName.set(skill.name, skill);
107
+ }
108
+ return [...byName.values()];
109
+ }
110
+ export function discoverSkills(opts) {
111
+ const warn = opts?.silent ? null : stderrWarn;
112
+ const next = {};
113
+ for (const dir of getSkillDirs()) {
114
+ for (const skill of scanSkillDir(dir, warn)) {
115
+ const previous = next[skill.name];
116
+ if (previous && sameFile(previous.location, skill.location))
117
+ continue;
118
+ if (previous) {
119
+ const versionNote = previous.version && skill.version && previous.version !== skill.version
120
+ ? ` (version ${previous.version} → ${skill.version})`
121
+ : "";
122
+ warn?.(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${skill.location} overrides ${previous.location}${versionNote}\x1b[0m`);
32
123
  }
124
+ next[skill.name] = skill;
33
125
  }
34
126
  }
127
+ const state = resolveSkillState();
128
+ loadedSkills = Object.fromEntries(Object.entries(next).map(([name, skill]) => [name, applyState(skill, state)]));
35
129
  const count = Object.keys(loadedSkills).length;
36
130
  if (count > 0 && !opts?.silent) {
37
- const disabled = new Set(loadConfig().disabledSkills ?? []);
38
- const enabledCount = Object.keys(loadedSkills).filter((n) => !disabled.has(n)).length;
39
- console.log(`\x1b[90m Skills: ${enabledCount} enabled${count > enabledCount ? `, ${count - enabledCount} disabled` : ""}\x1b[0m`);
131
+ const enabledCount = Object.values(loadedSkills).filter((s) => s.enabled).length;
132
+ warn?.(`\x1b[90m Skills: ${enabledCount} enabled${count > enabledCount ? `, ${count - enabledCount} disabled` : ""}\x1b[0m`);
133
+ }
134
+ }
135
+ function unquote(value) {
136
+ const trimmed = value.trim();
137
+ const quoted = trimmed.match(/^"([\s\S]*)"$/) ?? trimmed.match(/^'([\s\S]*)'$/);
138
+ return quoted ? quoted[1] : trimmed;
139
+ }
140
+ /**
141
+ * Read one top-level frontmatter key.
142
+ * Supports `key: value`, quoted values, plain multi-line scalars, and block
143
+ * scalars (`|`, `>`, `|-`, `>-`) — all of which real-world SKILL.md files use
144
+ * for long descriptions.
145
+ */
146
+ export function readFrontmatterValue(frontmatter, key) {
147
+ const lines = frontmatter.split("\n");
148
+ const index = lines.findIndex((line) => new RegExp(`^${key}:(\\s|$)`).test(line));
149
+ if (index === -1)
150
+ return null;
151
+ const inline = lines[index].slice(key.length + 1).trim();
152
+ const isBlockScalar = /^[|>][+-]?\d*$/.test(inline);
153
+ const body = [];
154
+ for (const line of lines.slice(index + 1)) {
155
+ if (line.trim() !== "" && !/^\s/.test(line))
156
+ break;
157
+ body.push(line.replace(/^\s+/, ""));
158
+ }
159
+ if (inline && !isBlockScalar) {
160
+ // Plain scalar: indented follow-up lines are continuations, joined by spaces.
161
+ const continuation = body.filter((l) => l !== "").join(" ");
162
+ return unquote(continuation ? `${inline} ${continuation}` : inline);
163
+ }
164
+ const joined = isBlockScalar && inline.startsWith(">") ? body.join(" ") : body.join("\n");
165
+ const value = joined.trim();
166
+ return value === "" ? null : value;
167
+ }
168
+ /** Read a YAML list, either `key: [a, b]` or a `- item` block. */
169
+ function readFrontmatterList(frontmatter, key) {
170
+ const lines = frontmatter.split("\n");
171
+ const index = lines.findIndex((line) => new RegExp(`^\\s*${key}:(\\s|$)`).test(line));
172
+ if (index === -1)
173
+ return [];
174
+ const header = lines[index];
175
+ const indent = header.match(/^\s*/)[0].length;
176
+ const inline = header.slice(header.indexOf(":") + 1).trim();
177
+ const flow = inline.match(/^\[([\s\S]*)\]$/);
178
+ if (flow) {
179
+ return flow[1]
180
+ .split(",")
181
+ .map((item) => unquote(item))
182
+ .filter(Boolean);
183
+ }
184
+ if (inline)
185
+ return [unquote(inline)].filter(Boolean);
186
+ const items = [];
187
+ for (const line of lines.slice(index + 1)) {
188
+ if (line.trim() === "")
189
+ continue;
190
+ const currentIndent = line.match(/^\s*/)[0].length;
191
+ if (currentIndent <= indent)
192
+ break;
193
+ const item = line.trim();
194
+ if (!item.startsWith("- "))
195
+ break;
196
+ items.push(unquote(item.slice(2)));
40
197
  }
198
+ return items;
199
+ }
200
+ /**
201
+ * Read `metadata.requires.bins` from a frontmatter block. Resolves the nested
202
+ * path explicitly so a stray top-level `bins:` key cannot be misread.
203
+ */
204
+ function readRequiredBins(frontmatter) {
205
+ const lines = frontmatter.split("\n");
206
+ const metaIdx = lines.findIndex((line) => /^metadata:(\s|$)/.test(line));
207
+ if (metaIdx === -1)
208
+ return [];
209
+ const metaIndent = lines[metaIdx].match(/^\s*/)[0].length;
210
+ const requiresIdx = lines.slice(metaIdx + 1).findIndex((line) => {
211
+ if (line.trim() === "" || /^\s*$/.test(line))
212
+ return false;
213
+ const indent = line.match(/^\s*/)[0].length;
214
+ return indent > metaIndent && /^requires:(\s|$)/.test(line.trim());
215
+ });
216
+ if (requiresIdx === -1)
217
+ return [];
218
+ const requiresLine = lines[metaIdx + 1 + requiresIdx];
219
+ const requiresIndent = requiresLine.match(/^\s*/)[0].length;
220
+ const binsIdx = lines.slice(metaIdx + 1 + requiresIdx + 1).findIndex((line) => {
221
+ if (line.trim() === "" || /^\s*$/.test(line))
222
+ return false;
223
+ const indent = line.match(/^\s*/)[0].length;
224
+ return indent > requiresIndent && /^bins:(\s|$)/.test(line.trim());
225
+ });
226
+ if (binsIdx === -1)
227
+ return [];
228
+ const block = lines.slice(metaIdx + 1 + requiresIdx + 1 + binsIdx).join("\n");
229
+ return readFrontmatterList(block, "bins");
41
230
  }
42
- function parseSkillFile(filePath) {
231
+ function parseSkillFile(filePath, warn) {
232
+ let raw;
43
233
  try {
44
- const raw = readFileSync(filePath, "utf-8");
45
- const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
46
- if (!fmMatch)
47
- return null;
48
- const frontmatter = fmMatch[1];
49
- const content = fmMatch[2];
50
- const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
51
- const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
52
- if (!nameMatch || !descMatch)
53
- return null;
54
- return {
55
- name: nameMatch[1].trim().replace(/^["']|["']$/g, ""),
56
- description: descMatch[1].trim().replace(/^["']|["']$/g, ""),
57
- location: filePath,
58
- content: content.trim(),
59
- };
234
+ raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
60
235
  }
61
- catch {
236
+ catch (err) {
237
+ warn?.(`\x1b[33m ⚠ Skill skipped (unreadable): ${filePath} — ${err.message}\x1b[0m`);
238
+ return null;
239
+ }
240
+ const fmMatch = raw.match(/^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n([\s\S]*))?$/);
241
+ if (!fmMatch) {
242
+ warn?.(`\x1b[33m ⚠ Skill skipped (missing --- frontmatter block): ${filePath}\x1b[0m`);
243
+ return null;
244
+ }
245
+ const frontmatter = fmMatch[1];
246
+ const name = readFrontmatterValue(frontmatter, "name");
247
+ const description = readFrontmatterValue(frontmatter, "description");
248
+ const missing = [!name && "name", !description && "description"].filter(Boolean);
249
+ if (!name || !description) {
250
+ warn?.(`\x1b[33m ⚠ Skill skipped (frontmatter missing ${missing.join(", ")}): ${filePath}\x1b[0m`);
62
251
  return null;
63
252
  }
253
+ return {
254
+ name,
255
+ description,
256
+ location: filePath,
257
+ content: (fmMatch[2] ?? "").trim(),
258
+ enabled: true,
259
+ allowedTools: readFrontmatterList(frontmatter, "allowed-tools"),
260
+ requiredBins: readRequiredBins(frontmatter),
261
+ version: readFrontmatterValue(frontmatter, "version") ?? undefined,
262
+ };
263
+ }
264
+ /** Every discovered skill, disabled ones included (`enabled` reflects config). */
265
+ export function getAllSkills() {
266
+ const state = resolveSkillState();
267
+ return Object.values(loadedSkills).map((s) => applyState(s, state));
64
268
  }
65
269
  export function getSkills() {
66
- const disabled = new Set(loadConfig().disabledSkills ?? []);
67
- return Object.values(loadedSkills).filter((s) => !disabled.has(s.name));
270
+ return getAllSkills().filter((s) => s.enabled);
68
271
  }
69
272
  export function getSkill(name) {
70
- return loadedSkills[name];
273
+ const skill = loadedSkills[name];
274
+ return skill ? applyState(skill, resolveSkillState()) : undefined;
275
+ }
276
+ /** Disabled or force-enabled names in either scope that match no discovered skill. */
277
+ export function getStaleDisabledSkills() {
278
+ const project = loadProjectConfig();
279
+ const names = new Set([
280
+ ...skillNames(loadConfig().disabledSkills),
281
+ ...skillNames(project.disabledSkills),
282
+ ...skillNames(project.enabledSkills),
283
+ ]);
284
+ return [...names].filter((name) => !loadedSkills[name]);
285
+ }
286
+ /**
287
+ * Toggle skills in one scope.
288
+ * Project scope wins over global: enabling in project scope records an explicit
289
+ * override so a globally disabled skill stays available in this repo.
290
+ * Returns per-skill effective state plus the scope that still disables it, if any.
291
+ */
292
+ export function setSkillEnabled(names, enabled, scope) {
293
+ if (scope === "global") {
294
+ const config = loadConfig();
295
+ const disabled = new Set(skillNames(config.disabledSkills));
296
+ for (const name of names) {
297
+ if (enabled)
298
+ disabled.delete(name);
299
+ else
300
+ disabled.add(name);
301
+ }
302
+ config.disabledSkills = [...disabled];
303
+ saveConfig(config);
304
+ }
305
+ else {
306
+ const project = loadProjectConfig();
307
+ const disabled = new Set(skillNames(project.disabledSkills));
308
+ const forced = new Set(skillNames(project.enabledSkills));
309
+ const globallyDisabled = new Set(skillNames(loadConfig().disabledSkills));
310
+ for (const name of names) {
311
+ if (enabled) {
312
+ disabled.delete(name);
313
+ if (globallyDisabled.has(name))
314
+ forced.add(name);
315
+ else
316
+ forced.delete(name);
317
+ }
318
+ else {
319
+ disabled.add(name);
320
+ forced.delete(name);
321
+ }
322
+ }
323
+ saveProjectConfig({ ...project, disabledSkills: [...disabled], enabledSkills: [...forced] });
324
+ }
325
+ const state = resolveSkillState();
326
+ return names.map((name) => {
327
+ const effective = !state.disabled.has(name);
328
+ return {
329
+ name,
330
+ enabled: effective,
331
+ ...(effective === enabled ? {} : { blockedBy: state.scopeOf.get(name) }),
332
+ };
333
+ });
334
+ }
335
+ function listSkillFiles(dir) {
336
+ const out = [];
337
+ let total = 0;
338
+ const walk = (current, depth) => {
339
+ let dirents;
340
+ try {
341
+ dirents = readdirSync(current, { withFileTypes: true });
342
+ }
343
+ catch {
344
+ return;
345
+ }
346
+ for (const d of dirents) {
347
+ if (d.name === "SKILL.md" || d.name.startsWith("."))
348
+ continue;
349
+ total++;
350
+ if (out.length < MAX_SKILL_FILES) {
351
+ out.push(d.isDirectory() ? `${path.join(current, d.name)}/` : path.join(current, d.name));
352
+ }
353
+ if (d.isDirectory() && depth > 0)
354
+ walk(path.join(current, d.name), depth - 1);
355
+ }
356
+ };
357
+ walk(dir, 2);
358
+ return { entries: out.sort(), total };
359
+ }
360
+ function missingBins(bins) {
361
+ if (bins.length === 0)
362
+ return [];
363
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
364
+ const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat", ".ps1"] : [""];
365
+ const has = (bin) => dirs.some((d) => exts.some((ext) => existsSync(path.join(d, `${bin}${ext}`))));
366
+ return bins.filter((bin) => !has(bin));
367
+ }
368
+ export function buildSkillContent(skill) {
369
+ const dir = path.dirname(skill.location);
370
+ const { entries, total } = listSkillFiles(dir);
371
+ const absent = missingBins(skill.requiredBins);
372
+ const body = [
373
+ `<skill_content name="${escapeXmlAttr(skill.name)}">`,
374
+ `# Skill: ${skill.name}`,
375
+ "",
376
+ `Base directory for this skill: ${dir}`,
377
+ "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
378
+ "",
379
+ skill.allowedTools.length
380
+ ? `This skill declares allowed-tools: ${escapeXml(skill.allowedTools.join(", "))}. Prefer these tools while the skill is active.`
381
+ : "",
382
+ absent.length
383
+ ? `⚠ Required binaries not found on PATH: ${absent.join(", ")}. Tell the user to install them before running this skill's commands.`
384
+ : "",
385
+ entries.length
386
+ ? [
387
+ "<skill_files>",
388
+ ...entries.map((f) => ` <file>${escapeXml(f)}</file>`),
389
+ total > entries.length ? ` <note>${total - entries.length} more entries not listed; use glob on the base directory</note>` : "",
390
+ "</skill_files>",
391
+ ]
392
+ .filter(Boolean)
393
+ .join("\n")
394
+ : "",
395
+ "",
396
+ skill.content,
397
+ `</skill_content>`,
398
+ ]
399
+ .filter(Boolean)
400
+ .join("\n");
401
+ return truncateToolOutput(body, { direction: "head", maxBytes: SKILL_CONTENT_MAX_BYTES }).content;
71
402
  }
72
403
  /**
73
404
  * Skill tool — content is loaded on-demand when the model calls this tool.
74
405
  * The tool description is kept minimal; the full skill list lives in the system prompt.
75
406
  */
76
- export function getSkillsTool() {
407
+ export function getSkillsTool(tracker = new Set()) {
77
408
  return tool({
78
409
  description: "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt. " +
79
410
  "Use this tool to inject the skill's instructions and resources into the current conversation. " +
@@ -86,46 +417,42 @@ export function getSkillsTool() {
86
417
  required: ["name"],
87
418
  }),
88
419
  execute: async ({ name }) => {
89
- const disabled = new Set(loadConfig().disabledSkills ?? []);
90
- if (disabled.has(name)) {
91
- return `Skill "${name}" is disabled. Available skills: ${getSkills().map((s) => s.name).join(", ") || "none"}`;
420
+ const state = resolveSkillState();
421
+ const available = Object.keys(loadedSkills).filter((n) => !state.disabled.has(n)).sort();
422
+ if (state.disabled.has(name)) {
423
+ return `Skill "${name}" is disabled (${state.scopeOf.get(name)} scope). Available skills: ${available.join(", ") || "none"}`;
92
424
  }
93
425
  const skill = loadedSkills[name];
94
426
  if (!skill) {
95
- const available = getSkills().map((s) => s.name);
96
427
  return `Skill "${name}" not found. Available skills: ${available.length ? available.join(", ") : "none"}`;
97
428
  }
98
- const dir = path.dirname(skill.location);
99
- let files = [];
100
- try {
101
- files = readdirSync(dir)
102
- .filter((f) => f !== "SKILL.md" && !statSync(path.join(dir, f)).isDirectory())
103
- .slice(0, 10);
429
+ if (tracker.has(name)) {
430
+ return `Skill "${name}" is already loaded in this conversation — its instructions are still in effect above. Base directory: ${path.dirname(skill.location)}`;
104
431
  }
105
- catch { }
106
- return [
107
- `<skill_content name="${skill.name}">`,
108
- `# Skill: ${skill.name}`,
109
- "",
110
- skill.content,
111
- "",
112
- `Base directory for this skill: ${dir}`,
113
- "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
114
- "",
115
- files.length ? `<skill_files>\n${files.map((f) => ` <file>${path.join(dir, f)}</file>`).join("\n")}\n</skill_files>` : "",
116
- `</skill_content>`,
117
- ]
118
- .filter(Boolean)
119
- .join("\n");
432
+ tracker.add(name);
433
+ return buildSkillContent(skill);
120
434
  },
121
435
  });
122
436
  }
437
+ function escapeXml(value) {
438
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
439
+ }
440
+ function escapeXmlAttr(value) {
441
+ return escapeXml(value).replace(/"/g, "&quot;");
442
+ }
443
+ /** Reverse of `escapeXmlAttr` — used to recover the raw skill name from a marker. */
444
+ function unescapeXmlAttr(value) {
445
+ return value
446
+ .replace(/&quot;/g, '"')
447
+ .replace(/&lt;/g, "<")
448
+ .replace(/&gt;/g, ">")
449
+ .replace(/&amp;/g, "&");
450
+ }
123
451
  /**
124
452
  * System prompt section — verbose XML format for better model comprehension.
125
453
  * Only name/description/location are injected; content is loaded on-demand via the skill tool.
126
454
  */
127
- export function getSkillsSystemPrompt() {
128
- const skills = getSkills();
455
+ export function buildSkillsPrompt(skills) {
129
456
  if (skills.length === 0)
130
457
  return "";
131
458
  return [
@@ -135,11 +462,74 @@ export function getSkillsSystemPrompt() {
135
462
  "<available_skills>",
136
463
  ...skills.flatMap((s) => [
137
464
  " <skill>",
138
- ` <name>${s.name}</name>`,
139
- ` <description>${s.description}</description>`,
140
- ` <location>${s.location}</location>`,
465
+ ` <name>${escapeXml(s.name)}</name>`,
466
+ ` <description>${escapeXml(s.description)}</description>`,
467
+ ` <location>${escapeXml(s.location)}</location>`,
141
468
  " </skill>",
142
469
  ]),
143
470
  "</available_skills>",
144
471
  ].join("\n");
145
472
  }
473
+ const SKILL_CONTENT_MARKER = /<skill_content name="([^"]*)"/g;
474
+ function textOf(part) {
475
+ if (typeof part === "string")
476
+ return part;
477
+ if (!part || typeof part !== "object")
478
+ return "";
479
+ const rec = part;
480
+ if (typeof rec.text === "string")
481
+ return rec.text;
482
+ const output = rec.output;
483
+ if (typeof output === "string")
484
+ return output;
485
+ if (output && typeof output === "object" && typeof output.value === "string") {
486
+ return output.value;
487
+ }
488
+ return "";
489
+ }
490
+ /** Skills whose content is already present in the conversation. */
491
+ export function collectLoadedSkillNames(messages) {
492
+ const names = new Set();
493
+ const scan = (text) => {
494
+ for (const m of text.matchAll(SKILL_CONTENT_MARKER))
495
+ names.add(unescapeXmlAttr(m[1]));
496
+ };
497
+ for (const msg of messages) {
498
+ if (typeof msg.content === "string")
499
+ scan(msg.content);
500
+ else if (Array.isArray(msg.content))
501
+ for (const part of msg.content)
502
+ scan(textOf(part));
503
+ }
504
+ return names;
505
+ }
506
+ /**
507
+ * Note appended to a compaction summary: the skill instructions themselves are
508
+ * gone with the summarized history, so the model is told what was active and
509
+ * how to get it back.
510
+ */
511
+ export function buildSkillReloadNote(names) {
512
+ if (names.length === 0)
513
+ return "";
514
+ const withDirs = names.map((name) => {
515
+ const skill = loadedSkills[name];
516
+ return skill ? `${name} (base dir: ${path.dirname(skill.location)})` : name;
517
+ });
518
+ return [
519
+ "## Skills Previously Loaded",
520
+ ...withDirs.map((entry) => `- ${entry}`),
521
+ "Their full instructions were dropped during compaction. Re-call the skill tool if you still need them.",
522
+ ].join("\n");
523
+ }
524
+ /**
525
+ * Single entry point for wiring skills into an agent: registers the `skill`
526
+ * tool and returns the matching system-prompt section. Keeping both sides here
527
+ * prevents the tool from being registered without `available_skills` in the prompt.
528
+ */
529
+ export function attachSkills(tools, tracker) {
530
+ const skills = getSkills();
531
+ if (skills.length === 0)
532
+ return "";
533
+ tools["skill"] = getSkillsTool(tracker);
534
+ return buildSkillsPrompt(skills);
535
+ }
@@ -0,0 +1,7 @@
1
+ const synthetic = new WeakSet();
2
+ export function markSyntheticMessage(msg) {
3
+ synthetic.add(msg);
4
+ }
5
+ export function isSyntheticMessage(msg) {
6
+ return synthetic.has(msg);
7
+ }
package/dist/title-gen.js CHANGED
@@ -16,7 +16,7 @@ export async function generateTitle(model, messages) {
16
16
  const userContent = typeof userMsg.content === "string"
17
17
  ? userMsg.content
18
18
  : Array.isArray(userMsg.content)
19
- ? userMsg.content.filter((p) => "text" in p).map((p) => p.text).join(" ")
19
+ ? userMsg.content.filter((p) => "text" in p).map((p) => p.text ?? "").join(" ")
20
20
  : "";
21
21
  if (!userContent.trim())
22
22
  return null;
@@ -38,6 +38,7 @@ export async function generateTitle(model, messages) {
38
38
  { role: "user", content: context },
39
39
  ],
40
40
  temperature: 0.5,
41
+ maxOutputTokens: 30,
41
42
  });
42
43
  const title = result.text.trim().replace(/^["']|["']$/g, "").slice(0, 60);
43
44
  return title || null;