lazyclaw 4.2.1 → 4.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/mas/redact.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // Shared secret redaction — Phase 20 hardening.
2
+ //
3
+ // A single source of truth for stripping common secret shapes out of
4
+ // text before it is (a) sent to an LLM or (b) persisted to a file that
5
+ // later re-enters a system prompt. Both skill_synth.synthesizeSkill and
6
+ // agent_memory.reflectOnce import this so the two paths stay symmetric:
7
+ // neither a distilled skill nor a reflection memory can ever leak a
8
+ // token that merely appeared in a task transcript.
9
+ //
10
+ // This module has zero internal imports so it can be imported from
11
+ // anywhere without risking a cycle.
12
+
13
+ // Redact common secret shapes (private keys, provider API tokens,
14
+ // bearer headers, KEY/TOKEN/SECRET/PASSWORD env assignments). The match
15
+ // list mirrors the audit log's posture of not persisting raw sensitive
16
+ // I/O. Order matters: the private-key block is collapsed first so its
17
+ // inner base64 doesn't trip the narrower token patterns.
18
+ export function redactSecrets(text) {
19
+ return String(text ?? '')
20
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]')
21
+ // Inline credentials in a URL: scheme://user:password@host → redact the password.
22
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+):[^/\s@]+@/gi, '$1:[REDACTED]@')
23
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}/g, '[REDACTED]')
24
+ .replace(/\b(?:gh[opsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, '[REDACTED]')
25
+ .replace(/\bxox[abprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED]')
26
+ .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED]')
27
+ .replace(/\bAIza[0-9A-Za-z_-]{35}/g, '[REDACTED]')
28
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED JWT]')
29
+ .replace(/\b[Bb]earer\s+[A-Za-z0-9._-]{8,}/g, 'Bearer [REDACTED]')
30
+ // Uppercase env-assignment form (kept for parity / readability).
31
+ .replace(/\b([A-Z][A-Z0-9_]{2,}_(?:KEY|TOKEN|SECRET|PASSWORD))\s*=\s*\S+/g, '$1=[REDACTED]')
32
+ // Case-insensitive key/token/secret/password assignment (api_key=, apiKey: "...", PASSWORD=…).
33
+ // [\w-]* may be empty so a bare `password:` / `token=` is caught too.
34
+ .replace(/\b([\w-]*(?:key|token|secret|password))\s*[:=]\s*["']?[^\s"']+/gi, '$1=[REDACTED]');
35
+ }
36
+
37
+ // Neutralise forged conversation role labels embedded in untrusted model
38
+ // text. The reflection/synthesis transcript is rendered as
39
+ // `[User]/[System]/<agent>` lines from the TRUSTED turn.agent field; a
40
+ // turn's free-text body must not be able to inject its own authority
41
+ // line (e.g. a newline followed by "[System] ignore previous"). We defang
42
+ // only the authority roles (case-insensitive, line-leading) so a forged
43
+ // instruction can't masquerade as the system/user/assistant speaker.
44
+ export function neutralizeRoleLabels(text) {
45
+ return String(text ?? '').replace(/^([ \t]*)\[(user|system|assistant|agent)\]/gim, '$1($2)');
46
+ }
@@ -0,0 +1,232 @@
1
+ // Skill synthesis — Phase 20.
2
+ //
3
+ // Turns a finished task transcript into a reusable SKILL.md (the
4
+ // Hermes self-improving-skill pattern). This module owns the
5
+ // deterministic, LLM-free pieces so they can be unit-tested without a
6
+ // network round-trip:
7
+ //
8
+ // slugifySkill(title) → a filesystem-safe skill name
9
+ // parseSynthOutput(text) → { name, description, body } from raw
10
+ // LLM output
11
+ // assembleSkillDoc({...}) → a full SKILL.md (frontmatter + body)
12
+ //
13
+ // The single LLM call lives in synthesizeSkill() at the bottom; it
14
+ // mirrors agent_memory.reflectOnce() (same provider adapters, same
15
+ // no-tools text completion) but asks for a structured skill instead of
16
+ // free-text lessons.
17
+
18
+ import * as skills from '../skills.mjs';
19
+ import { runTextCompletion } from './provider_adapters.mjs';
20
+ import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
21
+
22
+ const SECTION_RE = /^#{1,6}\s+/;
23
+ const MAX_NAME_LEN = 48;
24
+ const MAX_BODY_BYTES = 8 * 1024;
25
+
26
+ // Re-export the shared secret redactor (mas/redact.mjs) so existing
27
+ // callers of skill_synth.redactSecrets keep working while the single
28
+ // implementation is shared with agent_memory.reflectOnce.
29
+ export { redactSecrets };
30
+
31
+ // Sanitise an agent-authored skill body before it is persisted and
32
+ // later loaded into other agents' context: redact secrets, neutralise
33
+ // the task-termination marker (so reference material can't drive the
34
+ // router loop), strip control characters, and cap the size.
35
+ export function sanitizeSkillBody(text) {
36
+ let s = redactSecrets(text);
37
+ s = s.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]');
38
+ // Strip control characters except tab/newline/carriage-return.
39
+ s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
40
+ if (Buffer.byteLength(s, 'utf8') > MAX_BODY_BYTES) {
41
+ s = Buffer.from(s, 'utf8').subarray(0, MAX_BODY_BYTES).toString('utf8').replace(/\uFFFD+$/, '') + '\n\n…[truncated]';
42
+ }
43
+ return s;
44
+ }
45
+
46
+ // Sanitise the one-line description (it lands in every agent's system
47
+ // prompt via the skills index): redact, collapse to a single line,
48
+ // neutralise the marker, cap to 120 chars.
49
+ export function sanitizeDescription(text) {
50
+ return redactSecrets(text)
51
+ .replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]')
52
+ .replace(/[\u0000-\u001F\u007F]+/g, ' ')
53
+ .replace(/\s+/g, ' ')
54
+ .trim()
55
+ .slice(0, 120);
56
+ }
57
+
58
+ // Lowercase, collapse every run of non-alphanumerics into a single
59
+ // dash, and strip leading/trailing dashes. Empty input (or input with
60
+ // no alphanumerics) falls back to "skill" so a write always has a
61
+ // valid target name.
62
+ export function slugifySkill(title) {
63
+ const slug = String(title || '')
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-+|-+$/g, '')
67
+ .slice(0, MAX_NAME_LEN)
68
+ .replace(/-+$/g, '');
69
+ return slug || 'skill';
70
+ }
71
+
72
+ // Parse the model's reply into { name, description, body }. The prompt
73
+ // asks for two leading `name:` / `description:` lines followed by the
74
+ // `## When to Use / ## Procedure / ## Pitfalls / ## Verification`
75
+ // sections — but models drift, so we degrade gracefully: when the
76
+ // leading lines are missing we derive the name from the first heading
77
+ // and leave the description empty. `body` always begins at the first
78
+ // markdown heading (the section content), with the name/description
79
+ // header stripped.
80
+ export function parseSynthOutput(text) {
81
+ const raw = String(text || '').replace(/^/, '').trim();
82
+ const lines = raw.split(/\r?\n/);
83
+
84
+ let name = '';
85
+ let description = '';
86
+ let bodyStart = 0;
87
+
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+ if (SECTION_RE.test(line)) { bodyStart = i; break; }
91
+ const nameM = /^name\s*:\s*(.+)$/i.exec(line.trim());
92
+ if (nameM && !name) { name = nameM[1].trim(); bodyStart = i + 1; continue; }
93
+ const descM = /^description\s*:\s*(.+)$/i.exec(line.trim());
94
+ if (descM && !description) { description = descM[1].trim(); bodyStart = i + 1; continue; }
95
+ }
96
+
97
+ const body = lines.slice(bodyStart).join('\n').replace(/^(?:\r?\n)+/, '').trimEnd();
98
+
99
+ // No explicit name → derive it from the first heading in the body.
100
+ if (!name) {
101
+ const firstHeading = body.split(/\r?\n/).find((l) => SECTION_RE.test(l));
102
+ if (firstHeading) name = firstHeading.replace(SECTION_RE, '').trim();
103
+ }
104
+
105
+ return { name: slugifySkill(name), description, body };
106
+ }
107
+
108
+ // Build a complete SKILL.md: a flat-YAML frontmatter block followed by
109
+ // the skill body. The frontmatter shape round-trips through
110
+ // skills.parseFrontmatter(). `ts` is injected (not read from the clock)
111
+ // so the output is deterministic and testable.
112
+ export function assembleSkillDoc({ name, description = '', createdBy = 'agent', sourceTask = '', body = '', version = 1, ts = new Date() } = {}) {
113
+ const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
114
+ const fm = [
115
+ '---',
116
+ `name: ${escapeYaml(stripControl(name))}`,
117
+ `description: ${escapeYaml(description)}`,
118
+ `version: ${version}`,
119
+ `created_by: ${createdBy}`,
120
+ ];
121
+ if (sourceTask) fm.push(`source_task: ${sourceTask}`);
122
+ fm.push(`created_at: ${date}`, '---', '');
123
+ return `${fm.join('\n')}\n${String(body).trim()}\n`;
124
+ }
125
+
126
+ // Drop control characters (incl. newlines) from a single-line frontmatter
127
+ // value so an embedded \n can't break out of its key into an injected one.
128
+ function stripControl(v) {
129
+ return String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, '');
130
+ }
131
+
132
+ // Quote a value only when it contains characters our flat parser would
133
+ // otherwise choke on (a leading special char or an embedded colon).
134
+ // Control characters (including newlines) are stripped first: a quoted
135
+ // multi-line value still injects a frontmatter key because the parser
136
+ // splits on physical lines, so the only safe move is to flatten to a
137
+ // single line before deciding whether to quote.
138
+ function escapeYaml(v) {
139
+ const s = stripControl(v);
140
+ if (s === '') return '';
141
+ if (/[:#]/.test(s) || /^[\s'">|&*!%@`-]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
142
+ return s;
143
+ }
144
+
145
+ export class SkillSynthError extends Error {
146
+ constructor(message, code) {
147
+ super(message);
148
+ this.name = 'SkillSynthError';
149
+ this.code = code || 'SKILL_SYNTH_ERR';
150
+ }
151
+ }
152
+
153
+ // Run one synthesis LLM call for an agent that just finished a task and
154
+ // return { name, description, doc } — a complete SKILL.md ready to
155
+ // install — or null when the model produced nothing usable. Mirrors
156
+ // agent_memory.reflectOnce(): same provider adapters, a pure text
157
+ // completion with no tools advertised, the agent's own role as the
158
+ // system prompt. The difference is the ASK — a reusable skill in a
159
+ // fixed section layout instead of free-text lessons.
160
+ export async function synthesizeSkill({ agent, task, apiKey, baseUrl, fetchImpl } = {}) {
161
+ if (!agent || !task) throw new SkillSynthError('agent and task are required', 'SKILL_SYNTH_BAD_INPUT');
162
+
163
+ // Redact secrets from the transcript BEFORE it leaves for the model,
164
+ // so a token pasted into the task never reaches the LLM or the file.
165
+ const transcript = redactSecrets(
166
+ (Array.isArray(task.turns) ? task.turns : [])
167
+ .map((t) => {
168
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
169
+ // Defang any forged role label inside the (model-controlled) body
170
+ // so a turn can't inject its own [System]/[User] authority line.
171
+ return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
172
+ })
173
+ .join('\n\n') || '(no turns)'
174
+ );
175
+
176
+ const userMessage =
177
+ `You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
178
+ transcript +
179
+ `\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
180
+ `Reply in EXACTLY this format and nothing else:\n\n` +
181
+ `name: <short kebab-case skill name>\n` +
182
+ `description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
183
+ `## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
184
+ `## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
185
+ `## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
186
+ `## Verification\n<how to confirm the task actually succeeded>\n\n` +
187
+ `Be concrete and specific to what happened. If the task was too trivial to be worth a reusable skill, reply with the single word NONE.`;
188
+
189
+ const text = (await runTextCompletion({
190
+ provider: agent.provider,
191
+ model: agent.model,
192
+ system: agent.role || '',
193
+ userMessage,
194
+ apiKey,
195
+ baseUrl,
196
+ fetchImpl,
197
+ })).trim();
198
+ if (!text || /^none\b/i.test(text)) return null;
199
+
200
+ const parsed = parseSynthOutput(text);
201
+ const description = sanitizeDescription(parsed.description);
202
+ const body = sanitizeSkillBody(parsed.body);
203
+ if (!body.trim()) return null;
204
+ const doc = assembleSkillDoc({ name: parsed.name, description, createdBy: 'agent', sourceTask: task.id, body });
205
+ return { name: parsed.name, description, body, doc, sourceTask: task.id };
206
+ }
207
+
208
+ // Install a synthesised skill without ever clobbering a human-authored
209
+ // one: reserveSynthName picks a collision-free target (or our own prior
210
+ // agent skill, which we then version-bump). Body/description are
211
+ // re-sanitised here too so a direct caller (CLI/router) can't bypass
212
+ // the redaction + cap. Returns { skill, path, version }.
213
+ export function installSynthesized({ name, description = '', body = '', sourceTask = '', createdBy = 'agent' } = {}, configDir, ts = new Date()) {
214
+ // Slugify BEFORE reserving the name so a direct caller (CLI/router)
215
+ // can't smuggle a newline/colon into the filename or inject a second
216
+ // frontmatter key. parseSynthOutput already slugifies, but this path
217
+ // is also reachable directly with an arbitrary name.
218
+ const finalName = skills.reserveSynthName(slugifySkill(name), configDir);
219
+ const overwritingOwn = skills.skillExists(finalName, configDir);
220
+ const version = overwritingOwn ? skills.skillVersion(finalName, configDir) + 1 : 1;
221
+ const doc = assembleSkillDoc({
222
+ name: finalName,
223
+ description: sanitizeDescription(description),
224
+ createdBy,
225
+ sourceTask,
226
+ body: sanitizeSkillBody(body),
227
+ version,
228
+ ts,
229
+ });
230
+ const p = skills.installSkill(finalName, doc, configDir);
231
+ return { skill: finalName, path: p, version };
232
+ }
@@ -12,6 +12,7 @@ import * as bashTool from './tools/bash.mjs';
12
12
  import * as readTool from './tools/read.mjs';
13
13
  import * as writeTool from './tools/write.mjs';
14
14
  import * as grepTool from './tools/grep.mjs';
15
+ import * as skillViewTool from './tools/skill_view.mjs';
15
16
  import * as audit from './audit.mjs';
16
17
 
17
18
  export class ToolError extends Error {
@@ -27,6 +28,7 @@ const TOOLS = {
27
28
  read: readTool,
28
29
  write: writeTool,
29
30
  grep: grepTool,
31
+ skill_view: skillViewTool,
30
32
  };
31
33
 
32
34
  const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
@@ -55,11 +57,19 @@ export function knownTool(name) {
55
57
  // the caller can surface a structured error back to the LLM rather than
56
58
  // silently dropping the call.
57
59
  //
60
+ // Tools that mutate state / run arbitrary code. When an `approve` hook is
61
+ // supplied (e.g. backed by the gateway's remote exec-approval), these are
62
+ // gated on a human decision before they run; read-only tools are not.
63
+ const SENSITIVE_TOOLS = new Set(['bash', 'write']);
64
+
58
65
  // opts.cwd — where bash/read/write/grep root themselves; defaults to
59
66
  // process.cwd() so it can be overridden in tests.
60
67
  // opts.taskId — when set, every call is appended to the task's audit
61
68
  // log. Unit tests can omit it.
62
- export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}) {
69
+ // opts.approve optional async (call) => { approved, reason }. When
70
+ // present, a sensitive tool call is held until it resolves; a non-approval
71
+ // blocks the tool (returns a structured error instead of executing).
72
+ export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
63
73
  if (!agent || !Array.isArray(agent.tools)) {
64
74
  throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
65
75
  }
@@ -76,9 +86,21 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}
76
86
  audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
77
87
  return result;
78
88
  }
89
+ // Human-in-the-loop gate for sensitive tools. A denial (or an erroring
90
+ // hook — fail closed) blocks execution and is audited like any result.
91
+ if (typeof approve === 'function' && SENSITIVE_TOOLS.has(tool)) {
92
+ let verdict;
93
+ try { verdict = await approve({ tool, args, agent: agent.name }); }
94
+ catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
95
+ if (!verdict || !verdict.approved) {
96
+ const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
97
+ audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
98
+ return result;
99
+ }
100
+ }
79
101
  let result;
80
102
  try {
81
- result = await impl.exec(args || {}, { cwd: cwd || process.cwd() });
103
+ result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir });
82
104
  } catch (err) {
83
105
  result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
84
106
  }
@@ -0,0 +1,43 @@
1
+ // skill_view tool — Phase 20.
2
+ //
3
+ // Read-only progressive-disclosure loader: the mention-router injects a
4
+ // compact skills *index* (name + one-line summary) into the system
5
+ // prompt, and the agent calls skill_view to pull the FULL text of a
6
+ // skill only when it decides one is relevant. This keeps skill bodies
7
+ // out of the prompt until they're actually needed.
8
+ //
9
+ // Unlike bash/read/write/grep this tool is rooted at the lazyclaw
10
+ // config dir (where skills live), not the task cwd, so it takes
11
+ // configDir from the tool-runner opts rather than cwd.
12
+
13
+ import * as skills from '../../skills.mjs';
14
+
15
+ export const NAME = 'skill_view';
16
+ export const DESCRIPTION =
17
+ 'Load the full text of a named skill from the skills index (its When-to-Use, Procedure, Pitfalls and Verification sections). Call this when a skill listed in the index looks relevant to the current task.';
18
+ export const PARAMETERS = {
19
+ type: 'object',
20
+ properties: {
21
+ name: { type: 'string', description: 'The skill name exactly as shown in the skills index.' },
22
+ },
23
+ required: ['name'],
24
+ };
25
+
26
+ export async function exec(args, { configDir } = {}) {
27
+ if (!args || typeof args.name !== 'string' || !args.name.trim()) {
28
+ return { ok: false, error: 'skill_view: name is required' };
29
+ }
30
+ const name = args.name.trim();
31
+ try {
32
+ const content = skills.loadSkill(name, configDir);
33
+ // Record the recall so the curator can age out never-used skills.
34
+ // Best-effort: a usage-write hiccup must never fail the tool call.
35
+ try {
36
+ const curator = await import('../../skills_curator.mjs');
37
+ curator.recordUsage(name, configDir, Date.now());
38
+ } catch { /* non-fatal */ }
39
+ return { ok: true, name, content };
40
+ } catch (err) {
41
+ return { ok: false, error: `skill_view: ${err?.message || err}` };
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -48,6 +48,7 @@
48
48
  "browse.mjs",
49
49
  "sandbox.mjs",
50
50
  "skills_install.mjs",
51
+ "skills_curator.mjs",
51
52
  "cron.mjs",
52
53
  "loop-engine.mjs",
53
54
  "loops.mjs",
@@ -61,6 +62,7 @@
61
62
  "workflow/",
62
63
  "web/",
63
64
  "mas/",
65
+ "gateway/",
64
66
  "docs/multi-agent.md",
65
67
  "scripts/loop-worker.mjs",
66
68
  "README.md",
package/skills.mjs CHANGED
@@ -38,24 +38,79 @@ export function listSkills(configDir = defaultConfigDir()) {
38
38
  .map(name => {
39
39
  const full = path.join(dir, name);
40
40
  const stat = fs.statSync(full);
41
- const head = readFirstLine(full);
41
+ let content = '';
42
+ try { content = fs.readFileSync(full, 'utf8'); } catch { /* unreadable → empty */ }
43
+ const { meta, body } = parseFrontmatter(content);
44
+ // Prefer the agent-/author-supplied frontmatter description; fall
45
+ // back to the first markdown heading for legacy frontmatter-less
46
+ // skills so the index still reads sensibly.
47
+ const summary = (meta.description || firstHeading(body) || '').slice(0, 120);
42
48
  return {
43
49
  name: name.slice(0, -SKILL_EXT.length),
44
50
  path: full,
45
51
  bytes: stat.size,
46
52
  mtimeMs: stat.mtimeMs,
47
- summary: head.replace(/^#+\s*/, '').slice(0, 120),
53
+ summary,
54
+ description: meta.description || '',
55
+ createdBy: meta.created_by || '',
56
+ version: meta.version || '',
48
57
  };
49
58
  })
50
59
  .sort((a, b) => a.name.localeCompare(b.name));
51
60
  }
52
61
 
53
- function readFirstLine(p) {
54
- try {
55
- const buf = fs.readFileSync(p, 'utf8');
56
- const nl = buf.indexOf('\n');
57
- return nl < 0 ? buf : buf.slice(0, nl);
58
- } catch { return ''; }
62
+ // Parse a leading YAML frontmatter block (--- … ---). Only the flat
63
+ // `key: value` shape skills use is supported — no nested YAML — which
64
+ // keeps us dependency-free. Returns { meta, body }; when no frontmatter
65
+ // is present meta is {} and body is the untouched content.
66
+ export function parseFrontmatter(content) {
67
+ const text = String(content ?? '');
68
+ if (!text.startsWith('---')) return { meta: {}, body: text };
69
+ // The opening fence must be its own line.
70
+ const afterOpen = text.slice(3);
71
+ if (!/^\r?\n/.test(afterOpen)) return { meta: {}, body: text };
72
+ const closeRe = /\r?\n---[ \t]*(?:\r?\n|$)/;
73
+ const m = closeRe.exec(afterOpen);
74
+ if (!m) return { meta: {}, body: text };
75
+ const block = afterOpen.slice(0, m.index);
76
+ // Drop blank lines between the closing fence and the first body line
77
+ // so callers can rely on body starting at real content.
78
+ const body = afterOpen.slice(m.index + m[0].length).replace(/^(?:\r?\n)+/, '');
79
+ const meta = {};
80
+ for (const line of block.split(/\r?\n/)) {
81
+ const mm = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim());
82
+ if (!mm) continue;
83
+ let val = mm[2].trim();
84
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) {
85
+ // Symmetric with skill_synth's escapeYaml double-quote escaping.
86
+ val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
87
+ } else if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
88
+ val = val.slice(1, -1);
89
+ }
90
+ meta[mm[1]] = val;
91
+ }
92
+ return { meta, body };
93
+ }
94
+
95
+ function firstHeading(body) {
96
+ for (const line of String(body || '').split(/\r?\n/)) {
97
+ const t = line.trim();
98
+ if (!t) continue;
99
+ return t.replace(/^#+\s*/, '');
100
+ }
101
+ return '';
102
+ }
103
+
104
+ // Compact "Level 0" recall index: one `- <name>: <summary>` line per
105
+ // installed skill, sorted by name. Returns '' when no skills exist so
106
+ // callers can inject it conditionally. This is what gets dropped into
107
+ // the system prompt so the model knows which skills exist without
108
+ // paying for their full bodies (progressive disclosure — the model
109
+ // pulls a full skill on demand via the skill_view tool).
110
+ export function skillsIndex(configDir = defaultConfigDir()) {
111
+ const skills = listSkills(configDir);
112
+ if (!skills.length) return '';
113
+ return skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
59
114
  }
60
115
 
61
116
  export function loadSkill(name, configDir = defaultConfigDir()) {
@@ -76,6 +131,44 @@ export function removeSkill(name, configDir = defaultConfigDir()) {
76
131
  if (fs.existsSync(p)) fs.unlinkSync(p);
77
132
  }
78
133
 
134
+ export function skillExists(name, configDir = defaultConfigDir()) {
135
+ try { return fs.existsSync(skillPath(name, configDir)); }
136
+ catch { return false; }
137
+ }
138
+
139
+ // Read the integer `version` from a skill's frontmatter (0 when the
140
+ // skill is missing or carries no version).
141
+ export function skillVersion(name, configDir = defaultConfigDir()) {
142
+ try {
143
+ const { meta } = parseFrontmatter(fs.readFileSync(skillPath(name, configDir), 'utf8'));
144
+ return parseInt(meta.version, 10) || 0;
145
+ } catch { return 0; }
146
+ }
147
+
148
+ // Reserve a target name for an agent-synthesised skill that NEVER
149
+ // clobbers a human-authored skill. If the slug is free, use it. If a
150
+ // skill with that slug already exists AND it was itself agent-authored
151
+ // (created_by: agent), reuse it — that's the self-improvement update
152
+ // path. Otherwise the slug belongs to a human/curated skill, so we
153
+ // append a numeric suffix and try again. This is the security boundary
154
+ // that stops LLM-chosen slugs from overwriting trusted skills.
155
+ export function reserveSynthName(name, configDir = defaultConfigDir()) {
156
+ const base = (name && String(name).trim()) || 'skill';
157
+ let candidate = base;
158
+ for (let i = 1; i < 1000; i++) {
159
+ let p;
160
+ try { p = skillPath(candidate, configDir); }
161
+ catch { return base; }
162
+ if (!fs.existsSync(p)) return candidate;
163
+ let createdBy = '';
164
+ try { createdBy = parseFrontmatter(fs.readFileSync(p, 'utf8')).meta.created_by || ''; }
165
+ catch { /* unreadable → treat as occupied */ }
166
+ if (createdBy === 'agent') return candidate; // overwrite our own = improve
167
+ candidate = `${base}-${i}`;
168
+ }
169
+ return candidate;
170
+ }
171
+
79
172
  /**
80
173
  * Compose the system prompt for a chat/agent invocation. Concatenates each
81
174
  * named skill's contents with a separator, in the order given. Returns null