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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- 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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
439
|
+
}
|
|
440
|
+
function escapeXmlAttr(value) {
|
|
441
|
+
return escapeXml(value).replace(/"/g, """);
|
|
442
|
+
}
|
|
443
|
+
/** Reverse of `escapeXmlAttr` — used to recover the raw skill name from a marker. */
|
|
444
|
+
function unescapeXmlAttr(value) {
|
|
445
|
+
return value
|
|
446
|
+
.replace(/"/g, '"')
|
|
447
|
+
.replace(/</g, "<")
|
|
448
|
+
.replace(/>/g, ">")
|
|
449
|
+
.replace(/&/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
|
|
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
|
+
}
|
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;
|