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
|
@@ -51,6 +51,13 @@ export async function callOnce({
|
|
|
51
51
|
baseUrl,
|
|
52
52
|
fetchImpl,
|
|
53
53
|
signal,
|
|
54
|
+
// Group B / C9 — prompt caching. Opt-in; the existing phase 12b/24
|
|
55
|
+
// test surface asserts the no-cache byte shape so this defaults off.
|
|
56
|
+
// Production call sites (agent_turn, mention_router) pass `cache:true`
|
|
57
|
+
// so every real Messages API call from the tool-use path gets a
|
|
58
|
+
// cache_control:ephemeral block on the static system prefix AND on
|
|
59
|
+
// the last tool object (so the tool schema cache also benefits).
|
|
60
|
+
cache = false,
|
|
54
61
|
} = {}) {
|
|
55
62
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
56
63
|
throw new AnthropicToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
@@ -65,16 +72,45 @@ export async function callOnce({
|
|
|
65
72
|
max_tokens: maxTokens,
|
|
66
73
|
messages,
|
|
67
74
|
};
|
|
68
|
-
if (system && String(system).trim())
|
|
69
|
-
|
|
75
|
+
if (system && String(system).trim()) {
|
|
76
|
+
if (cache) {
|
|
77
|
+
// Lift the system string into a one-block array carrying
|
|
78
|
+
// cache_control. The Anthropic API accepts either a plain string
|
|
79
|
+
// OR an array of text blocks here; we use the array form so we
|
|
80
|
+
// can attach the breakpoint marker.
|
|
81
|
+
body.system = [{ type: 'text', text: String(system), cache_control: { type: 'ephemeral' } }];
|
|
82
|
+
} else {
|
|
83
|
+
body.system = String(system);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (tools && tools.length) {
|
|
87
|
+
if (cache) {
|
|
88
|
+
// Clone the array (don't mutate the caller's reference) and
|
|
89
|
+
// attach cache_control to the LAST tool — that marks the entire
|
|
90
|
+
// tool block as cacheable, per Anthropic's spec.
|
|
91
|
+
const cloned = tools.map((t, i) => (i === tools.length - 1
|
|
92
|
+
? { ...t, cache_control: { type: 'ephemeral' } }
|
|
93
|
+
: t));
|
|
94
|
+
body.tools = cloned;
|
|
95
|
+
} else {
|
|
96
|
+
body.tools = tools;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const headers = {
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
'x-api-key': apiKey,
|
|
103
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
104
|
+
};
|
|
105
|
+
if (cache) {
|
|
106
|
+
// The prompt-caching beta header. Required only on early adoption;
|
|
107
|
+
// safe to include on newer API versions where it's a no-op.
|
|
108
|
+
headers['anthropic-beta'] = 'prompt-caching-2024-07-31';
|
|
109
|
+
}
|
|
70
110
|
|
|
71
111
|
const res = await fetchFn(url, {
|
|
72
112
|
method: 'POST',
|
|
73
|
-
headers
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
'x-api-key': apiKey,
|
|
76
|
-
'anthropic-version': ANTHROPIC_VERSION,
|
|
77
|
-
},
|
|
113
|
+
headers,
|
|
78
114
|
body: JSON.stringify(body),
|
|
79
115
|
signal,
|
|
80
116
|
});
|
package/skills.mjs
CHANGED
|
@@ -15,6 +15,22 @@ import os from 'node:os';
|
|
|
15
15
|
const SKILLS_DIRNAME = 'skills';
|
|
16
16
|
const SKILL_EXT = '.md';
|
|
17
17
|
|
|
18
|
+
// Group B / M8 — memoize listSkills() + skillsIndex() per configDir.
|
|
19
|
+
// composePromptStack is called on every chat/agent turn; listing the
|
|
20
|
+
// skills directory + statting + reading every file used to cost ~2-5 ms
|
|
21
|
+
// at ~30 skills, and was the largest non-network item in the per-turn
|
|
22
|
+
// flame graph. Cache key is the skills/ directory's mtimeMs — any
|
|
23
|
+
// install/remove via this module bumps it (writeFile updates the
|
|
24
|
+
// containing dir's mtime). Manual edits with `vim` also bust the cache
|
|
25
|
+
// because writing a file in a directory updates that dir's mtime.
|
|
26
|
+
// _invalidateSkillsCache() is exported as the safety hatch for callers
|
|
27
|
+
// that want to be explicit.
|
|
28
|
+
const _indexCache = new Map(); // cfgDir → { mtimeMs, skills, index }
|
|
29
|
+
|
|
30
|
+
export function _invalidateSkillsCache(configDir = defaultConfigDir()) {
|
|
31
|
+
_indexCache.delete(configDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export function defaultConfigDir() {
|
|
19
35
|
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
20
36
|
}
|
|
@@ -33,7 +49,14 @@ export function skillPath(name, configDir = defaultConfigDir()) {
|
|
|
33
49
|
export function listSkills(configDir = defaultConfigDir()) {
|
|
34
50
|
const dir = skillsDir(configDir);
|
|
35
51
|
if (!fs.existsSync(dir)) return [];
|
|
36
|
-
|
|
52
|
+
// M8 — cache hit when the skills/ directory's mtime is unchanged.
|
|
53
|
+
let dirMtime = 0;
|
|
54
|
+
try { dirMtime = fs.statSync(dir).mtimeMs; } catch { /* unreadable → bypass cache */ }
|
|
55
|
+
const cached = _indexCache.get(configDir);
|
|
56
|
+
if (cached && cached.mtimeMs === dirMtime && dirMtime > 0) {
|
|
57
|
+
return cached.skills;
|
|
58
|
+
}
|
|
59
|
+
const skills = fs.readdirSync(dir)
|
|
37
60
|
.filter(name => name.endsWith(SKILL_EXT))
|
|
38
61
|
.map(name => {
|
|
39
62
|
const full = path.join(dir, name);
|
|
@@ -57,6 +80,10 @@ export function listSkills(configDir = defaultConfigDir()) {
|
|
|
57
80
|
};
|
|
58
81
|
})
|
|
59
82
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
if (dirMtime > 0) {
|
|
84
|
+
_indexCache.set(configDir, { mtimeMs: dirMtime, skills, index: null });
|
|
85
|
+
}
|
|
86
|
+
return skills;
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
// Parse a leading YAML frontmatter block (--- … ---). Only the flat
|
|
@@ -108,9 +135,19 @@ function firstHeading(body) {
|
|
|
108
135
|
// paying for their full bodies (progressive disclosure — the model
|
|
109
136
|
// pulls a full skill on demand via the skill_view tool).
|
|
110
137
|
export function skillsIndex(configDir = defaultConfigDir()) {
|
|
138
|
+
// Memoize the rendered index alongside the cached skills array — both
|
|
139
|
+
// are pure functions of the skills/ mtime.
|
|
140
|
+
const cached = _indexCache.get(configDir);
|
|
141
|
+
if (cached && cached.index !== null) return cached.index;
|
|
111
142
|
const skills = listSkills(configDir);
|
|
112
|
-
if (!skills.length)
|
|
113
|
-
|
|
143
|
+
if (!skills.length) {
|
|
144
|
+
if (cached) cached.index = '';
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
const index = skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
|
|
148
|
+
const refreshed = _indexCache.get(configDir);
|
|
149
|
+
if (refreshed) refreshed.index = index;
|
|
150
|
+
return index;
|
|
114
151
|
}
|
|
115
152
|
|
|
116
153
|
export function loadSkill(name, configDir = defaultConfigDir()) {
|
|
@@ -123,12 +160,14 @@ export function installSkill(name, content, configDir = defaultConfigDir()) {
|
|
|
123
160
|
const p = skillPath(name, configDir);
|
|
124
161
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
125
162
|
fs.writeFileSync(p, content);
|
|
163
|
+
_invalidateSkillsCache(configDir);
|
|
126
164
|
return p;
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
export function removeSkill(name, configDir = defaultConfigDir()) {
|
|
130
168
|
const p = skillPath(name, configDir);
|
|
131
169
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
170
|
+
_invalidateSkillsCache(configDir);
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
export function skillExists(name, configDir = defaultConfigDir()) {
|
package/tasks.mjs
CHANGED
|
@@ -157,7 +157,30 @@ export function appendTurn(id, turn, configDir = defaultConfigDir()) {
|
|
|
157
157
|
const t = getTask(id, configDir);
|
|
158
158
|
if (!t) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
|
|
159
159
|
const turns = Array.isArray(t.turns) ? [...t.turns, turn] : [turn];
|
|
160
|
-
|
|
160
|
+
const next = patchTask(id, { turns }, configDir);
|
|
161
|
+
// v5 Group A (M4): mirror the appended turn to the FTS5 sessions
|
|
162
|
+
// index using session_id = `task:<id>` so the recall tool can surface
|
|
163
|
+
// task transcripts the same way it surfaces chat sessions. Namespaced
|
|
164
|
+
// with the `task:` prefix to avoid colliding with chat session ids.
|
|
165
|
+
// Best-effort: any FTS failure stays inside the dynamic-import block
|
|
166
|
+
// so a missing index_db (e.g. in a stripped test env) never breaks
|
|
167
|
+
// task writes.
|
|
168
|
+
try {
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const { indexSessionTurn } = await import('./mas/index_db.mjs');
|
|
172
|
+
const turnIdx = turns.length - 1;
|
|
173
|
+
indexSessionTurn({
|
|
174
|
+
session_id: `task:${id}`,
|
|
175
|
+
turn_idx: turnIdx,
|
|
176
|
+
role: turn.agent === 'user' ? 'user' : 'assistant',
|
|
177
|
+
ts: Date.parse(turn.ts) || Date.now(),
|
|
178
|
+
content: turn.text || '',
|
|
179
|
+
}, configDir);
|
|
180
|
+
} catch { /* swallow */ }
|
|
181
|
+
})();
|
|
182
|
+
} catch { /* swallow */ }
|
|
183
|
+
return next;
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
export function removeTask(id, configDir = defaultConfigDir()) {
|
package/tui/repl.mjs
CHANGED
|
@@ -56,6 +56,12 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// ─── React mount ─────────────────────────────────────────────────────────
|
|
59
|
+
// v5.1 TODO (C7 follow-up): replace direct stdout writes in cli.mjs's
|
|
60
|
+
// _chatRunTurnFactory with a scrollback ref'd via this component. The
|
|
61
|
+
// shape would be an `onOutput` prop that pushes chunks into a `useState`
|
|
62
|
+
// array rendered through Ink's <Static items={scrollback}/>. v5.0.10
|
|
63
|
+
// ships with raw stdout writes interleaved with Ink (acceptable visual
|
|
64
|
+
// jank in exchange for unblocking the chat loop).
|
|
59
65
|
export function ReplApp({ splashProps, runTurn }) {
|
|
60
66
|
const [state, setState] = useState(makeReplState);
|
|
61
67
|
const { exit } = useApp();
|
package/tui/run_turn.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// tui/run_turn.mjs — shared chat-turn streaming closure (v5 Group C, C7).
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for the streaming + persist + post-task
|
|
4
|
+
// learning loop. The same factory backs:
|
|
5
|
+
// - the ink REPL path (ReplApp.runTurn)
|
|
6
|
+
// - the legacy readline path (cli.mjs's `for await (const line of rl)` body)
|
|
7
|
+
//
|
|
8
|
+
// One factory ⇒ one set of bugs. Both call sites get the same buffered
|
|
9
|
+
// writer (CJK-safe 30 ms coalescing), the same persistTurn dual-write
|
|
10
|
+
// (sessions + memory/recent), and the same fire-and-forget learning
|
|
11
|
+
// hook (queueMicrotask).
|
|
12
|
+
//
|
|
13
|
+
// `ctx` carries the chat-session state. Getter functions close over
|
|
14
|
+
// *current* bindings so a mid-session /provider switch takes effect on
|
|
15
|
+
// the very next turn without re-creating the closure.
|
|
16
|
+
//
|
|
17
|
+
// `writeFn` is the sink for streamed chunks — the legacy path passes
|
|
18
|
+
// `(s) => process.stdout.write(s)`; the ink path also writes to stdout
|
|
19
|
+
// for v5.0.10 (interleaves with Ink output; visual jank accepted in
|
|
20
|
+
// exchange for unblocking the chat loop). v5.1 TODO: route the ink
|
|
21
|
+
// writeFn through a scrollback ref in ReplApp.
|
|
22
|
+
//
|
|
23
|
+
// Errors are swallowed (matches v4 legacy behavior) — we write
|
|
24
|
+
// "error: ..." into writeFn but do not re-throw, so the caller's
|
|
25
|
+
// turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
|
|
26
|
+
// runs unconditionally.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} RunTurnCtx
|
|
30
|
+
* @property {Object} cfg Active config.
|
|
31
|
+
* @property {string} cfgDir Resolved configDir.
|
|
32
|
+
* @property {Object|null} sandboxSpec Parsed --sandbox spec (or null).
|
|
33
|
+
* @property {string} syntheticChatSessionId Session-id fallback when --session not set.
|
|
34
|
+
* @property {() => Array<{role:string,content:string}>} getMessages
|
|
35
|
+
* @property {() => { sendMessage: Function, name?: string }} getProv
|
|
36
|
+
* @property {() => string} getActiveProvName
|
|
37
|
+
* @property {() => string|null} getActiveModel
|
|
38
|
+
* @property {() => string|null} getSessionId
|
|
39
|
+
* @property {(role: string, content: string) => void} persistTurn
|
|
40
|
+
* @property {(u: any) => void} accumulateUsage
|
|
41
|
+
* @property {(provider: string) => string} resolveAuthKey Caller supplies; mirrors cli.mjs::_resolveAuthKey.
|
|
42
|
+
* @property {(n: number) => void} [onCharsSent] Optional observer (legacy path increments charsSent).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a runTurn(text, signal) closure that drives one provider turn
|
|
47
|
+
* end-to-end.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ ctx: RunTurnCtx, writeFn: (chunk: string) => void }} args
|
|
50
|
+
* @returns {(text: string, signal?: AbortSignal) => Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export function makeRunTurn({ ctx, writeFn }) {
|
|
53
|
+
return async function runTurn(text, signal) {
|
|
54
|
+
if (signal?.aborted) return;
|
|
55
|
+
const messages = ctx.getMessages();
|
|
56
|
+
messages.push({ role: 'user', content: text });
|
|
57
|
+
try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
|
|
58
|
+
catch { /* observer is best-effort */ }
|
|
59
|
+
ctx.persistTurn('user', text);
|
|
60
|
+
|
|
61
|
+
let acc = '';
|
|
62
|
+
let _writeBuf = '';
|
|
63
|
+
let _writeTimer = null;
|
|
64
|
+
const _flush = () => {
|
|
65
|
+
if (_writeBuf) {
|
|
66
|
+
try { writeFn(_writeBuf); }
|
|
67
|
+
catch { /* sink failure must not kill the turn */ }
|
|
68
|
+
_writeBuf = '';
|
|
69
|
+
}
|
|
70
|
+
_writeTimer = null;
|
|
71
|
+
};
|
|
72
|
+
const _writeChunk = (s) => {
|
|
73
|
+
_writeBuf += s;
|
|
74
|
+
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const sysMsg = messages.find((m) => m.role === 'system');
|
|
79
|
+
const prov = ctx.getProv();
|
|
80
|
+
const activeProvName = ctx.getActiveProvName();
|
|
81
|
+
const activeModel = ctx.getActiveModel();
|
|
82
|
+
// C8 — prompt-cache the static system prefix. The Anthropic
|
|
83
|
+
// provider prefers `systemStatic` when present; non-Anthropic
|
|
84
|
+
// providers ignore the field and fall back to the legacy
|
|
85
|
+
// single-block path with `cache:true`.
|
|
86
|
+
for await (const chunk of prov.sendMessage(messages, {
|
|
87
|
+
apiKey: ctx.resolveAuthKey(activeProvName),
|
|
88
|
+
model: activeModel,
|
|
89
|
+
sandbox: ctx.sandboxSpec,
|
|
90
|
+
signal,
|
|
91
|
+
onUsage: ctx.accumulateUsage,
|
|
92
|
+
cache: true,
|
|
93
|
+
...(sysMsg ? { systemStatic: sysMsg.content } : {}),
|
|
94
|
+
})) {
|
|
95
|
+
_writeChunk(chunk);
|
|
96
|
+
acc += chunk;
|
|
97
|
+
}
|
|
98
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
99
|
+
_flush();
|
|
100
|
+
try { writeFn('\n'); }
|
|
101
|
+
catch { /* sink failure must not kill the turn */ }
|
|
102
|
+
messages.push({ role: 'assistant', content: acc });
|
|
103
|
+
ctx.persistTurn('assistant', acc);
|
|
104
|
+
// v5 Group A (C1): close the post-task learning loop on every
|
|
105
|
+
// successful chat turn. Fire-and-forget via queueMicrotask so the
|
|
106
|
+
// next prompt is never blocked on trajectory write / synth.
|
|
107
|
+
try {
|
|
108
|
+
queueMicrotask(() => {
|
|
109
|
+
import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
|
|
110
|
+
agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
|
|
111
|
+
task: {
|
|
112
|
+
id: ctx.getSessionId() || ctx.syntheticChatSessionId,
|
|
113
|
+
title: '(chat turn)',
|
|
114
|
+
turns: messages.slice(-2).map((m) => ({
|
|
115
|
+
agent: m.role === 'user' ? 'user' : 'chat',
|
|
116
|
+
text: m.content,
|
|
117
|
+
ts: new Date().toISOString(),
|
|
118
|
+
})),
|
|
119
|
+
},
|
|
120
|
+
configDir: ctx.cfgDir,
|
|
121
|
+
cfg: ctx.cfg,
|
|
122
|
+
transcript: acc.slice(0, 8000),
|
|
123
|
+
})).catch(() => { /* learning loop is best-effort */ });
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never let learning hook break the chat */ }
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
128
|
+
_flush();
|
|
129
|
+
// ABORT errors are user-initiated; drop the partial reply (don't
|
|
130
|
+
// push an incomplete assistant message — next turn would treat
|
|
131
|
+
// it as a complete reply and confuse the model).
|
|
132
|
+
if (err?.code !== 'ABORT' && !signal?.aborted) {
|
|
133
|
+
try { writeFn(`error: ${err?.message || String(err)}\n`); }
|
|
134
|
+
catch { /* sink failure must not mask err */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
package/web/dashboard.html
CHANGED
|
@@ -1780,7 +1780,7 @@
|
|
|
1780
1780
|
<div class="name" style="margin-left:8px;">${escHtml(String(label))}</div>
|
|
1781
1781
|
<div class="dim row-actions">bm25 ${Number(h.bm25 || 0).toFixed(2)}</div>
|
|
1782
1782
|
</div>
|
|
1783
|
-
<div class="dim" style="margin-top:6px;font-size:12px;">${h.snippet || ''}</div>
|
|
1783
|
+
<div class="dim" style="margin-top:6px;font-size:12px;">${escHtml(h.snippet || '').replace(/<mark>/g,'<mark>').replace(/<\/mark>/g,'</mark>')}</div>
|
|
1784
1784
|
</div>`;
|
|
1785
1785
|
}).join('');
|
|
1786
1786
|
} catch (e) {
|