loreli 0.0.0 → 1.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 +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -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/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -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 +1558 -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 +237 -0
- package/packages/knowledge/src/index.js +412 -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 +279 -0
- package/packages/mcp/instructions.md +121 -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 +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -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 +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- package/index.js +0 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { Tmux } from 'loreli/tmux';
|
|
2
|
+
import { Agent } from './base.js';
|
|
3
|
+
import { inline } from './models.js';
|
|
4
|
+
import { logger } from 'loreli/log';
|
|
5
|
+
|
|
6
|
+
const log = logger('agent');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CLI-backed agent that runs inside a tmux pane.
|
|
10
|
+
*
|
|
11
|
+
* Each agent gets its own window in the loreli tmux session.
|
|
12
|
+
* Commands are passed directly to tmux as shell strings —
|
|
13
|
+
* no launcher scripts on disk. Tmux handles working directory
|
|
14
|
+
* via its `-c` flag, and env vars are inlined as `export K='V' && ...`.
|
|
15
|
+
*
|
|
16
|
+
* Backends override `buildCommand()` (via constructor) for their
|
|
17
|
+
* specific CLI flags, and optionally override `spawn()` and
|
|
18
|
+
* `_navigate()` for custom readiness detection.
|
|
19
|
+
*
|
|
20
|
+
* Backends that need workspace scaffolding (config files, hooks,
|
|
21
|
+
* token propagation) override the static `scaffold()` method to
|
|
22
|
+
* return a declarative descriptor consumed by `workspace.prepare()`.
|
|
23
|
+
*
|
|
24
|
+
* @extends Agent
|
|
25
|
+
*/
|
|
26
|
+
export class CliAgent extends Agent {
|
|
27
|
+
/**
|
|
28
|
+
* Return a scaffold descriptor for this backend's workspace needs.
|
|
29
|
+
*
|
|
30
|
+
* Each CLI backend overrides this to declare the config files,
|
|
31
|
+
* hooks, and additional files it needs written into the agent's
|
|
32
|
+
* working directory. Workspace code writes them generically —
|
|
33
|
+
* no backend-specific knowledge required.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} [context] - Agent context for env/token injection.
|
|
36
|
+
* @param {string} [context.session] - Session ID.
|
|
37
|
+
* @param {string} [context.agent] - Agent identity name.
|
|
38
|
+
* @param {string} [context.repo] - Target repository (owner/name).
|
|
39
|
+
* @param {string} [context.home] - Loreli home directory.
|
|
40
|
+
* @param {string} [context.token] - GitHub token.
|
|
41
|
+
* @param {string[]} [context.denied] - Commands to block.
|
|
42
|
+
* @returns {object|null} Scaffold descriptor or null if no scaffolding needed.
|
|
43
|
+
* @property {Array<{path: string, content: string, marker: string, format: string}>} configs - Config files.
|
|
44
|
+
* @property {Array<{path: string, key: string, entry: object, marker: string, defaults?: object}>} hooks - Hook entries to merge.
|
|
45
|
+
* @property {Array<{path: string, content: string, mode?: number}>} files - Additional files (e.g. .git/loreli.env).
|
|
46
|
+
*/
|
|
47
|
+
static scaffold() {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {object} opts.identity - Agent identity.
|
|
53
|
+
* @param {string} opts.role - Agent role.
|
|
54
|
+
* @param {string} opts.cwd - Working directory.
|
|
55
|
+
* @param {string} opts.command - CLI command to execute in the pane.
|
|
56
|
+
* @param {string} [opts.session='loreli'] - Tmux session name.
|
|
57
|
+
*/
|
|
58
|
+
constructor(opts) {
|
|
59
|
+
super(opts);
|
|
60
|
+
|
|
61
|
+
/** @type {string} CLI command to execute. */
|
|
62
|
+
this.command = opts.command;
|
|
63
|
+
|
|
64
|
+
/** @type {string} Tmux session name. */
|
|
65
|
+
this.session = opts.session ?? 'loreli';
|
|
66
|
+
|
|
67
|
+
/** @type {Tmux} Tmux instance. */
|
|
68
|
+
this.tmux = new Tmux();
|
|
69
|
+
|
|
70
|
+
/** @type {string|null} Assigned tmux pane ID. */
|
|
71
|
+
this.paneId = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Spawn the agent in a tmux window.
|
|
76
|
+
*
|
|
77
|
+
* Builds an inline shell command with env exports and passes it
|
|
78
|
+
* directly to tmux — no launcher scripts written to disk.
|
|
79
|
+
*
|
|
80
|
+
* 1. If the session doesn't exist, create it with the command as the
|
|
81
|
+
* initial window (no garbage default window — tmux auto-destroys
|
|
82
|
+
* the session when all windows die)
|
|
83
|
+
* 2. If the session already exists, add a new window
|
|
84
|
+
* 3. Transition to `spawned` state
|
|
85
|
+
*
|
|
86
|
+
* Backends that need custom behavior (e.g. Claude dialog navigation,
|
|
87
|
+
* Codex readiness detection) override this method but follow the
|
|
88
|
+
* same pattern via {@link CliAgent#_launch}.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
async spawn() {
|
|
93
|
+
const cmd = this._shell(`exec ${this.command}`);
|
|
94
|
+
this.paneId = await this._launch(cmd);
|
|
95
|
+
await this.tmux.set(this.paneId, 'remain-on-exit', 'on');
|
|
96
|
+
|
|
97
|
+
log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
|
|
98
|
+
this.transition('spawned');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build an inline shell command string with optional env exports.
|
|
103
|
+
*
|
|
104
|
+
* Joins `export K='V'` statements with ` && ` and appends the body.
|
|
105
|
+
* No files written to disk — the string is passed directly to tmux
|
|
106
|
+
* which runs it via `/bin/sh -c`.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} body - Shell command to execute.
|
|
109
|
+
* @param {object} [vars] - Environment variables to export.
|
|
110
|
+
* @returns {string} Complete shell command string.
|
|
111
|
+
*/
|
|
112
|
+
_shell(body, vars) {
|
|
113
|
+
const exports = inline(vars);
|
|
114
|
+
return exports ? `${exports} && ${body}` : body;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Launch a command in the tmux session.
|
|
119
|
+
*
|
|
120
|
+
* When the session does not exist, the command becomes the initial
|
|
121
|
+
* window — no empty default window is created. This lets tmux
|
|
122
|
+
* auto-destroy the session when all windows die, preventing orphans.
|
|
123
|
+
*
|
|
124
|
+
* When the session already exists, a new window is added.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} command - Shell command string.
|
|
127
|
+
* @returns {Promise<string>} The assigned pane ID.
|
|
128
|
+
*/
|
|
129
|
+
async _launch(command) {
|
|
130
|
+
if (!await this.tmux.has(this.session)) {
|
|
131
|
+
return this.tmux.create(this.session, { cwd: this.cwd, command });
|
|
132
|
+
}
|
|
133
|
+
return this.tmux.window(this.session, { cwd: this.cwd, command });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Send a message to the agent's tmux pane.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} message - Text to send via tmux send-keys.
|
|
140
|
+
* @returns {Promise<void>}
|
|
141
|
+
*/
|
|
142
|
+
async send(message) {
|
|
143
|
+
if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
|
|
144
|
+
if (this.canTransition('working')) this.transition('working');
|
|
145
|
+
await this.tmux.send(this.paneId, message);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Capture the current output of the agent's tmux pane.
|
|
150
|
+
*
|
|
151
|
+
* @param {number} [lines] - Number of history lines to capture.
|
|
152
|
+
* @returns {Promise<string>} Captured terminal output.
|
|
153
|
+
*/
|
|
154
|
+
async capture(lines) {
|
|
155
|
+
if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
|
|
156
|
+
return this.tmux.capture(this.paneId, lines);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if the agent's tmux pane is still alive.
|
|
161
|
+
*
|
|
162
|
+
* @returns {Promise<boolean>}
|
|
163
|
+
*/
|
|
164
|
+
async alive() {
|
|
165
|
+
if (!this.paneId) return false;
|
|
166
|
+
return this.tmux.alive(this.paneId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Kill the agent's tmux pane and reap the session if it is now empty.
|
|
171
|
+
*
|
|
172
|
+
* After removing the pane, checks whether the session still has any
|
|
173
|
+
* remaining panes. If not, the session is killed — this prevents
|
|
174
|
+
* orphaned sessions from accumulating when all agents have stopped.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<void>}
|
|
177
|
+
*/
|
|
178
|
+
async stop() {
|
|
179
|
+
log.info(`stopping ${this.identity?.name ?? '?'} pane=${this.paneId}`);
|
|
180
|
+
if (this.paneId) {
|
|
181
|
+
try {
|
|
182
|
+
await this.tmux.killPane(this.paneId);
|
|
183
|
+
} catch { /* pane may already be dead */ }
|
|
184
|
+
this.paneId = null;
|
|
185
|
+
|
|
186
|
+
// Reap the session when this was the last pane — prevents orphans
|
|
187
|
+
try {
|
|
188
|
+
const remaining = await this.tmux.panes(this.session);
|
|
189
|
+
if (remaining.length === 0) {
|
|
190
|
+
log.info(`session "${this.session}" is empty — reaping`);
|
|
191
|
+
await this.tmux.kill(this.session);
|
|
192
|
+
}
|
|
193
|
+
} catch { /* session may already be gone (tmux auto-destroyed it) */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (this.state !== 'dormant') this.transition('dormant');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { prepare, pathFor, mirror, create as createWorktree } from 'loreli/workspace';
|
|
2
|
+
import { vendor, pick } from 'loreli/identity';
|
|
3
|
+
import { resolve as resolveModel } from './models.js';
|
|
4
|
+
import { logger } from 'loreli/log';
|
|
5
|
+
|
|
6
|
+
const log = logger('factory');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Agent factory that centralizes the creation pipeline:
|
|
10
|
+
* discover → acquire identity → create cwd → select backend → instantiate.
|
|
11
|
+
*
|
|
12
|
+
* Both the orchestrator's `enlist()` and `rework()` paths were duplicating
|
|
13
|
+
* this sequence with subtle inconsistencies (e.g. rework used `defaultBackend()`
|
|
14
|
+
* instead of `forProvider()`). This class is the single source of truth.
|
|
15
|
+
*
|
|
16
|
+
* The factory **creates** agents but does not **spawn** them. Spawning
|
|
17
|
+
* and registration is the caller's responsibility (e.g. the orchestrator's
|
|
18
|
+
* `spawn()` method handles process start, map registration, and rollback).
|
|
19
|
+
*
|
|
20
|
+
* The factory composes the backend registry and identity registry — it does
|
|
21
|
+
* not own them. The orchestrator (or any other caller) injects them.
|
|
22
|
+
*/
|
|
23
|
+
export class Factory {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {import('./backends/index.js').BackendRegistry} opts.backends - Backend registry.
|
|
27
|
+
* @param {import('loreli/identity').Registry} opts.identities - Identity registry.
|
|
28
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
29
|
+
*/
|
|
30
|
+
constructor({ backends, identities, config }) {
|
|
31
|
+
/** @type {import('./backends/index.js').BackendRegistry} */
|
|
32
|
+
this.backends = backends;
|
|
33
|
+
|
|
34
|
+
/** @type {import('loreli/identity').Registry} */
|
|
35
|
+
this.identities = identities;
|
|
36
|
+
|
|
37
|
+
/** @type {object|undefined} */
|
|
38
|
+
this.config = config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create an agent for the given provider and role.
|
|
43
|
+
*
|
|
44
|
+
* Pipeline:
|
|
45
|
+
* 1. Discover available backends (idempotent)
|
|
46
|
+
* 2. Acquire a themed identity from the identity registry
|
|
47
|
+
* 3. Create the agent's working directory
|
|
48
|
+
* 4. Select the best backend via `forProvider()`
|
|
49
|
+
* 5. Instantiate the backend class
|
|
50
|
+
*
|
|
51
|
+
* The returned agent is in `idle` state — call `agent.spawn()` (or
|
|
52
|
+
* the orchestrator's `spawn()` for registration+rollback) to start it.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} provider - AI provider ('anthropic', 'openai').
|
|
55
|
+
* @param {string} role - Agent role ('action', 'reviewer', 'planner').
|
|
56
|
+
* @param {object} [opts] - Additional options.
|
|
57
|
+
* @param {string} [opts.theme='transformers'] - Theme for identity.
|
|
58
|
+
* @param {string} [opts.model='balanced'] - Model alias or exact string.
|
|
59
|
+
* @param {object} [opts.config] - Config instance override (falls back to factory-level config).
|
|
60
|
+
* @param {string} [opts.cwd] - Override working directory (default: ~/.loreli/workspaces/loreli-{name}).
|
|
61
|
+
* @returns {Promise<object>} The unspawned agent instance (state: idle).
|
|
62
|
+
*/
|
|
63
|
+
async create(provider, role, opts = {}) {
|
|
64
|
+
const theme = opts.theme ?? pick('transformers');
|
|
65
|
+
const model = opts.model ?? 'balanced';
|
|
66
|
+
|
|
67
|
+
await this.backends.discover();
|
|
68
|
+
|
|
69
|
+
const backendName = this.backends.forProvider(provider);
|
|
70
|
+
|
|
71
|
+
// Resolve the model alias to a concrete identifier so the identity
|
|
72
|
+
// carries the real model name for signatures and labels. Without
|
|
73
|
+
// this, identities created via Factory.create() (the enlist() path)
|
|
74
|
+
// would have an empty model while add_agent identities had the
|
|
75
|
+
// resolved name — causing "unknown" in reviewer stamps.
|
|
76
|
+
const config = opts.config ?? this.config;
|
|
77
|
+
const resolved = resolveModel(model, backendName, vendor(provider), config);
|
|
78
|
+
|
|
79
|
+
const identity = this.identities.acquire(theme, provider, resolved, opts.taken);
|
|
80
|
+
|
|
81
|
+
// Update context.agent before prepare() and backend construction
|
|
82
|
+
// so the Codex -c flags and .mcp.json env vars have the real name
|
|
83
|
+
// instead of null. Context is mutated in-place intentionally —
|
|
84
|
+
// callers (e.g. enlist) pass the same reference and read it back.
|
|
85
|
+
if (opts.context) {
|
|
86
|
+
opts.context.agent = identity.name;
|
|
87
|
+
|
|
88
|
+
if (!opts.context.denied) {
|
|
89
|
+
opts.context.denied = config?.get?.('agents.disallowedTools') ?? [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let cwd = opts.cwd ?? pathFor(identity.name);
|
|
94
|
+
|
|
95
|
+
// Collect scaffold descriptors from all registered backends so
|
|
96
|
+
// workspace.prepare() and workspace.create() write configs, hooks,
|
|
97
|
+
// and files generically — no backend-specific knowledge needed.
|
|
98
|
+
const descriptors = this.backends.scaffoldAll(opts.context);
|
|
99
|
+
|
|
100
|
+
// When we have full orchestration context (repo + home), create a
|
|
101
|
+
// git worktree instead of an empty directory. This gives the agent
|
|
102
|
+
// a fully checked-out repo with zero network overhead per agent —
|
|
103
|
+
// all from a shared bare mirror.
|
|
104
|
+
if (opts.context?.repo && opts.context?.home && !opts.cwd) {
|
|
105
|
+
const url = `https://github.com/${opts.context.repo}.git`;
|
|
106
|
+
const bare = await mirror(url, { home: opts.context.home, token: opts.context.token });
|
|
107
|
+
cwd = await createWorktree(bare, 'HEAD', identity.name, undefined, opts.context, descriptors);
|
|
108
|
+
log.info(`worktree ready at ${cwd} from mirror of ${opts.context.repo}`);
|
|
109
|
+
} else {
|
|
110
|
+
await prepare(cwd, opts.context, descriptors);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const agentOpts = { identity, role, cwd, model, config };
|
|
114
|
+
if (opts.context) agentOpts.context = opts.context;
|
|
115
|
+
|
|
116
|
+
log.info(`created ${identity.name} (${role}) via ${backendName}`);
|
|
117
|
+
return this.backends.create(backendName, agentOpts);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Agent } from './base.js';
|
|
2
|
+
export { CliAgent } from './cli.js';
|
|
3
|
+
export { Session, STATES, TRANSITIONS } from './session.js';
|
|
4
|
+
export { BackendRegistry } from './backends/index.js';
|
|
5
|
+
export { Factory } from './factory.js';
|
|
6
|
+
export { ClaudeBackend } from './backends/claude.js';
|
|
7
|
+
export { CodexBackend } from './backends/codex.js';
|
|
8
|
+
export { CursorBackend } from './backends/cursor.js';
|
|
9
|
+
export * as models from './models.js';
|
|
10
|
+
export * as output from './output.js';
|
|
11
|
+
export * as trace from './trace.js';
|
|
12
|
+
export { prepare } from 'loreli/workspace';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { defaults } from 'loreli/config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a model alias to a concrete model identifier.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order:
|
|
7
|
+
* 1. Config layer: `config.get('backends.{backend}.models.{alias}.{provider}')`
|
|
8
|
+
* 2. Built-in defaults: `defaults.backends[backend].models[alias][provider]`
|
|
9
|
+
* 3. Pass-through: return the alias string unchanged (exact model IDs)
|
|
10
|
+
*
|
|
11
|
+
* @param {string} alias - Alias ('fast', 'balanced', 'powerful') or exact model string.
|
|
12
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
13
|
+
* @param {string} provider - AI provider ('openai' | 'anthropic').
|
|
14
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
15
|
+
* @returns {string} Resolved model identifier.
|
|
16
|
+
*/
|
|
17
|
+
export function resolve(alias, backend, provider, config) {
|
|
18
|
+
// Config layer: highest priority
|
|
19
|
+
const fromConfig = config?.get?.(`backends.${backend}.models.${alias}.${provider}`);
|
|
20
|
+
if (fromConfig) return fromConfig;
|
|
21
|
+
|
|
22
|
+
// Built-in defaults layer
|
|
23
|
+
const fallback = defaults.backends?.[backend]?.models?.[alias]?.[provider];
|
|
24
|
+
if (fallback) return fallback;
|
|
25
|
+
|
|
26
|
+
// Pass-through for exact model strings
|
|
27
|
+
return alias;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Env var prefixes relevant to each backend. Used to inherit
|
|
32
|
+
* process.env vars so launcher scripts in tmux get the right
|
|
33
|
+
* proxy URLs, auth tokens, and feature flags.
|
|
34
|
+
*
|
|
35
|
+
* @type {Record<string, string[]>}
|
|
36
|
+
*/
|
|
37
|
+
const PREFIXES = {
|
|
38
|
+
claude: ['ANTHROPIC_', 'CLAUDE_'],
|
|
39
|
+
codex: ['OPENAI_', 'CODEX_'],
|
|
40
|
+
cursor: ['ANTHROPIC_', 'OPENAI_', 'CLAUDE_', 'CURSOR_']
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Collect process.env vars matching a backend's known prefixes.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} backend - Backend name.
|
|
47
|
+
* @returns {object} Matching env vars from process.env.
|
|
48
|
+
*/
|
|
49
|
+
function inherit(backend) {
|
|
50
|
+
const prefixes = PREFIXES[backend] ?? [];
|
|
51
|
+
if (!prefixes.length) return {};
|
|
52
|
+
|
|
53
|
+
const result = {};
|
|
54
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
55
|
+
if (value && prefixes.some(function match(p) { return key.startsWith(p); })) {
|
|
56
|
+
result[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Retrieve backend-specific environment variables.
|
|
64
|
+
*
|
|
65
|
+
* Merges two layers:
|
|
66
|
+
* 1. Inherited: process.env vars matching the backend's known prefixes
|
|
67
|
+
* (e.g. ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
|
68
|
+
* 2. Config overrides: `backends.{backend}.env` from loreli.yml
|
|
69
|
+
*
|
|
70
|
+
* Config values take precedence over process.env when keys collide.
|
|
71
|
+
* Returns undefined when no vars are collected from either layer.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
74
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
75
|
+
* @returns {object|undefined} Merged environment variables, or undefined if empty.
|
|
76
|
+
*/
|
|
77
|
+
export function env(backend, config) {
|
|
78
|
+
const inherited = inherit(backend);
|
|
79
|
+
|
|
80
|
+
const configured = config?.get?.(`backends.${backend}.env`);
|
|
81
|
+
const overrides = (configured && typeof configured === 'object') ? configured : {};
|
|
82
|
+
|
|
83
|
+
// Config overrides process.env
|
|
84
|
+
const merged = { ...inherited, ...overrides };
|
|
85
|
+
if (!Object.keys(merged).length) return undefined;
|
|
86
|
+
return merged;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format an env object into shell export lines for launcher scripts.
|
|
91
|
+
*
|
|
92
|
+
* Returns an empty string when no env is configured, keeping the
|
|
93
|
+
* launcher script clean. Values are single-quoted to prevent expansion.
|
|
94
|
+
*
|
|
95
|
+
* @param {object|undefined} vars - Key/value environment map.
|
|
96
|
+
* @returns {string} Shell export statements (with trailing newline), or empty string.
|
|
97
|
+
*/
|
|
98
|
+
export function format(vars) {
|
|
99
|
+
if (!vars || !Object.keys(vars).length) return '';
|
|
100
|
+
return Object.entries(vars)
|
|
101
|
+
.map(function line([k, v]) { return `export ${k}='${v}'`; })
|
|
102
|
+
.join('\n') + '\n';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Format an env object as a single-line shell export string.
|
|
107
|
+
*
|
|
108
|
+
* Designed for inline use with tmux commands — no newlines, exports
|
|
109
|
+
* joined with ` && `. Values are single-quoted to prevent expansion.
|
|
110
|
+
*
|
|
111
|
+
* @param {object|undefined} vars - Key/value environment map.
|
|
112
|
+
* @returns {string} Inline export string, or empty string when no vars.
|
|
113
|
+
*/
|
|
114
|
+
export function inline(vars) {
|
|
115
|
+
if (!vars || !Object.keys(vars).length) return '';
|
|
116
|
+
return Object.entries(vars)
|
|
117
|
+
.map(function line([k, v]) { return `export ${k}='${v}'`; })
|
|
118
|
+
.join(' && ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* List all known aliases for a given backend.
|
|
123
|
+
*
|
|
124
|
+
* When a config instance is provided, reads alias keys from config's
|
|
125
|
+
* backend models. Otherwise falls back to built-in defaults.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} backend - Backend name ('claude', 'codex', 'cursor').
|
|
128
|
+
* @param {object} [config] - Config instance with a `get()` method.
|
|
129
|
+
* @returns {string[]} Array of alias names.
|
|
130
|
+
*/
|
|
131
|
+
export function list(backend, config) {
|
|
132
|
+
if (!backend) throw new Error('models.list() requires a backend name');
|
|
133
|
+
|
|
134
|
+
const models = config?.get?.(`backends.${backend}.models`);
|
|
135
|
+
if (models && typeof models === 'object') return Object.keys(models);
|
|
136
|
+
|
|
137
|
+
const fallback = defaults.backends?.[backend]?.models;
|
|
138
|
+
if (fallback) return Object.keys(fallback);
|
|
139
|
+
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent output processing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Handles cleaning, truncating, and formatting captured terminal output
|
|
5
|
+
* from CLI-backed agents. Used by the orchestrator's relay and any other
|
|
6
|
+
* consumer that reads agent pane output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regex to strip ANSI escape codes from terminal output.
|
|
11
|
+
* Matches both CSI sequences (e.g. `\x1b[31m`) and OSC sequences
|
|
12
|
+
* (e.g. `\x1b]0;title\x07`).
|
|
13
|
+
*
|
|
14
|
+
* @type {RegExp}
|
|
15
|
+
*/
|
|
16
|
+
export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default maximum characters for truncated output.
|
|
20
|
+
*
|
|
21
|
+
* @type {number}
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CHARS = 12000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Strip ANSI escape codes from a string.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text - Raw terminal output.
|
|
29
|
+
* @returns {string} Clean text without ANSI codes.
|
|
30
|
+
*/
|
|
31
|
+
export function strip(text) {
|
|
32
|
+
return text.replace(ANSI_RE, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncate text to a maximum length, keeping the tail.
|
|
37
|
+
*
|
|
38
|
+
* When the input exceeds `max` characters, the beginning is removed
|
|
39
|
+
* and a `...(truncated)` prefix is prepended. This preserves the most
|
|
40
|
+
* recent output which is typically the most relevant.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text - Text to truncate.
|
|
43
|
+
* @param {number} [max=MAX_CHARS] - Maximum character count.
|
|
44
|
+
* @returns {string} Truncated text, or original if under the limit.
|
|
45
|
+
*/
|
|
46
|
+
export function truncate(text, max = MAX_CHARS) {
|
|
47
|
+
if (text.length <= max) return text;
|
|
48
|
+
return `...(truncated)\n${text.slice(-max)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clean agent output: strip ANSI codes, then truncate.
|
|
53
|
+
*
|
|
54
|
+
* Convenience function combining both operations in the standard order.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} text - Raw terminal output.
|
|
57
|
+
* @param {number} [max=MAX_CHARS] - Maximum character count.
|
|
58
|
+
* @returns {string} Cleaned and truncated text.
|
|
59
|
+
*/
|
|
60
|
+
export function clean(text, max = MAX_CHARS) {
|
|
61
|
+
return truncate(strip(text), max);
|
|
62
|
+
}
|