aether-code 0.32.0 → 0.32.1
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/LICENSE +21 -21
- package/README.md +222 -220
- package/bin/aether-code.js +516 -516
- package/package.json +77 -75
- package/scripts/postinstall.js +182 -0
- package/src/agent.js +332 -332
- package/src/api.js +323 -323
- package/src/box-input.js +132 -132
- package/src/config.js +38 -38
- package/src/diff.js +49 -49
- package/src/ink-input.js +91 -91
- package/src/menu.js +83 -83
- package/src/render.js +221 -221
- package/src/repl.js +291 -291
- package/src/setup.js +139 -139
- package/src/skills.js +198 -198
- package/src/tools.js +911 -911
- package/src/update-check.js +60 -60
package/src/skills.js
CHANGED
|
@@ -1,198 +1,198 @@
|
|
|
1
|
-
// Skills system: markdown files with YAML frontmatter that get loaded
|
|
2
|
-
// into the agent's system prompt on demand, based on what the user is
|
|
3
|
-
// asking about. Same idea Claude Code uses for its "superpowers" plugin —
|
|
4
|
-
// task-specific discipline injected just when it's relevant, instead of
|
|
5
|
-
// bloating every prompt with debugging-rules + TDD-rules + RE-rules + ...
|
|
6
|
-
//
|
|
7
|
-
// Skill file layout:
|
|
8
|
-
//
|
|
9
|
-
// ---
|
|
10
|
-
// name: re-analysis
|
|
11
|
-
// description: Use when reverse-engineering binaries, deobfuscating code,
|
|
12
|
-
// or analyzing protected executables
|
|
13
|
-
// triggers:
|
|
14
|
-
// pathPatterns: ["*.dll", "*.so", "*.exe", "*.bin", "*.elf"]
|
|
15
|
-
// promptKeywords: ["reverse engineer", "decompile", "deobfuscate"]
|
|
16
|
-
// ---
|
|
17
|
-
// # Skill body — markdown
|
|
18
|
-
// ...full instructions appended to the agent's system prompt...
|
|
19
|
-
//
|
|
20
|
-
// Skills live in:
|
|
21
|
-
// 1. ~/.aether/skills/*.md (user-installed)
|
|
22
|
-
// 2. <bundled>/skills/*.md (first-party, ships with aether-code)
|
|
23
|
-
//
|
|
24
|
-
// First-party skills cover the verticals Aether's audience cares about:
|
|
25
|
-
// debugging, RE, NSFW creative, security research, game modding.
|
|
26
|
-
|
|
27
|
-
import fs from "node:fs";
|
|
28
|
-
import os from "node:os";
|
|
29
|
-
import path from "node:path";
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
31
|
-
|
|
32
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
-
const BUNDLED_SKILLS_DIR = path.join(HERE, "..", "skills");
|
|
34
|
-
const USER_SKILLS_DIR = path.join(os.homedir(), ".aether", "skills");
|
|
35
|
-
|
|
36
|
-
// Parse a markdown file with YAML-like frontmatter. Not a full YAML parser
|
|
37
|
-
// (would be a dep) — handles the small subset we use: name, description,
|
|
38
|
-
// triggers.pathPatterns, triggers.promptKeywords. Throws on malformed input
|
|
39
|
-
// so a bad skill is caught at load time, not at trigger time.
|
|
40
|
-
export function parseSkill(raw, sourcePath = "<inline>") {
|
|
41
|
-
if (typeof raw !== "string" || raw.length === 0) {
|
|
42
|
-
throw new Error(`Skill ${sourcePath}: empty content`);
|
|
43
|
-
}
|
|
44
|
-
// Tolerate CRLF (Windows-authored skills / editors) — normalize to LF so the
|
|
45
|
-
// frontmatter delimiter and downstream line parsing don't choke on '\r'.
|
|
46
|
-
raw = raw.replace(/\r\n/g, "\n");
|
|
47
|
-
if (!raw.startsWith("---")) {
|
|
48
|
-
throw new Error(`Skill ${sourcePath}: missing YAML frontmatter (must start with '---')`);
|
|
49
|
-
}
|
|
50
|
-
const endMatch = raw.match(/\n---\n/);
|
|
51
|
-
if (!endMatch) {
|
|
52
|
-
throw new Error(`Skill ${sourcePath}: unterminated frontmatter (need closing '---' on its own line)`);
|
|
53
|
-
}
|
|
54
|
-
const frontmatter = raw.slice(3, endMatch.index).trim();
|
|
55
|
-
const body = raw.slice(endMatch.index + endMatch[0].length).trim();
|
|
56
|
-
|
|
57
|
-
const skill = {
|
|
58
|
-
sourcePath,
|
|
59
|
-
name: null,
|
|
60
|
-
description: "",
|
|
61
|
-
triggers: { pathPatterns: [], promptKeywords: [] },
|
|
62
|
-
body,
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// Minimal YAML: top-level `key: value` lines, plus a nested `triggers:`
|
|
66
|
-
// section with `pathPatterns:` and `promptKeywords:` arrays.
|
|
67
|
-
let section = null; // "triggers" when inside that block
|
|
68
|
-
for (const line of frontmatter.split("\n")) {
|
|
69
|
-
const trimmed = line.trim();
|
|
70
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
71
|
-
// Two-space indent → inside the current section.
|
|
72
|
-
const indented = /^\s{2,}\S/.test(line);
|
|
73
|
-
if (!indented) {
|
|
74
|
-
// top-level
|
|
75
|
-
section = null;
|
|
76
|
-
const [k, ...rest] = trimmed.split(":");
|
|
77
|
-
const key = k.trim();
|
|
78
|
-
const val = rest.join(":").trim();
|
|
79
|
-
if (key === "name") skill.name = stripQuotes(val);
|
|
80
|
-
else if (key === "description") skill.description = stripQuotes(val);
|
|
81
|
-
else if (key === "triggers") section = "triggers";
|
|
82
|
-
} else if (section === "triggers") {
|
|
83
|
-
const [k, ...rest] = trimmed.split(":");
|
|
84
|
-
const key = k.trim();
|
|
85
|
-
const val = rest.join(":").trim();
|
|
86
|
-
if (key === "pathPatterns" || key === "promptKeywords") {
|
|
87
|
-
skill.triggers[key] = parseInlineArray(val, sourcePath, key);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!skill.name) {
|
|
93
|
-
throw new Error(`Skill ${sourcePath}: missing required field "name"`);
|
|
94
|
-
}
|
|
95
|
-
if (!/^[a-z0-9_-]{1,60}$/i.test(skill.name)) {
|
|
96
|
-
throw new Error(`Skill ${sourcePath}: name "${skill.name}" must be 1-60 chars of [A-Za-z0-9_-]`);
|
|
97
|
-
}
|
|
98
|
-
if (skill.body.length === 0) {
|
|
99
|
-
throw new Error(`Skill ${sourcePath}: empty body — frontmatter must be followed by markdown content`);
|
|
100
|
-
}
|
|
101
|
-
return skill;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function stripQuotes(s) {
|
|
105
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
106
|
-
return s.slice(1, -1);
|
|
107
|
-
}
|
|
108
|
-
return s;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function parseInlineArray(val, source, key) {
|
|
112
|
-
// Accept `["a", "b"]` (inline JSON-ish array) since that's the common
|
|
113
|
-
// pattern in skill files. Anything else is a hard error so we don't
|
|
114
|
-
// silently miss a misformatted trigger list.
|
|
115
|
-
const m = val.match(/^\[(.*)\]$/);
|
|
116
|
-
if (!m) {
|
|
117
|
-
throw new Error(`Skill ${source}: triggers.${key} must be an inline array like ["a", "b"]`);
|
|
118
|
-
}
|
|
119
|
-
return m[1]
|
|
120
|
-
.split(",")
|
|
121
|
-
.map((s) => s.trim())
|
|
122
|
-
.filter(Boolean)
|
|
123
|
-
.map(stripQuotes);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Walk a directory of skill files and return parsed skills. Missing dir
|
|
128
|
-
* returns []. Malformed files throw so the user sees the error early.
|
|
129
|
-
*/
|
|
130
|
-
export function loadSkillsFromDir(dir) {
|
|
131
|
-
if (!fs.existsSync(dir)) return [];
|
|
132
|
-
const skills = [];
|
|
133
|
-
for (const name of fs.readdirSync(dir)) {
|
|
134
|
-
if (!name.endsWith(".md")) continue;
|
|
135
|
-
const filePath = path.join(dir, name);
|
|
136
|
-
const raw = fs.readFileSync(filePath, "utf8");
|
|
137
|
-
// Cap skill size: the body is injected into the prompt every turn it
|
|
138
|
-
// triggers, so a huge file would blow the context window / inflate cost.
|
|
139
|
-
if (raw.length > 50_000) {
|
|
140
|
-
process.stderr.write(` (skipping oversized skill ${name}: ${raw.length} bytes > 50KB)\n`);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
skills.push(parseSkill(raw, filePath));
|
|
144
|
-
}
|
|
145
|
-
return skills;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function loadAllSkills() {
|
|
149
|
-
return [...loadSkillsFromDir(BUNDLED_SKILLS_DIR), ...loadSkillsFromDir(USER_SKILLS_DIR)];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Glob-to-regex converter for path patterns (supports `*` and `?`).
|
|
154
|
-
* Anchored: matches the full string, not a substring.
|
|
155
|
-
*/
|
|
156
|
-
export function globToRegex(glob) {
|
|
157
|
-
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
158
|
-
const re = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
159
|
-
return new RegExp("^" + re + "$", "i");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Decide which skills' bodies should be appended to the system prompt for
|
|
164
|
-
* a given turn. A skill matches if ANY of its triggers fire:
|
|
165
|
-
* - a promptKeyword appears in the user's prompt (case-insensitive)
|
|
166
|
-
* - a pathPattern matches any file path in `referencedPaths`
|
|
167
|
-
* Returns the matching skills in insertion order (bundled before user).
|
|
168
|
-
*/
|
|
169
|
-
export function selectSkills({ skills, prompt = "", referencedPaths = [] }) {
|
|
170
|
-
const lowerPrompt = (prompt || "").toLowerCase();
|
|
171
|
-
const out = [];
|
|
172
|
-
for (const s of skills) {
|
|
173
|
-
const kwHit = s.triggers.promptKeywords.some((kw) =>
|
|
174
|
-
lowerPrompt.includes(kw.toLowerCase()),
|
|
175
|
-
);
|
|
176
|
-
const pathHit =
|
|
177
|
-
!kwHit &&
|
|
178
|
-
s.triggers.pathPatterns.some((g) => {
|
|
179
|
-
const re = globToRegex(g);
|
|
180
|
-
return referencedPaths.some((p) => re.test(path.basename(p)));
|
|
181
|
-
});
|
|
182
|
-
if (kwHit || pathHit) out.push(s);
|
|
183
|
-
}
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Build the text block to append to the system prompt when one or more
|
|
189
|
-
* skills are active for this turn. Empty string when nothing matched.
|
|
190
|
-
*/
|
|
191
|
-
export function renderSkillsBlock(activeSkills) {
|
|
192
|
-
if (activeSkills.length === 0) return "";
|
|
193
|
-
const sections = activeSkills.map((s) => `### Skill: ${s.name}\n${s.body}`);
|
|
194
|
-
return (
|
|
195
|
-
"\n\n=== LOADED SKILLS (apply when relevant to this turn) ===\n\n" +
|
|
196
|
-
sections.join("\n\n---\n\n")
|
|
197
|
-
);
|
|
198
|
-
}
|
|
1
|
+
// Skills system: markdown files with YAML frontmatter that get loaded
|
|
2
|
+
// into the agent's system prompt on demand, based on what the user is
|
|
3
|
+
// asking about. Same idea Claude Code uses for its "superpowers" plugin —
|
|
4
|
+
// task-specific discipline injected just when it's relevant, instead of
|
|
5
|
+
// bloating every prompt with debugging-rules + TDD-rules + RE-rules + ...
|
|
6
|
+
//
|
|
7
|
+
// Skill file layout:
|
|
8
|
+
//
|
|
9
|
+
// ---
|
|
10
|
+
// name: re-analysis
|
|
11
|
+
// description: Use when reverse-engineering binaries, deobfuscating code,
|
|
12
|
+
// or analyzing protected executables
|
|
13
|
+
// triggers:
|
|
14
|
+
// pathPatterns: ["*.dll", "*.so", "*.exe", "*.bin", "*.elf"]
|
|
15
|
+
// promptKeywords: ["reverse engineer", "decompile", "deobfuscate"]
|
|
16
|
+
// ---
|
|
17
|
+
// # Skill body — markdown
|
|
18
|
+
// ...full instructions appended to the agent's system prompt...
|
|
19
|
+
//
|
|
20
|
+
// Skills live in:
|
|
21
|
+
// 1. ~/.aether/skills/*.md (user-installed)
|
|
22
|
+
// 2. <bundled>/skills/*.md (first-party, ships with aether-code)
|
|
23
|
+
//
|
|
24
|
+
// First-party skills cover the verticals Aether's audience cares about:
|
|
25
|
+
// debugging, RE, NSFW creative, security research, game modding.
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const BUNDLED_SKILLS_DIR = path.join(HERE, "..", "skills");
|
|
34
|
+
const USER_SKILLS_DIR = path.join(os.homedir(), ".aether", "skills");
|
|
35
|
+
|
|
36
|
+
// Parse a markdown file with YAML-like frontmatter. Not a full YAML parser
|
|
37
|
+
// (would be a dep) — handles the small subset we use: name, description,
|
|
38
|
+
// triggers.pathPatterns, triggers.promptKeywords. Throws on malformed input
|
|
39
|
+
// so a bad skill is caught at load time, not at trigger time.
|
|
40
|
+
export function parseSkill(raw, sourcePath = "<inline>") {
|
|
41
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
42
|
+
throw new Error(`Skill ${sourcePath}: empty content`);
|
|
43
|
+
}
|
|
44
|
+
// Tolerate CRLF (Windows-authored skills / editors) — normalize to LF so the
|
|
45
|
+
// frontmatter delimiter and downstream line parsing don't choke on '\r'.
|
|
46
|
+
raw = raw.replace(/\r\n/g, "\n");
|
|
47
|
+
if (!raw.startsWith("---")) {
|
|
48
|
+
throw new Error(`Skill ${sourcePath}: missing YAML frontmatter (must start with '---')`);
|
|
49
|
+
}
|
|
50
|
+
const endMatch = raw.match(/\n---\n/);
|
|
51
|
+
if (!endMatch) {
|
|
52
|
+
throw new Error(`Skill ${sourcePath}: unterminated frontmatter (need closing '---' on its own line)`);
|
|
53
|
+
}
|
|
54
|
+
const frontmatter = raw.slice(3, endMatch.index).trim();
|
|
55
|
+
const body = raw.slice(endMatch.index + endMatch[0].length).trim();
|
|
56
|
+
|
|
57
|
+
const skill = {
|
|
58
|
+
sourcePath,
|
|
59
|
+
name: null,
|
|
60
|
+
description: "",
|
|
61
|
+
triggers: { pathPatterns: [], promptKeywords: [] },
|
|
62
|
+
body,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Minimal YAML: top-level `key: value` lines, plus a nested `triggers:`
|
|
66
|
+
// section with `pathPatterns:` and `promptKeywords:` arrays.
|
|
67
|
+
let section = null; // "triggers" when inside that block
|
|
68
|
+
for (const line of frontmatter.split("\n")) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
71
|
+
// Two-space indent → inside the current section.
|
|
72
|
+
const indented = /^\s{2,}\S/.test(line);
|
|
73
|
+
if (!indented) {
|
|
74
|
+
// top-level
|
|
75
|
+
section = null;
|
|
76
|
+
const [k, ...rest] = trimmed.split(":");
|
|
77
|
+
const key = k.trim();
|
|
78
|
+
const val = rest.join(":").trim();
|
|
79
|
+
if (key === "name") skill.name = stripQuotes(val);
|
|
80
|
+
else if (key === "description") skill.description = stripQuotes(val);
|
|
81
|
+
else if (key === "triggers") section = "triggers";
|
|
82
|
+
} else if (section === "triggers") {
|
|
83
|
+
const [k, ...rest] = trimmed.split(":");
|
|
84
|
+
const key = k.trim();
|
|
85
|
+
const val = rest.join(":").trim();
|
|
86
|
+
if (key === "pathPatterns" || key === "promptKeywords") {
|
|
87
|
+
skill.triggers[key] = parseInlineArray(val, sourcePath, key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!skill.name) {
|
|
93
|
+
throw new Error(`Skill ${sourcePath}: missing required field "name"`);
|
|
94
|
+
}
|
|
95
|
+
if (!/^[a-z0-9_-]{1,60}$/i.test(skill.name)) {
|
|
96
|
+
throw new Error(`Skill ${sourcePath}: name "${skill.name}" must be 1-60 chars of [A-Za-z0-9_-]`);
|
|
97
|
+
}
|
|
98
|
+
if (skill.body.length === 0) {
|
|
99
|
+
throw new Error(`Skill ${sourcePath}: empty body — frontmatter must be followed by markdown content`);
|
|
100
|
+
}
|
|
101
|
+
return skill;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stripQuotes(s) {
|
|
105
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
106
|
+
return s.slice(1, -1);
|
|
107
|
+
}
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseInlineArray(val, source, key) {
|
|
112
|
+
// Accept `["a", "b"]` (inline JSON-ish array) since that's the common
|
|
113
|
+
// pattern in skill files. Anything else is a hard error so we don't
|
|
114
|
+
// silently miss a misformatted trigger list.
|
|
115
|
+
const m = val.match(/^\[(.*)\]$/);
|
|
116
|
+
if (!m) {
|
|
117
|
+
throw new Error(`Skill ${source}: triggers.${key} must be an inline array like ["a", "b"]`);
|
|
118
|
+
}
|
|
119
|
+
return m[1]
|
|
120
|
+
.split(",")
|
|
121
|
+
.map((s) => s.trim())
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.map(stripQuotes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk a directory of skill files and return parsed skills. Missing dir
|
|
128
|
+
* returns []. Malformed files throw so the user sees the error early.
|
|
129
|
+
*/
|
|
130
|
+
export function loadSkillsFromDir(dir) {
|
|
131
|
+
if (!fs.existsSync(dir)) return [];
|
|
132
|
+
const skills = [];
|
|
133
|
+
for (const name of fs.readdirSync(dir)) {
|
|
134
|
+
if (!name.endsWith(".md")) continue;
|
|
135
|
+
const filePath = path.join(dir, name);
|
|
136
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
137
|
+
// Cap skill size: the body is injected into the prompt every turn it
|
|
138
|
+
// triggers, so a huge file would blow the context window / inflate cost.
|
|
139
|
+
if (raw.length > 50_000) {
|
|
140
|
+
process.stderr.write(` (skipping oversized skill ${name}: ${raw.length} bytes > 50KB)\n`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
skills.push(parseSkill(raw, filePath));
|
|
144
|
+
}
|
|
145
|
+
return skills;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function loadAllSkills() {
|
|
149
|
+
return [...loadSkillsFromDir(BUNDLED_SKILLS_DIR), ...loadSkillsFromDir(USER_SKILLS_DIR)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Glob-to-regex converter for path patterns (supports `*` and `?`).
|
|
154
|
+
* Anchored: matches the full string, not a substring.
|
|
155
|
+
*/
|
|
156
|
+
export function globToRegex(glob) {
|
|
157
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
158
|
+
const re = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
159
|
+
return new RegExp("^" + re + "$", "i");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide which skills' bodies should be appended to the system prompt for
|
|
164
|
+
* a given turn. A skill matches if ANY of its triggers fire:
|
|
165
|
+
* - a promptKeyword appears in the user's prompt (case-insensitive)
|
|
166
|
+
* - a pathPattern matches any file path in `referencedPaths`
|
|
167
|
+
* Returns the matching skills in insertion order (bundled before user).
|
|
168
|
+
*/
|
|
169
|
+
export function selectSkills({ skills, prompt = "", referencedPaths = [] }) {
|
|
170
|
+
const lowerPrompt = (prompt || "").toLowerCase();
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const s of skills) {
|
|
173
|
+
const kwHit = s.triggers.promptKeywords.some((kw) =>
|
|
174
|
+
lowerPrompt.includes(kw.toLowerCase()),
|
|
175
|
+
);
|
|
176
|
+
const pathHit =
|
|
177
|
+
!kwHit &&
|
|
178
|
+
s.triggers.pathPatterns.some((g) => {
|
|
179
|
+
const re = globToRegex(g);
|
|
180
|
+
return referencedPaths.some((p) => re.test(path.basename(p)));
|
|
181
|
+
});
|
|
182
|
+
if (kwHit || pathHit) out.push(s);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build the text block to append to the system prompt when one or more
|
|
189
|
+
* skills are active for this turn. Empty string when nothing matched.
|
|
190
|
+
*/
|
|
191
|
+
export function renderSkillsBlock(activeSkills) {
|
|
192
|
+
if (activeSkills.length === 0) return "";
|
|
193
|
+
const sections = activeSkills.map((s) => `### Skill: ${s.name}\n${s.body}`);
|
|
194
|
+
return (
|
|
195
|
+
"\n\n=== LOADED SKILLS (apply when relevant to this turn) ===\n\n" +
|
|
196
|
+
sections.join("\n\n---\n\n")
|
|
197
|
+
);
|
|
198
|
+
}
|