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.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. 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
+ }