lazyclaw 4.2.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
@@ -0,0 +1,304 @@
1
+ // HTTP/SSE realisation of the device gateway — Phase 27.
2
+ //
3
+ // The OpenClaw "gateway" is a long-lived process companion nodes connect
4
+ // to and authenticate against. lazyclaw realises that over the EXISTING
5
+ // HTTP daemon (no `ws` dependency, no hand-rolled RFC6455 framing): the
6
+ // device-auth handshake is two POSTs, authenticated routes carry the
7
+ // device's bearer token, and server-pushed events ride an SSE stream.
8
+ // The security model is unchanged from gateway/device_auth.mjs:
9
+ //
10
+ // POST /gateway/connect/challenge {deviceId?} -> { nonce, ts }
11
+ // Mint a single-use, time-boxed challenge (ChallengeRegistry).
12
+ //
13
+ // POST /gateway/connect {payload, signature, publicKey, nonce, ...}
14
+ // verifyConnect (Ed25519 sig + identity-binding + nonce + freshness),
15
+ // then ChallengeRegistry.consume (single-use / anti-replay). On
16
+ // success: an APPROVED device gets its rotated bearer token; an
17
+ // unapproved device gets a 403 `pending` receipt and a pairing
18
+ // request is recorded for the operator to `lazyclaw nodes approve`.
19
+ //
20
+ // GET /gateway/whoami (Authorization: Bearer <deviceToken>,
21
+ // x-device-id: <deviceId>) -> { deviceId }
22
+ // GET /gateway/events (same auth) -> text/event-stream
23
+ // Authenticated push channel. broadcast() fans an event to every live
24
+ // subscriber. Event producers (e.g. remote tool-call approval) are a
25
+ // later pass; the transport + auth are here now.
26
+ //
27
+ // These routes are mounted by daemon.mjs OUTSIDE the daemon's shared
28
+ // `auth-token` gate: device-auth is their own gate, and the only
29
+ // unauthenticated route (challenge) returns nothing but a random nonce.
30
+
31
+ import crypto from 'node:crypto';
32
+ import {
33
+ PairingStore,
34
+ deviceIdFromPublicKey,
35
+ verifyConnect,
36
+ } from './device_auth.mjs';
37
+ import { redactSecrets } from '../mas/redact.mjs';
38
+
39
+ // Matches the daemon's readTextBody cap (1 MiB) so the Content-Length
40
+ // pre-check and the stream reader agree on the limit.
41
+ const GATEWAY_MAX_BODY = 1_048_576;
42
+
43
+ function writeJson(res, status, body) {
44
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
45
+ res.end(JSON.stringify(body));
46
+ }
47
+
48
+ function bearerToken(req) {
49
+ const h = req.headers?.authorization || req.headers?.Authorization || '';
50
+ const m = /^Bearer\s+(.+)$/i.exec(String(h).trim());
51
+ return m ? m[1].trim() : '';
52
+ }
53
+
54
+ /**
55
+ * Build a gateway instance. `challengeRegistry` is created ONCE per daemon
56
+ * process and shared across requests — a challenge minted by one request
57
+ * is consumed by a later one, so it must outlive a single call. `nowFn`
58
+ * is injected for deterministic tests.
59
+ */
60
+ export function createGateway({ configDir, challengeRegistry, nowFn = Date.now, heartbeatMs = 0 } = {}) {
61
+ if (!challengeRegistry) throw new Error('createGateway requires a challengeRegistry');
62
+ // Each entry: { res, deviceId }. Bounded globally and per-device so an
63
+ // authenticated device can't exhaust sockets/fds with open streams.
64
+ const sseClients = new Set();
65
+ const MAX_SSE_GLOBAL = 256;
66
+ const MAX_SSE_PER_DEVICE = 8;
67
+ const deviceStreamCount = (deviceId) => {
68
+ let n = 0;
69
+ for (const c of sseClients) if (c.deviceId === deviceId) n++;
70
+ return n;
71
+ };
72
+
73
+ // A fresh PairingStore per op re-reads the on-disk file, so an `approve`
74
+ // performed by the CLI in another process is visible to the daemon
75
+ // without a restart.
76
+ const store = () => new PairingStore(configDir);
77
+
78
+ // ── exec-approval producer ───────────────────────────────────────
79
+ // A trusted in-process caller (the daemon's POST /exec/request, which
80
+ // sits behind the daemon auth-token gate) calls requestApproval(); that
81
+ // broadcasts `exec.approval.requested` to every subscribed device and
82
+ // returns a Promise that settles when a device POSTs
83
+ // /gateway/exec/resolve (or on timeout). This is the remote
84
+ // human-in-the-loop gate for sensitive tool calls.
85
+ const approvals = new Map(); // id -> { detail, resolveFn, timer, createdAt }
86
+ const MAX_APPROVALS = 256;
87
+ const APPROVAL_TTL_MS = 5 * 60 * 1000;
88
+
89
+ // What the device is shown about a pending approval. Args may carry
90
+ // secrets (e.g. a bash command with a token), so the summary is redacted
91
+ // and capped before it leaves the process.
92
+ function approvalView(detail = {}) {
93
+ const summary = redactSecrets(String(detail.summary ?? detail.args ?? '')).slice(0, 500);
94
+ return { tool: detail.tool || '', agentId: detail.agentId || '', summary };
95
+ }
96
+
97
+ function requestApproval(detail = {}, { timeoutMs = APPROVAL_TTL_MS } = {}) {
98
+ // Bound the table — deny-and-evict the oldest pending if we're full so
99
+ // an approval flood can't grow memory without limit.
100
+ if (approvals.size >= MAX_APPROVALS) {
101
+ const oldest = approvals.keys().next().value;
102
+ if (oldest !== undefined) resolveApproval(oldest, false, 'system:capacity');
103
+ }
104
+ const id = 'ap_' + crypto.randomBytes(9).toString('hex');
105
+ let resolveFn;
106
+ const promise = new Promise((r) => { resolveFn = r; });
107
+ // Clamp both ends: a caller can't pin an HTTP socket + entry for an
108
+ // arbitrary (e.g. multi-day) duration, nor poll faster than 1s.
109
+ const ttl = Math.min(APPROVAL_TTL_MS, Math.max(1000, timeoutMs || APPROVAL_TTL_MS));
110
+ const timer = setTimeout(() => {
111
+ if (approvals.has(id)) {
112
+ approvals.delete(id);
113
+ resolveFn({ id, approved: false, reason: 'timeout' });
114
+ broadcast('exec.approval.resolved', { id, approved: false, reason: 'timeout' });
115
+ }
116
+ }, ttl);
117
+ if (typeof timer.unref === 'function') timer.unref();
118
+ approvals.set(id, { detail, resolveFn, timer, createdAt: nowFn() });
119
+ broadcast('exec.approval.requested', { id, ...approvalView(detail) });
120
+ return { id, promise };
121
+ }
122
+
123
+ // Trust model: every APPROVED device is uniformly trusted — any of them
124
+ // may resolve any pending approval and list pending ids. There is no
125
+ // per-device scoping by design (a paired device is an operator surface).
126
+ // Approval ids are unguessable (crypto.randomBytes), so only an
127
+ // already-trusted, authenticated device can act. If you pair a
128
+ // lower-trust device, revoke it (`lazyclaw nodes revoke`) rather than
129
+ // relying on approval scoping.
130
+ function resolveApproval(id, decision, by = '') {
131
+ const a = approvals.get(id);
132
+ if (!a) return { ok: false, reason: 'unknown or already resolved' };
133
+ clearTimeout(a.timer);
134
+ approvals.delete(id);
135
+ const approved = decision === true || decision === 'approve' || decision === 'allow';
136
+ a.resolveFn({ id, approved, by, reason: approved ? 'approved' : 'denied' });
137
+ broadcast('exec.approval.resolved', { id, approved, by });
138
+ return { ok: true, id, approved };
139
+ }
140
+
141
+ function pendingApprovals() {
142
+ return [...approvals.entries()].map(([id, a]) => ({ id, createdAt: a.createdAt, ...approvalView(a.detail) }));
143
+ }
144
+
145
+ async function readJsonBody(req, readBody) {
146
+ let raw;
147
+ try { raw = await readBody(req); }
148
+ catch { return { __tooLarge: true }; } // body exceeded the cap
149
+ if (!raw) return {};
150
+ try { return JSON.parse(raw); } catch { return null; } // null => malformed
151
+ }
152
+
153
+ function authDevice(req) {
154
+ const token = bearerToken(req);
155
+ const deviceId = String(req.headers?.['x-device-id'] || '').trim();
156
+ if (!token || !deviceId) return null;
157
+ if (!store().verifyToken(deviceId, token)) return null;
158
+ return { deviceId };
159
+ }
160
+
161
+ async function handle(req, res, { readBody }) {
162
+ const url = new URL(req.url || '/', 'http://localhost');
163
+ const p = url.pathname;
164
+ const m = req.method;
165
+
166
+ // ── challenge ────────────────────────────────────────────────
167
+ if (m === 'POST' && p === '/gateway/connect/challenge') {
168
+ const challenge = challengeRegistry.create();
169
+ return writeJson(res, 200, challenge);
170
+ }
171
+
172
+ // ── connect ──────────────────────────────────────────────────
173
+ if (m === 'POST' && p === '/gateway/connect') {
174
+ // Reject an oversized body up front via Content-Length so we answer
175
+ // 413 cleanly instead of the body reader destroying the socket
176
+ // (ECONNRESET) once the cap is hit mid-stream.
177
+ const clen = Number(req.headers?.['content-length'] || 0);
178
+ if (Number.isFinite(clen) && clen > GATEWAY_MAX_BODY) {
179
+ return writeJson(res, 413, { ok: false, reason: 'request body too large' });
180
+ }
181
+ const body = await readJsonBody(req, readBody);
182
+ if (body && body.__tooLarge) return writeJson(res, 413, { ok: false, reason: 'request body too large' });
183
+ if (!body) return writeJson(res, 400, { ok: false, reason: 'malformed body' });
184
+ const { payload, signature, publicKey, nonce, platform = '', label = '' } = body;
185
+ if (!payload || !signature || !publicKey || !nonce) {
186
+ return writeJson(res, 400, { ok: false, reason: 'payload, signature, publicKey and nonce are required' });
187
+ }
188
+ let deviceId;
189
+ try {
190
+ deviceId = deviceIdFromPublicKey(publicKey);
191
+ } catch {
192
+ // Generic — don't reflect node:crypto parse internals.
193
+ return writeJson(res, 400, { ok: false, reason: 'invalid public key' });
194
+ }
195
+ const now = nowFn();
196
+ const verdict = verifyConnect({ payload, signature, publicKey, challenge: { nonce }, nowMs: now });
197
+ if (!verdict.ok) {
198
+ return writeJson(res, 401, { ok: false, reason: verdict.reason });
199
+ }
200
+ // Single-use / anti-replay: only AFTER a valid signature, and only
201
+ // once. A replayed (payload,signature) re-presents the same nonce,
202
+ // which consume() has already retired.
203
+ if (!challengeRegistry.consume(nonce, now)) {
204
+ return writeJson(res, 401, { ok: false, reason: 'challenge expired or already used' });
205
+ }
206
+ const st = store();
207
+ if (st.isApproved(deviceId)) {
208
+ return writeJson(res, 200, { ok: true, deviceId, token: st.tokenFor(deviceId) });
209
+ }
210
+ // Not approved — record intent once (don't pile up duplicates) and
211
+ // tell the device to wait for the operator's approval.
212
+ const existing = st.pendingForDevice(deviceId);
213
+ let receipt;
214
+ if (existing) {
215
+ receipt = { requestId: existing.requestId };
216
+ } else {
217
+ try {
218
+ receipt = st.requestPairing({ deviceId, platform, label });
219
+ } catch (err) {
220
+ // Pending-requests ceiling hit — shed load rather than 500.
221
+ if (err && err.code === 'PAIRING_CAP') {
222
+ return writeJson(res, 429, { ok: false, reason: 'too many pending pairing requests; try later' });
223
+ }
224
+ throw err;
225
+ }
226
+ }
227
+ return writeJson(res, 403, { ok: false, status: 'pending', deviceId, requestId: receipt.requestId });
228
+ }
229
+
230
+ // ── whoami (token-authenticated) ─────────────────────────────
231
+ if (m === 'GET' && p === '/gateway/whoami') {
232
+ const ident = authDevice(req);
233
+ if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
234
+ return writeJson(res, 200, { ok: true, deviceId: ident.deviceId });
235
+ }
236
+
237
+ // ── exec approval: device resolves a pending request ─────────
238
+ if (m === 'POST' && p === '/gateway/exec/resolve') {
239
+ const ident = authDevice(req);
240
+ if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
241
+ const body = await readJsonBody(req, readBody);
242
+ if (body && body.__tooLarge) return writeJson(res, 413, { ok: false, reason: 'request body too large' });
243
+ if (!body || !body.id) return writeJson(res, 400, { ok: false, reason: 'id and decision are required' });
244
+ const r = resolveApproval(body.id, body.decision, ident.deviceId);
245
+ return writeJson(res, r.ok ? 200 : 404, r);
246
+ }
247
+
248
+ // ── exec approval: device lists what's awaiting a decision ───
249
+ if (m === 'GET' && p === '/gateway/exec/pending') {
250
+ const ident = authDevice(req);
251
+ if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
252
+ return writeJson(res, 200, { pending: pendingApprovals() });
253
+ }
254
+
255
+ // ── events (SSE, token-authenticated) ────────────────────────
256
+ if (m === 'GET' && p === '/gateway/events') {
257
+ const ident = authDevice(req);
258
+ if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
259
+ if (sseClients.size >= MAX_SSE_GLOBAL) {
260
+ return writeJson(res, 429, { ok: false, reason: 'event-stream capacity reached' });
261
+ }
262
+ if (deviceStreamCount(ident.deviceId) >= MAX_SSE_PER_DEVICE) {
263
+ return writeJson(res, 429, { ok: false, reason: 'too many event streams for this device' });
264
+ }
265
+ res.writeHead(200, {
266
+ 'content-type': 'text/event-stream; charset=utf-8',
267
+ 'cache-control': 'no-cache',
268
+ connection: 'keep-alive',
269
+ });
270
+ res.write(`event: connected\ndata: ${JSON.stringify({ deviceId: ident.deviceId })}\n\n`);
271
+ const entry = { res, deviceId: ident.deviceId };
272
+ sseClients.add(entry);
273
+ const onClose = () => { sseClients.delete(entry); };
274
+ if (typeof req.once === 'function') req.once('close', onClose);
275
+ if (typeof res.once === 'function') res.once('close', onClose);
276
+ return; // connection stays open
277
+ }
278
+
279
+ return writeJson(res, 404, { ok: false, reason: 'no such gateway route' });
280
+ }
281
+
282
+ // Fan an event out to every live SSE subscriber. Best-effort: a dead
283
+ // socket is dropped, never throws into the caller.
284
+ function broadcast(eventName, data) {
285
+ const frame = `event: ${eventName}\ndata: ${JSON.stringify(data ?? {})}\n\n`;
286
+ for (const entry of sseClients) {
287
+ try { entry.res.write(frame); } catch { sseClients.delete(entry); }
288
+ }
289
+ return sseClients.size;
290
+ }
291
+
292
+ // Optional keep-alive: periodic `tick` event so SSE proxies don't idle
293
+ // out the stream and subscribers can prove the channel is live. Opt-in
294
+ // (heartbeatMs>0) and unref'd so it never holds the process open or
295
+ // leaks in unit tests that don't ask for it.
296
+ let heartbeat = null;
297
+ if (heartbeatMs > 0) {
298
+ heartbeat = setInterval(() => { broadcast('tick', { ts: nowFn() }); }, heartbeatMs);
299
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
300
+ }
301
+ function close() { if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } }
302
+
303
+ return { handle, broadcast, sseClients, requestApproval, resolveApproval, pendingApprovals, close };
304
+ }
@@ -16,6 +16,9 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import os from 'node:os';
18
18
 
19
+ import { runTextCompletion } from './provider_adapters.mjs';
20
+ import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
21
+
19
22
  export const DEFAULT_MAX_CHARS = 12 * 1024;
20
23
  const AGENTS_MEM_DIR = path.join('memory', 'agents');
21
24
 
@@ -53,6 +56,11 @@ export function readMemory(name, configDir = defaultConfigDir(), maxChars = DEFA
53
56
  let cut = raw.slice(0, maxChars);
54
57
  const lastBlank = cut.lastIndexOf('\n\n');
55
58
  if (lastBlank > maxChars * 0.6) cut = cut.slice(0, lastBlank);
59
+ // Drop a trailing lone high-surrogate (U+D800..U+DBFF) left behind
60
+ // when the slice landed in the middle of a surrogate pair, so the
61
+ // returned string is never invalid UTF-16.
62
+ const lastUnit = cut.charCodeAt(cut.length - 1);
63
+ if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) cut = cut.slice(0, -1);
56
64
  return cut + '\n\n…[older entries truncated]\n';
57
65
  } catch {
58
66
  return '';
@@ -135,19 +143,28 @@ export function buildMemoryBlock(name, configDir = defaultConfigDir(), maxChars
135
143
  // to the on-disk file (auto mode) or surface it to the user (manual
136
144
  // command). Throws on hard failure; the router catches and logs.
137
145
  //
138
- // We use the agent's own provider via the existing tool-use callOnce
139
- // adapters because they are already wired up with apiKey/baseUrl
140
- // passthrough. No tools are advertised — reflection is pure text.
146
+ // We use the agent's own provider via the shared no-tools text
147
+ // completion (mas/provider_adapters.mjs), which is already wired for
148
+ // apiKey/baseUrl passthrough. No tools are advertised — reflection is
149
+ // pure text. This module owns the reflection-specific transcript +
150
+ // prompt; the call mechanics are shared with skill synthesis.
141
151
  export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, maxBullets = 6 } = {}) {
142
152
  if (!agent || !task) throw new AgentMemoryError('agent and task are required', 'AGENT_MEMORY_BAD_INPUT');
143
- const adapter = await pickAdapter(agent.provider);
144
153
 
145
- const transcript = (Array.isArray(task.turns) ? task.turns : [])
146
- .map((t) => {
147
- const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
148
- return `[${who}] ${t.text || ''}`;
149
- })
150
- .join('\n\n') || '(no turns)';
154
+ // Redact secrets from the transcript BEFORE it leaves for the model,
155
+ // so a token pasted into a task turn never reaches the LLM. Symmetric
156
+ // with skill_synth.synthesizeSkill, which already redacts its
157
+ // transcript; both share mas/redact.mjs.
158
+ const transcript = redactSecrets(
159
+ (Array.isArray(task.turns) ? task.turns : [])
160
+ .map((t) => {
161
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
162
+ // Defang any forged role label inside the (model-controlled) body
163
+ // so a turn can't inject its own [System]/[User] authority line.
164
+ return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
165
+ })
166
+ .join('\n\n') || '(no turns)',
167
+ );
151
168
 
152
169
  const userMessage =
153
170
  `You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
@@ -157,33 +174,17 @@ export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, max
157
174
  `gotchas, teammate preferences. Do NOT repeat generic advice. Do NOT exceed ${maxBullets} ` +
158
175
  `bullets. Reply with the bullets only — no headers, no preamble.`;
159
176
 
160
- const initialUser = adapter.initialUserMessage
161
- ? adapter.initialUserMessage(userMessage)
162
- : { role: 'user', content: userMessage };
163
-
164
- const resp = await adapter.callOnce({
165
- messages: [initialUser],
166
- tools: [],
177
+ const text = await runTextCompletion({
178
+ provider: agent.provider,
167
179
  model: agent.model,
168
- apiKey,
169
180
  system: agent.role || '',
181
+ userMessage,
182
+ apiKey,
170
183
  baseUrl,
171
184
  fetchImpl,
172
185
  });
173
- if (resp.kind !== 'final') {
174
- throw new AgentMemoryError(`reflection expected text reply, got ${resp.kind}`, 'AGENT_MEMORY_NO_TEXT');
175
- }
176
- const text = (resp.text || '').trim();
177
- return text;
178
- }
179
-
180
- async function pickAdapter(provider) {
181
- switch (provider) {
182
- case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
183
- case 'openai': return await import('../providers/tool_use/openai.mjs');
184
- case 'gemini': return await import('../providers/tool_use/gemini.mjs');
185
- case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
186
- default:
187
- throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
188
- }
186
+ // Redact again on the way back: the model may echo a secret it saw in
187
+ // the transcript, and this body is persisted via prependEntry and
188
+ // replayed into every future system prompt.
189
+ return redactSecrets(text).trim();
189
190
  }
@@ -25,6 +25,7 @@ import * as anthropic from '../providers/tool_use/anthropic.mjs';
25
25
  import * as openai from '../providers/tool_use/openai.mjs';
26
26
  import * as gemini from '../providers/tool_use/gemini.mjs';
27
27
  import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
28
+ import { put as _trajPut } from './trajectory_store.mjs';
28
29
 
29
30
  export class AgentTurnError extends Error {
30
31
  constructor(message, code) {
@@ -70,6 +71,8 @@ export async function runAgentTurn({
70
71
  apiKey,
71
72
  maxIterations = DEFAULT_MAX_ITERATIONS,
72
73
  signal,
74
+ approve,
75
+ trajectoryRef,
73
76
  } = {}) {
74
77
  if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
75
78
  const adapter = adapterFor(agent.provider);
@@ -91,6 +94,29 @@ export async function runAgentTurn({
91
94
  const toolCalls = [];
92
95
  let iterations = 0;
93
96
  let lastText = '';
97
+ if (trajectoryRef && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
98
+
99
+ const _maybePersistTrajectory = async (outcome) => {
100
+ if (!trajectoryRef) return;
101
+ try {
102
+ await _trajPut({
103
+ taskId, agentName: agent.name || 'agent',
104
+ workerProvider: agent.provider, workerModel: agent.model,
105
+ startedAt: trajectoryRef.startedAt || Date.now(),
106
+ endedAt: Date.now(),
107
+ systemPrompt: agent.role || '',
108
+ userMessages: userMessage ? [String(userMessage)] : [],
109
+ turns: toolCalls.map((c, i) => ({
110
+ turnIdx: i, role: 'tool', content: '',
111
+ toolCalls: [{ name: c.name, args: c.input, result: JSON.stringify(c.result), success: c.ok, durationMs: 0 }],
112
+ })).concat(lastText ? [{
113
+ turnIdx: toolCalls.length, role: 'assistant', content: lastText, toolCalls: [],
114
+ }] : []),
115
+ finalAnswer: lastText,
116
+ outcome,
117
+ }, { configDir });
118
+ } catch { /* trajectory failure must not break the agent turn */ }
119
+ };
94
120
 
95
121
  while (iterations < maxIterations) {
96
122
  if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
@@ -102,6 +128,7 @@ export async function runAgentTurn({
102
128
  if (resp.text) lastText = resp.text;
103
129
 
104
130
  if (resp.kind === 'final') {
131
+ await _maybePersistTrajectory('done');
105
132
  return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
106
133
  }
107
134
 
@@ -118,7 +145,7 @@ export async function runAgentTurn({
118
145
  try {
119
146
  result = await runTool({
120
147
  agent, tool: call.name, args: call.input,
121
- taskId, configDir, cwd,
148
+ taskId, configDir, cwd, approve,
122
149
  });
123
150
  if (result && result.ok === false) ok = false;
124
151
  } catch (err) {
@@ -139,9 +166,11 @@ export async function runAgentTurn({
139
166
  // model so it can recover. Only an extraordinary error (e.g. the
140
167
  // provider returned a malformed envelope) bails out here.
141
168
  if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
169
+ await _maybePersistTrajectory('failed');
142
170
  return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
143
171
  }
144
172
  }
145
173
 
174
+ await _maybePersistTrajectory('abandoned');
146
175
  return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
147
176
  }
@@ -0,0 +1,108 @@
1
+ // Confidence calculator for v5 skills — spec §0.1 H2, §3.5.
2
+ //
3
+ // Pure functions, no I/O. Used by skill_synth v2 to stamp frontmatter
4
+ // and by trajectory_store recall ranking to weight near-duplicates.
5
+ //
6
+ // wilsonLowerBound(s, n) — 95% Wilson lower bound on success rate.
7
+ // crossCliDampen(score, trainer, provider, factor?)
8
+ // — multiply by `factor` (default 0.85)
9
+ // when trainer provider differs from
10
+ // worker provider (canonical decision
11
+ // §0.1 H2). Phase H2 makes the factor
12
+ // tunable; same-family scores are
13
+ // always returned unchanged.
14
+ // recencyDecay(ageMs, halfLifeMs) — exponential decay weight (0..1].
15
+ // computeConfidence({successes, trials, ageMs, trainer, provider, dampenFactor?})
16
+ // — composed score in [0, 1].
17
+ // resolveDampenFactor(cfg) — pull `crossCliDampenFactor` out of
18
+ // cfg.orchestra.learning (or legacy
19
+ // cfg.orchestrator.learning); clamps
20
+ // to [0,1]; defaults to 0.85 on miss.
21
+
22
+ const Z = 1.96; // 95% confidence two-sided
23
+
24
+ export const DEFAULT_CROSS_CLI_DAMPEN = 0.85;
25
+
26
+ export function wilsonLowerBound(successes, trials) {
27
+ const s = Number(successes) || 0;
28
+ const n = Number(trials) || 0;
29
+ if (n <= 0) return 0;
30
+ const phat = s / n;
31
+ const z2 = Z * Z;
32
+ const denom = 1 + z2 / n;
33
+ const center = phat + z2 / (2 * n);
34
+ const margin = Z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n);
35
+ const lb = (center - margin) / denom;
36
+ return Math.max(0, Math.min(1, lb));
37
+ }
38
+
39
+ const PROVIDER_FAMILY = {
40
+ 'claude-cli': 'anthropic',
41
+ 'anthropic': 'anthropic',
42
+ 'codex-cli': 'openai',
43
+ 'openai': 'openai',
44
+ 'gemini-cli': 'gemini',
45
+ 'gemini': 'gemini',
46
+ 'ollama': 'ollama',
47
+ };
48
+
49
+ export function sameFamily(a, b) {
50
+ if (!a || !b) return false;
51
+ return PROVIDER_FAMILY[a] === PROVIDER_FAMILY[b];
52
+ }
53
+
54
+ // Normalise a caller-supplied dampening factor. Anything non-finite (NaN,
55
+ // non-numeric strings, undefined) falls back to the canonical 0.85 so a
56
+ // typo in config does not silently zero out every cross-CLI skill.
57
+ function _normalizeFactor(factor) {
58
+ if (factor === null || factor === undefined) return DEFAULT_CROSS_CLI_DAMPEN;
59
+ const n = Number(factor);
60
+ if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
61
+ if (n < 0) return 0;
62
+ if (n > 1) return 1;
63
+ return n;
64
+ }
65
+
66
+ export function crossCliDampen(score, trainerProvider, workerProvider, factor) {
67
+ if (!trainerProvider || !workerProvider) return score;
68
+ if (sameFamily(trainerProvider, workerProvider)) return score;
69
+ return score * _normalizeFactor(factor);
70
+ }
71
+
72
+ export function recencyDecay(ageMs, halfLifeMs = 30 * 24 * 60 * 60 * 1000) {
73
+ const t = Math.max(0, Number(ageMs) || 0);
74
+ const hl = Math.max(1, Number(halfLifeMs) || 1);
75
+ return Math.pow(0.5, t / hl);
76
+ }
77
+
78
+ export function computeConfidence({
79
+ successes = 0,
80
+ trials = 0,
81
+ ageMs = 0,
82
+ trainerProvider = null,
83
+ workerProvider = null,
84
+ halfLifeMs,
85
+ dampenFactor,
86
+ } = {}) {
87
+ const base = wilsonLowerBound(successes, trials);
88
+ const decayed = base * recencyDecay(ageMs, halfLifeMs);
89
+ const dampened = crossCliDampen(decayed, trainerProvider, workerProvider, dampenFactor);
90
+ return Math.max(0, Math.min(1, dampened));
91
+ }
92
+
93
+ // Pull the tunable factor out of config. Honours both the canonical
94
+ // `cfg.orchestra.learning.crossCliDampenFactor` shape and the legacy
95
+ // `cfg.orchestrator.learning.crossCliDampenFactor` (v4 callers). Returns
96
+ // 0.85 when missing/invalid so callers never have to gate on undefined.
97
+ export function resolveDampenFactor(cfg) {
98
+ if (!cfg || typeof cfg !== 'object') return DEFAULT_CROSS_CLI_DAMPEN;
99
+ const orchestra = cfg.orchestra || cfg.orchestrator || {};
100
+ const learning = orchestra.learning || {};
101
+ const raw = learning.crossCliDampenFactor;
102
+ if (raw === undefined || raw === null) return DEFAULT_CROSS_CLI_DAMPEN;
103
+ const n = Number(raw);
104
+ if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
105
+ if (n < 0) return 0;
106
+ if (n > 1) return 1;
107
+ return n;
108
+ }