lazyclaw 5.1.0 → 5.3.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.
@@ -26,6 +26,7 @@ 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
28
  import { put as _trajPut } from './trajectory_store.mjs';
29
+ import { composePromptStack } from './prompt_stack.mjs';
29
30
 
30
31
  export class AgentTurnError extends Error {
31
32
  constructor(message, code) {
@@ -37,6 +38,27 @@ export class AgentTurnError extends Error {
37
38
 
38
39
  const DEFAULT_MAX_ITERATIONS = 10;
39
40
 
41
+ // Mark the last content block in an Anthropic-shaped message array
42
+ // with cache_control:ephemeral so the prompt-cache breakpoint advances
43
+ // per loop iteration. Operates on the LAST message in the array (the
44
+ // one we just appended) — if its content is an array of blocks, the
45
+ // final block carries the marker; if it's a plain string we lift it
46
+ // into a single text block. Mutates in place — caller owns the array.
47
+ function _markLastContentCacheable(msgs) {
48
+ if (!Array.isArray(msgs) || msgs.length === 0) return;
49
+ const last = msgs[msgs.length - 1];
50
+ if (!last || typeof last !== 'object') return;
51
+ if (Array.isArray(last.content)) {
52
+ if (last.content.length === 0) return;
53
+ const block = last.content[last.content.length - 1];
54
+ if (block && typeof block === 'object') {
55
+ block.cache_control = { type: 'ephemeral' };
56
+ }
57
+ } else if (typeof last.content === 'string') {
58
+ last.content = [{ type: 'text', text: last.content, cache_control: { type: 'ephemeral' } }];
59
+ }
60
+ }
61
+
40
62
  function adapterFor(provider) {
41
63
  switch (provider) {
42
64
  case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
@@ -72,13 +94,48 @@ export async function runAgentTurn({
72
94
  maxIterations = DEFAULT_MAX_ITERATIONS,
73
95
  signal,
74
96
  approve,
75
- trajectoryRef,
97
+ // v5 (Group A — C3): trajectoryRef is OPT-OUT, not opt-in. A caller
98
+ // that doesn't pass one but DOES pass a configDir gets a
99
+ // default-stamped record so every production agent turn lands in
100
+ // trajectory_store. Two opt-outs exist for callers that don't want
101
+ // disk side effects (tool-use unit tests, sandboxed harnesses):
102
+ // - set process.env.LAZYCLAW_NO_TRAJECTORY='1'
103
+ // - omit configDir (legacy unit-test surface — see _maybePersistTrajectory)
104
+ trajectoryRef = { startedAt: Date.now() },
105
+ // v5 (canonical decision C5) — when true, the system slot passed to
106
+ // the adapter is the full 8-layer composePromptStack output instead
107
+ // of the bare agent.role. Default `false` for byte-stability with
108
+ // existing tool-use tests; callers that have NOT already pre-built
109
+ // the stack (e.g. the future delegation tool, direct API consumers)
110
+ // should opt in.
111
+ usePromptStack = false,
112
+ // Group B / C9 — prompt caching. Off by default to preserve the
113
+ // byte-stable adapter shape that phase 12b et al. depend on; the
114
+ // mention router (production caller) flips it on so every MAS turn
115
+ // hits the Anthropic prompt cache.
116
+ cache = false,
76
117
  } = {}) {
77
118
  if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
78
119
  const adapter = adapterFor(agent.provider);
79
120
 
80
121
  const tools = adapter.toolSchemas(listToolSchemas(agent.tools));
81
122
 
123
+ // Compose the layered system prompt once, up front, so every adapter
124
+ // round-trip in the loop sees the same string. composePromptStack
125
+ // returns '' on a fresh install — when it does, we fall back to
126
+ // agent.role so the legacy single-layer shape is preserved.
127
+ let systemPrompt = agent.role || '';
128
+ if (usePromptStack) {
129
+ try {
130
+ const stacked = composePromptStack({
131
+ cfgDir: configDir,
132
+ agent,
133
+ workspace: agent.workspace || '',
134
+ });
135
+ if (stacked && stacked.trim()) systemPrompt = stacked;
136
+ } catch { /* best-effort — never block a turn on stack composition */ }
137
+ }
138
+
82
139
  // Seed messages from prior history + the new user input. Callers
83
140
  // pass history in a provider-neutral [{role, content}] shape; the
84
141
  // adapter normalises it into its native message format (e.g. Gemini's
@@ -94,10 +151,16 @@ export async function runAgentTurn({
94
151
  const toolCalls = [];
95
152
  let iterations = 0;
96
153
  let lastText = '';
97
- if (trajectoryRef && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
154
+ if (trajectoryRef && typeof trajectoryRef === 'object' && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
98
155
 
99
156
  const _maybePersistTrajectory = async (outcome) => {
157
+ if (process.env.LAZYCLAW_NO_TRAJECTORY === '1') return;
100
158
  if (!trajectoryRef) return;
159
+ // Defence-in-depth: callers that don't pass configDir (legacy unit
160
+ // tests of the tool-use loop) shouldn't see a side-effect on
161
+ // ~/.lazyclaw. The canonical post-task funnel (mas/learning.mjs)
162
+ // always passes configDir, so production paths still persist.
163
+ if (!configDir) return;
101
164
  try {
102
165
  await _trajPut({
103
166
  taskId, agentName: agent.name || 'agent',
@@ -122,8 +185,8 @@ export async function runAgentTurn({
122
185
  if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
123
186
  iterations++;
124
187
  const resp = await adapter.callOnce({
125
- messages, tools, model: agent.model, apiKey, system: agent.role,
126
- fetchImpl, baseUrl, signal,
188
+ messages, tools, model: agent.model, apiKey, system: systemPrompt,
189
+ fetchImpl, baseUrl, signal, cache,
127
190
  });
128
191
  if (resp.text) lastText = resp.text;
129
192
 
@@ -136,7 +199,17 @@ export async function runAgentTurn({
136
199
  // tool_result messages correlate, then run each tool and append the
137
200
  // adapter-shaped tool-result entries (one for Anthropic, N for
138
201
  // OpenAI, …).
139
- messages.push(...adapter.assistantTurnMessages(resp));
202
+ // Group B / C9 — advance the prompt-cache breakpoint by marking the
203
+ // freshly-appended assistant + tool_result content with
204
+ // cache_control:ephemeral so the next iteration's prefix (which
205
+ // includes the previous round of tool exchanges) is itself cached.
206
+ // Only the LAST cache_control block in the request actually counts
207
+ // as the live breakpoint per Anthropic's spec — so attaching it to
208
+ // every iteration's latest tool turn is safe and lets the cache
209
+ // walk forward as the conversation grows.
210
+ const _newAssistant = adapter.assistantTurnMessages(resp);
211
+ if (cache && agent.provider === 'anthropic') _markLastContentCacheable(_newAssistant);
212
+ messages.push(..._newAssistant);
140
213
  const results = [];
141
214
  let toolErrored = false;
142
215
  for (const call of resp.calls) {
@@ -160,7 +233,9 @@ export async function runAgentTurn({
160
233
  results.push({ id: call.id, content: result, isError: !ok });
161
234
  if (!ok) toolErrored = true;
162
235
  }
163
- messages.push(...adapter.toolResultMessages(results));
236
+ const _newToolResult = adapter.toolResultMessages(results);
237
+ if (cache && agent.provider === 'anthropic') _markLastContentCacheable(_newToolResult);
238
+ messages.push(..._newToolResult);
164
239
 
165
240
  // We feed every tool error (denied/unknown/runtime) back to the
166
241
  // model so it can recover. Only an extraordinary error (e.g. the
package/mas/index_db.mjs CHANGED
@@ -21,6 +21,26 @@ function defaultConfigDir() {
21
21
  return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
22
22
  }
23
23
 
24
+ // m11 — when a write-through hook fails, append a structured entry to
25
+ // <configDir>/index-failures.jsonl so `lazyclaw doctor` can surface
26
+ // recent failures (last 24h) and the operator can rebuild before the
27
+ // silent stale-index problem compounds. Best-effort: any error during
28
+ // the append itself is swallowed (we don't want to spam stderr from a
29
+ // background hook).
30
+ function _logIndexFailure(configDir, scope, err) {
31
+ try {
32
+ fs.mkdirSync(configDir, { recursive: true });
33
+ const file = path.join(configDir, 'index-failures.jsonl');
34
+ const entry = {
35
+ ts: new Date().toISOString(),
36
+ event: 'index.write.failed',
37
+ scope,
38
+ error: String(err?.message || err || 'unknown'),
39
+ };
40
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
41
+ } catch { /* swallow — surface only via console.warn below */ }
42
+ }
43
+
24
44
  function dbPath(configDir) {
25
45
  return path.join(configDir, 'index.db');
26
46
  }
@@ -131,6 +151,7 @@ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
131
151
  String(row.role || ''), Number(row.ts || Date.now()),
132
152
  );
133
153
  } catch (e) {
154
+ _logIndexFailure(configDir, 'sessions', e);
134
155
  // eslint-disable-next-line no-console
135
156
  console.warn('[index_db] indexSessionTurn failed:', e.message);
136
157
  }
@@ -145,6 +166,7 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
145
166
  String(row.group_name || ''),
146
167
  );
147
168
  } catch (e) {
169
+ _logIndexFailure(configDir, 'skills', e);
148
170
  // eslint-disable-next-line no-console
149
171
  console.warn('[index_db] indexSkill failed:', e.message);
150
172
  }
@@ -159,6 +181,7 @@ export function indexTrajectory(row, configDir = defaultConfigDir()) {
159
181
  String(row.outcome || ''),
160
182
  );
161
183
  } catch (e) {
184
+ _logIndexFailure(configDir, 'trajectories', e);
162
185
  // eslint-disable-next-line no-console
163
186
  console.warn('[index_db] indexTrajectory failed:', e.message);
164
187
  }
@@ -172,6 +195,7 @@ export function indexMemory(row, configDir = defaultConfigDir()) {
172
195
  String(row.topic || ''), String(row.kind || ''),
173
196
  );
174
197
  } catch (e) {
198
+ _logIndexFailure(configDir, 'memories', e);
175
199
  // eslint-disable-next-line no-console
176
200
  console.warn('[index_db] indexMemory failed:', e.message);
177
201
  }
@@ -190,21 +214,57 @@ function sanitizeFtsQuery(q) {
190
214
  return s.replace(/[-:+]/g, ' ').replace(/\s+/g, ' ').trim();
191
215
  }
192
216
 
217
+ /**
218
+ * Cross-scope FTS5 recall. Content-only semantics — all UNINDEXED
219
+ * metadata columns (session_id, skill_name, agent, outcome, topic, …)
220
+ * are returned as `metadata` but NOT searchable via MATCH (m9). Use
221
+ * `opts.where` to post-filter on metadata equality.
222
+ *
223
+ * @param {string} query FTS5 MATCH query. By default the query is
224
+ * sanitised: `-`, `:`, `+` rewritten to spaces
225
+ * so a bareword like "write-through" doesn't
226
+ * get parsed as NOT-through. Pass `opts.raw=true`
227
+ * to bypass sanitisation when you need real
228
+ * FTS5 operators (NOT, OR, near).
229
+ * @param {Object} [opts]
230
+ * @param {string} [opts.configDir]
231
+ * @param {string[]} [opts.scope] default: all four scopes
232
+ * @param {number} [opts.k] default 10, capped at 50
233
+ * @param {boolean} [opts.raw] bypass sanitiseFtsQuery (power users)
234
+ * @param {Object} [opts.where] metadata equality post-filter, e.g.
235
+ * { trained_by: 'human', agent: 'reviewer' }
236
+ * Applied after FTS5 returns hits; values
237
+ * are coerced to string before compare.
238
+ */
193
239
  export function recall(query, opts = {}) {
194
240
  const t0 = process.hrtime.bigint();
195
241
  const configDir = opts.configDir || defaultConfigDir();
196
242
  const scope = opts.scope || ['sessions', 'skills', 'trajectories', 'memories'];
197
243
  const k = Math.min(Math.max(Number(opts.k) || 10, 1), 50);
198
244
  const s = _stmts(configDir);
199
- const safeQuery = sanitizeFtsQuery(query);
245
+ const safeQuery = opts.raw ? String(query ?? '').trim() : sanitizeFtsQuery(query);
246
+ const whereKeys = (opts.where && typeof opts.where === 'object') ? Object.keys(opts.where) : [];
247
+ // Over-fetch when a where-filter is set: post-filter can drop most
248
+ // hits, so request 2x k from FTS5 so the final trimmed slice has
249
+ // enough to fill k. Capped at the FTS5-side 200 hard limit.
250
+ const fetchK = whereKeys.length ? Math.min(200, k * 2) : k;
200
251
  const hits = [];
201
252
  for (const sc of scope) {
202
253
  const stmt = s.queries[sc];
203
254
  if (!stmt) continue;
204
255
  try {
205
- const rows = stmt.all(safeQuery, k);
256
+ const rows = stmt.all(safeQuery, fetchK);
206
257
  for (const r of rows) {
207
258
  const { scope: sc2, bm25, snippet, ...metadata } = r;
259
+ // Apply where-filter at row level — keeps the bm25 ordering
260
+ // intact and avoids re-fetching after sort.
261
+ if (whereKeys.length) {
262
+ let skip = false;
263
+ for (const wk of whereKeys) {
264
+ if (String(metadata[wk] ?? '') !== String(opts.where[wk])) { skip = true; break; }
265
+ }
266
+ if (skip) continue;
267
+ }
208
268
  hits.push({ scope: sc2, rank: hits.length, bm25, snippet, metadata });
209
269
  }
210
270
  } catch (e) {
@@ -0,0 +1,371 @@
1
+ // mas/learning.mjs — v5.0 spec §3.6 / canonical decision C2.
2
+ //
3
+ // Canonical funnel for the five post-task learning triggers. Every
4
+ // learning signal (a finished task, an active-recall miss, a periodic
5
+ // curation pass, etc.) lands in `runLearning(trigger, ctx)` so the
6
+ // orchestra hub has exactly one fan-out site rather than five scattered
7
+ // `import x.synthesize`/`y.persist` call chains.
8
+ //
9
+ // Triggers (frozen list — caller-side enum):
10
+ // post-task — a task finished successfully. Persist the
11
+ // trajectory, synthesise a SKILL.md, update the
12
+ // USER.md dialectic model, stamp confidence.
13
+ // post-failure — a task failed. Synthesise an anti-pattern
14
+ // skill so a future agent avoids the same trap.
15
+ // nudge — a nudge cluster accumulated enough evidence
16
+ // to be worth distilling. Phase 2 fully wires
17
+ // this; v5.0.10 keeps the dispatch hook so
18
+ // callers can opt in early.
19
+ // active-recall-miss — a skill was recalled but failed to apply.
20
+ // Decrement its confidence; archive when it
21
+ // falls below the activation threshold.
22
+ // periodic-curation — cron-driven skills_curator replay. Phase 2
23
+ // full implementation; v5.0.10 emits a warning
24
+ // and returns a no-op result so a cron entry
25
+ // configured today does not crash.
26
+ //
27
+ // Hard contract: a single broken handler MUST NOT poison the others.
28
+ // Each sub-routine is wrapped in its own try/catch so e.g. a missing
29
+ // trainer api-key won't block the trajectory write.
30
+
31
+ import * as trajectoryStore from './trajectory_store.mjs';
32
+ import * as skillSynth from './skill_synth.mjs';
33
+ import * as userModeler from './user_modeler.mjs';
34
+ import * as confidence from './confidence.mjs';
35
+ import * as skills from '../skills.mjs';
36
+ import { resolveTrainer } from '../providers/registry.mjs';
37
+
38
+ export const TRIGGERS = Object.freeze([
39
+ 'post-task',
40
+ 'post-failure',
41
+ 'nudge',
42
+ 'active-recall-miss',
43
+ 'periodic-curation',
44
+ ]);
45
+
46
+ export class LearningError extends Error {
47
+ constructor(message, code) {
48
+ super(message);
49
+ this.name = 'LearningError';
50
+ this.code = code || 'LEARNING_ERR';
51
+ }
52
+ }
53
+
54
+ // Public entry point. Returns { trigger, results, errors }; never throws
55
+ // for a known trigger — the caller (orchestrator microtask, mention
56
+ // router) expects best-effort semantics. Unknown triggers return an
57
+ // `{ error: 'UNKNOWN_TRIGGER' }` envelope so a typo in config surfaces
58
+ // loudly without taking down the calling stream.
59
+ export async function runLearning(trigger, ctx = {}) {
60
+ if (!TRIGGERS.includes(trigger)) {
61
+ return { trigger, error: 'UNKNOWN_TRIGGER', known: TRIGGERS.slice() };
62
+ }
63
+ const logger = typeof ctx.logger === 'function' ? ctx.logger : () => {};
64
+ switch (trigger) {
65
+ case 'post-task': return await _runPostTask(ctx, logger);
66
+ case 'post-failure': return await _runPostFailure(ctx, logger);
67
+ case 'nudge': return await _runNudge(ctx, logger);
68
+ case 'active-recall-miss': return await _runActiveRecallMiss(ctx, logger);
69
+ case 'periodic-curation': return await _runPeriodicCuration(ctx, logger);
70
+ default:
71
+ return { trigger, error: 'UNKNOWN_TRIGGER' };
72
+ }
73
+ }
74
+
75
+ // ── post-task ────────────────────────────────────────────────────────
76
+ //
77
+ // ctx: { agent, task, configDir, cfg, transcript?, trajectoryRef?,
78
+ // apiKey?, baseUrl?, fetchImpl? }
79
+ //
80
+ // Runs four sub-routines independently:
81
+ // 1. trajectory_store.put — durable evidence (FTS5 mirror too).
82
+ // 2. synthesizeSkill — distil a reusable SKILL.md.
83
+ // 3. updateUserModel — Honcho-style dialectic USER.md.
84
+ // 4. computeConfidence — stamp on the trained skill record.
85
+ export async function _runPostTask(ctx, logger) {
86
+ const errors = [];
87
+ const results = {};
88
+ const trainer = _safeResolveTrainer(ctx.cfg, ctx.agent);
89
+
90
+ // 1. trajectory_store.put
91
+ try {
92
+ const rec = _buildTrajectoryRecord(ctx, 'done');
93
+ // Stamp the trainer provider/model on the trajectory so a later
94
+ // recall + cross-CLI dampening lookup can see who synthesised the
95
+ // signal vs. who executed the turn (canonical decision H2).
96
+ rec.trainerProvider = trainer.provider || '';
97
+ rec.trainerModel = trainer.model || '';
98
+ results.trajectory = await trajectoryStore.put(rec, { configDir: ctx.configDir });
99
+ } catch (e) {
100
+ errors.push({ step: 'trajectory', error: String(e?.message || e) });
101
+ logger(`[learning] trajectory put failed: ${e?.message || e}\n`);
102
+ }
103
+
104
+ // 2. computeConfidence — pure function, no I/O. We hoist this above
105
+ // synthesizeSkill so the v5 frontmatter actually gets the
106
+ // confidence value (M1). Without the hoist, synthesise wrote
107
+ // `confidence: null` even though the loop computed the score
108
+ // afterwards.
109
+ try {
110
+ results.confidence = confidence.computeConfidence({
111
+ successes: 1,
112
+ trials: 1,
113
+ ageMs: 0,
114
+ trainerProvider: trainer.provider,
115
+ workerProvider: ctx.agent?.provider || trainer.provider,
116
+ dampenFactor: confidence.resolveDampenFactor(ctx.cfg),
117
+ });
118
+ } catch (e) {
119
+ errors.push({ step: 'confidence', error: String(e?.message || e) });
120
+ }
121
+
122
+ // 3. synthesizeSkill (best-effort — needs an agent + provider key).
123
+ // All v5 frontmatter fields land here (trained_by, confidence,
124
+ // trajectory_ref) so the produced SKILL.md is ready for recall +
125
+ // cross-CLI dampening without a follow-up patch.
126
+ if (ctx.agent && ctx.task) {
127
+ try {
128
+ results.skill = await skillSynth.synthesizeSkill({
129
+ agent: { ...ctx.agent, provider: trainer.provider, model: trainer.model },
130
+ task: ctx.task,
131
+ apiKey: ctx.apiKey,
132
+ baseUrl: ctx.baseUrl,
133
+ fetchImpl: ctx.fetchImpl,
134
+ outcome: 'done',
135
+ trainedBy: trainer.provider,
136
+ trainedOnModel: trainer.model,
137
+ trajectoryRef: results.trajectory?.id || null,
138
+ confidence: results.confidence,
139
+ });
140
+ // Persist the produced SKILL.md into the shared skills/ dir so the
141
+ // next agent turn's recall surfaces it. installSynthesized forwards
142
+ // every v5 frontmatter field (see Group A — C6 fix in skill_synth).
143
+ if (results.skill && ctx.configDir) {
144
+ try {
145
+ results.installed = skillSynth.installSynthesized({
146
+ name: results.skill.name,
147
+ description: results.skill.description,
148
+ body: results.skill.body,
149
+ sourceTask: ctx.task.id || '',
150
+ createdBy: 'agent',
151
+ trainedBy: trainer.provider,
152
+ trainedOnModel: trainer.model,
153
+ trajectoryRef: results.trajectory?.id || null,
154
+ confidence: results.confidence,
155
+ outcome: 'done',
156
+ }, ctx.configDir);
157
+ } catch (e) {
158
+ errors.push({ step: 'skillInstall', error: String(e?.message || e) });
159
+ logger(`[learning] skill install failed: ${e?.message || e}\n`);
160
+ }
161
+ }
162
+ } catch (e) {
163
+ errors.push({ step: 'skill', error: String(e?.message || e) });
164
+ logger(`[learning] skill synth failed: ${e?.message || e}\n`);
165
+ }
166
+ }
167
+
168
+ // 4. updateUserModel — needs session turns (any normalised
169
+ // [{role, content}] list). Falls back to task.turns translated.
170
+ if (ctx.sessionTurns || ctx.task?.turns) {
171
+ try {
172
+ results.userModel = await userModeler.updateUserModel({
173
+ sessionTurns: ctx.sessionTurns || _toSessionTurns(ctx.task.turns),
174
+ provider: trainer.provider,
175
+ model: trainer.model,
176
+ apiKey: ctx.apiKey,
177
+ baseUrl: ctx.baseUrl,
178
+ fetchImpl: ctx.fetchImpl,
179
+ configDir: ctx.configDir,
180
+ });
181
+ } catch (e) {
182
+ errors.push({ step: 'userModel', error: String(e?.message || e) });
183
+ logger(`[learning] user model update failed: ${e?.message || e}\n`);
184
+ }
185
+ }
186
+
187
+ return { trigger: 'post-task', results, errors };
188
+ }
189
+
190
+ // ── post-failure ─────────────────────────────────────────────────────
191
+ //
192
+ // Same shape as post-task but synthesizeSkill runs with outcome:
193
+ // 'failed' so the resulting SKILL.md is tagged anti_pattern: true and
194
+ // grouped under 'anti-pattern'.
195
+ export async function _runPostFailure(ctx, logger) {
196
+ const errors = [];
197
+ const results = {};
198
+ const trainer = _safeResolveTrainer(ctx.cfg, ctx.agent);
199
+
200
+ try {
201
+ const rec = _buildTrajectoryRecord(ctx, 'failed');
202
+ results.trajectory = await trajectoryStore.put(rec, { configDir: ctx.configDir });
203
+ } catch (e) {
204
+ errors.push({ step: 'trajectory', error: String(e?.message || e) });
205
+ logger(`[learning] failure trajectory put failed: ${e?.message || e}\n`);
206
+ }
207
+
208
+ if (ctx.agent && ctx.task) {
209
+ try {
210
+ results.skill = await skillSynth.synthesizeSkill({
211
+ agent: { ...ctx.agent, provider: trainer.provider, model: trainer.model },
212
+ task: ctx.task,
213
+ apiKey: ctx.apiKey,
214
+ baseUrl: ctx.baseUrl,
215
+ fetchImpl: ctx.fetchImpl,
216
+ outcome: 'failed',
217
+ trainedBy: trainer.provider,
218
+ trainedOnModel: trainer.model,
219
+ trajectoryRef: results.trajectory?.id || null,
220
+ });
221
+ } catch (e) {
222
+ errors.push({ step: 'skill', error: String(e?.message || e) });
223
+ logger(`[learning] anti-pattern synth failed: ${e?.message || e}\n`);
224
+ }
225
+ }
226
+
227
+ return { trigger: 'post-failure', results, errors };
228
+ }
229
+
230
+ // ── nudge ────────────────────────────────────────────────────────────
231
+ //
232
+ // A nudge cluster is a group of low-confidence signals that, taken
233
+ // together, are worth a skill. v5.0.10 wires the dispatch path and
234
+ // runs synthesizeSkill against the cluster's representative task; the
235
+ // full cluster-collapse engine lands in Phase 2.
236
+ export async function _runNudge(ctx, logger) {
237
+ const errors = [];
238
+ const results = {};
239
+ const cluster = ctx.cluster;
240
+ if (!cluster || !Array.isArray(cluster.items) || cluster.items.length === 0) {
241
+ return { trigger: 'nudge', results: {}, errors: [{ step: 'cluster', error: 'empty cluster' }] };
242
+ }
243
+ const trainer = _safeResolveTrainer(ctx.cfg, ctx.agent);
244
+ const representative = cluster.items[cluster.items.length - 1];
245
+ try {
246
+ results.skill = await skillSynth.synthesizeSkill({
247
+ agent: { ...(ctx.agent || {}), provider: trainer.provider, model: trainer.model },
248
+ task: representative,
249
+ apiKey: ctx.apiKey,
250
+ baseUrl: ctx.baseUrl,
251
+ fetchImpl: ctx.fetchImpl,
252
+ outcome: 'done',
253
+ trainedBy: trainer.provider,
254
+ trainedOnModel: trainer.model,
255
+ });
256
+ } catch (e) {
257
+ errors.push({ step: 'skill', error: String(e?.message || e) });
258
+ logger(`[learning] nudge synth failed: ${e?.message || e}\n`);
259
+ }
260
+ return { trigger: 'nudge', results, errors };
261
+ }
262
+
263
+ // ── active-recall-miss ───────────────────────────────────────────────
264
+ //
265
+ // A skill was loaded into context but failed to apply. Decrement its
266
+ // confidence (frontmatter); archive (remove) the skill when confidence
267
+ // drops below 0.3 so it stops bloating future system prompts.
268
+ //
269
+ // ctx: { skill: { name }, configDir, cfg, archiveThreshold? }
270
+ export async function _runActiveRecallMiss(ctx, logger) {
271
+ const errors = [];
272
+ const results = {};
273
+ const name = ctx.skill?.name;
274
+ if (!name) {
275
+ return { trigger: 'active-recall-miss', results: {}, errors: [{ step: 'input', error: 'skill.name required' }] };
276
+ }
277
+ const threshold = Number.isFinite(+ctx.archiveThreshold) ? +ctx.archiveThreshold : 0.3;
278
+ try {
279
+ if (!skills.skillExists(name, ctx.configDir)) {
280
+ return { trigger: 'active-recall-miss', results: { skipped: 'not found' }, errors };
281
+ }
282
+ const raw = skills.loadSkill(name, ctx.configDir);
283
+ const { meta, body } = skills.parseFrontmatter(raw);
284
+ const priorConf = Number(meta.confidence);
285
+ const baseConf = Number.isFinite(priorConf) ? priorConf : 0.5;
286
+ const nextConf = Math.max(0, baseConf - 0.1);
287
+ if (nextConf < threshold) {
288
+ skills.removeSkill(name, ctx.configDir);
289
+ results.action = 'archived';
290
+ results.priorConfidence = baseConf;
291
+ results.nextConfidence = nextConf;
292
+ } else {
293
+ // Re-emit the file with the decremented confidence. We rebuild
294
+ // through assembleSkillDoc so the frontmatter stays canonical.
295
+ const updated = skillSynth.assembleSkillDoc({
296
+ name,
297
+ description: meta.description || '',
298
+ body,
299
+ createdBy: meta.created_by || 'agent',
300
+ sourceTask: meta.source_task || '',
301
+ version: Number(meta.version) || 1,
302
+ trainedBy: meta.trained_by || null,
303
+ trainedOnModel: meta.trained_on_model || null,
304
+ trajectoryRef: meta.trajectory_ref || null,
305
+ confidence: nextConf,
306
+ outcome: meta.anti_pattern === true || meta.anti_pattern === 'true' ? 'failed' : 'done',
307
+ group: meta.group || null,
308
+ });
309
+ skills.installSkill(name, updated, ctx.configDir);
310
+ results.action = 'decremented';
311
+ results.priorConfidence = baseConf;
312
+ results.nextConfidence = nextConf;
313
+ }
314
+ } catch (e) {
315
+ errors.push({ step: 'decrement', error: String(e?.message || e) });
316
+ logger(`[learning] active-recall-miss handler failed: ${e?.message || e}\n`);
317
+ }
318
+ return { trigger: 'active-recall-miss', results, errors };
319
+ }
320
+
321
+ // ── periodic-curation ────────────────────────────────────────────────
322
+ //
323
+ // Phase 2 full implementation. v5.0.10 emits a structured warning so a
324
+ // cron entry created today returns a stable shape and operators can see
325
+ // the hook fired without blowing up.
326
+ export async function _runPeriodicCuration(_ctx, logger) {
327
+ logger('[learning] periodic-curation is a Phase 2 hook — no-op for now.\n');
328
+ return { trigger: 'periodic-curation', results: { stub: true }, errors: [] };
329
+ }
330
+
331
+ // ── helpers ──────────────────────────────────────────────────────────
332
+
333
+ function _safeResolveTrainer(cfg, agent) {
334
+ try {
335
+ return resolveTrainer(cfg || { provider: agent?.provider, model: agent?.model });
336
+ } catch {
337
+ return { provider: agent?.provider || '', model: agent?.model || '' };
338
+ }
339
+ }
340
+
341
+ function _toSessionTurns(turns) {
342
+ if (!Array.isArray(turns)) return [];
343
+ return turns.map((t) => ({
344
+ role: t.agent === 'user' ? 'user' : 'assistant',
345
+ content: String(t.text || ''),
346
+ }));
347
+ }
348
+
349
+ function _buildTrajectoryRecord(ctx, outcome) {
350
+ const task = ctx.task || {};
351
+ const agent = ctx.agent || {};
352
+ const turns = Array.isArray(task.turns) ? task.turns : [];
353
+ return {
354
+ taskId: task.id || '',
355
+ agentName: agent.name || 'agent',
356
+ workerProvider: agent.provider || '',
357
+ workerModel: agent.model || '',
358
+ startedAt: ctx.trajectoryRef?.startedAt || Date.now(),
359
+ endedAt: Date.now(),
360
+ systemPrompt: agent.role || '',
361
+ userMessages: turns.filter(t => t.agent === 'user').map(t => String(t.text || '')),
362
+ turns: turns.map((t, i) => ({
363
+ turnIdx: i,
364
+ role: t.agent === 'user' ? 'user' : 'assistant',
365
+ content: String(t.text || ''),
366
+ toolCalls: Array.isArray(t.toolCalls) ? t.toolCalls : [],
367
+ })),
368
+ finalAnswer: turns.length ? String(turns[turns.length - 1].text || '') : '',
369
+ outcome,
370
+ };
371
+ }