chainlesschain 0.45.75 → 0.45.76
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/README.md +52 -15
- package/package.json +1 -1
- package/src/commands/learning.js +273 -0
- package/src/commands/lowcode.js +23 -8
- package/src/gateways/discord/discord-formatter.js +89 -0
- package/src/gateways/gateway-base.js +189 -0
- package/src/gateways/telegram/telegram-formatter.js +93 -0
- package/src/index.js +2 -0
- package/src/lib/app-builder.js +136 -8
- package/src/lib/autonomous-agent.js +8 -1
- package/src/lib/cli-context-engineering.js +15 -0
- package/src/lib/execution-backend.js +239 -0
- package/src/lib/hook-manager.js +2 -0
- package/src/lib/iteration-budget.js +175 -0
- package/src/lib/learning/learning-hooks.js +117 -0
- package/src/lib/learning/learning-tables.js +66 -0
- package/src/lib/learning/outcome-feedback.js +243 -0
- package/src/lib/learning/reflection-engine.js +323 -0
- package/src/lib/learning/skill-improver.js +536 -0
- package/src/lib/learning/skill-synthesizer.js +315 -0
- package/src/lib/learning/trajectory-store.js +409 -0
- package/src/lib/plugin-autodiscovery.js +224 -0
- package/src/lib/session-search.js +193 -0
- package/src/lib/sub-agent-context.js +7 -2
- package/src/lib/user-profile.js +172 -0
- package/src/lib/web-ui-server.js +1 -1
- package/src/repl/agent-repl.js +109 -0
- package/src/runtime/agent-core.js +75 -4
- package/src/runtime/coding-agent-contract-shared.cjs +35 -0
- package/src/runtime/coding-agent-policy.cjs +10 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution Backend — abstraction for command execution environments.
|
|
3
|
+
*
|
|
4
|
+
* Provides LocalBackend (default), DockerBackend, and SSHBackend.
|
|
5
|
+
* The agent's run_shell and run_code tools delegate to the active backend.
|
|
6
|
+
*
|
|
7
|
+
* Config: .chainlesschain/config.json → agent.executionBackend
|
|
8
|
+
* Feature flag: EXECUTION_BACKENDS (off by default)
|
|
9
|
+
*
|
|
10
|
+
* @module execution-backend
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
// ─── Exported for test injection ────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const _deps = {
|
|
18
|
+
execSync,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ─── Base class ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export class ExecutionBackend {
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.type = "base";
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Execute a command.
|
|
31
|
+
* @param {string} command - The command to run
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {string} [opts.cwd] - Working directory
|
|
34
|
+
* @param {number} [opts.timeout] - Timeout in ms
|
|
35
|
+
* @param {number} [opts.maxBuffer] - Max output buffer size
|
|
36
|
+
* @returns {{ stdout: string, stderr: string, exitCode: number }}
|
|
37
|
+
*/
|
|
38
|
+
execute(command, opts = {}) {
|
|
39
|
+
throw new Error("execute() must be implemented by subclass");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {string} Backend description for logging */
|
|
43
|
+
describe() {
|
|
44
|
+
return `${this.type} backend`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Local Backend ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export class LocalBackend extends ExecutionBackend {
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
super(options);
|
|
53
|
+
this.type = "local";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
execute(command, opts = {}) {
|
|
57
|
+
const cwd = opts.cwd || process.cwd();
|
|
58
|
+
const timeout = opts.timeout || 60000;
|
|
59
|
+
const maxBuffer = opts.maxBuffer || 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const stdout = _deps.execSync(command, {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
timeout,
|
|
66
|
+
maxBuffer,
|
|
67
|
+
});
|
|
68
|
+
return { stdout: stdout || "", stderr: "", exitCode: 0 };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return {
|
|
71
|
+
stdout: (err.stdout || "").toString(),
|
|
72
|
+
stderr: (err.stderr || err.message || "").toString(),
|
|
73
|
+
exitCode: err.status || 1,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe() {
|
|
79
|
+
return "local (direct execution)";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Docker Backend ─────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export class DockerBackend extends ExecutionBackend {
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} options
|
|
88
|
+
* @param {string} options.container - Container name or ID (for exec mode)
|
|
89
|
+
* @param {string} [options.image] - Image name (for run mode — ephemeral containers)
|
|
90
|
+
* @param {string} [options.workdir] - Working directory inside container
|
|
91
|
+
* @param {string[]} [options.volumes] - Volume mounts (host:container format)
|
|
92
|
+
* @param {string} [options.shell] - Shell to use (default: sh)
|
|
93
|
+
*/
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
super(options);
|
|
96
|
+
this.type = "docker";
|
|
97
|
+
this.container = options.container || null;
|
|
98
|
+
this.image = options.image || null;
|
|
99
|
+
this.workdir = options.workdir || "/workspace";
|
|
100
|
+
this.volumes = options.volumes || [];
|
|
101
|
+
this.shell = options.shell || "sh";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
execute(command, opts = {}) {
|
|
105
|
+
const timeout = opts.timeout || 60000;
|
|
106
|
+
const maxBuffer = opts.maxBuffer || 1024 * 1024;
|
|
107
|
+
const cwd = opts.cwd || this.workdir;
|
|
108
|
+
|
|
109
|
+
let dockerCmd;
|
|
110
|
+
if (this.container) {
|
|
111
|
+
// Exec into existing container
|
|
112
|
+
dockerCmd = `docker exec -w "${cwd}" ${this.container} ${this.shell} -c "${this._escapeCommand(command)}"`;
|
|
113
|
+
} else if (this.image) {
|
|
114
|
+
// Run ephemeral container
|
|
115
|
+
const volumeArgs = this.volumes.map((v) => `-v "${v}"`).join(" ");
|
|
116
|
+
dockerCmd = `docker run --rm -w "${cwd}" ${volumeArgs} ${this.image} ${this.shell} -c "${this._escapeCommand(command)}"`;
|
|
117
|
+
} else {
|
|
118
|
+
return {
|
|
119
|
+
stdout: "",
|
|
120
|
+
stderr: "Docker backend: neither container nor image specified",
|
|
121
|
+
exitCode: 1,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const stdout = _deps.execSync(dockerCmd, {
|
|
127
|
+
encoding: "utf8",
|
|
128
|
+
timeout,
|
|
129
|
+
maxBuffer,
|
|
130
|
+
});
|
|
131
|
+
return { stdout: stdout || "", stderr: "", exitCode: 0 };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
stdout: (err.stdout || "").toString(),
|
|
135
|
+
stderr: (err.stderr || err.message || "").toString(),
|
|
136
|
+
exitCode: err.status || 1,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_escapeCommand(cmd) {
|
|
142
|
+
return cmd.replace(/"/g, '\\"');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe() {
|
|
146
|
+
if (this.container) return `docker exec (container: ${this.container})`;
|
|
147
|
+
return `docker run (image: ${this.image})`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── SSH Backend ────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export class SSHBackend extends ExecutionBackend {
|
|
154
|
+
/**
|
|
155
|
+
* @param {object} options
|
|
156
|
+
* @param {string} options.host - Remote host
|
|
157
|
+
* @param {string} [options.user] - SSH user
|
|
158
|
+
* @param {string} [options.key] - Path to SSH private key
|
|
159
|
+
* @param {number} [options.port] - SSH port (default: 22)
|
|
160
|
+
* @param {string} [options.workdir] - Remote working directory
|
|
161
|
+
*/
|
|
162
|
+
constructor(options = {}) {
|
|
163
|
+
super(options);
|
|
164
|
+
this.type = "ssh";
|
|
165
|
+
this.host = options.host;
|
|
166
|
+
this.user = options.user || "";
|
|
167
|
+
this.key = options.key || "";
|
|
168
|
+
this.port = options.port || 22;
|
|
169
|
+
this.workdir = options.workdir || "~";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
execute(command, opts = {}) {
|
|
173
|
+
const timeout = opts.timeout || 60000;
|
|
174
|
+
const maxBuffer = opts.maxBuffer || 1024 * 1024;
|
|
175
|
+
const cwd = opts.cwd || this.workdir;
|
|
176
|
+
|
|
177
|
+
if (!this.host) {
|
|
178
|
+
return {
|
|
179
|
+
stdout: "",
|
|
180
|
+
stderr: "SSH backend: host not specified",
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const userHost = this.user ? `${this.user}@${this.host}` : this.host;
|
|
186
|
+
const keyArg = this.key ? `-i "${this.key}"` : "";
|
|
187
|
+
const portArg = this.port !== 22 ? `-p ${this.port}` : "";
|
|
188
|
+
const remoteCmd = `cd "${cwd}" && ${command}`;
|
|
189
|
+
|
|
190
|
+
const sshCmd = `ssh ${keyArg} ${portArg} -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${userHost} "${this._escapeCommand(remoteCmd)}"`;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const stdout = _deps.execSync(sshCmd, {
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
timeout,
|
|
196
|
+
maxBuffer,
|
|
197
|
+
});
|
|
198
|
+
return { stdout: stdout || "", stderr: "", exitCode: 0 };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return {
|
|
201
|
+
stdout: (err.stdout || "").toString(),
|
|
202
|
+
stderr: (err.stderr || err.message || "").toString(),
|
|
203
|
+
exitCode: err.status || 1,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_escapeCommand(cmd) {
|
|
209
|
+
return cmd.replace(/"/g, '\\"');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
describe() {
|
|
213
|
+
const userHost = this.user ? `${this.user}@${this.host}` : this.host;
|
|
214
|
+
return `ssh (${userHost}:${this.port})`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Factory ────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create an execution backend from config.
|
|
222
|
+
* @param {object} [config] - Backend config from config.json
|
|
223
|
+
* @param {string} [config.type] - "local" | "docker" | "ssh"
|
|
224
|
+
* @param {object} [config.options] - Backend-specific options
|
|
225
|
+
* @returns {ExecutionBackend}
|
|
226
|
+
*/
|
|
227
|
+
export function createBackend(config = {}) {
|
|
228
|
+
const type = (config.type || "local").toLowerCase();
|
|
229
|
+
|
|
230
|
+
switch (type) {
|
|
231
|
+
case "docker":
|
|
232
|
+
return new DockerBackend(config.options || {});
|
|
233
|
+
case "ssh":
|
|
234
|
+
return new SSHBackend(config.options || {});
|
|
235
|
+
case "local":
|
|
236
|
+
default:
|
|
237
|
+
return new LocalBackend(config.options || {});
|
|
238
|
+
}
|
|
239
|
+
}
|
package/src/lib/hook-manager.js
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iteration Budget — shared, configurable iteration limit for agent loops.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the hardcoded MAX_ITERATIONS constant with a first-class budget
|
|
5
|
+
* object that is shared across parent and child agents, supports progressive
|
|
6
|
+
* warnings, and can be configured via config.json or environment variable.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by Hermes Agent's shared iteration budget system.
|
|
9
|
+
*
|
|
10
|
+
* @module iteration-budget
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BUDGET = 50;
|
|
16
|
+
const WARNING_THRESHOLD = 0.7; // 70%
|
|
17
|
+
const WRAPPING_UP_THRESHOLD = 0.9; // 90%
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Warning level enum.
|
|
21
|
+
*/
|
|
22
|
+
export const WarningLevel = {
|
|
23
|
+
NONE: "none",
|
|
24
|
+
WARNING: "warning", // 70-89%
|
|
25
|
+
WRAPPING_UP: "wrapping-up", // 90-99%
|
|
26
|
+
EXHAUSTED: "exhausted", // 100%
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ─── IterationBudget ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export class IterationBudget {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {number} [options.limit] - Maximum iterations (default: 50)
|
|
35
|
+
* @param {string} [options.owner] - Identifier for the budget creator (e.g. session ID)
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this._limit = options.limit || IterationBudget.resolveLimit();
|
|
39
|
+
this._consumed = 0;
|
|
40
|
+
this._owner = options.owner || null;
|
|
41
|
+
this._warnings = []; // timestamps of emitted warnings
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the budget limit from config/env/default.
|
|
46
|
+
* Priority: CC_ITERATION_BUDGET env > default
|
|
47
|
+
*/
|
|
48
|
+
static resolveLimit() {
|
|
49
|
+
const env = process.env.CC_ITERATION_BUDGET;
|
|
50
|
+
if (env) {
|
|
51
|
+
const parsed = parseInt(env, 10);
|
|
52
|
+
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
53
|
+
}
|
|
54
|
+
return DEFAULT_BUDGET;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Total iteration limit. */
|
|
58
|
+
get limit() {
|
|
59
|
+
return this._limit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Number of iterations consumed so far. */
|
|
63
|
+
get consumed() {
|
|
64
|
+
return this._consumed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Consume one iteration. Returns the current warning level after consumption.
|
|
69
|
+
* @returns {string} WarningLevel value
|
|
70
|
+
*/
|
|
71
|
+
consume() {
|
|
72
|
+
this._consumed++;
|
|
73
|
+
return this.warningLevel();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Number of iterations remaining.
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
remaining() {
|
|
81
|
+
return Math.max(0, this._limit - this._consumed);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Percentage of budget consumed (0.0 – 1.0+).
|
|
86
|
+
* @returns {number}
|
|
87
|
+
*/
|
|
88
|
+
percentage() {
|
|
89
|
+
if (this._limit === 0) return 1;
|
|
90
|
+
return this._consumed / this._limit;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether the budget is exhausted.
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
isExhausted() {
|
|
98
|
+
return this._consumed >= this._limit;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether there is still budget remaining.
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
hasRemaining() {
|
|
106
|
+
return this._consumed < this._limit;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Current warning level based on consumption percentage.
|
|
111
|
+
* @returns {string} WarningLevel value
|
|
112
|
+
*/
|
|
113
|
+
warningLevel() {
|
|
114
|
+
const pct = this.percentage();
|
|
115
|
+
if (pct >= 1) return WarningLevel.EXHAUSTED;
|
|
116
|
+
if (pct >= WRAPPING_UP_THRESHOLD) return WarningLevel.WRAPPING_UP;
|
|
117
|
+
if (pct >= WARNING_THRESHOLD) return WarningLevel.WARNING;
|
|
118
|
+
return WarningLevel.NONE;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Record that a warning was emitted (for dedup in the agent loop).
|
|
123
|
+
* @param {string} level - WarningLevel value
|
|
124
|
+
*/
|
|
125
|
+
recordWarning(level) {
|
|
126
|
+
this._warnings.push({ level, at: this._consumed });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a warning at this level has already been recorded.
|
|
131
|
+
* @param {string} level
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
hasWarned(level) {
|
|
135
|
+
return this._warnings.some((w) => w.level === level);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate a human-readable summary of budget usage.
|
|
140
|
+
* Useful when the budget is exhausted and the agent needs to report status.
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
toSummary() {
|
|
144
|
+
const pct = Math.round(this.percentage() * 100);
|
|
145
|
+
return (
|
|
146
|
+
`Iteration budget: ${this._consumed}/${this._limit} (${pct}%). ` +
|
|
147
|
+
`${this.remaining()} iterations remaining.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a warning message suitable for appending to tool results.
|
|
153
|
+
* @returns {string|null} Warning message or null if no warning needed
|
|
154
|
+
*/
|
|
155
|
+
toWarningMessage() {
|
|
156
|
+
const level = this.warningLevel();
|
|
157
|
+
const remaining = this.remaining();
|
|
158
|
+
switch (level) {
|
|
159
|
+
case WarningLevel.WARNING:
|
|
160
|
+
return `[Budget Warning] ${remaining} iterations remaining out of ${this._limit}. Start wrapping up your work.`;
|
|
161
|
+
case WarningLevel.WRAPPING_UP:
|
|
162
|
+
return `[Budget Critical] Only ${remaining} iterations remaining! Finish immediately and return your results.`;
|
|
163
|
+
case WarningLevel.EXHAUSTED:
|
|
164
|
+
return `[Budget Exhausted] No iterations remaining. Returning work summary.`;
|
|
165
|
+
default:
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Defaults ───────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export const DEFAULT_ITERATION_BUDGET = DEFAULT_BUDGET;
|
|
174
|
+
export const BUDGET_WARNING_THRESHOLD = WARNING_THRESHOLD;
|
|
175
|
+
export const BUDGET_WRAPPING_UP_THRESHOLD = WRAPPING_UP_THRESHOLD;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learning Loop — Hook registration.
|
|
3
|
+
*
|
|
4
|
+
* Wires the TrajectoryStore into the existing hook infrastructure:
|
|
5
|
+
* - UserPromptSubmit → startTrajectory()
|
|
6
|
+
* - PostToolUse → appendToolCall()
|
|
7
|
+
* - SessionEnd → (reserved for ReflectionEngine in P3)
|
|
8
|
+
*
|
|
9
|
+
* This module does NOT modify agent-core.js or agent-repl.js.
|
|
10
|
+
* Instead it provides an init function that the REPL calls once
|
|
11
|
+
* during session setup, passing a LearningLoop context object.
|
|
12
|
+
*
|
|
13
|
+
* Design:
|
|
14
|
+
* - Fire-and-forget: learning failures never break the host flow
|
|
15
|
+
* - No-op when learningCtx is null or disabled
|
|
16
|
+
* - Stateless — the trajectory ID is tracked on learningCtx
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} LearningContext
|
|
21
|
+
* @property {import("./trajectory-store.js").TrajectoryStore} trajectoryStore
|
|
22
|
+
* @property {string|null} currentTrajectoryId — set per user turn
|
|
23
|
+
* @property {boolean} enabled
|
|
24
|
+
* @property {string} sessionId
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Called on UserPromptSubmit — starts a new trajectory for this turn.
|
|
29
|
+
* @param {LearningContext} ctx
|
|
30
|
+
* @param {string} prompt — the user's raw input
|
|
31
|
+
*/
|
|
32
|
+
export function onUserPromptSubmit(ctx, prompt) {
|
|
33
|
+
if (!ctx || !ctx.enabled || !ctx.trajectoryStore) return;
|
|
34
|
+
try {
|
|
35
|
+
ctx.currentTrajectoryId = ctx.trajectoryStore.startTrajectory(
|
|
36
|
+
ctx.sessionId,
|
|
37
|
+
prompt,
|
|
38
|
+
);
|
|
39
|
+
} catch (_err) {
|
|
40
|
+
// Learning failures never break the REPL
|
|
41
|
+
ctx.currentTrajectoryId = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Called on PostToolUse — appends a tool call to the current trajectory.
|
|
47
|
+
* @param {LearningContext} ctx
|
|
48
|
+
* @param {{tool:string, args:any, result:any, durationMs?:number, status?:string}} record
|
|
49
|
+
*/
|
|
50
|
+
export function onPostToolUse(ctx, record) {
|
|
51
|
+
if (!ctx || !ctx.enabled || !ctx.trajectoryStore || !ctx.currentTrajectoryId)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
ctx.trajectoryStore.appendToolCall(ctx.currentTrajectoryId, record);
|
|
55
|
+
} catch (_err) {
|
|
56
|
+
// Swallow — never break the agent loop
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Called on response-complete — completes the current trajectory.
|
|
62
|
+
* @param {LearningContext} ctx
|
|
63
|
+
* @param {{finalResponse?:string, tags?:string[]}} data
|
|
64
|
+
* @returns {object|null} completed trajectory or null
|
|
65
|
+
*/
|
|
66
|
+
export function onResponseComplete(ctx, data) {
|
|
67
|
+
if (!ctx || !ctx.enabled || !ctx.trajectoryStore || !ctx.currentTrajectoryId)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const trajectory = ctx.trajectoryStore.completeTrajectory(
|
|
71
|
+
ctx.currentTrajectoryId,
|
|
72
|
+
data,
|
|
73
|
+
);
|
|
74
|
+
// Reset for next turn
|
|
75
|
+
const finishedId = ctx.currentTrajectoryId;
|
|
76
|
+
ctx.currentTrajectoryId = null;
|
|
77
|
+
return trajectory;
|
|
78
|
+
} catch (_err) {
|
|
79
|
+
ctx.currentTrajectoryId = null;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Called on SessionEnd — reserved for ReflectionEngine (P3).
|
|
86
|
+
* Currently a no-op placeholder.
|
|
87
|
+
* @param {LearningContext} ctx
|
|
88
|
+
*/
|
|
89
|
+
export function onSessionEnd(ctx) {
|
|
90
|
+
if (!ctx || !ctx.enabled) return;
|
|
91
|
+
// P3: will call reflectionEngine.onSessionEnd(ctx.sessionId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a LearningContext for a session.
|
|
96
|
+
* Returns null if db is not available or learning is disabled.
|
|
97
|
+
*
|
|
98
|
+
* @param {import("better-sqlite3").Database|null} db
|
|
99
|
+
* @param {string} sessionId
|
|
100
|
+
* @param {{enabled?:boolean}} [config]
|
|
101
|
+
* @returns {LearningContext|null}
|
|
102
|
+
*/
|
|
103
|
+
export function createLearningContext(db, sessionId, config = {}) {
|
|
104
|
+
if (!db) return null;
|
|
105
|
+
const enabled = config.enabled !== false;
|
|
106
|
+
if (!enabled) return null;
|
|
107
|
+
|
|
108
|
+
// Lazy import to avoid circular deps — TrajectoryStore calls ensureLearningTables
|
|
109
|
+
const { TrajectoryStore } = require("./trajectory-store.js");
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
trajectoryStore: new TrajectoryStore(db),
|
|
113
|
+
currentTrajectoryId: null,
|
|
114
|
+
enabled: true,
|
|
115
|
+
sessionId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learning Loop — Database table definitions.
|
|
3
|
+
*
|
|
4
|
+
* Tables:
|
|
5
|
+
* - learning_trajectories — full execution traces (intent → tools → response)
|
|
6
|
+
* - learning_trajectory_tags — per-trajectory tag index for pattern matching
|
|
7
|
+
* - skill_improvement_log — skill patch history (Phase P2)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create all learning-related tables. Idempotent (IF NOT EXISTS).
|
|
12
|
+
* @param {import("better-sqlite3").Database} db
|
|
13
|
+
*/
|
|
14
|
+
export function ensureLearningTables(db) {
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS learning_trajectories (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
session_id TEXT NOT NULL,
|
|
19
|
+
user_intent TEXT,
|
|
20
|
+
tool_chain TEXT NOT NULL DEFAULT '[]',
|
|
21
|
+
tool_count INTEGER DEFAULT 0,
|
|
22
|
+
final_response TEXT,
|
|
23
|
+
outcome_score REAL,
|
|
24
|
+
outcome_source TEXT,
|
|
25
|
+
complexity_level TEXT DEFAULT 'simple',
|
|
26
|
+
synthesized_skill TEXT,
|
|
27
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
28
|
+
completed_at TEXT
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
db.exec(`
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_traj_session
|
|
34
|
+
ON learning_trajectories(session_id)
|
|
35
|
+
`);
|
|
36
|
+
db.exec(`
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_traj_score
|
|
38
|
+
ON learning_trajectories(outcome_score)
|
|
39
|
+
`);
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_traj_complexity
|
|
42
|
+
ON learning_trajectories(complexity_level)
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS learning_trajectory_tags (
|
|
47
|
+
trajectory_id TEXT NOT NULL,
|
|
48
|
+
tag TEXT NOT NULL,
|
|
49
|
+
UNIQUE(trajectory_id, tag)
|
|
50
|
+
)
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
db.exec(`
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_traj_tags_tid
|
|
55
|
+
ON learning_trajectory_tags(trajectory_id)
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
db.exec(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS skill_improvement_log (
|
|
60
|
+
skill_name TEXT NOT NULL,
|
|
61
|
+
trigger_type TEXT NOT NULL,
|
|
62
|
+
detail TEXT,
|
|
63
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
64
|
+
)
|
|
65
|
+
`);
|
|
66
|
+
}
|