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