lazyclaw 5.4.4 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
package/dotenv_min.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// dotenv_min.mjs — minimal .env loader shared by the CLI and the Ink slash
|
|
2
|
+
// dispatcher. Loads <cfgDir>/.env into process.env (without overwriting
|
|
3
|
+
// already-set vars) so Slack / provider tokens are available. Extracted from
|
|
4
|
+
// cli.mjs `_loadDotenvIfAny` so the /task Slack-close flow can reuse it.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function loadDotenvIfAny(cfgDir) {
|
|
10
|
+
const p = path.join(cfgDir, '.env');
|
|
11
|
+
if (!fs.existsSync(p)) return { path: p, loaded: 0 };
|
|
12
|
+
let loaded = 0;
|
|
13
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
14
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
15
|
+
if (!line || line.trimStart().startsWith('#')) continue;
|
|
16
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
17
|
+
if (!m) continue;
|
|
18
|
+
let val = m[2].trim();
|
|
19
|
+
if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
|
20
|
+
if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
|
|
21
|
+
}
|
|
22
|
+
return { path: p, loaded };
|
|
23
|
+
}
|
package/first_run.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// first_run.mjs — decide what onboarding the chat entrypoint runs.
|
|
2
|
+
//
|
|
3
|
+
// 'setup' — genuine fresh install (no provider, interactive): run the full
|
|
4
|
+
// 5-step guided setup (provider+model, workspace, skills) instead
|
|
5
|
+
// of only the provider picker the Ink path used to run.
|
|
6
|
+
// 'pick' — explicit `chat --pick`: just re-pick provider/model.
|
|
7
|
+
// 'none' — provider already configured, or non-interactive (automation).
|
|
8
|
+
//
|
|
9
|
+
// Pure so it unit-tests without a TTY.
|
|
10
|
+
export function firstRunMode({ hasProvider, flagPick, isTTY }) {
|
|
11
|
+
if (!isTTY) return 'none';
|
|
12
|
+
if (flagPick) return 'pick';
|
|
13
|
+
if (!hasProvider) return 'setup';
|
|
14
|
+
return 'none';
|
|
15
|
+
}
|
package/goals_cron.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// goals_cron.mjs — attach/detach a cron schedule to a goal.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from cli.mjs (_attachGoalCron / _detachGoalCron) so the Ink
|
|
4
|
+
// /goal slash can actually schedule (and unschedule) a goal instead of just
|
|
5
|
+
// recording the cron string and telling the user to re-run from the CLI.
|
|
6
|
+
// Dependency-injected on readConfig / writeConfig and the cron module so the
|
|
7
|
+
// core unit-tests with a fake cron and no real launchd/crontab writes.
|
|
8
|
+
// `LAZYCLAW_SKIP_CRON_INSTALL` skips the backend install (config still written).
|
|
9
|
+
|
|
10
|
+
export async function attachGoalCron({ readConfig, writeConfig, cron, name, schedule }) {
|
|
11
|
+
cron.parseCronSpec(schedule); // validate before touching state
|
|
12
|
+
const cfg = readConfig();
|
|
13
|
+
const jobName = `goal-${name}`;
|
|
14
|
+
const cmd = ['lazyclaw', 'goal', 'tick', name];
|
|
15
|
+
cron.upsertJob(cfg, jobName, schedule, cmd);
|
|
16
|
+
writeConfig(cfg);
|
|
17
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
|
|
18
|
+
const backend = cron.pickBackend();
|
|
19
|
+
if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
|
|
20
|
+
else cron.installCrontabJob(jobName, schedule, cmd);
|
|
21
|
+
return { jobName, skipped: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function detachGoalCron({ readConfig, writeConfig, cron, name }) {
|
|
25
|
+
const cfg = readConfig();
|
|
26
|
+
const jobName = `goal-${name}`;
|
|
27
|
+
if (!cfg.cron || !cfg.cron[jobName]) return false;
|
|
28
|
+
cron.removeJob(cfg, jobName);
|
|
29
|
+
writeConfig(cfg);
|
|
30
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
|
|
31
|
+
const backend = cron.pickBackend();
|
|
32
|
+
try {
|
|
33
|
+
if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
|
|
34
|
+
else cron.uninstallCrontabJob(jobName);
|
|
35
|
+
} catch { /* best-effort — cron sync recovers */ }
|
|
36
|
+
return true;
|
|
37
|
+
}
|
package/lib/args.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// CLI argument parsing + shell-completion generation, extracted from cli.mjs.
|
|
2
|
+
// Pure leaf module: no imports, no side effects — just the subcommand inventory
|
|
3
|
+
// and the parseArgs/completion functions that main() and `lazyclaw completion`
|
|
4
|
+
// share.
|
|
5
|
+
|
|
6
|
+
// Subcommand inventory used by `lazyclaw completion`. Single source of
|
|
7
|
+
// truth so adding a subcommand updates the completion script too. The
|
|
8
|
+
// dispatcher in main() is the runtime authority; this list mirrors it.
|
|
9
|
+
export const SUBCOMMANDS = [
|
|
10
|
+
'run', 'resume', 'inspect', 'clear', 'validate', 'graph',
|
|
11
|
+
'config', 'chat', 'agent',
|
|
12
|
+
'doctor', 'status', 'onboard',
|
|
13
|
+
'sessions', 'skills', 'providers',
|
|
14
|
+
'daemon', 'version', 'completion', 'help',
|
|
15
|
+
'export', 'import',
|
|
16
|
+
'rates',
|
|
17
|
+
// v5.0 — sandbox 6-backend (Phase D)
|
|
18
|
+
'sandbox',
|
|
19
|
+
// OpenClaw-parity subsurfaces (v3.93–v3.98)
|
|
20
|
+
'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
|
|
21
|
+
// v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
|
|
22
|
+
'setup', 'dashboard',
|
|
23
|
+
// v3.99.22 — multi-agent orchestrator config
|
|
24
|
+
'orchestrator',
|
|
25
|
+
// v3.99.30 — /loop and /goal slash commands (in-session + detached)
|
|
26
|
+
'loop', 'loops', 'goal', 'memory',
|
|
27
|
+
// v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
|
|
28
|
+
'slack',
|
|
29
|
+
// v4.3.0 — Telegram long-poll listener (zero-install mobile control)
|
|
30
|
+
'telegram',
|
|
31
|
+
// v4.3.0 — Matrix /sync long-poll listener
|
|
32
|
+
'matrix',
|
|
33
|
+
// v5.0 — channels plugin loader (Phase F)
|
|
34
|
+
'channels',
|
|
35
|
+
// v4.1.0 — multi-agent slack system (Phase 9+)
|
|
36
|
+
'agent', 'team', 'task',
|
|
37
|
+
// v5.0 Phase G — persona compose + cross-tool import (spec §9, §10)
|
|
38
|
+
'personality', 'migrate', 'hermes', 'openclaw',
|
|
39
|
+
// v5.0.6 — arrow-key launcher menu (was the no-arg default in v5.0.5-)
|
|
40
|
+
'menu',
|
|
41
|
+
// v5.0 Phase H1 — trajectory exporter (spec §2.7)
|
|
42
|
+
'trajectories',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const SUBCOMMAND_SUBS = {
|
|
46
|
+
config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
|
|
47
|
+
sessions: ['list', 'show', 'clear', 'export', 'search'],
|
|
48
|
+
skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
|
|
49
|
+
providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
|
|
50
|
+
rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
|
|
51
|
+
sandbox: ['list', 'test', 'add', 'use'],
|
|
52
|
+
completion: ['bash', 'zsh'],
|
|
53
|
+
auth: ['list', 'add', 'remove', 'use', 'rotate'],
|
|
54
|
+
pairing: ['list', 'add', 'remove'],
|
|
55
|
+
nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
|
|
56
|
+
message: ['list', 'add', 'remove', 'send'],
|
|
57
|
+
workspace: ['list', 'init', 'show', 'remove', 'path'],
|
|
58
|
+
cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
|
|
59
|
+
orchestrator: ['status', 'set-planner', 'workers', 'set-max-subtasks', 'clear'],
|
|
60
|
+
loops: ['list', 'show', 'kill', 'tail'],
|
|
61
|
+
goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
|
|
62
|
+
memory: ['show', 'dream', 'edit'],
|
|
63
|
+
slack: ['listen'],
|
|
64
|
+
telegram: ['listen'],
|
|
65
|
+
matrix: ['listen'],
|
|
66
|
+
agent: ['add', 'list', 'show', 'edit', 'remove'],
|
|
67
|
+
team: ['add', 'list', 'show', 'edit', 'remove'],
|
|
68
|
+
task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
|
|
69
|
+
trajectories: ['export'],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Flags whose presence is the signal — they don't consume the next arg
|
|
73
|
+
// even when one is available. Without this allow-list,
|
|
74
|
+
// `lazyclaw run --parallel demo wf.mjs` would set `flags.parallel='demo'`
|
|
75
|
+
// and silently lose the session id; the user would only see a
|
|
76
|
+
// "missing positional" error after the dispatcher rejected it.
|
|
77
|
+
export const BOOLEAN_FLAGS = new Set([
|
|
78
|
+
'parallel',
|
|
79
|
+
'parallel-persistent',
|
|
80
|
+
'once',
|
|
81
|
+
'non-interactive',
|
|
82
|
+
'include-secrets',
|
|
83
|
+
'include-sessions',
|
|
84
|
+
'overwrite-skills',
|
|
85
|
+
'no-overwrite-config',
|
|
86
|
+
'import-sessions',
|
|
87
|
+
'show-thinking',
|
|
88
|
+
'usage',
|
|
89
|
+
'cost',
|
|
90
|
+
'response-cache',
|
|
91
|
+
'help', // also handled as a subcommand alias
|
|
92
|
+
'version',
|
|
93
|
+
'summary', // inspect: trim per-node detail
|
|
94
|
+
'regex', // sessions search: treat query as a regex
|
|
95
|
+
'lr', // graph: emit Mermaid `graph LR` (left-right)
|
|
96
|
+
'force', // rates copy: overwrite existing destination
|
|
97
|
+
'aggregate', // inspect (list mode): per-node stats across sessions
|
|
98
|
+
'all', // providers test: run all providers in parallel
|
|
99
|
+
'with-turn-count', // sessions list: include turn count per session
|
|
100
|
+
'no-probe', // providers add: skip the /v1/models reachability probe
|
|
101
|
+
'pick', // onboard / chat: force the interactive picker even when provider already set
|
|
102
|
+
'detach', // loop: fork worker and return immediately
|
|
103
|
+
'use-memory', // loop: prepend core memory to each iteration
|
|
104
|
+
'force', // goal tick --force: bypass schedule when invoked manually
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
export function parseArgs(argv) {
|
|
108
|
+
const out = { positional: [], flags: {} };
|
|
109
|
+
for (let i = 0; i < argv.length; i++) {
|
|
110
|
+
const a = argv[i];
|
|
111
|
+
// POSIX `--`: everything after is positional verbatim. Used by
|
|
112
|
+
// `cron add <name> "<spec>" -- <cmd> [args...]` so a recurring
|
|
113
|
+
// command with --flag of its own doesn't get parsed as our flag.
|
|
114
|
+
if (a === '--') {
|
|
115
|
+
for (let j = i + 1; j < argv.length; j++) out.positional.push(argv[j]);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (a.startsWith('--')) {
|
|
119
|
+
const eq = a.indexOf('=');
|
|
120
|
+
if (eq >= 0) {
|
|
121
|
+
out.flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
122
|
+
} else {
|
|
123
|
+
const name = a.slice(2);
|
|
124
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
125
|
+
// Known boolean — never consumes the next arg.
|
|
126
|
+
out.flags[name] = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const next = argv[i + 1];
|
|
130
|
+
if (next === undefined || next.startsWith('--')) {
|
|
131
|
+
// Unknown flag at end-of-args or before another --flag: still boolean.
|
|
132
|
+
out.flags[name] = true;
|
|
133
|
+
} else {
|
|
134
|
+
out.flags[name] = next;
|
|
135
|
+
i += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else out.positional.push(a);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function bashCompletion() {
|
|
144
|
+
// Standard bash COMPREPLY pattern. We split COMP_WORDS into:
|
|
145
|
+
// [0] = lazyclaw, [1] = subcommand, [2+] = subcommand args.
|
|
146
|
+
// Two-level completion: word index 1 → top subcommands; index 2 → the
|
|
147
|
+
// sub-subcommand list (if defined for that subcommand). Beyond index 2
|
|
148
|
+
// we don't try to enumerate dynamic items (session ids etc.) — that
|
|
149
|
+
// would require running the CLI on every <Tab>, which is too slow.
|
|
150
|
+
const subs = SUBCOMMANDS.join(' ');
|
|
151
|
+
const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
|
|
152
|
+
.map(([name, list]) => ` ${name})\n COMPREPLY=( $(compgen -W "${list.join(' ')}" -- "$cur") )\n ;;`)
|
|
153
|
+
.join('\n');
|
|
154
|
+
return `# lazyclaw bash completion. Source from your shell:
|
|
155
|
+
# eval "$(node /path/to/cli.mjs completion bash)"
|
|
156
|
+
_lazyclaw_completion() {
|
|
157
|
+
local cur prev words cword
|
|
158
|
+
_init_completion 2>/dev/null || {
|
|
159
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
160
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
161
|
+
cword=$COMP_CWORD
|
|
162
|
+
}
|
|
163
|
+
if [ "$cword" -eq 1 ]; then
|
|
164
|
+
COMPREPLY=( $(compgen -W "${subs}" -- "$cur") )
|
|
165
|
+
return 0
|
|
166
|
+
fi
|
|
167
|
+
if [ "$cword" -eq 2 ]; then
|
|
168
|
+
case "\${COMP_WORDS[1]}" in
|
|
169
|
+
${subSubsCases}
|
|
170
|
+
esac
|
|
171
|
+
return 0
|
|
172
|
+
fi
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
complete -F _lazyclaw_completion lazyclaw
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function zshCompletion() {
|
|
180
|
+
// _arguments-style. We list subcommands then dispatch on the first
|
|
181
|
+
// positional via a single `_describe`. Sub-subcommands handled by a
|
|
182
|
+
// case inside the function. Same coverage rationale as bash.
|
|
183
|
+
const subs = SUBCOMMANDS.map(s => ` '${s}'`).join('\n');
|
|
184
|
+
const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
|
|
185
|
+
.map(([name, list]) => ` (${name}) _values 'sub' ${list.map(v => `'${v}'`).join(' ')} ;;`)
|
|
186
|
+
.join('\n');
|
|
187
|
+
return `#compdef lazyclaw
|
|
188
|
+
# lazyclaw zsh completion. Add to fpath, or eval inline:
|
|
189
|
+
# eval "$(node /path/to/cli.mjs completion zsh)"
|
|
190
|
+
_lazyclaw() {
|
|
191
|
+
local subs=(
|
|
192
|
+
${subs}
|
|
193
|
+
)
|
|
194
|
+
if (( CURRENT == 2 )); then
|
|
195
|
+
_values 'subcommand' \${subs[@]}
|
|
196
|
+
return
|
|
197
|
+
fi
|
|
198
|
+
if (( CURRENT == 3 )); then
|
|
199
|
+
case \${words[2]} in
|
|
200
|
+
${subSubsCases}
|
|
201
|
+
esac
|
|
202
|
+
return
|
|
203
|
+
fi
|
|
204
|
+
}
|
|
205
|
+
compdef _lazyclaw lazyclaw
|
|
206
|
+
_lazyclaw "$@"
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Subcommand-classifier sets for the multi-agent surfaces. cli.mjs uses
|
|
211
|
+
// AGENT_REG_SUBS to decide whether a bare `agent <sub>` routes to the agent
|
|
212
|
+
// registry vs a one-shot agent run. TEAM_SUBS/TASK_SUBS mirror their command's
|
|
213
|
+
// subcommands for the same disambiguation pattern.
|
|
214
|
+
export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
|
|
215
|
+
export const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
|
|
216
|
+
export const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Shared config + key/url resolution helpers, extracted from cli.mjs so the
|
|
2
|
+
// per-domain command modules (commands/*.mjs) can import them without pulling
|
|
3
|
+
// in the whole entrypoint. Leaf module: depends only on node builtins and the
|
|
4
|
+
// owner-only secure writer — never on the provider registry (the registry
|
|
5
|
+
// dependency is injected via setRegistryEnvResolver to keep this a leaf and
|
|
6
|
+
// avoid a config<->registry_boot import cycle).
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
// Owner-only (0600/0700) atomic writer for config.json — it holds plaintext
|
|
11
|
+
// API keys / auth profiles and must not be group/other readable.
|
|
12
|
+
import { writeJsonSecure, tightenIfLoose } from '../secure_write.mjs';
|
|
13
|
+
|
|
14
|
+
export function configPath() {
|
|
15
|
+
const override = process.env.LAZYCLAW_CONFIG_DIR;
|
|
16
|
+
const dir = override ? override : path.join(os.homedir(), '.lazyclaw');
|
|
17
|
+
return path.join(dir, 'config.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readConfig() {
|
|
21
|
+
const p = configPath();
|
|
22
|
+
if (!fs.existsSync(p)) return {};
|
|
23
|
+
// Migrate already-deployed world/group-readable config.json (plaintext keys)
|
|
24
|
+
// to 0600 the first time we touch it. Best-effort, idempotent.
|
|
25
|
+
tightenIfLoose(p);
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
27
|
+
catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeConfig(cfg) {
|
|
31
|
+
// 0600 file in a 0700 dir, atomically — config.json stores plaintext API
|
|
32
|
+
// keys / auth profiles, so it must be owner-only on disk.
|
|
33
|
+
writeJsonSecure(configPath(), cfg);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Injected built-in OpenAI-compat env-var resolver. registry_boot wires this
|
|
37
|
+
// to registry.resolveBuiltinEnvKey on ensureRegistry() so _resolveAuthKey can
|
|
38
|
+
// fall back to provider env vars without importing the registry (no cycle).
|
|
39
|
+
let _envResolver = null;
|
|
40
|
+
export function setRegistryEnvResolver(fn) { _envResolver = fn; }
|
|
41
|
+
|
|
42
|
+
// Synchronous, dependency-free resolver for the api-key the
|
|
43
|
+
// chat / agent flow sends. Mirrors config_features.resolveApiKey
|
|
44
|
+
// without forcing the dynamic import on every hot-path call.
|
|
45
|
+
// 1. cfg.authProfiles[provider] active label, if set
|
|
46
|
+
// 2. first profile in the array
|
|
47
|
+
// 3. customProviders[<provider>].apiKey (custom OpenAI-compat entries)
|
|
48
|
+
// 4. PROVIDER_INFO[<provider>].envKey / altEnvKeys env var (built-in
|
|
49
|
+
// OpenAI-compat: nim → NVIDIA_API_KEY, openrouter → OPENROUTER_API_KEY, …)
|
|
50
|
+
// 5. legacy single `cfg["api-key"]` (pre-v3.93 configs)
|
|
51
|
+
export function _resolveAuthKey(cfg, provider) {
|
|
52
|
+
const arr = (cfg.authProfiles || {})[provider] || [];
|
|
53
|
+
const active = (cfg.authActiveProfile || {})[provider];
|
|
54
|
+
const hit = arr.find((p) => p && p.label === active) || arr[0];
|
|
55
|
+
if (hit?.key) return hit.key;
|
|
56
|
+
const custom = Array.isArray(cfg.customProviders)
|
|
57
|
+
? cfg.customProviders.find((p) => p && p.name === provider)
|
|
58
|
+
: null;
|
|
59
|
+
if (custom?.apiKey) return custom.apiKey;
|
|
60
|
+
// Built-in OpenAI-compat env var fallback. Skipped silently when the
|
|
61
|
+
// registry module isn't loaded yet (every chat / agent path calls
|
|
62
|
+
// ensureRegistry() before _resolveAuthKey, so this is just defence-in-depth).
|
|
63
|
+
if (typeof _envResolver === 'function') {
|
|
64
|
+
const envHit = _envResolver(provider);
|
|
65
|
+
if (envHit) return envHit;
|
|
66
|
+
}
|
|
67
|
+
return cfg['api-key'] || '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-provider base-URL override (used by tests + private gateways).
|
|
71
|
+
// Single source of truth — the reflect / skill-synth / task-tick paths
|
|
72
|
+
// all resolve through here so a new provider's env var lands in one spot.
|
|
73
|
+
export function _resolveBaseUrl(provider) {
|
|
74
|
+
return {
|
|
75
|
+
anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
|
|
76
|
+
openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
|
|
77
|
+
gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
|
|
78
|
+
}[provider] || undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readVersionFromRepo() {
|
|
82
|
+
// Two source-of-truth lookups, in order:
|
|
83
|
+
// 1. The npm-published package's own package.json (sits next to the
|
|
84
|
+
// entrypoint once installed via `npm i -g lazyclaw`).
|
|
85
|
+
// 2. The monorepo's VERSION file at the repo root (one or two
|
|
86
|
+
// levels up depending on how the file is symlinked / copied).
|
|
87
|
+
// Either one wins on first hit. Falls back to '0.0.0' so the CLI
|
|
88
|
+
// never crashes on a stripped-down install. The candidate list is a
|
|
89
|
+
// superset spanning both the repo-root entrypoint and this lib/ module
|
|
90
|
+
// location so resolution is identical regardless of where it runs from.
|
|
91
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
92
|
+
const candidates = [
|
|
93
|
+
{ kind: 'pkg', path: path.resolve(here, './package.json') },
|
|
94
|
+
{ kind: 'pkg', path: path.resolve(here, '../package.json') },
|
|
95
|
+
{ kind: 'pkg', path: path.resolve(here, '../../package.json') },
|
|
96
|
+
{ kind: 'version', path: path.resolve(here, '../../VERSION') },
|
|
97
|
+
{ kind: 'version', path: path.resolve(here, '../../../VERSION') },
|
|
98
|
+
{ kind: 'version', path: path.resolve(here, '../../../../VERSION') },
|
|
99
|
+
];
|
|
100
|
+
for (const c of candidates) {
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(c.path, 'utf8').trim();
|
|
103
|
+
if (!raw) continue;
|
|
104
|
+
if (c.kind === 'pkg') {
|
|
105
|
+
const v = JSON.parse(raw).version;
|
|
106
|
+
if (v) return v;
|
|
107
|
+
} else {
|
|
108
|
+
return raw;
|
|
109
|
+
}
|
|
110
|
+
} catch { /* keep trying */ }
|
|
111
|
+
}
|
|
112
|
+
return '0.0.0';
|
|
113
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Provider-registry bootstrap, extracted from cli.mjs so every command module
|
|
2
|
+
// (commands/*.mjs) shares one lazily-loaded registry instance instead of each
|
|
3
|
+
// re-importing and re-registering. One-directional dependency: registry_boot
|
|
4
|
+
// -> config (never the reverse), so this stays cycle-free.
|
|
5
|
+
import { readConfig, _resolveAuthKey, setRegistryEnvResolver } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
let _registryMod = null;
|
|
8
|
+
|
|
9
|
+
// Throwing accessor — use when the caller has already awaited ensureRegistry()
|
|
10
|
+
// and a null registry would be a programmer error.
|
|
11
|
+
export function requireRegistry() {
|
|
12
|
+
if (!_registryMod) {
|
|
13
|
+
throw new Error('registry module not pre-loaded — call ensureRegistry() first');
|
|
14
|
+
}
|
|
15
|
+
return _registryMod;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Nullable accessor — returns the loaded registry module or null. Mirrors the
|
|
19
|
+
// old bare `_registryMod` read semantics for sites that tolerate "not loaded".
|
|
20
|
+
export function getRegistry() {
|
|
21
|
+
return _registryMod;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureRegistry() {
|
|
25
|
+
if (!_registryMod) {
|
|
26
|
+
_registryMod = await import('../providers/registry.mjs');
|
|
27
|
+
// Wire config._resolveAuthKey's built-in env-var fallback to the registry
|
|
28
|
+
// without config importing the registry (keeps config a leaf module).
|
|
29
|
+
if (typeof _registryMod.resolveBuiltinEnvKey === 'function') {
|
|
30
|
+
setRegistryEnvResolver((p) => _registryMod.resolveBuiltinEnvKey(p));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Re-run registration on every call so config changes within the same
|
|
34
|
+
// process (e.g. setup wizard adding a custom endpoint mid-session) take
|
|
35
|
+
// effect for the next chat / agent / picker invocation. registerCustom-
|
|
36
|
+
// Providers is idempotent — re-registering the same name is a no-op.
|
|
37
|
+
try {
|
|
38
|
+
if (typeof _registryMod.registerCustomProviders === 'function') {
|
|
39
|
+
_registryMod.registerCustomProviders(readConfig());
|
|
40
|
+
}
|
|
41
|
+
} catch { /* never let a malformed cfg.customProviders block startup */ }
|
|
42
|
+
// Wire the orchestrator's live cfg + auth-key resolver. We do this on
|
|
43
|
+
// every ensureRegistry() call (cheap — just replaces the closure) so a
|
|
44
|
+
// mid-session config edit (custom provider added, env var exported)
|
|
45
|
+
// takes effect on the next orchestrator turn without a restart.
|
|
46
|
+
try {
|
|
47
|
+
if (typeof _registryMod.registerOrchestrator === 'function') {
|
|
48
|
+
_registryMod.registerOrchestrator({
|
|
49
|
+
cfgGetter: readConfig,
|
|
50
|
+
keyResolver: _resolveAuthKey,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
} catch { /* defensive */ }
|
|
54
|
+
return _registryMod;
|
|
55
|
+
}
|
package/mas/agent_turn.mjs
CHANGED
|
@@ -94,6 +94,7 @@ export async function runAgentTurn({
|
|
|
94
94
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
95
95
|
signal,
|
|
96
96
|
approve,
|
|
97
|
+
security,
|
|
97
98
|
// v5 (Group A — C3): trajectoryRef is OPT-OUT, not opt-in. A caller
|
|
98
99
|
// that doesn't pass one but DOES pass a configDir gets a
|
|
99
100
|
// default-stamped record so every production agent turn lands in
|
|
@@ -218,7 +219,7 @@ export async function runAgentTurn({
|
|
|
218
219
|
try {
|
|
219
220
|
result = await runTool({
|
|
220
221
|
agent, tool: call.name, args: call.input,
|
|
221
|
-
taskId, configDir, cwd, approve,
|
|
222
|
+
taskId, configDir, cwd, approve, security,
|
|
222
223
|
});
|
|
223
224
|
if (result && result.ok === false) ok = false;
|
|
224
225
|
} catch (err) {
|
package/mas/index_db.mjs
CHANGED
|
@@ -85,6 +85,16 @@ function prepareStatements(db) {
|
|
|
85
85
|
insertMemory: db.prepare(
|
|
86
86
|
`INSERT INTO fts_memories(content, topic, kind)
|
|
87
87
|
VALUES (?, ?, ?)`),
|
|
88
|
+
// Upsert-by-natural-key deletes (skills/trajectories/memories only —
|
|
89
|
+
// these get re-indexed for the same key on every save/put, so a bare
|
|
90
|
+
// INSERT would accumulate duplicate FTS rows that skew bm25 and eat the
|
|
91
|
+
// recall k-budget). Sessions are NOT deduped this way: each turn has a
|
|
92
|
+
// unique (session_id,turn_idx) in normal flow, and a per-turn full-table
|
|
93
|
+
// DELETE would reintroduce O(n^2) on the hot write path; session dedup is
|
|
94
|
+
// handled by reindexAll rebuilding from scratch.
|
|
95
|
+
deleteSkill: db.prepare(`DELETE FROM fts_skills WHERE skill_name = ?`),
|
|
96
|
+
deleteTrajectory: db.prepare(`DELETE FROM fts_trajectories WHERE trajectory_id = ?`),
|
|
97
|
+
deleteMemory: db.prepare(`DELETE FROM fts_memories WHERE topic = ? AND kind = ?`),
|
|
88
98
|
queries: {
|
|
89
99
|
sessions: db.prepare(
|
|
90
100
|
`SELECT 'sessions' AS scope, bm25(fts_sessions) AS bm25,
|
|
@@ -160,6 +170,7 @@ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
|
|
|
160
170
|
export function indexSkill(row, configDir = defaultConfigDir()) {
|
|
161
171
|
try {
|
|
162
172
|
const s = _stmts(configDir);
|
|
173
|
+
s.deleteSkill.run(String(row.skill_name || '')); // upsert by skill_name
|
|
163
174
|
s.insertSkill.run(
|
|
164
175
|
redactSecrets(String(row.content || '')),
|
|
165
176
|
String(row.skill_name || ''), String(row.trained_by || ''),
|
|
@@ -175,6 +186,7 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
|
|
|
175
186
|
export function indexTrajectory(row, configDir = defaultConfigDir()) {
|
|
176
187
|
try {
|
|
177
188
|
const s = _stmts(configDir);
|
|
189
|
+
s.deleteTrajectory.run(String(row.trajectory_id || '')); // upsert by trajectory_id
|
|
178
190
|
s.insertTrajectory.run(
|
|
179
191
|
redactSecrets(String(row.content || '')),
|
|
180
192
|
String(row.trajectory_id || ''), String(row.agent || ''),
|
|
@@ -190,6 +202,7 @@ export function indexTrajectory(row, configDir = defaultConfigDir()) {
|
|
|
190
202
|
export function indexMemory(row, configDir = defaultConfigDir()) {
|
|
191
203
|
try {
|
|
192
204
|
const s = _stmts(configDir);
|
|
205
|
+
s.deleteMemory.run(String(row.topic || ''), String(row.kind || '')); // upsert by (topic,kind)
|
|
193
206
|
s.insertMemory.run(
|
|
194
207
|
redactSecrets(String(row.content || '')),
|
|
195
208
|
String(row.topic || ''), String(row.kind || ''),
|
|
@@ -287,6 +300,9 @@ export function integrityCheck(configDir = defaultConfigDir()) {
|
|
|
287
300
|
return { ok: r === 'ok', result: r };
|
|
288
301
|
}
|
|
289
302
|
|
|
303
|
+
// Destructive primitive: drop the db (+ WAL sidecars) and recreate the empty
|
|
304
|
+
// schema. Callers that want a *populated* index must use reindexAll — a bare
|
|
305
|
+
// rebuild leaves recall returning zero hits.
|
|
290
306
|
export function rebuild(configDir = defaultConfigDir()) {
|
|
291
307
|
closeIndex(configDir);
|
|
292
308
|
const p = dbPath(configDir);
|
|
@@ -300,3 +316,69 @@ export function rebuild(configDir = defaultConfigDir()) {
|
|
|
300
316
|
}
|
|
301
317
|
openIndex(configDir);
|
|
302
318
|
}
|
|
319
|
+
|
|
320
|
+
// Minimal frontmatter splitter (trained_by / group are the only keys reindex
|
|
321
|
+
// needs); avoids importing skills.mjs and risking an import cycle.
|
|
322
|
+
function _miniFrontmatter(raw) {
|
|
323
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(String(raw || ''));
|
|
324
|
+
if (!m) return { meta: {}, body: String(raw || '') };
|
|
325
|
+
const meta = {};
|
|
326
|
+
for (const line of m[1].split('\n')) {
|
|
327
|
+
const i = line.indexOf(':');
|
|
328
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
329
|
+
}
|
|
330
|
+
return { meta, body: m[2] };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Rebuild AND repopulate the FTS index from the on-disk source of truth
|
|
334
|
+
// (sessions JSONL, flat skill .md, memory core/episodic). This is what
|
|
335
|
+
// `index rebuild` / doctor recovery must call — a bare rebuild() zeroes recall.
|
|
336
|
+
// Shared by scripts/migrate-v5 and the daemon POST /index/rebuild route.
|
|
337
|
+
export function reindexAll(configDir = defaultConfigDir()) {
|
|
338
|
+
rebuild(configDir);
|
|
339
|
+
// Sessions — flat <configDir>/sessions/<id>.jsonl, one turn per line.
|
|
340
|
+
const sessDir = path.join(configDir, 'sessions');
|
|
341
|
+
if (fs.existsSync(sessDir)) {
|
|
342
|
+
for (const f of fs.readdirSync(sessDir)) {
|
|
343
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
344
|
+
const id = f.slice(0, -'.jsonl'.length);
|
|
345
|
+
let idx = 0;
|
|
346
|
+
const raw = fs.readFileSync(path.join(sessDir, f), 'utf8');
|
|
347
|
+
for (const line of raw.split('\n')) {
|
|
348
|
+
if (!line) continue;
|
|
349
|
+
try {
|
|
350
|
+
const o = JSON.parse(line);
|
|
351
|
+
indexSessionTurn({ session_id: id, turn_idx: idx++, role: o.role || 'user', ts: o.ts || 0, content: o.content || '' }, configDir);
|
|
352
|
+
} catch { /* skip malformed line */ }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Skills — canonical flat <configDir>/skills/<name>.md (skip the .archive dir).
|
|
357
|
+
const skillsDir = path.join(configDir, 'skills');
|
|
358
|
+
if (fs.existsSync(skillsDir)) {
|
|
359
|
+
for (const f of fs.readdirSync(skillsDir)) {
|
|
360
|
+
if (!f.endsWith('.md')) continue;
|
|
361
|
+
const name = f.slice(0, -'.md'.length);
|
|
362
|
+
const { meta, body } = _miniFrontmatter(fs.readFileSync(path.join(skillsDir, f), 'utf8'));
|
|
363
|
+
indexSkill({
|
|
364
|
+
skill_name: name,
|
|
365
|
+
trained_by: meta.trained_by || 'legacy',
|
|
366
|
+
group_name: meta.group || (name.includes('-') ? name.split('-')[0] : 'legacy'),
|
|
367
|
+
content: body,
|
|
368
|
+
}, configDir);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Memory — core.md + episodic/*.md.
|
|
372
|
+
const memDir = path.join(configDir, 'memory');
|
|
373
|
+
if (fs.existsSync(memDir)) {
|
|
374
|
+
const core = path.join(memDir, 'core.md');
|
|
375
|
+
if (fs.existsSync(core)) indexMemory({ topic: 'core', kind: 'core', content: fs.readFileSync(core, 'utf8') }, configDir);
|
|
376
|
+
const epi = path.join(memDir, 'episodic');
|
|
377
|
+
if (fs.existsSync(epi)) {
|
|
378
|
+
for (const f of fs.readdirSync(epi)) {
|
|
379
|
+
if (!f.endsWith('.md')) continue;
|
|
380
|
+
indexMemory({ topic: f.slice(0, -'.md'.length), kind: 'episodic', content: fs.readFileSync(path.join(epi, f), 'utf8') }, configDir);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
package/mas/learning.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import * as userModeler from './user_modeler.mjs';
|
|
|
34
34
|
import * as confidence from './confidence.mjs';
|
|
35
35
|
import * as skills from '../skills.mjs';
|
|
36
36
|
import { resolveTrainer } from '../providers/registry.mjs';
|
|
37
|
+
import { hasClaudeCliSession } from '../providers/claude_cli_detect.mjs';
|
|
37
38
|
|
|
38
39
|
export const TRIGGERS = Object.freeze([
|
|
39
40
|
'post-task',
|
|
@@ -330,9 +331,24 @@ export async function _runPeriodicCuration(_ctx, logger) {
|
|
|
330
331
|
|
|
331
332
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
332
333
|
|
|
334
|
+
let _trainerNoticed = false;
|
|
335
|
+
|
|
333
336
|
function _safeResolveTrainer(cfg, agent) {
|
|
334
337
|
try {
|
|
335
|
-
|
|
338
|
+
const c = cfg || { provider: agent?.provider, model: agent?.model };
|
|
339
|
+
// Use real claude-cli session detection in production (the resolver's own
|
|
340
|
+
// default stub only checked an env var that `claude login` never sets).
|
|
341
|
+
const resolved = resolveTrainer(c, { detectClaudeCli: hasClaudeCliSession });
|
|
342
|
+
// Honesty: if the user asked for trainer.provider:'auto' but no claude-cli
|
|
343
|
+
// session was found, the learning loop bills the chat provider — say so once
|
|
344
|
+
// so the "$0 on Claude Pro" promise never fails silently.
|
|
345
|
+
if (!_trainerNoticed && c && c.trainer && c.trainer.provider === 'auto' && resolved.provider !== 'claude-cli') {
|
|
346
|
+
_trainerNoticed = true;
|
|
347
|
+
try {
|
|
348
|
+
process.stderr.write(`[trainer] no claude-cli session detected → learning will use "${resolved.provider || 'the chat provider'}" (billed per token). Run 'claude login', or set trainer.provider explicitly, to control cost.\n`);
|
|
349
|
+
} catch { /* stderr closed */ }
|
|
350
|
+
}
|
|
351
|
+
return resolved;
|
|
336
352
|
} catch {
|
|
337
353
|
return { provider: agent?.provider || '', model: agent?.model || '' };
|
|
338
354
|
}
|