loreli 0.0.0 → 2.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.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { discover as discoverModels } from '../discover.js';
|
|
3
|
+
import { logger } from 'loreli/log';
|
|
4
|
+
|
|
5
|
+
const log = logger('backends');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Map of built-in backends with their module paths and metadata.
|
|
9
|
+
*
|
|
10
|
+
* @type {Record<string, {path: string, cls: string, binary: string, provider: string}>}
|
|
11
|
+
*/
|
|
12
|
+
const BUILTIN_BACKENDS = {
|
|
13
|
+
claude: { path: './claude.js', cls: 'ClaudeBackend', binary: 'claude', provider: 'anthropic' },
|
|
14
|
+
codex: { path: './codex.js', cls: 'CodexBackend', binary: 'codex', provider: 'openai' },
|
|
15
|
+
cursor: { path: './cursor.js', cls: 'CursorBackend', binary: 'cursor-agent', provider: 'multi' }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discovers and manages available agent backends.
|
|
20
|
+
*
|
|
21
|
+
* Checks for CLI binaries on PATH and lazy-loads backend classes at
|
|
22
|
+
* discovery time. All backends are interactive CLI agents running in
|
|
23
|
+
* tmux panes.
|
|
24
|
+
*/
|
|
25
|
+
export class BackendRegistry {
|
|
26
|
+
constructor() {
|
|
27
|
+
/** @type {Map<string, {cls: Function|null, provider: string, binary?: string}>} */
|
|
28
|
+
this.backends = new Map();
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, {name: string, provider: string, binary: string}>} */
|
|
31
|
+
this.discovered = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runtime-discovered models per backend. Populated by
|
|
35
|
+
* {@link discover} after binary detection. Keyed by backend
|
|
36
|
+
* name; each value contains a flat model list and a tier map.
|
|
37
|
+
*
|
|
38
|
+
* @type {Map<string, {models: object[], tiers: object}>}
|
|
39
|
+
*/
|
|
40
|
+
this.models = new Map();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Failure counts per backend. Used to detect degraded backends
|
|
44
|
+
* that repeatedly fail (e.g. budget exhaustion, rate limits).
|
|
45
|
+
*
|
|
46
|
+
* @type {Map<string, {count: number, first: number, last: number}>}
|
|
47
|
+
*/
|
|
48
|
+
this._failures = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Warning counts per backend. Tracks transient issues (dialogs,
|
|
52
|
+
* prompts) that were successfully remediated. Only promotes to a
|
|
53
|
+
* failure after reaching the warning threshold.
|
|
54
|
+
*
|
|
55
|
+
* @type {Map<string, {count: number, first: number, last: number}>}
|
|
56
|
+
*/
|
|
57
|
+
this._warnings = new Map();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tracks whether model/backends discovery has already been completed
|
|
61
|
+
* for oneshot calls in this registry instance.
|
|
62
|
+
*
|
|
63
|
+
* @type {boolean}
|
|
64
|
+
*/
|
|
65
|
+
this._discoveredOnce = false;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* In-flight discovery promise used to prevent concurrent oneshot
|
|
69
|
+
* calls from racing discovery and clearing shared state.
|
|
70
|
+
*
|
|
71
|
+
* @type {Promise<void>|null}
|
|
72
|
+
*/
|
|
73
|
+
this._discovering = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a backend class.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} name - Backend name (e.g. 'claude', 'codex').
|
|
80
|
+
* @param {Function} cls - Backend class constructor.
|
|
81
|
+
* @param {object} meta - Backend metadata.
|
|
82
|
+
* @param {string} meta.provider - AI provider ('anthropic', 'openai').
|
|
83
|
+
* @param {string} [meta.binary] - CLI binary name to check on PATH.
|
|
84
|
+
*/
|
|
85
|
+
register(name, cls, meta) {
|
|
86
|
+
this.backends.set(name, { cls, ...meta });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Discover available backends by checking for CLI binaries on PATH,
|
|
91
|
+
* then discover available models from backends that support it.
|
|
92
|
+
*
|
|
93
|
+
* Model discovery runs in parallel with backend class loading.
|
|
94
|
+
* Results are stored in {@link models} for use during resolution.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [config] - Optional config for discovery-time env overrides.
|
|
97
|
+
* @returns {Promise<void>}
|
|
98
|
+
*/
|
|
99
|
+
async discover(config) {
|
|
100
|
+
this.discovered.clear();
|
|
101
|
+
|
|
102
|
+
for (const [name, meta] of Object.entries(BUILTIN_BACKENDS)) {
|
|
103
|
+
if (which(meta.binary)) {
|
|
104
|
+
const mod = await import(meta.path);
|
|
105
|
+
const cls = mod[meta.cls];
|
|
106
|
+
|
|
107
|
+
this.discovered.set(name, { name, provider: meta.provider, binary: meta.binary });
|
|
108
|
+
this.backends.set(name, { cls, provider: meta.provider, binary: meta.binary });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.models = await discoverModels(this, { config });
|
|
113
|
+
this._discoveredOnce = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* List all discovered (available) backends.
|
|
118
|
+
*
|
|
119
|
+
* @returns {Array<{name: string, provider: string, binary: string}>}
|
|
120
|
+
*/
|
|
121
|
+
available() {
|
|
122
|
+
return [...this.discovered.values()];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List unique providers from discovered backends.
|
|
127
|
+
*
|
|
128
|
+
* When cursor-agent is discovered (provider `multi`), it is expanded
|
|
129
|
+
* into `cursor-openai` and `cursor-anthropic` virtual providers.
|
|
130
|
+
* This makes every provider in the returned list directly usable for
|
|
131
|
+
* identity acquisition and yin/yang pairing.
|
|
132
|
+
*
|
|
133
|
+
* @returns {string[]} Array of provider names.
|
|
134
|
+
*/
|
|
135
|
+
providers() {
|
|
136
|
+
const providerSet = new Set();
|
|
137
|
+
for (const info of this.discovered.values()) {
|
|
138
|
+
if (info.provider === 'multi') {
|
|
139
|
+
providerSet.add('cursor-openai');
|
|
140
|
+
providerSet.add('cursor-anthropic');
|
|
141
|
+
} else if (info.provider !== 'unknown') {
|
|
142
|
+
providerSet.add(info.provider);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...providerSet];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if a specific backend is available.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} name - Backend name.
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
has(name) {
|
|
155
|
+
return this.backends.has(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get a backend entry by name.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} name - Backend name.
|
|
162
|
+
* @returns {{cls: Function|null, provider: string, binary?: string}|undefined}
|
|
163
|
+
*/
|
|
164
|
+
get(name) {
|
|
165
|
+
return this.backends.get(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Record a backend failure. Repeated failures within a time window
|
|
170
|
+
* mark the backend as degraded, triggering fallback to cursor-agent.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} name - Backend name (e.g. 'codex', 'claude').
|
|
173
|
+
*/
|
|
174
|
+
recordFailure(name) {
|
|
175
|
+
const entry = this._failures.get(name) ?? { count: 0, first: 0, last: 0 };
|
|
176
|
+
entry.count++;
|
|
177
|
+
if (!entry.first) entry.first = Date.now();
|
|
178
|
+
entry.last = Date.now();
|
|
179
|
+
this._failures.set(name, entry);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a backend is degraded. A backend is degraded when it has
|
|
184
|
+
* 2+ failures within the last 30 minutes — indicating a systemic
|
|
185
|
+
* issue like budget exhaustion or API outage.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} name - Backend name.
|
|
188
|
+
* @returns {boolean} True if the backend is degraded.
|
|
189
|
+
*/
|
|
190
|
+
degraded(name) {
|
|
191
|
+
const entry = this._failures.get(name);
|
|
192
|
+
if (!entry) return false;
|
|
193
|
+
const window = 30 * 60 * 1000;
|
|
194
|
+
return entry.count >= 2 && (Date.now() - entry.last) < window;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Record a transient warning for a backend. Warnings track
|
|
199
|
+
* recoverable issues (dialogs dismissed, prompts answered) that
|
|
200
|
+
* don't indicate a systemic failure. After 3 warnings within the
|
|
201
|
+
* degradation window, promotes to a full failure.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} name - Backend name (e.g. 'codex', 'claude').
|
|
204
|
+
*/
|
|
205
|
+
recordWarning(name) {
|
|
206
|
+
const entry = this._warnings.get(name) ?? { count: 0, first: 0, last: 0 };
|
|
207
|
+
entry.count++;
|
|
208
|
+
if (!entry.first) entry.first = Date.now();
|
|
209
|
+
entry.last = Date.now();
|
|
210
|
+
this._warnings.set(name, entry);
|
|
211
|
+
|
|
212
|
+
if (entry.count >= 3) {
|
|
213
|
+
this.recordFailure(name);
|
|
214
|
+
this.recordFailure(name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Clear failure history for a backend. Called when a backend
|
|
220
|
+
* successfully completes work, proving it has recovered.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} name - Backend name.
|
|
223
|
+
*/
|
|
224
|
+
clearFailures(name) {
|
|
225
|
+
this._failures.delete(name);
|
|
226
|
+
this._warnings.delete(name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detect known blocking patterns in agent pane output using the
|
|
231
|
+
* backend's static diagnose() method.
|
|
232
|
+
*
|
|
233
|
+
* Each backend knows its own CLI's quirks — update dialogs, trust
|
|
234
|
+
* prompts, selection menus. This method delegates to the correct
|
|
235
|
+
* backend based on name, providing a regex-based fallback when LLM
|
|
236
|
+
* classification is unavailable.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} name - Backend name (e.g. 'codex', 'claude').
|
|
239
|
+
* @param {string} output - Captured pane content.
|
|
240
|
+
* @returns {{category: string, reasoning: string}|null} Diagnosis, or null.
|
|
241
|
+
*/
|
|
242
|
+
diagnose(name, output) {
|
|
243
|
+
const info = this.backends.get(name);
|
|
244
|
+
if (!info?.cls?.diagnose) return null;
|
|
245
|
+
return info.cls.diagnose(output);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Select the best backend for a given AI provider.
|
|
250
|
+
*
|
|
251
|
+
* Resolution order:
|
|
252
|
+
* 1. Virtual cursor providers (`cursor-openai`, `cursor-anthropic`)
|
|
253
|
+
* route directly to the cursor backend
|
|
254
|
+
* 2. Exact provider match (claude for anthropic, codex for openai)
|
|
255
|
+
* — skipped when the matched backend is degraded and a multi-
|
|
256
|
+
* provider fallback exists
|
|
257
|
+
* 3. Multi-provider backend (cursor-agent — runs any provider)
|
|
258
|
+
* 4. Generic default fallback via {@link defaultBackend}
|
|
259
|
+
*
|
|
260
|
+
* @param {string} provider - AI provider ('anthropic', 'openai', 'cursor-openai', 'cursor-anthropic').
|
|
261
|
+
* @returns {string} Backend name.
|
|
262
|
+
* @throws {Error} When no suitable backend is available.
|
|
263
|
+
*/
|
|
264
|
+
forProvider(provider) {
|
|
265
|
+
if (provider?.startsWith('cursor-')) {
|
|
266
|
+
for (const info of this.discovered.values()) {
|
|
267
|
+
if (info.provider === 'multi') return info.name;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const info of this.discovered.values()) {
|
|
272
|
+
if (info.provider === provider) {
|
|
273
|
+
if (this.degraded(info.name)) {
|
|
274
|
+
const cursor = this._cursorFallback();
|
|
275
|
+
if (cursor) return cursor;
|
|
276
|
+
}
|
|
277
|
+
return info.name;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const info of this.discovered.values()) {
|
|
282
|
+
if (info.provider === 'multi') return info.name;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return this.defaultBackend();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find the cursor (multi-provider) backend among discovered backends.
|
|
290
|
+
*
|
|
291
|
+
* @returns {string|null} Backend name, or null if not available.
|
|
292
|
+
*/
|
|
293
|
+
_cursorFallback() {
|
|
294
|
+
for (const info of this.discovered.values()) {
|
|
295
|
+
if (info.provider === 'multi') return info.name;
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Return the default backend name using priority:
|
|
302
|
+
* 1. claude (Anthropic)
|
|
303
|
+
* 2. cursor (multi-provider)
|
|
304
|
+
* 3. first discovered backend
|
|
305
|
+
*
|
|
306
|
+
* @returns {string} Backend name.
|
|
307
|
+
* @throws {Error} When no backends are available.
|
|
308
|
+
*/
|
|
309
|
+
defaultBackend() {
|
|
310
|
+
if (this.discovered.has('claude')) return 'claude';
|
|
311
|
+
if (this.discovered.has('cursor')) return 'cursor';
|
|
312
|
+
|
|
313
|
+
const first = this.discovered.keys().next();
|
|
314
|
+
if (!first.done) return first.value;
|
|
315
|
+
|
|
316
|
+
throw new Error('No backends available — install claude, cursor-agent, or codex CLI');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve the best backend for a provider with optional explicit override.
|
|
321
|
+
*
|
|
322
|
+
* Resolution order:
|
|
323
|
+
* 1. Explicit override: if provided and registered, use it directly
|
|
324
|
+
* 2. Provider-native match via {@link forProvider}
|
|
325
|
+
*
|
|
326
|
+
* @param {string} provider - AI provider ('anthropic', 'openai', 'cursor-openai', 'cursor-anthropic').
|
|
327
|
+
* @param {string} [explicit] - Explicitly requested backend name.
|
|
328
|
+
* @returns {string} Backend name.
|
|
329
|
+
* @throws {Error} When no suitable backend is available.
|
|
330
|
+
*/
|
|
331
|
+
resolve(provider, explicit) {
|
|
332
|
+
if (explicit && this.has(explicit)) return explicit;
|
|
333
|
+
return this.forProvider(provider);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Collect scaffold descriptors from all registered backends.
|
|
338
|
+
*
|
|
339
|
+
* Calls the static `scaffold(context)` method on every backend
|
|
340
|
+
* class that defines one. Returns a flat array of descriptors
|
|
341
|
+
* that `workspace.prepare()` can write generically.
|
|
342
|
+
*
|
|
343
|
+
* @param {object} [context] - Agent context for env/token injection.
|
|
344
|
+
* @returns {object[]} Array of scaffold descriptors.
|
|
345
|
+
*/
|
|
346
|
+
scaffoldAll(context) {
|
|
347
|
+
const descriptors = [];
|
|
348
|
+
for (const [, info] of this.backends) {
|
|
349
|
+
if (typeof info.cls?.scaffold === 'function') {
|
|
350
|
+
const descriptor = info.cls.scaffold(context);
|
|
351
|
+
if (descriptor) descriptors.push(descriptor);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return descriptors;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Collect config path metadata from all registered backends.
|
|
359
|
+
*
|
|
360
|
+
* Returns the union of `configs` entries from all backend scaffold
|
|
361
|
+
* descriptors — used by the start tool to know which files to
|
|
362
|
+
* ensure in the remote repository.
|
|
363
|
+
*
|
|
364
|
+
* @returns {Array<{path: string, marker: string, format: string}>}
|
|
365
|
+
*/
|
|
366
|
+
configPaths() {
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
const paths = [];
|
|
369
|
+
for (const [, info] of this.backends) {
|
|
370
|
+
if (typeof info.cls?.scaffold !== 'function') continue;
|
|
371
|
+
const descriptor = info.cls.scaffold();
|
|
372
|
+
if (!descriptor?.configs) continue;
|
|
373
|
+
for (const cfg of descriptor.configs) {
|
|
374
|
+
if (!seen.has(cfg.path)) {
|
|
375
|
+
seen.add(cfg.path);
|
|
376
|
+
paths.push({ path: cfg.path, marker: cfg.marker, format: cfg.format });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return paths;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* One-shot LLM call using the best available backend.
|
|
385
|
+
*
|
|
386
|
+
* Prefers backends with discovery data and falls back across all
|
|
387
|
+
* discovered oneshot-capable backends on failure.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} prompt - Text prompt to send.
|
|
390
|
+
* @param {object} [opts] - Options forwarded to the backend's oneshot().
|
|
391
|
+
* @param {string} [opts.model='fast'] - Model alias or exact string.
|
|
392
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
393
|
+
* @param {number} [opts.timeout=60000] - Max execution time in ms.
|
|
394
|
+
* @returns {Promise<string>} LLM response text.
|
|
395
|
+
*/
|
|
396
|
+
async oneshot(prompt, opts = {}) {
|
|
397
|
+
if (!this._discoveredOnce) {
|
|
398
|
+
if (!this._discovering) {
|
|
399
|
+
const self = this;
|
|
400
|
+
this._discovering = this.discover(opts.config).finally(function done() {
|
|
401
|
+
self._discovering = null;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
await this._discovering;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const order = this._oneshotOrder();
|
|
408
|
+
if (!order.length) throw new Error('No backends available for oneshot');
|
|
409
|
+
|
|
410
|
+
let last;
|
|
411
|
+
for (const name of order) {
|
|
412
|
+
try {
|
|
413
|
+
const info = this.backends.get(name);
|
|
414
|
+
return await info.cls.oneshot(prompt, { ...opts, discovered: this.models });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
last = err;
|
|
417
|
+
log.warn(`oneshot ${name} failed: ${err.message}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
throw last;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Build backend order for oneshot calls.
|
|
426
|
+
*
|
|
427
|
+
* Backends with model discovery are tried first, followed by the
|
|
428
|
+
* remaining discovered backends that support static `oneshot()`.
|
|
429
|
+
*
|
|
430
|
+
* @returns {string[]} Ordered backend names.
|
|
431
|
+
*/
|
|
432
|
+
_oneshotOrder() {
|
|
433
|
+
const order = [];
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
|
|
436
|
+
for (const [name] of this.models) {
|
|
437
|
+
if (!this.backends.has(name)) continue;
|
|
438
|
+
const info = this.backends.get(name);
|
|
439
|
+
if (typeof info?.cls?.oneshot !== 'function') continue;
|
|
440
|
+
order.push(name);
|
|
441
|
+
seen.add(name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const [name] of this.discovered) {
|
|
445
|
+
if (seen.has(name) || !this.backends.has(name)) continue;
|
|
446
|
+
const info = this.backends.get(name);
|
|
447
|
+
if (typeof info?.cls?.oneshot !== 'function') continue;
|
|
448
|
+
order.push(name);
|
|
449
|
+
seen.add(name);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return order;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Create a backend instance.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} name - Backend name.
|
|
459
|
+
* @param {object} opts - Options passed to the backend constructor.
|
|
460
|
+
* @returns {object} Backend instance.
|
|
461
|
+
* @throws {Error} When the backend is not registered or has no class.
|
|
462
|
+
*/
|
|
463
|
+
create(name, opts) {
|
|
464
|
+
const info = this.backends.get(name);
|
|
465
|
+
if (!info) throw new Error(`Unknown backend: "${name}"`);
|
|
466
|
+
if (!info.cls) throw new Error(`Backend "${name}" has no implementation class registered`);
|
|
467
|
+
const agent = new info.cls({ ...opts, discovered: this.models });
|
|
468
|
+
agent.backend = name;
|
|
469
|
+
return agent;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Check if a binary exists on PATH.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} binary - Binary name to check.
|
|
477
|
+
* @returns {boolean} True if the binary is on PATH.
|
|
478
|
+
*/
|
|
479
|
+
function which(binary) {
|
|
480
|
+
try {
|
|
481
|
+
execFileSync('which', [binary], { stdio: 'ignore' });
|
|
482
|
+
return true;
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { logger } from 'loreli/log';
|
|
3
|
+
|
|
4
|
+
const log = logger('agent');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Valid agent states and their allowed transitions.
|
|
8
|
+
*
|
|
9
|
+
* Agent states parallel Session states but include `idle` as the
|
|
10
|
+
* pre-spawn state. The graph prevents impossible transitions like
|
|
11
|
+
* `dormant -> working`.
|
|
12
|
+
*
|
|
13
|
+
* @type {Record<string, Set<string>>}
|
|
14
|
+
*/
|
|
15
|
+
const AGENT_TRANSITIONS = {
|
|
16
|
+
idle: new Set(['spawned', 'dormant']),
|
|
17
|
+
spawned: new Set(['working', 'standby', 'dormant']),
|
|
18
|
+
working: new Set(['standby', 'reviewing', 'awaiting_hitl', 'dormant']),
|
|
19
|
+
standby: new Set(['working', 'reviewing', 'awaiting_hitl', 'dormant']),
|
|
20
|
+
reviewing: new Set(['working', 'standby', 'awaiting_hitl', 'dormant']),
|
|
21
|
+
awaiting_hitl: new Set(['working', 'standby', 'dormant']),
|
|
22
|
+
dormant: new Set(['spawned'])
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Abstract base class for all agent backends.
|
|
27
|
+
*
|
|
28
|
+
* Transport-agnostic: a tmux-backed agent maintains a persistent pane,
|
|
29
|
+
* a future SDK-backed agent holds an open API session. Both satisfy
|
|
30
|
+
* the same interface.
|
|
31
|
+
*
|
|
32
|
+
* @extends EventEmitter
|
|
33
|
+
*/
|
|
34
|
+
export class Agent extends EventEmitter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {object} opts.identity - Agent identity (from loreli/identity).
|
|
38
|
+
* @param {string} opts.role - Agent role ('planner' | 'action' | 'reviewer').
|
|
39
|
+
* @param {string} opts.cwd - Working directory for the agent.
|
|
40
|
+
*/
|
|
41
|
+
constructor({ identity, role, cwd }) {
|
|
42
|
+
super();
|
|
43
|
+
|
|
44
|
+
/** @type {object} Agent identity. */
|
|
45
|
+
this.identity = identity;
|
|
46
|
+
|
|
47
|
+
/** @type {string} Agent role. */
|
|
48
|
+
this.role = role;
|
|
49
|
+
|
|
50
|
+
/** @type {string} Working directory. */
|
|
51
|
+
this.cwd = cwd;
|
|
52
|
+
|
|
53
|
+
/** @type {string} Current agent state. */
|
|
54
|
+
this._state = 'idle';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Current agent state.
|
|
59
|
+
*
|
|
60
|
+
* @type {string} idle | spawned | working | standby | reviewing | dormant
|
|
61
|
+
*/
|
|
62
|
+
get state() {
|
|
63
|
+
return this._state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start the agent process.
|
|
68
|
+
*
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
* @throws {Error} Must be overridden by subclass.
|
|
71
|
+
*/
|
|
72
|
+
async spawn() {
|
|
73
|
+
throw new Error('spawn: not implemented — subclass must override');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Deliver work/instructions to the running agent.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} _message - The message to send.
|
|
80
|
+
* @returns {Promise<void>}
|
|
81
|
+
* @throws {Error} Must be overridden by subclass.
|
|
82
|
+
*/
|
|
83
|
+
async send(_message) {
|
|
84
|
+
throw new Error('send: not implemented — subclass must override');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Graceful shutdown of the agent.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
* @throws {Error} Must be overridden by subclass.
|
|
92
|
+
*/
|
|
93
|
+
async stop() {
|
|
94
|
+
throw new Error('stop: not implemented — subclass must override');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read latest output from the agent.
|
|
99
|
+
*
|
|
100
|
+
* @returns {Promise<string>}
|
|
101
|
+
* @throws {Error} Must be overridden by subclass.
|
|
102
|
+
*/
|
|
103
|
+
async capture() {
|
|
104
|
+
throw new Error('capture: not implemented — subclass must override');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check whether a transition to the given state is valid.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} next - Target state to check.
|
|
111
|
+
* @returns {boolean} True if the transition is allowed.
|
|
112
|
+
*/
|
|
113
|
+
canTransition(next) {
|
|
114
|
+
return AGENT_TRANSITIONS[this._state]?.has(next) ?? false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Transition to a new state, emitting a 'state' event.
|
|
119
|
+
* Validates the transition against AGENT_TRANSITIONS to prevent
|
|
120
|
+
* impossible state changes.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} next - The new state.
|
|
123
|
+
* @throws {Error} If the transition is invalid.
|
|
124
|
+
*/
|
|
125
|
+
transition(next) {
|
|
126
|
+
const prev = this._state;
|
|
127
|
+
|
|
128
|
+
const allowed = AGENT_TRANSITIONS[prev];
|
|
129
|
+
if (!allowed?.has(next)) {
|
|
130
|
+
const valid = allowed ? [...allowed].join(', ') || 'none (terminal)' : 'unknown';
|
|
131
|
+
throw new Error(`Invalid agent transition: "${prev}" -> "${next}". Allowed: ${valid}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this._state = next;
|
|
135
|
+
log.debug(`${this.identity?.name ?? '?'}: ${prev} -> ${next}`);
|
|
136
|
+
this.emit('state', { prev, next, identity: this.identity });
|
|
137
|
+
}
|
|
138
|
+
}
|