lazyclaw 5.1.0 → 5.2.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/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +280 -55
- package/daemon.mjs +87 -7
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/repl.mjs +6 -0
- package/tui/run_turn.mjs +138 -0
- package/web/dashboard.html +1 -1
package/mas/mention_router.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import * as tasksMod from '../tasks.mjs';
|
|
|
25
25
|
import * as agentMemory from './agent_memory.mjs';
|
|
26
26
|
import * as skillSynth from './skill_synth.mjs';
|
|
27
27
|
import * as skills from '../skills.mjs';
|
|
28
|
+
import { composePromptStack } from './prompt_stack.mjs';
|
|
28
29
|
|
|
29
30
|
export class MentionRouterError extends Error {
|
|
30
31
|
constructor(message, code) {
|
|
@@ -97,9 +98,31 @@ export function buildTurnContext({ task, team, agent, agentRecord, teammates, co
|
|
|
97
98
|
// The agent loads a full skill on demand with the skill_view tool —
|
|
98
99
|
// progressive disclosure, so skill bodies don't bloat every prompt.
|
|
99
100
|
const skillsBlock = buildSkillsBlock(configDir);
|
|
101
|
+
// v5 (canonical decision C5) — prepend the 8-layer prompt stack so the
|
|
102
|
+
// workspace SOUL / personality / USER.md / long-term memory layers land
|
|
103
|
+
// ahead of the agent's role. composePromptStack returns '' on a fresh
|
|
104
|
+
// install so the prompt is byte-identical to the pre-v5 shape when no
|
|
105
|
+
// layer source exists on disk. Wrapped in try/catch so a missing
|
|
106
|
+
// configDir file never crashes a live agent turn.
|
|
107
|
+
let promptStack = '';
|
|
108
|
+
try {
|
|
109
|
+
promptStack = composePromptStack({
|
|
110
|
+
cfgDir: configDir,
|
|
111
|
+
agent: agentRecord,
|
|
112
|
+
workspace: task?.workspace || agentRecord.workspace || '',
|
|
113
|
+
}) || '';
|
|
114
|
+
} catch { /* best-effort — see comment above */ }
|
|
115
|
+
// When composePromptStack emitted a Role layer (layer 3) we drop the
|
|
116
|
+
// bare `role` line below to avoid duplicating agent.role inside the
|
|
117
|
+
// system prompt. The stack helper builds the Role layer iff
|
|
118
|
+
// agentRecord.role is non-empty, so checking `promptStack` includes
|
|
119
|
+
// the marker `## Role (` is sufficient.
|
|
120
|
+
const stackHasRole = promptStack.includes('## Role (');
|
|
100
121
|
const system = [
|
|
101
|
-
|
|
102
|
-
|
|
122
|
+
promptStack || null,
|
|
123
|
+
promptStack && '\n\n---\n',
|
|
124
|
+
stackHasRole ? null : role,
|
|
125
|
+
!stackHasRole && role && '\n\n---\n',
|
|
103
126
|
memBlock || null,
|
|
104
127
|
skillsBlock || null,
|
|
105
128
|
`You are *${agentRecord.displayName || agentRecord.name}* on team "${team.displayName || team.name}".`,
|
|
@@ -113,7 +136,56 @@ export function buildTurnContext({ task, team, agent, agentRecord, teammates, co
|
|
|
113
136
|
renderTranscript(task.turns),
|
|
114
137
|
`\n\n# Your turn (as ${agentRecord.name}):`,
|
|
115
138
|
];
|
|
116
|
-
|
|
139
|
+
|
|
140
|
+
// Group B / C10 — also emit history-as-messages. The legacy
|
|
141
|
+
// single-string `user` field is kept for callers that snapshot it
|
|
142
|
+
// (existing tests), but production code (runTaskTurn) now wires
|
|
143
|
+
// `history` into runAgentTurn so Anthropic's prompt cache + KV
|
|
144
|
+
// cache can lock onto the prior turns byte-identically across
|
|
145
|
+
// router iterations.
|
|
146
|
+
//
|
|
147
|
+
// Mapping:
|
|
148
|
+
// - turn.agent === 'user' → role:'user', plain content
|
|
149
|
+
// - turn.agent === speaker → role:'assistant' (the model's own
|
|
150
|
+
// prior turn — looks like an assistant's text from its POV)
|
|
151
|
+
// - turn.agent === any other teammate → role:'user' with a
|
|
152
|
+
// `[FROM x]` prefix so the model knows it's a peer speaking,
|
|
153
|
+
// not the human user
|
|
154
|
+
// We always prepend a single user-role kickoff message with the
|
|
155
|
+
// task spec so the first message in the history is anchored on the
|
|
156
|
+
// task; and we always append a final user-role "your turn" marker
|
|
157
|
+
// so the model knows when to speak.
|
|
158
|
+
const history = [];
|
|
159
|
+
const taskKickoff = [
|
|
160
|
+
`# Task: ${task.title}`,
|
|
161
|
+
task.description ? `\n${task.description}\n` : '',
|
|
162
|
+
].join('').trim();
|
|
163
|
+
if (taskKickoff) history.push({ role: 'user', content: taskKickoff });
|
|
164
|
+
for (const t of (Array.isArray(task.turns) ? task.turns : [])) {
|
|
165
|
+
if (!t || !t.agent) continue;
|
|
166
|
+
const txt = String(t.text || '');
|
|
167
|
+
if (!txt) continue;
|
|
168
|
+
if (t.agent === 'user') {
|
|
169
|
+
history.push({ role: 'user', content: txt });
|
|
170
|
+
} else if (t.agent === agentRecord.name) {
|
|
171
|
+
history.push({ role: 'assistant', content: txt });
|
|
172
|
+
} else if (t.agent === 'system') {
|
|
173
|
+
// System pseudo-turns (kickoff seed) collapse into a user-role
|
|
174
|
+
// prefix-tagged note so message-role alternation stays clean.
|
|
175
|
+
history.push({ role: 'user', content: `[SYSTEM] ${txt}` });
|
|
176
|
+
} else {
|
|
177
|
+
history.push({ role: 'user', content: `[FROM ${t.agent}] ${txt}` });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// "Your turn" marker — always a user-role tail so the model is
|
|
181
|
+
// prompted to speak as the named agent. Combined with the speaker
|
|
182
|
+
// mapping above this gives Anthropic a stable cacheable prefix:
|
|
183
|
+
// the first N-1 messages are identical across router iterations
|
|
184
|
+
// for the same task, and only this final marker (plus a new prior
|
|
185
|
+
// turn) changes per router pass.
|
|
186
|
+
history.push({ role: 'user', content: `# Your turn (as ${agentRecord.name})` });
|
|
187
|
+
|
|
188
|
+
return { system, user: userParts.join(''), history };
|
|
117
189
|
}
|
|
118
190
|
|
|
119
191
|
// Build the system-prompt block listing the skills the agent can pull
|
|
@@ -238,7 +310,7 @@ async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, confi
|
|
|
238
310
|
for (const name of collectParticipants(task)) {
|
|
239
311
|
const agentRecord = agentsById[name];
|
|
240
312
|
if (!agentRecord) continue;
|
|
241
|
-
if (agentRecord.memoryWrite
|
|
313
|
+
if ((agentRecord.memoryWrite ?? 'auto') !== 'auto') continue;
|
|
242
314
|
try {
|
|
243
315
|
const body = await agentMemory.reflectOnce({
|
|
244
316
|
agent: agentRecord,
|
|
@@ -257,18 +329,19 @@ async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, confi
|
|
|
257
329
|
}
|
|
258
330
|
}
|
|
259
331
|
|
|
260
|
-
// Phase 20 — fire one skill-synthesis LLM call per
|
|
261
|
-
// whose skillWrite is 'auto'
|
|
262
|
-
// the
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
332
|
+
// Phase 20 / v5 Group A (M3) — fire one skill-synthesis LLM call per
|
|
333
|
+
// participating agent whose skillWrite is 'auto' (the default since
|
|
334
|
+
// v5). Installs the resulting SKILL.md into the shared skills dir.
|
|
335
|
+
// Legacy v4 agents (no explicit skillWrite field) inherit the new
|
|
336
|
+
// 'auto' default via the `?? 'auto'` guard below — without a forced
|
|
337
|
+
// migration. Best-effort: a failed synthesis is logged, never thrown,
|
|
338
|
+
// so it can't poison a finished task.
|
|
266
339
|
async function autoSynthSkills({ task, agentsById, apiKey, baseUrl, fetchImpl, configDir, logger = () => {} }) {
|
|
267
340
|
if (!task || !Array.isArray(task.turns)) return;
|
|
268
341
|
for (const name of collectParticipants(task)) {
|
|
269
342
|
const agentRecord = agentsById[name];
|
|
270
343
|
if (!agentRecord) continue;
|
|
271
|
-
if (agentRecord.skillWrite !== 'auto') continue;
|
|
344
|
+
if ((agentRecord.skillWrite ?? 'auto') !== 'auto') continue;
|
|
272
345
|
try {
|
|
273
346
|
const result = await skillSynth.synthesizeSkill({ agent: agentRecord, task, apiKey, baseUrl, fetchImpl });
|
|
274
347
|
if (result) {
|
|
@@ -370,10 +443,18 @@ export async function runTaskTurn({
|
|
|
370
443
|
try {
|
|
371
444
|
result = await agentTurn.runAgentTurn({
|
|
372
445
|
agent: { ...agentRecord, role: ctx.system },
|
|
373
|
-
|
|
374
|
-
history
|
|
446
|
+
// Group B / C10 — feed the transcript as a proper messages
|
|
447
|
+
// history. The kickoff + prior turns + "your turn" marker
|
|
448
|
+
// live in ctx.history; we leave userMessage empty so the
|
|
449
|
+
// adapter doesn't double-append a redundant user turn.
|
|
450
|
+
userMessage: '',
|
|
451
|
+
history: ctx.history,
|
|
375
452
|
taskId: current.id,
|
|
376
453
|
configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve,
|
|
454
|
+
// C9 — enable Anthropic prompt caching for the static system
|
|
455
|
+
// prefix + tool definitions. Non-anthropic adapters ignore
|
|
456
|
+
// the flag (it's a no-op for OpenAI/Gemini/claude-cli).
|
|
457
|
+
cache: true,
|
|
377
458
|
});
|
|
378
459
|
} catch (err) {
|
|
379
460
|
await clearTypingPlaceholder(typing, logger);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// mas/orchestra.mjs — v5.0 spec §3 / canonical decision C2.
|
|
2
|
+
//
|
|
3
|
+
// "Orchestra" hub — the eventual home of the multi-agent dispatcher
|
|
4
|
+
// (planner + workers + synthesis). For v5.0.10 it acts as a thin
|
|
5
|
+
// coordinator layer:
|
|
6
|
+
//
|
|
7
|
+
// 1. Re-exports `makeOrchestratorProvider` from providers/orchestrator.mjs
|
|
8
|
+
// so cli.mjs / providers/registry.mjs can migrate the import path
|
|
9
|
+
// without breaking ~20 call sites in one commit.
|
|
10
|
+
// 2. Owns `POST_HOC_HOOK_NAMES` — the canonical list of triggers the
|
|
11
|
+
// orchestrator may fan into mas/learning.mjs. Mirrors the
|
|
12
|
+
// learning module's TRIGGERS so callers can `import { POST_HOC_HOOK_NAMES }
|
|
13
|
+
// from 'mas/orchestra.mjs'` without pulling the whole learning
|
|
14
|
+
// surface.
|
|
15
|
+
// 3. Provides `firePostTask({ ... })` — a microtask-safe wrapper around
|
|
16
|
+
// `learning.runLearning('post-task', …)` so the SYNTHESIS phase of
|
|
17
|
+
// providers/orchestrator.mjs can hook in via a single import line.
|
|
18
|
+
//
|
|
19
|
+
// Future (Phase 2): the dispatcher itself moves in here and
|
|
20
|
+
// providers/orchestrator.mjs becomes a thin shim that re-exports from
|
|
21
|
+
// mas/orchestra.mjs. Until then the boundary is one-way — orchestra
|
|
22
|
+
// imports from providers/orchestrator.mjs, never the other way around.
|
|
23
|
+
|
|
24
|
+
import { makeOrchestratorProvider as _make } from '../providers/orchestrator.mjs';
|
|
25
|
+
import { TRIGGERS, runLearning } from './learning.mjs';
|
|
26
|
+
|
|
27
|
+
// Re-export so callers can migrate gradually.
|
|
28
|
+
export const makeOrchestratorProvider = _make;
|
|
29
|
+
|
|
30
|
+
// Public alias mirroring the spec language ("post-hoc hooks"). Frozen
|
|
31
|
+
// so a typo on the consumer side surfaces at import time.
|
|
32
|
+
export const POST_HOC_HOOK_NAMES = TRIGGERS;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fire a learning trigger without blocking the caller's stream. Wraps
|
|
36
|
+
* runLearning in queueMicrotask + a swallow-everything catch so a
|
|
37
|
+
* misconfigured trainer key (or a transient FS error) can never poison
|
|
38
|
+
* the user-facing orchestrator output.
|
|
39
|
+
*
|
|
40
|
+
* @param {(typeof TRIGGERS)[number]} trigger
|
|
41
|
+
* @param {Record<string, unknown>} ctx
|
|
42
|
+
*/
|
|
43
|
+
export function fireLearning(trigger, ctx) {
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
runLearning(trigger, ctx).catch(() => { /* swallow */ });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convenience for the common case — the orchestrator's SYNTHESIS phase
|
|
51
|
+
* just finished, fire the post-task hook. Equivalent to
|
|
52
|
+
* fireLearning('post-task', ctx).
|
|
53
|
+
*/
|
|
54
|
+
export function firePostTask(ctx) {
|
|
55
|
+
fireLearning('post-task', ctx);
|
|
56
|
+
}
|
package/mas/skill_synth.mjs
CHANGED
|
@@ -297,7 +297,25 @@ function buildAntiPatternPrompt(task, transcript) {
|
|
|
297
297
|
// agent skill, which we then version-bump). Body/description are
|
|
298
298
|
// re-sanitised here too so a direct caller (CLI/router) can't bypass
|
|
299
299
|
// the redaction + cap. Returns { skill, path, version }.
|
|
300
|
-
export function installSynthesized({
|
|
300
|
+
export function installSynthesized({
|
|
301
|
+
name,
|
|
302
|
+
description = '',
|
|
303
|
+
body = '',
|
|
304
|
+
sourceTask = '',
|
|
305
|
+
createdBy = 'agent',
|
|
306
|
+
// v5 (Group A — C6 + M1): forward every frontmatter field
|
|
307
|
+
// synthesizeSkill / runLearning already computed. Without these
|
|
308
|
+
// forwards the resulting SKILL.md was missing trained_by, confidence,
|
|
309
|
+
// cross_cli_tested and the anti-pattern flag — exactly the metadata
|
|
310
|
+
// the canonical learning loop needs to rank skills cross-CLI.
|
|
311
|
+
trainedBy = null,
|
|
312
|
+
trainedOnModel = null,
|
|
313
|
+
trajectoryRef = null,
|
|
314
|
+
confidence = null,
|
|
315
|
+
crossCliTested = null,
|
|
316
|
+
outcome = 'done',
|
|
317
|
+
group = null,
|
|
318
|
+
} = {}, configDir, ts = new Date()) {
|
|
301
319
|
// Slugify BEFORE reserving the name so a direct caller (CLI/router)
|
|
302
320
|
// can't smuggle a newline/colon into the filename or inject a second
|
|
303
321
|
// frontmatter key. parseSynthOutput already slugifies, but this path
|
|
@@ -313,6 +331,13 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
|
|
|
313
331
|
body: sanitizeSkillBody(body),
|
|
314
332
|
version,
|
|
315
333
|
ts,
|
|
334
|
+
trainedBy,
|
|
335
|
+
trainedOnModel,
|
|
336
|
+
trajectoryRef,
|
|
337
|
+
confidence,
|
|
338
|
+
crossCliTested,
|
|
339
|
+
outcome,
|
|
340
|
+
group,
|
|
316
341
|
});
|
|
317
342
|
const p = skills.installSkill(finalName, doc, configDir);
|
|
318
343
|
// Phase A: FTS5 mirror (spec §4.4). Group fallback per canonical C5.
|
|
@@ -320,9 +345,18 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
|
|
|
320
345
|
const { meta, body: skillBody } = parseFrontmatter(doc);
|
|
321
346
|
const group = meta.group
|
|
322
347
|
|| (finalName.includes('-') ? finalName.split('-')[0] : 'legacy');
|
|
348
|
+
// Operator-precedence fix (Group A — M5): the original expression
|
|
349
|
+
// meta.trained_by || createdBy === 'agent' ? 'agent' : 'user'
|
|
350
|
+
// parses as
|
|
351
|
+
// (meta.trained_by || (createdBy === 'agent')) ? 'agent' : 'user'
|
|
352
|
+
// so an agent-installed skill with frontmatter `trained_by: human`
|
|
353
|
+
// was being indexed as `trained_by: 'agent'` (the truthy `meta.trained_by`
|
|
354
|
+
// collapses to a bare boolean inside the ternary). The corrected
|
|
355
|
+
// parenthesisation honours frontmatter first, then falls back to
|
|
356
|
+
// createdBy-derived 'agent' / 'user'.
|
|
323
357
|
_indexSkill({
|
|
324
358
|
skill_name: finalName,
|
|
325
|
-
trained_by: meta.trained_by || createdBy === 'agent' ? 'agent' : 'user',
|
|
359
|
+
trained_by: meta.trained_by || (createdBy === 'agent' ? 'agent' : 'user'),
|
|
326
360
|
group_name: group,
|
|
327
361
|
content: skillBody,
|
|
328
362
|
}, configDir);
|
package/mas/tool_runner.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as registry from './tools/registry.mjs';
|
|
7
7
|
import * as audit from './audit.mjs';
|
|
8
|
+
import { DEFAULT_TOOLS } from '../agents.mjs';
|
|
8
9
|
|
|
9
10
|
export class ToolError extends Error {
|
|
10
11
|
constructor(message, code) {
|
|
@@ -14,9 +15,15 @@ export class ToolError extends Error {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Resolve the tool whitelist into JSON-Schema entries.
|
|
19
|
+
// - undefined → DEFAULT_TOOLS (the 5 safe defaults a fresh agent gets)
|
|
20
|
+
// - [] → advertise zero tools (matches the deny-check
|
|
21
|
+
// semantics in runTool — an agent with no whitelist
|
|
22
|
+
// is allowed to use NOTHING, not everything)
|
|
23
|
+
// - ['bash',…] → the explicit list, intersected with the registry
|
|
17
24
|
export function listToolSchemas(names) {
|
|
18
25
|
const out = [];
|
|
19
|
-
const wanted = Array.isArray(names)
|
|
26
|
+
const wanted = names === undefined ? DEFAULT_TOOLS : (Array.isArray(names) ? names : []);
|
|
20
27
|
for (const name of wanted) {
|
|
21
28
|
const t = registry.lookup(name);
|
|
22
29
|
if (!t) continue;
|
package/mas/tools/git.mjs
CHANGED
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
|
|
6
6
|
function git(cwd, args, opts = {}) {
|
|
7
|
-
|
|
7
|
+
// M11 / C12 — detect a missing git binary (Windows without
|
|
8
|
+
// Git-for-Windows, minimal Docker base images) up front and surface
|
|
9
|
+
// a clear remediation hint. The historical behaviour returned
|
|
10
|
+
// {ok:false} with a cryptic spawn error, which made the
|
|
11
|
+
// "lazyclaw doctor" path the only reliable signal. Now any agent
|
|
12
|
+
// touching this tool also gets a one-line diagnostic.
|
|
13
|
+
const exe = process.env.GIT_EXECUTABLE || 'git';
|
|
14
|
+
const r = spawnSync(exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
15
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
stdout: '',
|
|
19
|
+
stderr: '',
|
|
20
|
+
exitCode: null,
|
|
21
|
+
error: 'GIT_NOT_INSTALLED',
|
|
22
|
+
hint: 'git binary not found on PATH. Install Git, or set GIT_EXECUTABLE. See `lazyclaw doctor`.',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
8
25
|
return { ok: r.status === 0, stdout: r.stdout, stderr: r.stderr, exitCode: r.status };
|
|
9
26
|
}
|
|
10
27
|
|
package/mas/tools/recall.mjs
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
// filter: optional object of UNINDEXED column equality filters
|
|
14
14
|
// (session_id, agent, outcome, trained_by, group_name, kind, since)
|
|
15
15
|
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import os from 'node:os';
|
|
16
19
|
import { openIndex, recall as indexRecall } from '../index_db.mjs';
|
|
20
|
+
import { sameFamily } from '../confidence.mjs';
|
|
17
21
|
|
|
18
22
|
export const NAME = 'recall';
|
|
19
23
|
export const DESCRIPTION =
|
|
@@ -26,6 +30,11 @@ export const PARAMETERS = {
|
|
|
26
30
|
k: { type: 'integer', minimum: 1, maximum: 50 },
|
|
27
31
|
summarize: { type: 'boolean' },
|
|
28
32
|
filter: { type: 'object', additionalProperties: true },
|
|
33
|
+
// M10 — cross-CLI provider-aware ranking. When the caller is a
|
|
34
|
+
// worker on a specific provider (e.g. anthropic/openai/gemini),
|
|
35
|
+
// skills whose frontmatter cross_cli_tested[] includes that
|
|
36
|
+
// provider's family are boosted ahead of untested duplicates.
|
|
37
|
+
workerProvider: { type: 'string', description: 'Boost skills whose cross_cli_tested[] includes this provider family (e.g. "anthropic").' },
|
|
29
38
|
},
|
|
30
39
|
required: ['query'],
|
|
31
40
|
};
|
|
@@ -33,6 +42,39 @@ export const PARAMETERS = {
|
|
|
33
42
|
const DEFAULT_SCOPES = ['sessions', 'skills', 'trajectories', 'memories'];
|
|
34
43
|
const MAX_K = 50;
|
|
35
44
|
|
|
45
|
+
function _defaultConfigDir() {
|
|
46
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Minimal frontmatter parser — read just the cross_cli_tested provider
|
|
50
|
+
// list for a skill by name. Returns [] when the file is missing or
|
|
51
|
+
// parse fails (best-effort ranking helper, not a strict loader).
|
|
52
|
+
function _readSkillCrossCli(skillName, configDir) {
|
|
53
|
+
if (!skillName) return [];
|
|
54
|
+
try {
|
|
55
|
+
const dir = configDir || _defaultConfigDir();
|
|
56
|
+
const filePath = path.join(dir, 'skills', `${skillName}.md`);
|
|
57
|
+
if (!fs.existsSync(filePath)) return [];
|
|
58
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
59
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
60
|
+
if (!m) return [];
|
|
61
|
+
const fm = m[1];
|
|
62
|
+
// Find cross_cli_tested block — YAML list form:
|
|
63
|
+
// cross_cli_tested:
|
|
64
|
+
// - provider: anthropic
|
|
65
|
+
// …
|
|
66
|
+
const idx = fm.indexOf('cross_cli_tested:');
|
|
67
|
+
if (idx < 0) return [];
|
|
68
|
+
const tail = fm.slice(idx);
|
|
69
|
+
const providers = [];
|
|
70
|
+
for (const line of tail.split('\n')) {
|
|
71
|
+
const pm = line.match(/^\s*-?\s*provider:\s*['"]?([\w.-]+)['"]?\s*$/);
|
|
72
|
+
if (pm) providers.push(pm[1]);
|
|
73
|
+
}
|
|
74
|
+
return providers;
|
|
75
|
+
} catch { return []; }
|
|
76
|
+
}
|
|
77
|
+
|
|
36
78
|
let _stubRecall = null;
|
|
37
79
|
export function __setRecall(fn) { _stubRecall = typeof fn === 'function' ? fn : null; }
|
|
38
80
|
|
|
@@ -83,12 +125,34 @@ export async function exec(args, { configDir } = {}) {
|
|
|
83
125
|
});
|
|
84
126
|
}
|
|
85
127
|
|
|
128
|
+
// M10 — cross-CLI provider-aware boost. For each `skills` hit, peek
|
|
129
|
+
// at the skill file's cross_cli_tested[] frontmatter. If any entry's
|
|
130
|
+
// provider is in the same family as workerProvider, promote the hit
|
|
131
|
+
// above untested siblings. We do this AFTER the FTS5 ranking so the
|
|
132
|
+
// base bm25 ordering still dominates — boosting is a tie-breaker /
|
|
133
|
+
// small re-rank, not a wholesale replacement.
|
|
134
|
+
const workerProvider = args.workerProvider ? String(args.workerProvider).trim() : '';
|
|
135
|
+
if (workerProvider) {
|
|
136
|
+
const boosted = hits.map((h, idx) => {
|
|
137
|
+
if (h.scope !== 'skills') return { h, boost: 0, idx };
|
|
138
|
+
const skillName = h.metadata?.skill_name || '';
|
|
139
|
+
const tested = _readSkillCrossCli(skillName, configDir);
|
|
140
|
+
const matched = tested.some((p) => sameFamily(p, workerProvider));
|
|
141
|
+
return { h, boost: matched ? 1 : 0, idx };
|
|
142
|
+
});
|
|
143
|
+
// Stable sort: matching skills first (boost desc), then preserve
|
|
144
|
+
// original FTS5 order via idx for ties.
|
|
145
|
+
boosted.sort((a, b) => (b.boost - a.boost) || (a.idx - b.idx));
|
|
146
|
+
hits = boosted.map((b) => ({ ...b.h, crossCliBoosted: b.boost > 0 }));
|
|
147
|
+
}
|
|
148
|
+
|
|
86
149
|
return {
|
|
87
150
|
ok: true,
|
|
88
151
|
query,
|
|
89
152
|
hits: hits.slice(0, k),
|
|
90
153
|
summary: null, // v5.0: raw hits only; v5.1 wires trainer.
|
|
91
154
|
summarizedBy: null,
|
|
155
|
+
workerProvider: workerProvider || null,
|
|
92
156
|
latencyMs: Date.now() - t0,
|
|
93
157
|
};
|
|
94
158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"config_features.mjs",
|
|
51
51
|
"workspace.mjs",
|
|
52
52
|
"browse.mjs",
|
|
53
|
+
"chat_window.mjs",
|
|
53
54
|
"sandbox.mjs",
|
|
54
55
|
"sandbox/",
|
|
55
56
|
"skills_install.mjs",
|
package/providers/anthropic.mjs
CHANGED
|
@@ -145,16 +145,32 @@ export const anthropicProvider = {
|
|
|
145
145
|
stream: true,
|
|
146
146
|
messages: apiMessages,
|
|
147
147
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
148
|
+
// Multi-block system support (Group B / C8). When the caller has
|
|
149
|
+
// separated the STATIC prefix (workspace + skills index + agent.role
|
|
150
|
+
// + USER.md) from the VOLATILE per-turn content, we ship two text
|
|
151
|
+
// blocks so the prompt cache hits the long static prefix while the
|
|
152
|
+
// volatile suffix changes per turn. Static block carries
|
|
153
|
+
// cache_control:ephemeral; volatile does not.
|
|
154
|
+
const hasMultiBlock = (opts.systemStatic && String(opts.systemStatic).trim()) || (opts.systemVolatile && String(opts.systemVolatile).trim());
|
|
155
|
+
if (hasMultiBlock) {
|
|
156
|
+
const blocks = [];
|
|
157
|
+
const staticText = opts.systemStatic ? String(opts.systemStatic) : '';
|
|
158
|
+
const volatileText = opts.systemVolatile ? String(opts.systemVolatile) : '';
|
|
159
|
+
if (staticText) blocks.push({ type: 'text', text: staticText, cache_control: { type: 'ephemeral' } });
|
|
160
|
+
if (volatileText) blocks.push({ type: 'text', text: volatileText });
|
|
161
|
+
body.system = blocks;
|
|
162
|
+
} else {
|
|
163
|
+
const sys = opts.system || messages.find(m => m.role === 'system')?.content;
|
|
164
|
+
if (sys) {
|
|
165
|
+
// Prompt caching: when opts.cache is truthy, mark the system prompt
|
|
166
|
+
// as ephemeral-cacheable so repeated calls with the same system
|
|
167
|
+
// prefix only pay full input cost once. The Messages API expects
|
|
168
|
+
// an array of text blocks here, so we lift the string into one.
|
|
169
|
+
if (opts.cache) {
|
|
170
|
+
body.system = [{ type: 'text', text: String(sys), cache_control: { type: 'ephemeral' } }];
|
|
171
|
+
} else {
|
|
172
|
+
body.system = sys;
|
|
173
|
+
}
|
|
158
174
|
}
|
|
159
175
|
}
|
|
160
176
|
// Extended thinking. opts.thinking: { enabled?: boolean, budgetTokens?: number }.
|
|
@@ -230,30 +230,85 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
230
230
|
yield `\n`;
|
|
231
231
|
|
|
232
232
|
// ── Phase 2: EXECUTE ────────────────────────────────────────────
|
|
233
|
-
|
|
233
|
+
// Concurrency policy (canonical spec §3 + C11 fix):
|
|
234
|
+
// concurrency <= 1 → sequential, streams each worker's chunks
|
|
235
|
+
// inline as they arrive (live-feedback UX).
|
|
236
|
+
// concurrency >= 2 → Promise.all-based parallel dispatch. Each
|
|
237
|
+
// worker buffers its own chunks; we flush
|
|
238
|
+
// them IN PLAN ORDER (subtask 1, then 2, …)
|
|
239
|
+
// so the user-facing output stays readable
|
|
240
|
+
// regardless of which worker finished first.
|
|
241
|
+
// The number is clamped to [1, workers.length] so a runaway value
|
|
242
|
+
// can't accidentally over-subscribe a single worker.
|
|
243
|
+
const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : 1;
|
|
244
|
+
const concurrency = Math.max(1, Math.min(rawConcurrency, workers.length));
|
|
245
|
+
yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}${concurrency > 1 ? ` (concurrency=${concurrency}, parallel)` : ''}\n\n`;
|
|
234
246
|
const results = [];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
if (concurrency <= 1) {
|
|
248
|
+
// Sequential streaming path — historical default, preserved.
|
|
249
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
250
|
+
const sub = trimmed[i];
|
|
251
|
+
const worker = workers[i % workers.length];
|
|
252
|
+
yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
|
|
253
|
+
let res = '';
|
|
254
|
+
try {
|
|
255
|
+
for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
|
|
256
|
+
apiKey: keyResolver(cfg, worker.name),
|
|
257
|
+
model: worker.model || undefined,
|
|
258
|
+
signal: callerOpts.signal,
|
|
259
|
+
})) {
|
|
260
|
+
const s = String(chunk);
|
|
261
|
+
res += s;
|
|
262
|
+
yield s;
|
|
263
|
+
}
|
|
264
|
+
results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: res, error: null });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
const msg = e?.message || String(e);
|
|
267
|
+
yield `\n⚠ worker error: ${msg}\n`;
|
|
268
|
+
results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: '', error: msg });
|
|
249
269
|
}
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
270
|
+
yield `\n\n---\n\n`;
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
// Parallel dispatch — buffer chunks per subtask, then flush in
|
|
274
|
+
// plan order. We start every subtask up-front (Promise.all over
|
|
275
|
+
// the slice) so wall-clock equals max-per-subtask, not sum.
|
|
276
|
+
// A single subtask failure does NOT block the others — the
|
|
277
|
+
// _runSubtask wrapper catches and records the error inline.
|
|
278
|
+
async function _runSubtask(sub, worker) {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
let error = null;
|
|
281
|
+
try {
|
|
282
|
+
for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
|
|
283
|
+
apiKey: keyResolver(cfg, worker.name),
|
|
284
|
+
model: worker.model || undefined,
|
|
285
|
+
signal: callerOpts.signal,
|
|
286
|
+
})) {
|
|
287
|
+
chunks.push(String(chunk));
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
error = e?.message || String(e);
|
|
291
|
+
}
|
|
292
|
+
return { sub, worker, chunks, error };
|
|
293
|
+
}
|
|
294
|
+
const settled = await Promise.all(
|
|
295
|
+
trimmed.map((sub, i) => _runSubtask(sub, workers[i % workers.length])),
|
|
296
|
+
);
|
|
297
|
+
// Flush in plan order so the synthesis prompt + user view see
|
|
298
|
+
// subtask 1, then 2, etc.
|
|
299
|
+
for (const { sub, worker, chunks, error } of settled) {
|
|
300
|
+
yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
|
|
301
|
+
const text = chunks.join('');
|
|
302
|
+
if (text) yield text;
|
|
303
|
+
if (error) yield `\n⚠ worker error: ${error}\n`;
|
|
304
|
+
results.push({
|
|
305
|
+
...sub,
|
|
306
|
+
worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`,
|
|
307
|
+
result: text,
|
|
308
|
+
error,
|
|
309
|
+
});
|
|
310
|
+
yield `\n\n---\n\n`;
|
|
255
311
|
}
|
|
256
|
-
yield `\n\n---\n\n`;
|
|
257
312
|
}
|
|
258
313
|
|
|
259
314
|
// ── Phase 3: SYNTHESIS ──────────────────────────────────────────
|
|
@@ -276,6 +331,29 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
276
331
|
} catch (e) {
|
|
277
332
|
yield `⚠ synthesis error: ${e?.message || String(e)}. Worker outputs above are the final material — please review them directly.\n`;
|
|
278
333
|
}
|
|
334
|
+
|
|
335
|
+
// v5 (canonical decision C2) — fire the canonical post-task
|
|
336
|
+
// learning hook in a microtask so the user's stream is not
|
|
337
|
+
// blocked. Failures are silent here; mas/learning.mjs already
|
|
338
|
+
// swallows each sub-routine's errors and the audit log captures
|
|
339
|
+
// observability on its own. We import lazily so the orchestrator
|
|
340
|
+
// module stays cheap to load when learning is unused.
|
|
341
|
+
queueMicrotask(() => {
|
|
342
|
+
import('../mas/orchestra.mjs')
|
|
343
|
+
.then(o => o.firePostTask({
|
|
344
|
+
cfg,
|
|
345
|
+
agent: { name: 'orchestrator', provider: planner.name, model: planner.model, role: SYNTHESIS_SYSTEM },
|
|
346
|
+
task: {
|
|
347
|
+
id: `orch-${Date.now()}`,
|
|
348
|
+
title: userText.slice(0, 80),
|
|
349
|
+
turns: [
|
|
350
|
+
{ agent: 'user', text: userText },
|
|
351
|
+
...results.map(r => ({ agent: r.worker, text: r.result || r.error || '' })),
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
}))
|
|
355
|
+
.catch(() => { /* swallow — learning is best-effort */ });
|
|
356
|
+
});
|
|
279
357
|
},
|
|
280
358
|
};
|
|
281
359
|
}
|
package/providers/registry.mjs
CHANGED
|
@@ -524,6 +524,12 @@ export function parseSlashProviderModel(s) {
|
|
|
524
524
|
const RESERVED_PROVIDER_NAMES = new Set([
|
|
525
525
|
'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
|
|
526
526
|
'orchestrator',
|
|
527
|
+
// M12 — `test` is reserved because the daemon intercepts
|
|
528
|
+
// GET /providers/test and POST /providers/test for batch provider
|
|
529
|
+
// probing. Allowing a custom provider literally named "test" would
|
|
530
|
+
// shadow those routes (GET/DELETE for the literal name become
|
|
531
|
+
// unreachable). Reject up-front in the POST validator.
|
|
532
|
+
'test',
|
|
527
533
|
'__add_custom__', '__custom_model__', '__fetch_models__',
|
|
528
534
|
]);
|
|
529
535
|
|