lazyclaw 4.2.2 → 5.0.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.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -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 +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -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,331 @@
|
|
|
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
|
+
import { indexSkill as _indexSkill } from './index_db.mjs';
|
|
22
|
+
import { parseFrontmatter } from '../skills.mjs';
|
|
23
|
+
|
|
24
|
+
const SECTION_RE = /^#{1,6}\s+/;
|
|
25
|
+
const MAX_NAME_LEN = 48;
|
|
26
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
27
|
+
|
|
28
|
+
// Re-export the shared secret redactor (mas/redact.mjs) so existing
|
|
29
|
+
// callers of skill_synth.redactSecrets keep working while the single
|
|
30
|
+
// implementation is shared with agent_memory.reflectOnce.
|
|
31
|
+
export { redactSecrets };
|
|
32
|
+
|
|
33
|
+
// Sanitise an agent-authored skill body before it is persisted and
|
|
34
|
+
// later loaded into other agents' context: redact secrets, neutralise
|
|
35
|
+
// the task-termination marker (so reference material can't drive the
|
|
36
|
+
// router loop), strip control characters, and cap the size.
|
|
37
|
+
export function sanitizeSkillBody(text) {
|
|
38
|
+
let s = redactSecrets(text);
|
|
39
|
+
s = s.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]');
|
|
40
|
+
// Strip control characters except tab/newline/carriage-return.
|
|
41
|
+
s = s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
|
42
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_BODY_BYTES) {
|
|
43
|
+
s = Buffer.from(s, 'utf8').subarray(0, MAX_BODY_BYTES).toString('utf8').replace(/\uFFFD+$/, '') + '\n\n…[truncated]';
|
|
44
|
+
}
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sanitise the one-line description (it lands in every agent's system
|
|
49
|
+
// prompt via the skills index): redact, collapse to a single line,
|
|
50
|
+
// neutralise the marker, cap to 120 chars.
|
|
51
|
+
export function sanitizeDescription(text) {
|
|
52
|
+
return redactSecrets(text)
|
|
53
|
+
.replace(/\[\[TASK_DONE\]\]/g, '[[task-done]]')
|
|
54
|
+
.replace(/[\u0000-\u001F\u007F]+/g, ' ')
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim()
|
|
57
|
+
.slice(0, 120);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Lowercase, collapse every run of non-alphanumerics into a single
|
|
61
|
+
// dash, and strip leading/trailing dashes. Empty input (or input with
|
|
62
|
+
// no alphanumerics) falls back to "skill" so a write always has a
|
|
63
|
+
// valid target name.
|
|
64
|
+
export function slugifySkill(title) {
|
|
65
|
+
const slug = String(title || '')
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
68
|
+
.replace(/^-+|-+$/g, '')
|
|
69
|
+
.slice(0, MAX_NAME_LEN)
|
|
70
|
+
.replace(/-+$/g, '');
|
|
71
|
+
return slug || 'skill';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse the model's reply into { name, description, body }. The prompt
|
|
75
|
+
// asks for two leading `name:` / `description:` lines followed by the
|
|
76
|
+
// `## When to Use / ## Procedure / ## Pitfalls / ## Verification`
|
|
77
|
+
// sections — but models drift, so we degrade gracefully: when the
|
|
78
|
+
// leading lines are missing we derive the name from the first heading
|
|
79
|
+
// and leave the description empty. `body` always begins at the first
|
|
80
|
+
// markdown heading (the section content), with the name/description
|
|
81
|
+
// header stripped.
|
|
82
|
+
export function parseSynthOutput(text) {
|
|
83
|
+
const raw = String(text || '').replace(/^/, '').trim();
|
|
84
|
+
const lines = raw.split(/\r?\n/);
|
|
85
|
+
|
|
86
|
+
let name = '';
|
|
87
|
+
let description = '';
|
|
88
|
+
let bodyStart = 0;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i];
|
|
92
|
+
if (SECTION_RE.test(line)) { bodyStart = i; break; }
|
|
93
|
+
const nameM = /^name\s*:\s*(.+)$/i.exec(line.trim());
|
|
94
|
+
if (nameM && !name) { name = nameM[1].trim(); bodyStart = i + 1; continue; }
|
|
95
|
+
const descM = /^description\s*:\s*(.+)$/i.exec(line.trim());
|
|
96
|
+
if (descM && !description) { description = descM[1].trim(); bodyStart = i + 1; continue; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const body = lines.slice(bodyStart).join('\n').replace(/^(?:\r?\n)+/, '').trimEnd();
|
|
100
|
+
|
|
101
|
+
// No explicit name → derive it from the first heading in the body.
|
|
102
|
+
if (!name) {
|
|
103
|
+
const firstHeading = body.split(/\r?\n/).find((l) => SECTION_RE.test(l));
|
|
104
|
+
if (firstHeading) name = firstHeading.replace(SECTION_RE, '').trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { name: slugifySkill(name), description, body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build a complete SKILL.md: a flat-YAML frontmatter block followed by
|
|
111
|
+
// the skill body. v5: adds trained_by / trained_on_model / trajectory_ref /
|
|
112
|
+
// confidence / cross_cli_tested (array) / anti_pattern (boolean) and a
|
|
113
|
+
// group fallback. The frontmatter shape round-trips through
|
|
114
|
+
// skills.parseFrontmatter(). `ts` is injected (not read from the clock)
|
|
115
|
+
// so the output is deterministic and testable.
|
|
116
|
+
export function assembleSkillDoc({
|
|
117
|
+
name,
|
|
118
|
+
description = '',
|
|
119
|
+
createdBy = 'agent',
|
|
120
|
+
sourceTask = '',
|
|
121
|
+
body = '',
|
|
122
|
+
version = 1,
|
|
123
|
+
ts = new Date(),
|
|
124
|
+
// v5 additions:
|
|
125
|
+
trainedBy = null,
|
|
126
|
+
trainedOnModel = null,
|
|
127
|
+
trajectoryRef = null,
|
|
128
|
+
confidence = null,
|
|
129
|
+
crossCliTested = null, // array of {provider, model, outcome, tested_at}
|
|
130
|
+
outcome = 'done', // 'done' | 'failed' | 'abandoned' (spec §0.1 C1)
|
|
131
|
+
group = null,
|
|
132
|
+
} = {}) {
|
|
133
|
+
const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
|
|
134
|
+
const isAntiPattern = outcome === 'failed';
|
|
135
|
+
const finalGroup = group || (isAntiPattern ? 'anti-pattern' : deriveGroup(name));
|
|
136
|
+
const fm = [
|
|
137
|
+
'---',
|
|
138
|
+
`name: ${escapeYaml(stripControl(name))}`,
|
|
139
|
+
`description: ${escapeYaml(description)}`,
|
|
140
|
+
`version: ${version}`,
|
|
141
|
+
`group: ${escapeYaml(finalGroup)}`,
|
|
142
|
+
`created_by: ${createdBy}`,
|
|
143
|
+
];
|
|
144
|
+
if (sourceTask) fm.push(`source_task: ${sourceTask}`);
|
|
145
|
+
fm.push(`created_at: ${date}`);
|
|
146
|
+
if (trainedBy) fm.push(`trained_by: ${escapeYaml(trainedBy)}`);
|
|
147
|
+
if (trainedOnModel) fm.push(`trained_on_model: ${escapeYaml(trainedOnModel)}`);
|
|
148
|
+
if (trajectoryRef) fm.push(`trajectory_ref: ${escapeYaml(trajectoryRef)}`);
|
|
149
|
+
if (confidence !== null && confidence !== undefined) {
|
|
150
|
+
fm.push(`confidence: ${Number(confidence).toFixed(2)}`);
|
|
151
|
+
}
|
|
152
|
+
if (isAntiPattern) fm.push(`anti_pattern: true`);
|
|
153
|
+
if (Array.isArray(crossCliTested) && crossCliTested.length) {
|
|
154
|
+
fm.push('cross_cli_tested:');
|
|
155
|
+
for (const t of crossCliTested) {
|
|
156
|
+
fm.push(` - provider: ${escapeYaml(t.provider || '')}`);
|
|
157
|
+
if (t.model) fm.push(` model: ${escapeYaml(t.model)}`);
|
|
158
|
+
if (t.outcome) fm.push(` outcome: ${escapeYaml(t.outcome)}`);
|
|
159
|
+
if (t.tested_at) fm.push(` tested_at: ${escapeYaml(t.tested_at)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
fm.push('---', '');
|
|
163
|
+
return `${fm.join('\n')}\n${String(body).trim()}\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Canonical fallback (spec §0.1 C5): filename hyphen prefix → 'legacy'.
|
|
167
|
+
function deriveGroup(name) {
|
|
168
|
+
const s = String(name || '');
|
|
169
|
+
const dash = s.indexOf('-');
|
|
170
|
+
if (dash > 0) return s.slice(0, dash);
|
|
171
|
+
return 'legacy';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Drop control characters (incl. newlines) from a single-line frontmatter
|
|
175
|
+
// value so an embedded \n can't break out of its key into an injected one.
|
|
176
|
+
function stripControl(v) {
|
|
177
|
+
return String(v ?? '').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Quote a value only when it contains characters our flat parser would
|
|
181
|
+
// otherwise choke on (a leading special char or an embedded colon).
|
|
182
|
+
// Control characters (including newlines) are stripped first: a quoted
|
|
183
|
+
// multi-line value still injects a frontmatter key because the parser
|
|
184
|
+
// splits on physical lines, so the only safe move is to flatten to a
|
|
185
|
+
// single line before deciding whether to quote.
|
|
186
|
+
function escapeYaml(v) {
|
|
187
|
+
const s = stripControl(v);
|
|
188
|
+
if (s === '') return '';
|
|
189
|
+
if (/[:#]/.test(s) || /^[\s'">|&*!%@`-]/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export class SkillSynthError extends Error {
|
|
194
|
+
constructor(message, code) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.name = 'SkillSynthError';
|
|
197
|
+
this.code = code || 'SKILL_SYNTH_ERR';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run one synthesis LLM call for an agent that just finished a task and
|
|
202
|
+
// return { name, description, doc } — a complete SKILL.md ready to
|
|
203
|
+
// install — or null when the model produced nothing usable. Mirrors
|
|
204
|
+
// agent_memory.reflectOnce(): same provider adapters, a pure text
|
|
205
|
+
// completion with no tools advertised, the agent's own role as the
|
|
206
|
+
// system prompt. The difference is the ASK — a reusable skill in a
|
|
207
|
+
// fixed section layout instead of free-text lessons.
|
|
208
|
+
export async function synthesizeSkill({
|
|
209
|
+
agent, task, apiKey, baseUrl, fetchImpl,
|
|
210
|
+
outcome = 'done',
|
|
211
|
+
trainedBy = null,
|
|
212
|
+
trainedOnModel = null,
|
|
213
|
+
trajectoryRef = null,
|
|
214
|
+
confidence = null,
|
|
215
|
+
crossCliTested = null,
|
|
216
|
+
} = {}) {
|
|
217
|
+
if (!agent || !task) throw new SkillSynthError('agent and task are required', 'SKILL_SYNTH_BAD_INPUT');
|
|
218
|
+
if (outcome !== 'done' && outcome !== 'failed' && outcome !== 'abandoned') {
|
|
219
|
+
throw new SkillSynthError(`bad outcome "${outcome}"`, 'SKILL_SYNTH_BAD_OUTCOME');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const transcript = redactSecrets(
|
|
223
|
+
(Array.isArray(task.turns) ? task.turns : [])
|
|
224
|
+
.map((t) => {
|
|
225
|
+
const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
|
|
226
|
+
return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
|
|
227
|
+
})
|
|
228
|
+
.join('\n\n') || '(no turns)'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const userMessage = outcome === 'failed'
|
|
232
|
+
? buildAntiPatternPrompt(task, transcript)
|
|
233
|
+
: buildSkillPrompt(task, transcript);
|
|
234
|
+
|
|
235
|
+
const text = (await runTextCompletion({
|
|
236
|
+
provider: agent.provider,
|
|
237
|
+
model: agent.model,
|
|
238
|
+
system: agent.role || '',
|
|
239
|
+
userMessage,
|
|
240
|
+
apiKey, baseUrl, fetchImpl,
|
|
241
|
+
})).trim();
|
|
242
|
+
if (!text || /^none\b/i.test(text)) return null;
|
|
243
|
+
|
|
244
|
+
const parsed = parseSynthOutput(text);
|
|
245
|
+
const description = sanitizeDescription(parsed.description);
|
|
246
|
+
const body = sanitizeSkillBody(parsed.body);
|
|
247
|
+
if (!body.trim()) return null;
|
|
248
|
+
const doc = assembleSkillDoc({
|
|
249
|
+
name: parsed.name,
|
|
250
|
+
description,
|
|
251
|
+
createdBy: 'agent',
|
|
252
|
+
sourceTask: task.id,
|
|
253
|
+
body,
|
|
254
|
+
outcome,
|
|
255
|
+
trainedBy,
|
|
256
|
+
trainedOnModel,
|
|
257
|
+
trajectoryRef,
|
|
258
|
+
confidence,
|
|
259
|
+
crossCliTested,
|
|
260
|
+
});
|
|
261
|
+
return { name: parsed.name, description, body, doc, sourceTask: task.id, outcome };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildSkillPrompt(task, transcript) {
|
|
265
|
+
return (
|
|
266
|
+
`You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
|
|
267
|
+
transcript +
|
|
268
|
+
`\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
|
|
269
|
+
`Reply in EXACTLY this format and nothing else:\n\n` +
|
|
270
|
+
`name: <short kebab-case skill name>\n` +
|
|
271
|
+
`description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
|
|
272
|
+
`## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
|
|
273
|
+
`## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
|
|
274
|
+
`## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
|
|
275
|
+
`## Verification\n<how to confirm the task actually succeeded>\n\n` +
|
|
276
|
+
`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.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildAntiPatternPrompt(task, transcript) {
|
|
281
|
+
return (
|
|
282
|
+
`Task "${task.title || '(untitled)'}" (id ${task.id}) FAILED. Transcript:\n\n` +
|
|
283
|
+
transcript +
|
|
284
|
+
`\n\nDistil this into an ANTI-PATTERN note that a future agent will read and avoid. ` +
|
|
285
|
+
`Reply in EXACTLY this format and nothing else:\n\n` +
|
|
286
|
+
`name: <short kebab-case anti-pattern name, prefixed with "avoid-">\n` +
|
|
287
|
+
`description: <one line, ≤ 120 chars, describing the failure mode to avoid>\n\n` +
|
|
288
|
+
`## What Failed\n<concrete description of what was attempted and how it broke>\n\n` +
|
|
289
|
+
`## Why\n<root cause, with file paths or error messages where known>\n\n` +
|
|
290
|
+
`## Avoid\n<the rule the next agent should follow instead>\n\n` +
|
|
291
|
+
`Be specific. If the failure was too transient to generalise, reply with the single word NONE.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Install a synthesised skill without ever clobbering a human-authored
|
|
296
|
+
// one: reserveSynthName picks a collision-free target (or our own prior
|
|
297
|
+
// agent skill, which we then version-bump). Body/description are
|
|
298
|
+
// re-sanitised here too so a direct caller (CLI/router) can't bypass
|
|
299
|
+
// the redaction + cap. Returns { skill, path, version }.
|
|
300
|
+
export function installSynthesized({ name, description = '', body = '', sourceTask = '', createdBy = 'agent' } = {}, configDir, ts = new Date()) {
|
|
301
|
+
// Slugify BEFORE reserving the name so a direct caller (CLI/router)
|
|
302
|
+
// can't smuggle a newline/colon into the filename or inject a second
|
|
303
|
+
// frontmatter key. parseSynthOutput already slugifies, but this path
|
|
304
|
+
// is also reachable directly with an arbitrary name.
|
|
305
|
+
const finalName = skills.reserveSynthName(slugifySkill(name), configDir);
|
|
306
|
+
const overwritingOwn = skills.skillExists(finalName, configDir);
|
|
307
|
+
const version = overwritingOwn ? skills.skillVersion(finalName, configDir) + 1 : 1;
|
|
308
|
+
const doc = assembleSkillDoc({
|
|
309
|
+
name: finalName,
|
|
310
|
+
description: sanitizeDescription(description),
|
|
311
|
+
createdBy,
|
|
312
|
+
sourceTask,
|
|
313
|
+
body: sanitizeSkillBody(body),
|
|
314
|
+
version,
|
|
315
|
+
ts,
|
|
316
|
+
});
|
|
317
|
+
const p = skills.installSkill(finalName, doc, configDir);
|
|
318
|
+
// Phase A: FTS5 mirror (spec §4.4). Group fallback per canonical C5.
|
|
319
|
+
try {
|
|
320
|
+
const { meta, body: skillBody } = parseFrontmatter(doc);
|
|
321
|
+
const group = meta.group
|
|
322
|
+
|| (finalName.includes('-') ? finalName.split('-')[0] : 'legacy');
|
|
323
|
+
_indexSkill({
|
|
324
|
+
skill_name: finalName,
|
|
325
|
+
trained_by: meta.trained_by || createdBy === 'agent' ? 'agent' : 'user',
|
|
326
|
+
group_name: group,
|
|
327
|
+
content: skillBody,
|
|
328
|
+
}, configDir);
|
|
329
|
+
} catch { /* swallow */ }
|
|
330
|
+
return { skill: finalName, path: p, version };
|
|
331
|
+
}
|
package/mas/tool_runner.mjs
CHANGED
|
@@ -2,16 +2,8 @@
|
|
|
2
2
|
// the agent is allowed to use the tool, runs the tool, audits the call,
|
|
3
3
|
// and returns a uniform { ok, result?, error? } shape that the provider
|
|
4
4
|
// adapters serialise into their respective tool-result content blocks.
|
|
5
|
-
//
|
|
6
|
-
// `bash`, `read`, `write`, `grep` ship with Phase 12a. `web_search`,
|
|
7
|
-
// `web_fetch`, `slack_post` are advertised in the registry's
|
|
8
|
-
// metadata-only entry so the dashboard can show them, but their `exec`
|
|
9
|
-
// throws TOOL_NOT_IMPLEMENTED until later phases wire them up.
|
|
10
5
|
|
|
11
|
-
import * as
|
|
12
|
-
import * as readTool from './tools/read.mjs';
|
|
13
|
-
import * as writeTool from './tools/write.mjs';
|
|
14
|
-
import * as grepTool from './tools/grep.mjs';
|
|
6
|
+
import * as registry from './tools/registry.mjs';
|
|
15
7
|
import * as audit from './audit.mjs';
|
|
16
8
|
|
|
17
9
|
export class ToolError extends Error {
|
|
@@ -22,63 +14,42 @@ export class ToolError extends Error {
|
|
|
22
14
|
}
|
|
23
15
|
}
|
|
24
16
|
|
|
25
|
-
const TOOLS = {
|
|
26
|
-
bash: bashTool,
|
|
27
|
-
read: readTool,
|
|
28
|
-
write: writeTool,
|
|
29
|
-
grep: grepTool,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
|
|
33
|
-
|
|
34
17
|
export function listToolSchemas(names) {
|
|
35
18
|
const out = [];
|
|
36
|
-
const wanted = Array.isArray(names) && names.length ? names :
|
|
19
|
+
const wanted = Array.isArray(names) && names.length ? names : registry.listNames();
|
|
37
20
|
for (const name of wanted) {
|
|
38
|
-
const t =
|
|
21
|
+
const t = registry.lookup(name);
|
|
39
22
|
if (!t) continue;
|
|
40
|
-
out.push({ name: t.
|
|
23
|
+
out.push({ name: t.name, description: t.description, parameters: t.parameters });
|
|
41
24
|
}
|
|
42
25
|
return out;
|
|
43
26
|
}
|
|
44
27
|
|
|
45
|
-
export function isImplemented(name) {
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function knownTool(name) {
|
|
50
|
-
return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
|
|
51
|
-
}
|
|
28
|
+
export function isImplemented(name) { return registry.lookup(name) !== null; }
|
|
29
|
+
export function knownTool(name) { return registry.lookup(name) !== null; }
|
|
52
30
|
|
|
53
|
-
|
|
54
|
-
// when the call falls outside it, we throw ToolError('TOOL_DENIED') so
|
|
55
|
-
// the caller can surface a structured error back to the LLM rather than
|
|
56
|
-
// silently dropping the call.
|
|
57
|
-
//
|
|
58
|
-
// opts.cwd — where bash/read/write/grep root themselves; defaults to
|
|
59
|
-
// process.cwd() so it can be overridden in tests.
|
|
60
|
-
// opts.taskId — when set, every call is appended to the task's audit
|
|
61
|
-
// log. Unit tests can omit it.
|
|
62
|
-
export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}) {
|
|
31
|
+
export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
|
|
63
32
|
if (!agent || !Array.isArray(agent.tools)) {
|
|
64
33
|
throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
|
|
65
34
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
35
|
+
const impl = registry.lookup(tool);
|
|
36
|
+
if (!impl) throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
|
|
69
37
|
if (!agent.tools.includes(tool)) {
|
|
70
38
|
throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
|
|
71
39
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
40
|
+
if (typeof approve === 'function' && impl.sensitive) {
|
|
41
|
+
let verdict;
|
|
42
|
+
try { verdict = await approve({ tool, args, agent: agent.name }); }
|
|
43
|
+
catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
|
|
44
|
+
if (!verdict || !verdict.approved) {
|
|
45
|
+
const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
|
|
46
|
+
audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
78
49
|
}
|
|
79
50
|
let result;
|
|
80
51
|
try {
|
|
81
|
-
result = await impl.exec(args || {}, { cwd: cwd || process.cwd() });
|
|
52
|
+
result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent });
|
|
82
53
|
} catch (err) {
|
|
83
54
|
result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
|
|
84
55
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// browser — playwright-driven browser_navigate / click / back / screenshot.
|
|
2
|
+
// Playwright is already a devDep (playwright.config.ts); we lazy-import and
|
|
3
|
+
// return a structured error if it is missing at runtime. A persistent
|
|
4
|
+
// headless Chromium context is reused across calls in the same process.
|
|
5
|
+
|
|
6
|
+
let _backend = null;
|
|
7
|
+
export function __setBrowserBackend(b) { _backend = b; }
|
|
8
|
+
|
|
9
|
+
let _ctx = null;
|
|
10
|
+
async function ensureCtx() {
|
|
11
|
+
if (_backend) return _backend;
|
|
12
|
+
if (_ctx) return _ctx;
|
|
13
|
+
let pw;
|
|
14
|
+
try { pw = await import('playwright'); }
|
|
15
|
+
catch { throw new Error('browser: playwright not installed (npm i playwright)'); }
|
|
16
|
+
const browser = await pw.chromium.launch({ headless: true });
|
|
17
|
+
const context = await browser.newContext();
|
|
18
|
+
const page = await context.newPage();
|
|
19
|
+
_ctx = {
|
|
20
|
+
navigate: async (url) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() }; },
|
|
21
|
+
click: async (sel) => { await page.click(sel); return { clicked: sel }; },
|
|
22
|
+
back: async () => { await page.goBack(); return { url: page.url() }; },
|
|
23
|
+
screenshot: async (path) => { await page.screenshot({ path, fullPage: true }); return { path }; },
|
|
24
|
+
};
|
|
25
|
+
return _ctx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeHttp(url) {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
32
|
+
return true;
|
|
33
|
+
} catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const browser_navigate = {
|
|
37
|
+
name: 'browser_navigate', category: 'browser', sensitive: true,
|
|
38
|
+
description: 'Navigate to an http(s) URL in a headless Chromium session.',
|
|
39
|
+
parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
|
|
40
|
+
async exec(args) {
|
|
41
|
+
if (!safeHttp(args.url)) return { ok: false, error: 'browser_navigate: http(s) only' };
|
|
42
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.navigate(args.url)) }; }
|
|
43
|
+
catch (e) { return { ok: false, error: `browser_navigate: ${e.message}` }; }
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const browser_click = {
|
|
48
|
+
name: 'browser_click', category: 'browser', sensitive: true,
|
|
49
|
+
description: 'Click an element by CSS selector.',
|
|
50
|
+
parameters: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] },
|
|
51
|
+
async exec(args) {
|
|
52
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.click(args.selector)) }; }
|
|
53
|
+
catch (e) { return { ok: false, error: `browser_click: ${e.message}` }; }
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const browser_back = {
|
|
58
|
+
name: 'browser_back', category: 'browser', sensitive: true,
|
|
59
|
+
description: 'Navigate back in browser history.',
|
|
60
|
+
parameters: { type: 'object', properties: {} },
|
|
61
|
+
async exec() {
|
|
62
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.back()) }; }
|
|
63
|
+
catch (e) { return { ok: false, error: `browser_back: ${e.message}` }; }
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const browser_screenshot = {
|
|
68
|
+
name: 'browser_screenshot', category: 'browser', sensitive: true,
|
|
69
|
+
description: 'Capture a full-page PNG to <path>.',
|
|
70
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
71
|
+
async exec(args) {
|
|
72
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.screenshot(args.path)) }; }
|
|
73
|
+
catch (e) { return { ok: false, error: `browser_screenshot: ${e.message}` }; }
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const TOOLS = [browser_navigate, browser_click, browser_back, browser_screenshot];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// clarify — surface a question to the human user mid-turn. The actual
|
|
2
|
+
// prompting mechanism is host-dependent (REPL prompt, Slack DM, gateway
|
|
3
|
+
// SSE), so the runtime hooks an asker via __setAsker. Returns the user's
|
|
4
|
+
// reply as a plain string. Pattern lifted from Hermes (spec §7 sub-12).
|
|
5
|
+
|
|
6
|
+
let _asker = null;
|
|
7
|
+
export function __setAsker(fn) { _asker = fn; }
|
|
8
|
+
|
|
9
|
+
export const TOOL = {
|
|
10
|
+
name: 'clarify',
|
|
11
|
+
category: 'agents',
|
|
12
|
+
sensitive: false,
|
|
13
|
+
description: 'Ask the user a clarifying question and wait for the reply.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: { question: { type: 'string' }, choices: { type: 'array', items: { type: 'string' } } },
|
|
17
|
+
required: ['question'],
|
|
18
|
+
},
|
|
19
|
+
async exec(args, ctx) {
|
|
20
|
+
if (!args?.question) return { ok: false, error: 'clarify: question required' };
|
|
21
|
+
if (typeof _asker === 'function') {
|
|
22
|
+
try {
|
|
23
|
+
const answer = await _asker({ question: args.question, choices: args.choices });
|
|
24
|
+
return { ok: true, answer };
|
|
25
|
+
} catch (e) { return { ok: false, error: `clarify: ${e.message}` }; }
|
|
26
|
+
}
|
|
27
|
+
if (ctx?.isTTY) {
|
|
28
|
+
const readline = await import('node:readline/promises');
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const answer = await rl.question(`[clarify] ${args.question} > `);
|
|
31
|
+
rl.close();
|
|
32
|
+
return { ok: true, answer };
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, error: 'clarify: no asker bound and not running in a TTY' };
|
|
35
|
+
},
|
|
36
|
+
};
|