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.
- 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/editor.mjs +81 -1
- package/tui/repl.mjs +259 -22
- package/tui/run_turn.mjs +138 -0
- package/tui/slash_commands.mjs +37 -0
- package/tui/slash_popup.mjs +157 -0
- package/tui/splash.mjs +65 -43
- package/web/dashboard.html +1 -1
package/mas/agent_turn.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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) {
|
package/mas/learning.mjs
ADDED
|
@@ -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
|
+
}
|