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/README.md +155 -0
- package/agents.mjs +19 -3
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/cli.mjs +381 -60
- package/daemon.mjs +98 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +2 -1
- package/mas/mention_router.mjs +75 -4
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +232 -0
- package/mas/tool_runner.mjs +24 -2
- package/mas/tools/skill_view.mjs +43 -0
- package/package.json +3 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
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
|
+
}
|
package/mas/tool_runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|