lazyclaw 4.3.0 → 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 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +398 -6
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- 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/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 +20 -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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// recall tool — Phase B (v5 §4.5).
|
|
2
|
+
//
|
|
3
|
+
// FTS5-backed cross-scope recall. Reads from mas/index_db.mjs (the
|
|
4
|
+
// SQLite mirror populated by Phase A's write-through hooks).
|
|
5
|
+
//
|
|
6
|
+
// Args:
|
|
7
|
+
// query: required string
|
|
8
|
+
// scope: optional array of 'sessions'|'skills'|'trajectories'|'memories'
|
|
9
|
+
// (default: all four)
|
|
10
|
+
// k: optional integer, default 10, hard-capped at 50
|
|
11
|
+
// summarize: optional boolean (v5.1+ wires the trainer; v5.0 leaves
|
|
12
|
+
// summary null when set, so the agent gets raw hits.)
|
|
13
|
+
// filter: optional object of UNINDEXED column equality filters
|
|
14
|
+
// (session_id, agent, outcome, trained_by, group_name, kind, since)
|
|
15
|
+
|
|
16
|
+
import { openIndex, recall as indexRecall } from '../index_db.mjs';
|
|
17
|
+
|
|
18
|
+
export const NAME = 'recall';
|
|
19
|
+
export const DESCRIPTION =
|
|
20
|
+
'Search prior sessions, skills, trajectories, and memories by FTS5 query. Returns ranked snippets with metadata. Use this BEFORE asking the user to repeat themselves or before solving a problem from scratch.';
|
|
21
|
+
export const PARAMETERS = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
query: { type: 'string', description: 'FTS5 MATCH query. Plain words are AND-ed.' },
|
|
25
|
+
scope: { type: 'array', items: { type: 'string', enum: ['sessions', 'skills', 'trajectories', 'memories'] } },
|
|
26
|
+
k: { type: 'integer', minimum: 1, maximum: 50 },
|
|
27
|
+
summarize: { type: 'boolean' },
|
|
28
|
+
filter: { type: 'object', additionalProperties: true },
|
|
29
|
+
},
|
|
30
|
+
required: ['query'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SCOPES = ['sessions', 'skills', 'trajectories', 'memories'];
|
|
34
|
+
const MAX_K = 50;
|
|
35
|
+
|
|
36
|
+
let _stubRecall = null;
|
|
37
|
+
export function __setRecall(fn) { _stubRecall = typeof fn === 'function' ? fn : null; }
|
|
38
|
+
|
|
39
|
+
export async function exec(args, { configDir } = {}) {
|
|
40
|
+
if (!args || typeof args.query !== 'string' || !args.query.trim()) {
|
|
41
|
+
return { ok: false, error: 'recall: query is required' };
|
|
42
|
+
}
|
|
43
|
+
const query = args.query.trim();
|
|
44
|
+
const scopes = Array.isArray(args.scope) && args.scope.length ? args.scope : DEFAULT_SCOPES;
|
|
45
|
+
const k = Math.max(1, Math.min(MAX_K, Number(args.k) || 10));
|
|
46
|
+
const filter = args.filter && typeof args.filter === 'object' ? args.filter : {};
|
|
47
|
+
const t0 = Date.now();
|
|
48
|
+
|
|
49
|
+
let out;
|
|
50
|
+
if (_stubRecall) {
|
|
51
|
+
try {
|
|
52
|
+
out = await _stubRecall(query, { scope: scopes, k });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, error: `recall: stub threw — ${err?.message || err}` };
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
try {
|
|
58
|
+
openIndex(configDir);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
out = indexRecall(query, { configDir, scope: scopes, k });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, error: `recall: query failed — ${err?.message || err}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Apply optional UNINDEXED filter (metadata-level equality / since predicate).
|
|
70
|
+
let hits = Array.isArray(out.hits) ? out.hits : [];
|
|
71
|
+
const filterKeys = Object.keys(filter);
|
|
72
|
+
if (filterKeys.length) {
|
|
73
|
+
hits = hits.filter((h) => {
|
|
74
|
+
const meta = h.metadata || {};
|
|
75
|
+
for (const key of filterKeys) {
|
|
76
|
+
if (key === 'since') {
|
|
77
|
+
if (Number(meta.ts || 0) < Number(filter.since)) return false;
|
|
78
|
+
} else if (String(meta[key] ?? '') !== String(filter[key])) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
query,
|
|
89
|
+
hits: hits.slice(0, k),
|
|
90
|
+
summary: null, // v5.0: raw hits only; v5.1 wires trainer.
|
|
91
|
+
summarizedBy: null,
|
|
92
|
+
latencyMs: Date.now() - t0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const TOOL = {
|
|
97
|
+
name: NAME,
|
|
98
|
+
category: 'learning',
|
|
99
|
+
sensitive: false,
|
|
100
|
+
description: DESCRIPTION,
|
|
101
|
+
parameters: PARAMETERS,
|
|
102
|
+
exec,
|
|
103
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Tool registry — aggregates every first-party tool group plus any MCP-imported
|
|
2
|
+
// tools so callers (tool_runner, splash renderer, agent toolset resolver) can
|
|
3
|
+
// ask for them by name without knowing which file they live in.
|
|
4
|
+
//
|
|
5
|
+
// Each tool record: {name, category, sensitive, description, parameters, exec}
|
|
6
|
+
// - name: unique key (mcp tools use "mcp:<server>:<tool>")
|
|
7
|
+
// - category: 'exec' | 'fs' | 'net' | 'data' | 'agents' | 'learning' | ...
|
|
8
|
+
// - sensitive: when true, tool_runner requires `approve` hook before exec
|
|
9
|
+
// - parameters: JSON-Schema object (same shape as Phase 12a)
|
|
10
|
+
// - exec(args, ctx) -> {ok, ...}
|
|
11
|
+
|
|
12
|
+
import * as bashTool from './bash.mjs';
|
|
13
|
+
import * as readTool from './read.mjs';
|
|
14
|
+
import * as writeTool from './write.mjs';
|
|
15
|
+
import * as grepTool from './grep.mjs';
|
|
16
|
+
|
|
17
|
+
function adaptLegacy(mod, { category, sensitive }) {
|
|
18
|
+
return {
|
|
19
|
+
name: mod.NAME,
|
|
20
|
+
category,
|
|
21
|
+
sensitive,
|
|
22
|
+
description: mod.DESCRIPTION,
|
|
23
|
+
parameters: mod.PARAMETERS,
|
|
24
|
+
exec: mod.exec,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Built-in (Phase 12a) tools, adapted to v5 shape.
|
|
29
|
+
const BUILTINS = [
|
|
30
|
+
adaptLegacy(bashTool, { category: 'exec', sensitive: true }),
|
|
31
|
+
adaptLegacy(readTool, { category: 'fs', sensitive: false }),
|
|
32
|
+
adaptLegacy(writeTool, { category: 'fs', sensitive: true }),
|
|
33
|
+
adaptLegacy(grepTool, { category: 'fs', sensitive: false }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
import { TOOL as editTool } from './edit.mjs';
|
|
37
|
+
import { TOOL as patchTool } from './patch.mjs';
|
|
38
|
+
import { TOOL as recallTool } from './recall.mjs';
|
|
39
|
+
import { TOOLS as learningTools } from './learning.mjs';
|
|
40
|
+
import { TOOLS as webTools } from './web.mjs';
|
|
41
|
+
import { TOOLS as osTools } from './os.mjs';
|
|
42
|
+
import { TOOLS as codingTools } from './coding.mjs';
|
|
43
|
+
import { TOOLS as gitGroupTools } from './git.mjs';
|
|
44
|
+
import { TOOLS as schedTools } from './scheduling.mjs';
|
|
45
|
+
import { TOOLS as delTools } from './delegation.mjs';
|
|
46
|
+
import { TOOLS as mediaTools } from './media.mjs';
|
|
47
|
+
import { TOOLS as haTools } from './ha.mjs';
|
|
48
|
+
import { TOOL as clarifyTool } from './clarify.mjs';
|
|
49
|
+
import { TOOLS as browserTools } from './browser.mjs';
|
|
50
|
+
|
|
51
|
+
BUILTINS.push(editTool, patchTool);
|
|
52
|
+
BUILTINS.push(recallTool);
|
|
53
|
+
for (const t of learningTools) BUILTINS.push(t);
|
|
54
|
+
for (const t of webTools) BUILTINS.push(t);
|
|
55
|
+
for (const t of osTools) BUILTINS.push(t);
|
|
56
|
+
for (const t of codingTools) BUILTINS.push(t);
|
|
57
|
+
for (const t of gitGroupTools) BUILTINS.push(t);
|
|
58
|
+
for (const t of schedTools) BUILTINS.push(t);
|
|
59
|
+
for (const t of delTools) BUILTINS.push(t);
|
|
60
|
+
for (const t of mediaTools) BUILTINS.push(t);
|
|
61
|
+
for (const t of haTools) BUILTINS.push(t);
|
|
62
|
+
BUILTINS.push(clarifyTool);
|
|
63
|
+
for (const t of browserTools) BUILTINS.push(t);
|
|
64
|
+
|
|
65
|
+
// Mutable; new groups (Tasks 2-14) push here; MCP client (Task 15) also pushes.
|
|
66
|
+
const TOOLS = new Map();
|
|
67
|
+
for (const t of BUILTINS) TOOLS.set(t.name, t);
|
|
68
|
+
|
|
69
|
+
export function register(tool) {
|
|
70
|
+
if (!tool || typeof tool.name !== 'string') throw new Error('registry.register: tool.name required');
|
|
71
|
+
if (typeof tool.exec !== 'function') throw new Error(`registry.register(${tool.name}): exec required`);
|
|
72
|
+
if (typeof tool.sensitive !== 'boolean') throw new Error(`registry.register(${tool.name}): sensitive required`);
|
|
73
|
+
TOOLS.set(tool.name, tool);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerGroup(group) {
|
|
77
|
+
if (!Array.isArray(group)) throw new Error('registry.registerGroup: array required');
|
|
78
|
+
for (const t of group) register(t);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function unregister(name) { return TOOLS.delete(name); }
|
|
82
|
+
|
|
83
|
+
export function lookup(name) { return TOOLS.get(name) || null; }
|
|
84
|
+
|
|
85
|
+
export function listAll() { return [...TOOLS.values()]; }
|
|
86
|
+
|
|
87
|
+
export function listNames() { return [...TOOLS.keys()]; }
|
|
88
|
+
|
|
89
|
+
export function byCategory() {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const t of TOOLS.values()) (out[t.category] ||= []).push(t);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// scheduling — cron_add / cron_remove / cron_list. Wraps cron.mjs but the
|
|
2
|
+
// backend is overridable for tests via __setCronBackend.
|
|
3
|
+
|
|
4
|
+
let _backend = null;
|
|
5
|
+
export function __setCronBackend(b) { _backend = b; }
|
|
6
|
+
|
|
7
|
+
async function getBackend() {
|
|
8
|
+
if (_backend) return _backend;
|
|
9
|
+
const cron = await import('../../cron.mjs').catch(() => null);
|
|
10
|
+
if (!cron) throw new Error('scheduling: cron.mjs not available');
|
|
11
|
+
return {
|
|
12
|
+
add: async (j) => cron.add ? cron.add(j) : { ok: false, error: 'cron.add missing' },
|
|
13
|
+
list: async () => cron.list ? cron.list() : [],
|
|
14
|
+
remove: async (n) => cron.remove ? cron.remove(n) : { ok: false, error: 'cron.remove missing' },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Field-count validator independent of cron.mjs internals so we get a clean error.
|
|
19
|
+
function looksLikeCronSpec(s) {
|
|
20
|
+
return typeof s === 'string' && s.trim().split(/\s+/).length === 5;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cron_add = {
|
|
24
|
+
name: 'cron_add', category: 'scheduling', sensitive: true,
|
|
25
|
+
description: 'Schedule a recurring agent run or shell command.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
name: { type: 'string' },
|
|
30
|
+
spec: { type: 'string', description: '5-field cron spec.' },
|
|
31
|
+
command: { type: 'string' },
|
|
32
|
+
},
|
|
33
|
+
required: ['name', 'spec', 'command'],
|
|
34
|
+
},
|
|
35
|
+
async exec(args) {
|
|
36
|
+
if (!looksLikeCronSpec(args.spec)) return { ok: false, error: `cron_add: bad cron spec "${args.spec}"` };
|
|
37
|
+
const b = await getBackend();
|
|
38
|
+
return b.add({ name: args.name, spec: args.spec, command: args.command });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const cron_remove = {
|
|
43
|
+
name: 'cron_remove', category: 'scheduling', sensitive: true,
|
|
44
|
+
description: 'Remove a scheduled job by name.',
|
|
45
|
+
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
46
|
+
async exec(args) {
|
|
47
|
+
const b = await getBackend();
|
|
48
|
+
return b.remove(args.name);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const cron_list = {
|
|
53
|
+
name: 'cron_list', category: 'scheduling', sensitive: true,
|
|
54
|
+
description: 'List scheduled jobs.',
|
|
55
|
+
parameters: { type: 'object', properties: {} },
|
|
56
|
+
async exec() {
|
|
57
|
+
const b = await getBackend();
|
|
58
|
+
return { ok: true, jobs: await b.list() };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const TOOLS = [cron_add, cron_remove, cron_list];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// web — web_fetch (undici, SSRF block), web_search (Brave/Tavily/SerpAPI when
|
|
2
|
+
// an API key env var is set), url_extract (extract links from HTML).
|
|
3
|
+
// SSRF policy: reject loopback, RFC1918 private, link-local, file:, ftp:,
|
|
4
|
+
// and any non-http(s) scheme.
|
|
5
|
+
|
|
6
|
+
import { fetch } from 'undici';
|
|
7
|
+
import dns from 'node:dns/promises';
|
|
8
|
+
|
|
9
|
+
const PRIVATE_V4 = [
|
|
10
|
+
/^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[0-1])\./,
|
|
11
|
+
/^127\./, /^169\.254\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
async function isSafeUrl(url) {
|
|
15
|
+
let u;
|
|
16
|
+
try { u = new URL(url); } catch { return { ok: false, error: 'bad URL' }; }
|
|
17
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `scheme ${u.protocol} blocked` };
|
|
18
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
19
|
+
if (host === 'localhost' || host === '0.0.0.0') return { ok: false, error: 'loopback blocked (SSRF)' };
|
|
20
|
+
if (PRIVATE_V4.some(re => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
|
|
21
|
+
if (host.includes(':')) return { ok: false, error: 'IPv6 disabled' };
|
|
22
|
+
if (!/^[a-z0-9.-]+$/i.test(host)) return { ok: false, error: 'bad host' };
|
|
23
|
+
try {
|
|
24
|
+
const addrs = await dns.lookup(host, { all: true });
|
|
25
|
+
for (const a of addrs) {
|
|
26
|
+
if (PRIVATE_V4.some(re => re.test(a.address))) return { ok: false, error: 'resolves to private address (SSRF)' };
|
|
27
|
+
if (a.address === '127.0.0.1' || a.address === '::1') return { ok: false, error: 'resolves to loopback (SSRF)' };
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { ok: false, error: `dns: ${e.message}` };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const web_fetch = {
|
|
36
|
+
name: 'web_fetch', category: 'net', sensitive: true,
|
|
37
|
+
description: 'Fetch a public URL. Loopback / private / non-http(s) URLs are blocked.',
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
url: { type: 'string' },
|
|
42
|
+
method: { type: 'string', enum: ['GET', 'POST'] },
|
|
43
|
+
headers: { type: 'object' },
|
|
44
|
+
body: { type: 'string' },
|
|
45
|
+
maxBytes:{ type: 'number' },
|
|
46
|
+
},
|
|
47
|
+
required: ['url'],
|
|
48
|
+
},
|
|
49
|
+
async exec(args) {
|
|
50
|
+
const safe = await isSafeUrl(args.url);
|
|
51
|
+
if (!safe.ok) return { ok: false, error: `web_fetch: ${safe.error}` };
|
|
52
|
+
const maxBytes = Math.min(args.maxBytes || 2_000_000, 5_000_000);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(args.url, {
|
|
55
|
+
method: args.method || 'GET',
|
|
56
|
+
headers: args.headers || {},
|
|
57
|
+
body: args.body,
|
|
58
|
+
redirect: 'follow',
|
|
59
|
+
});
|
|
60
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
61
|
+
const truncated = buf.length > maxBytes;
|
|
62
|
+
return {
|
|
63
|
+
ok: true, status: res.status,
|
|
64
|
+
headers: Object.fromEntries(res.headers),
|
|
65
|
+
body: buf.slice(0, maxBytes).toString('utf8'),
|
|
66
|
+
truncated,
|
|
67
|
+
};
|
|
68
|
+
} catch (e) { return { ok: false, error: `web_fetch: ${e.message}` }; }
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const web_search = {
|
|
73
|
+
name: 'web_search', category: 'net', sensitive: false,
|
|
74
|
+
description: 'Search the public web via Brave (BRAVE_API_KEY), Tavily (TAVILY_API_KEY), or SerpAPI (SERPAPI_API_KEY).',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { query: { type: 'string' }, k: { type: 'number' } },
|
|
78
|
+
required: ['query'],
|
|
79
|
+
},
|
|
80
|
+
async exec(args, ctx) {
|
|
81
|
+
const env = ctx?.env || process.env;
|
|
82
|
+
if (env.BRAVE_API_KEY) return braveSearch(args, env.BRAVE_API_KEY);
|
|
83
|
+
if (env.TAVILY_API_KEY) return tavilySearch(args, env.TAVILY_API_KEY);
|
|
84
|
+
if (env.SERPAPI_API_KEY) return serpApiSearch(args, env.SERPAPI_API_KEY);
|
|
85
|
+
return { ok: false, error: 'web_search: no provider configured (set BRAVE_API_KEY / TAVILY_API_KEY / SERPAPI_API_KEY)' };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function braveSearch({ query, k = 5 }, key) {
|
|
90
|
+
try {
|
|
91
|
+
const r = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${k}`, {
|
|
92
|
+
headers: { 'X-Subscription-Token': key, 'Accept': 'application/json' },
|
|
93
|
+
});
|
|
94
|
+
const j = await r.json();
|
|
95
|
+
return { ok: true, results: (j?.web?.results || []).slice(0, k).map(x => ({ title: x.title, url: x.url, snippet: x.description })) };
|
|
96
|
+
} catch (e) { return { ok: false, error: `brave: ${e.message}` }; }
|
|
97
|
+
}
|
|
98
|
+
async function tavilySearch({ query, k = 5 }, key) {
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch('https://api.tavily.com/search', {
|
|
101
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ api_key: key, query, max_results: k }),
|
|
103
|
+
});
|
|
104
|
+
const j = await r.json();
|
|
105
|
+
return { ok: true, results: (j?.results || []).slice(0, k).map(x => ({ title: x.title, url: x.url, snippet: x.content })) };
|
|
106
|
+
} catch (e) { return { ok: false, error: `tavily: ${e.message}` }; }
|
|
107
|
+
}
|
|
108
|
+
async function serpApiSearch({ query, k = 5 }, key) {
|
|
109
|
+
try {
|
|
110
|
+
const r = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&num=${k}&api_key=${key}`);
|
|
111
|
+
const j = await r.json();
|
|
112
|
+
return { ok: true, results: (j?.organic_results || []).slice(0, k).map(x => ({ title: x.title, url: x.link, snippet: x.snippet })) };
|
|
113
|
+
} catch (e) { return { ok: false, error: `serpapi: ${e.message}` }; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const url_extract = {
|
|
117
|
+
name: 'url_extract', category: 'net', sensitive: false,
|
|
118
|
+
description: 'Extract all href URLs from an HTML string.',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: { html: { type: 'string' }, base: { type: 'string' } },
|
|
122
|
+
required: ['html'],
|
|
123
|
+
},
|
|
124
|
+
async exec(args) {
|
|
125
|
+
const urls = new Set();
|
|
126
|
+
const re = /href\s*=\s*["']([^"']+)["']/gi;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = re.exec(args.html))) {
|
|
129
|
+
try {
|
|
130
|
+
urls.add(args.base ? new URL(m[1], args.base).toString() : m[1]);
|
|
131
|
+
} catch { urls.add(m[1]); }
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, urls: [...urls] };
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const TOOLS = [web_fetch, web_search, url_extract];
|
package/mas/toolsets.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// toolsets — named bundles of tool names that an agent can be assigned via
|
|
2
|
+
// `lazyclaw agent edit <name> --toolset coding-min`. Built-ins ship in
|
|
3
|
+
// code; user-defined sets live in <configDir>/toolsets.json.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const BUILTIN = {
|
|
9
|
+
'coding-min': { tools: ['bash', 'read', 'write', 'edit', 'patch', 'grep', 'git_status', 'git_diff'] },
|
|
10
|
+
'web-research':{ tools: ['web_fetch', 'web_search', 'url_extract', 'read', 'write', 'recall'] },
|
|
11
|
+
'devops': { tools: ['bash', 'git_status', 'git_diff', 'git_log', 'git_commit', 'cron_add', 'cron_list', 'http_request'] },
|
|
12
|
+
'learning': { tools: ['recall', 'skill_view', 'skill_create', 'skill_edit', 'memory_read', 'memory_write', 'user_view', 'user_update'] },
|
|
13
|
+
'media': { tools: ['image_describe', 'image_generate', 'transcribe'] },
|
|
14
|
+
'agentic': { tools: ['task_spawn', 'delegate', 'clarify', 'recall', 'skill_view'] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function configFile(opts) {
|
|
18
|
+
const dir = opts?.configDir || process.env.LAZYCLAW_CONFIG_DIR || path.join(process.env.HOME || '.', '.lazyclaw');
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
return path.join(dir, 'toolsets.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readUser(opts) {
|
|
24
|
+
const f = configFile(opts);
|
|
25
|
+
if (!fs.existsSync(f)) return {};
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
|
|
27
|
+
catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeUser(data, opts) {
|
|
31
|
+
fs.writeFileSync(configFile(opts), JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listToolsets(opts) {
|
|
35
|
+
const user = readUser(opts);
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, t] of Object.entries(BUILTIN)) out.push({ name, ...t, source: 'builtin' });
|
|
38
|
+
for (const [name, t] of Object.entries(user)) out.push({ name, ...t, source: 'user' });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveToolset(name, opts) {
|
|
43
|
+
const user = readUser(opts);
|
|
44
|
+
const t = user[name] || BUILTIN[name];
|
|
45
|
+
if (!t || !Array.isArray(t.tools)) throw new Error(`toolset "${name}" not found`);
|
|
46
|
+
return [...t.tools];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function addToolset({ name, tools }, opts) {
|
|
50
|
+
if (!name || !Array.isArray(tools)) throw new Error('addToolset: name + tools[] required');
|
|
51
|
+
if (BUILTIN[name]) throw new Error(`toolset "${name}" is built-in; pick a different name`);
|
|
52
|
+
const data = readUser(opts);
|
|
53
|
+
data[name] = { tools };
|
|
54
|
+
writeUser(data, opts);
|
|
55
|
+
return data[name];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function removeToolset(name, opts) {
|
|
59
|
+
if (BUILTIN[name]) throw new Error(`cannot remove built-in toolset "${name}"`);
|
|
60
|
+
const data = readUser(opts);
|
|
61
|
+
delete data[name];
|
|
62
|
+
writeUser(data, opts);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// mas/trajectory_export.mjs — Phase H1.
|
|
2
|
+
//
|
|
3
|
+
// Trajectory exporter (spec §2.7). Read-only serialiser — never spawns
|
|
4
|
+
// a trainer, never touches weights. Reads JSONL records produced by
|
|
5
|
+
// mas/trajectory_store.mjs and emits one of four downstream formats:
|
|
6
|
+
//
|
|
7
|
+
// jsonl — raw transcripts (record verbatim, one per line)
|
|
8
|
+
// atropos — NousResearch/atropos: {messages, reward, metadata}
|
|
9
|
+
// axolotl — Axolotl ShareGPT: {conversations: [{from, value}]}
|
|
10
|
+
// openai-ft — OpenAI fine-tune: {messages: [{role, content}]}
|
|
11
|
+
//
|
|
12
|
+
// Filters: --since <Nd|Nh|Nm> (relative window), --outcome <done|failed|abandoned>.
|
|
13
|
+
// `reward` for atropos is null by default per spec Appendix B.6 #22
|
|
14
|
+
// (reward signal undefined for v5.0 — `--reward none` is the default).
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
|
|
20
|
+
export const FORMATS = Object.freeze(['atropos', 'axolotl', 'openai-ft', 'jsonl']);
|
|
21
|
+
|
|
22
|
+
function defaultConfigDir() {
|
|
23
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trajectoriesDir(configDir) {
|
|
27
|
+
return path.join(configDir, 'trajectories');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse "7d" / "12h" / "30m" into a millisecond window relative to now.
|
|
31
|
+
// Returns null if input is empty/undefined (no filter).
|
|
32
|
+
function parseSince(spec) {
|
|
33
|
+
if (!spec) return null;
|
|
34
|
+
const m = String(spec).match(/^(\d+)\s*([dhm])$/i);
|
|
35
|
+
if (!m) throw new Error(`invalid --since: ${spec} (expected Nd|Nh|Nm)`);
|
|
36
|
+
const n = parseInt(m[1], 10);
|
|
37
|
+
const unit = m[2].toLowerCase();
|
|
38
|
+
const mult = unit === 'd' ? 86400_000 : unit === 'h' ? 3600_000 : 60_000;
|
|
39
|
+
return Date.now() - n * mult;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function* iterRecords(configDir) {
|
|
43
|
+
const root = trajectoriesDir(configDir);
|
|
44
|
+
if (!fs.existsSync(root)) return;
|
|
45
|
+
const buckets = fs.readdirSync(root).sort();
|
|
46
|
+
for (const bucket of buckets) {
|
|
47
|
+
const bdir = path.join(root, bucket);
|
|
48
|
+
let stat; try { stat = fs.statSync(bdir); } catch { continue; }
|
|
49
|
+
if (!stat.isDirectory()) continue;
|
|
50
|
+
for (const f of fs.readdirSync(bdir).sort()) {
|
|
51
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = fs.readFileSync(path.join(bdir, f), 'utf8'); } catch { continue; }
|
|
54
|
+
for (const line of raw.split('\n')) {
|
|
55
|
+
const s = line.trim();
|
|
56
|
+
if (!s) continue;
|
|
57
|
+
try { yield JSON.parse(s); } catch { /* skip corrupt */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a uniform message list from a TrajectoryRecord. The store's
|
|
64
|
+
// canonical shape carries systemPrompt + userMessages + turns; downstream
|
|
65
|
+
// formats all want a chronological role+content sequence so we synthesise
|
|
66
|
+
// one here.
|
|
67
|
+
function toMessages(rec) {
|
|
68
|
+
const out = [];
|
|
69
|
+
if (rec.systemPrompt) out.push({ role: 'system', content: String(rec.systemPrompt) });
|
|
70
|
+
for (const u of rec.userMessages || []) {
|
|
71
|
+
out.push({ role: 'user', content: String(u) });
|
|
72
|
+
}
|
|
73
|
+
for (const t of rec.turns || []) {
|
|
74
|
+
if (!t || !t.role) continue;
|
|
75
|
+
// turns may already include the user echo; keep them — downstream
|
|
76
|
+
// trainers expect duplicates filtered at their own dedupe stage.
|
|
77
|
+
out.push({ role: t.role, content: String(t.content || '') });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatAtropos(rec) {
|
|
83
|
+
return {
|
|
84
|
+
messages: toMessages(rec),
|
|
85
|
+
reward: null, // §B.6 #22 default
|
|
86
|
+
metadata: {
|
|
87
|
+
id: rec.id,
|
|
88
|
+
taskId: rec.taskId,
|
|
89
|
+
agentName: rec.agentName,
|
|
90
|
+
workerProvider: rec.workerProvider,
|
|
91
|
+
workerModel: rec.workerModel,
|
|
92
|
+
outcome: rec.outcome,
|
|
93
|
+
startedAt: rec.startedAt,
|
|
94
|
+
endedAt: rec.endedAt,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatAxolotl(rec) {
|
|
100
|
+
// ShareGPT role mapping: system→system, user→human, assistant→gpt,
|
|
101
|
+
// tool→tool. Keeps the channel a downstream Axolotl recipe can filter.
|
|
102
|
+
const FROM = { system: 'system', user: 'human', assistant: 'gpt', tool: 'tool' };
|
|
103
|
+
return {
|
|
104
|
+
conversations: toMessages(rec).map(m => ({
|
|
105
|
+
from: FROM[m.role] || m.role,
|
|
106
|
+
value: m.content,
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatOpenAIFT(rec) {
|
|
112
|
+
return { messages: toMessages(rec) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serialise(rec, format) {
|
|
116
|
+
switch (format) {
|
|
117
|
+
case 'jsonl': return JSON.stringify(rec);
|
|
118
|
+
case 'atropos': return JSON.stringify(formatAtropos(rec));
|
|
119
|
+
case 'axolotl': return JSON.stringify(formatAxolotl(rec));
|
|
120
|
+
case 'openai-ft': return JSON.stringify(formatOpenAIFT(rec));
|
|
121
|
+
default: throw new Error(`unknown format: ${format}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Public API. Returns { count, outFile, format }. Filesystem side effect
|
|
126
|
+
// is a single .jsonl under <outDir> named `trajectories-<format>-<ts>.jsonl`.
|
|
127
|
+
export async function exportTrajectories({
|
|
128
|
+
format,
|
|
129
|
+
configDir,
|
|
130
|
+
outDir,
|
|
131
|
+
since,
|
|
132
|
+
filter = {},
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (!FORMATS.includes(format)) throw new Error(`unknown format: ${format}`);
|
|
135
|
+
const cfgDir = configDir || defaultConfigDir();
|
|
136
|
+
const dest = outDir || path.join(cfgDir, 'exports');
|
|
137
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const sinceMs = parseSince(since);
|
|
140
|
+
const outcomeFilter = filter && filter.outcome;
|
|
141
|
+
|
|
142
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
143
|
+
const outFile = path.join(dest, `trajectories-${format}-${stamp}.jsonl`);
|
|
144
|
+
const fd = fs.openSync(outFile, 'w');
|
|
145
|
+
let count = 0;
|
|
146
|
+
try {
|
|
147
|
+
for (const rec of iterRecords(cfgDir)) {
|
|
148
|
+
if (sinceMs !== null && (rec.startedAt || 0) < sinceMs) continue;
|
|
149
|
+
if (outcomeFilter && rec.outcome !== outcomeFilter) continue;
|
|
150
|
+
fs.writeSync(fd, serialise(rec, format) + '\n');
|
|
151
|
+
count++;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
fs.closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
return { count, outFile, format };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Parse `outcome=done` style key=value filter strings into a plain object.
|
|
160
|
+
// Exported so the CLI can share the parser.
|
|
161
|
+
export function parseFilterArg(str) {
|
|
162
|
+
if (!str) return {};
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const part of String(str).split(',')) {
|
|
165
|
+
const [k, v] = part.split('=').map(s => s && s.trim());
|
|
166
|
+
if (k && v) out[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|