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.
@@ -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()) body.system = String(system);
69
- if (tools && tools.length) body.tools = tools;
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
- return fs.readdirSync(dir)
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) return '';
113
- return skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
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
- return patchTask(id, { turns }, configDir);
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();
@@ -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
+ }
@@ -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(/&lt;mark&gt;/g,'<mark>').replace(/&lt;\/mark&gt;/g,'</mark>')}</div>
1784
1784
  </div>`;
1785
1785
  }).join('');
1786
1786
  } catch (e) {