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,620 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { mark, has, stripLast } from 'loreli/marker';
|
|
5
|
+
import transformers from './themes/transformers.js';
|
|
6
|
+
import pokemon from './themes/pokemon.js';
|
|
7
|
+
import marvel from './themes/marvel.js';
|
|
8
|
+
import digimon from './themes/digimon.js';
|
|
9
|
+
import starwars from './themes/starwars.js';
|
|
10
|
+
import lotr from './themes/lotr.js';
|
|
11
|
+
import dragonball from './themes/dragonball.js';
|
|
12
|
+
import avatar from './themes/avatar.js';
|
|
13
|
+
import zelda from './themes/zelda.js';
|
|
14
|
+
/**
|
|
15
|
+
* Infer the AI provider from a model identifier string.
|
|
16
|
+
*
|
|
17
|
+
* Uses prefix-based heuristics for known providers. Falls back to
|
|
18
|
+
* 'unknown' for unrecognized models.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} model - Full model identifier.
|
|
21
|
+
* @returns {string} Provider name ('anthropic', 'openai', or 'unknown').
|
|
22
|
+
*/
|
|
23
|
+
export function infer(model) {
|
|
24
|
+
if (!model) return 'unknown';
|
|
25
|
+
const lower = model.toLowerCase();
|
|
26
|
+
if (lower.startsWith('claude')) return 'anthropic';
|
|
27
|
+
if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai';
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a full API model identifier to a human-readable display name.
|
|
33
|
+
*
|
|
34
|
+
* Strips trailing date suffixes (e.g. `-20250514`) that versioned model
|
|
35
|
+
* IDs carry. Returns the input unchanged when no date suffix is found.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} model - Full model identifier.
|
|
38
|
+
* @returns {string} Human-readable model name.
|
|
39
|
+
*/
|
|
40
|
+
export function display(model) {
|
|
41
|
+
return model.replace(/-\d{8}$/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} FactionData
|
|
46
|
+
* @property {string} faction - Faction name.
|
|
47
|
+
* @property {string[]} names - Character names.
|
|
48
|
+
* @property {string[]} signoffs - Themed sign-off messages.
|
|
49
|
+
* @property {string[]} claims - Themed claim messages (use `{name}` placeholder).
|
|
50
|
+
* @property {string[]} approvals - Themed approval messages (use `{name}` placeholder).
|
|
51
|
+
* @property {string[]} releases - Themed release messages (use `{name}` placeholder).
|
|
52
|
+
* @property {string[]} hitl - Themed Human In The Loop messages (use `{mentions}` placeholder).
|
|
53
|
+
* @property {string[]} promotions - Themed promotion messages (use `{name}` and `{number}` placeholders).
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} CouncilData
|
|
58
|
+
* @property {string} name - The authority's name.
|
|
59
|
+
* @property {string[]} proofOfLife - Themed proof-of-life messages (use `{agent}` placeholder).
|
|
60
|
+
* @property {string[]} [signoffs] - Sign-off messages for council-level comments.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {{yang: FactionData, yin: FactionData, council?: CouncilData}} ThemeData
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the loreli version from the root package.json.
|
|
69
|
+
* Cached after first read to avoid repeated fs access.
|
|
70
|
+
*
|
|
71
|
+
* @returns {string} The version string.
|
|
72
|
+
*/
|
|
73
|
+
let cachedVersion = null;
|
|
74
|
+
function version() {
|
|
75
|
+
if (cachedVersion) return cachedVersion;
|
|
76
|
+
try {
|
|
77
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
78
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
79
|
+
cachedVersion = pkg.version ?? '0.0.0';
|
|
80
|
+
} catch {
|
|
81
|
+
cachedVersion = '0.0.0';
|
|
82
|
+
}
|
|
83
|
+
return cachedVersion;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* All supported themes indexed by name.
|
|
88
|
+
*
|
|
89
|
+
* @type {Record<string, ThemeData>}
|
|
90
|
+
*/
|
|
91
|
+
const themes = { transformers, pokemon, marvel, digimon, starwars, lotr, dragonball, avatar, zelda };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Explicit provider-to-side mapping for known providers.
|
|
95
|
+
*
|
|
96
|
+
* The `cursor-*` virtual providers represent cursor-agent running a
|
|
97
|
+
* specific vendor's models. They inherit the side of their vendor so
|
|
98
|
+
* adversarial pairing works naturally even in cursor-only environments.
|
|
99
|
+
*
|
|
100
|
+
* @type {Record<string, 'yang'|'yin'>}
|
|
101
|
+
*/
|
|
102
|
+
const sides = {
|
|
103
|
+
openai: 'yang',
|
|
104
|
+
anthropic: 'yin',
|
|
105
|
+
'cursor-openai': 'yang',
|
|
106
|
+
'cursor-anthropic': 'yin'
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the yin/yang side for a provider. Known providers use explicit
|
|
111
|
+
* mappings; unknown providers get a deterministic side via char-code hash.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} provider - AI provider name.
|
|
114
|
+
* @returns {'yang'|'yin'} The resolved side.
|
|
115
|
+
*/
|
|
116
|
+
export function side(provider) {
|
|
117
|
+
if (sides[provider]) return sides[provider];
|
|
118
|
+
const sum = [...provider].reduce(function hash(acc, ch) { return acc + ch.charCodeAt(0); }, 0);
|
|
119
|
+
return sum % 2 === 0 ? 'yang' : 'yin';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Compute side-capability metadata for a discovered provider list.
|
|
124
|
+
*
|
|
125
|
+
* Loreli's orchestration policies depend on whether both sides are
|
|
126
|
+
* present (dual-side adversarial mode) or only one side is available
|
|
127
|
+
* (single-side fresh-instance mode).
|
|
128
|
+
*
|
|
129
|
+
* @param {string[]} providers - Provider names from backend discovery.
|
|
130
|
+
* @returns {{sides: Array<'yang'|'yin'>, count: number, mode: 'none'|'single'|'dual', hasDual: boolean}}
|
|
131
|
+
*/
|
|
132
|
+
export function capability(providers) {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (const provider of providers ?? []) {
|
|
135
|
+
if (!provider) continue;
|
|
136
|
+
seen.add(side(provider));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sides = [...seen];
|
|
140
|
+
const count = sides.length;
|
|
141
|
+
const mode = count >= 2 ? 'dual' : (count === 1 ? 'single' : 'none');
|
|
142
|
+
return { sides, count, mode, hasDual: mode === 'dual' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Normalize a provider name to its canonical vendor.
|
|
147
|
+
*
|
|
148
|
+
* Virtual providers like `cursor-anthropic` and `cursor-openai` are
|
|
149
|
+
* wrappers around cursor-agent that carry vendor identity for pairing.
|
|
150
|
+
* This function strips the `cursor-` prefix so model resolution and
|
|
151
|
+
* config lookups use the canonical vendor key (`anthropic`, `openai`).
|
|
152
|
+
*
|
|
153
|
+
* Native providers pass through unchanged.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} provider - Provider name (e.g. 'cursor-openai', 'anthropic').
|
|
156
|
+
* @returns {string} Canonical vendor name (e.g. 'openai', 'anthropic').
|
|
157
|
+
*/
|
|
158
|
+
export function vendor(provider) {
|
|
159
|
+
if (provider?.startsWith('cursor-')) return provider.slice(7);
|
|
160
|
+
return provider;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Select a single theme from a config value that may be a string or array.
|
|
165
|
+
*
|
|
166
|
+
* When given an array, picks one element at random. When given a string,
|
|
167
|
+
* returns it directly. Falls back to `'transformers'` for nullish input.
|
|
168
|
+
*
|
|
169
|
+
* @param {string|string[]} value - Theme config value (string or array of theme names).
|
|
170
|
+
* @returns {string} A single theme name.
|
|
171
|
+
*/
|
|
172
|
+
export function pick(value) {
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
return value[Math.floor(Math.random() * value.length)];
|
|
175
|
+
}
|
|
176
|
+
return value ?? 'transformers';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the council authority name for a given theme.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} theme - Theme name (e.g. 'transformers').
|
|
183
|
+
* @returns {string} Council name (e.g. 'The Allspark'), or 'Council' as fallback.
|
|
184
|
+
*/
|
|
185
|
+
export function council(theme) {
|
|
186
|
+
return themes[theme]?.council?.name ?? 'Council';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reverse mapping: given a provider, return the opposing provider.
|
|
191
|
+
*
|
|
192
|
+
* Native CLI providers oppose each other directly. The `cursor-*`
|
|
193
|
+
* virtual providers form their own pair so cursor-only environments
|
|
194
|
+
* still get adversarial review (e.g. cursor-agent on Claude reviews
|
|
195
|
+
* cursor-agent on GPT).
|
|
196
|
+
*
|
|
197
|
+
* @type {Record<string, string>}
|
|
198
|
+
*/
|
|
199
|
+
const opposites = {
|
|
200
|
+
openai: 'anthropic',
|
|
201
|
+
anthropic: 'openai',
|
|
202
|
+
'cursor-openai': 'cursor-anthropic',
|
|
203
|
+
'cursor-anthropic': 'cursor-openai'
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Represents a unique, themed agent identity.
|
|
208
|
+
*
|
|
209
|
+
* Names follow `{character}-{instance}` format where instance increments
|
|
210
|
+
* per reuse (e.g. optimus-0, optimus-1).
|
|
211
|
+
*/
|
|
212
|
+
export class Identity {
|
|
213
|
+
/**
|
|
214
|
+
* @param {object} opts
|
|
215
|
+
* @param {string} opts.name - Character name (e.g. 'optimus').
|
|
216
|
+
* @param {number} [opts.instance] - Instance counter. Omit for singleton identities (e.g. council).
|
|
217
|
+
* @param {string} opts.faction - Faction name (e.g. 'autobots').
|
|
218
|
+
* @param {string} opts.provider - AI provider ('openai' | 'anthropic').
|
|
219
|
+
* @param {string} opts.model - Model identifier.
|
|
220
|
+
* @param {string} opts.theme - Theme name.
|
|
221
|
+
* @param {boolean} [opts.council] - Council identity — signoffs and proofOfLife read from the theme's council section.
|
|
222
|
+
*/
|
|
223
|
+
constructor({ name, instance, faction, provider, model, theme, council }) {
|
|
224
|
+
/** @type {string} The raw character name without instance suffix. */
|
|
225
|
+
this.character = name;
|
|
226
|
+
|
|
227
|
+
/** @type {number|undefined} Instance counter for this character. */
|
|
228
|
+
this.instance = instance;
|
|
229
|
+
|
|
230
|
+
/** @type {string} Full identity name: `{character}-{instance}` or just `{character}` for council. */
|
|
231
|
+
this.name = instance != null ? `${name}-${instance}` : name;
|
|
232
|
+
|
|
233
|
+
/** @type {string} Faction name (e.g. 'autobots', 'decepticons', or council name). */
|
|
234
|
+
this.faction = faction;
|
|
235
|
+
|
|
236
|
+
/** @type {boolean} Whether this is a council-level identity. */
|
|
237
|
+
this.council = council ?? false;
|
|
238
|
+
|
|
239
|
+
/** @type {string} AI provider ('openai' | 'anthropic'). */
|
|
240
|
+
this.provider = provider;
|
|
241
|
+
|
|
242
|
+
/** @type {string} Model identifier. */
|
|
243
|
+
this.model = model ?? '';
|
|
244
|
+
|
|
245
|
+
/** @type {string} Theme name. */
|
|
246
|
+
this.theme = theme;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Serialize for JSON storage.
|
|
251
|
+
*
|
|
252
|
+
* @returns {object} Plain object representation.
|
|
253
|
+
*/
|
|
254
|
+
toJSON() {
|
|
255
|
+
const json = {
|
|
256
|
+
name: this.name,
|
|
257
|
+
character: this.character,
|
|
258
|
+
instance: this.instance,
|
|
259
|
+
faction: this.faction,
|
|
260
|
+
provider: this.provider,
|
|
261
|
+
model: this.model,
|
|
262
|
+
theme: this.theme
|
|
263
|
+
};
|
|
264
|
+
if (this.council) json.council = true;
|
|
265
|
+
return json;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Generate GitHub labels that identify this agent.
|
|
270
|
+
*
|
|
271
|
+
* All labels are namespaced under `loreli:` to avoid collisions
|
|
272
|
+
* with existing repository labels. Model names are converted to
|
|
273
|
+
* human-readable display names (e.g. 'claude-sonnet-4' instead of
|
|
274
|
+
* 'claude-sonnet-4-20250514').
|
|
275
|
+
*
|
|
276
|
+
* @param {string} [role] - Agent role to include as label.
|
|
277
|
+
* @returns {string[]} Label names for GitHub issues/PRs.
|
|
278
|
+
*/
|
|
279
|
+
labels(role) {
|
|
280
|
+
const result = ['loreli', `loreli:${this.provider}`];
|
|
281
|
+
if (this.model) result.push(`loreli:${display(this.model)}`);
|
|
282
|
+
if (role) result.push(`loreli:${role}`);
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Pick a random sign-off message for this identity's faction.
|
|
288
|
+
*
|
|
289
|
+
* @returns {string} A themed sign-off message.
|
|
290
|
+
*/
|
|
291
|
+
signoff() {
|
|
292
|
+
const data = themes[this.theme];
|
|
293
|
+
if (!data) return `— **${this.name}**`;
|
|
294
|
+
|
|
295
|
+
if (this.council) {
|
|
296
|
+
const arr = data.council?.signoffs;
|
|
297
|
+
if (!arr?.length) return `— **${this.name}**`;
|
|
298
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const faction = data[side(this.provider)];
|
|
302
|
+
if (!faction?.signoffs?.length) return `— **${this.name}**`;
|
|
303
|
+
|
|
304
|
+
const index = Math.floor(Math.random() * faction.signoffs.length);
|
|
305
|
+
return faction.signoffs[index];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Pick a random themed claim message for this identity's faction.
|
|
310
|
+
* The `{name}` placeholder in the theme data is replaced with the
|
|
311
|
+
* agent's full name.
|
|
312
|
+
*
|
|
313
|
+
* @returns {string} A themed claim message.
|
|
314
|
+
*/
|
|
315
|
+
claim() {
|
|
316
|
+
return this._themed('claims', `Claimed by **${this.name}**`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pick a random themed approval message for this identity's faction.
|
|
321
|
+
*
|
|
322
|
+
* @returns {string} A themed approval message.
|
|
323
|
+
*/
|
|
324
|
+
approval() {
|
|
325
|
+
return this._themed('approvals', `Approved by **${this.name}**`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Pick a random themed release message for this identity's faction.
|
|
330
|
+
*
|
|
331
|
+
* @returns {string} A themed release message.
|
|
332
|
+
*/
|
|
333
|
+
release() {
|
|
334
|
+
return this._themed('releases', `**Released** — agent \`${this.name}\` was terminated. This issue is available for re-claim.`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Pick a random themed promotion message for this identity's faction.
|
|
339
|
+
* Replaces `{name}` and `{number}` placeholders with the agent's full
|
|
340
|
+
* name and the new issue number. GitHub auto-links `#N` references,
|
|
341
|
+
* so no explicit URL is needed.
|
|
342
|
+
*
|
|
343
|
+
* @param {number} number - The promoted issue number.
|
|
344
|
+
* @returns {string} A themed promotion message.
|
|
345
|
+
*/
|
|
346
|
+
promote(number) {
|
|
347
|
+
const data = themes[this.theme];
|
|
348
|
+
const faction = data?.[side(this.provider)];
|
|
349
|
+
const arr = faction?.promotions;
|
|
350
|
+
|
|
351
|
+
if (!arr?.length) return `Promoted to issue #${number}`;
|
|
352
|
+
|
|
353
|
+
const index = Math.floor(Math.random() * arr.length);
|
|
354
|
+
return arr[index]
|
|
355
|
+
.replace('{name}', this.name)
|
|
356
|
+
.replace('{number}', `#${number}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pick a random themed Human In The Loop message for this identity's faction.
|
|
361
|
+
* The `{mentions}` placeholder is replaced with the provided reviewer
|
|
362
|
+
* mentions string. The `**Human review required**` header is always
|
|
363
|
+
* preserved for human readability.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} mentions - Reviewer mentions (e.g. '@alice, @bob').
|
|
366
|
+
* @returns {string} A themed HITL message with header and mentions.
|
|
367
|
+
*/
|
|
368
|
+
hitl(mentions) {
|
|
369
|
+
const data = themes[this.theme];
|
|
370
|
+
const faction = data?.[side(this.provider)];
|
|
371
|
+
const arr = faction?.hitl;
|
|
372
|
+
|
|
373
|
+
if (!arr?.length) {
|
|
374
|
+
return `**Human review required**\n\nAgent review is complete. ${mentions} — please review and merge when satisfied.`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const index = Math.floor(Math.random() * arr.length);
|
|
378
|
+
return arr[index].replace('{mentions}', mentions).replace('{name}', this.name);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Pick a random themed proof-of-life request message from the
|
|
383
|
+
* council (central authority) section of the theme.
|
|
384
|
+
*
|
|
385
|
+
* Reads from `council.proofOfLife` — not from a faction — because
|
|
386
|
+
* proof-of-life is an orchestrator-level action that transcends
|
|
387
|
+
* the yang/yin split.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} agent - Name of the agent being checked.
|
|
390
|
+
* @returns {string} A themed proof-of-life request message.
|
|
391
|
+
*/
|
|
392
|
+
proofOfLife(agent) {
|
|
393
|
+
const data = themes[this.theme];
|
|
394
|
+
const arr = data?.council?.proofOfLife;
|
|
395
|
+
if (!arr?.length) return `Requesting proof of life from **${agent}**`;
|
|
396
|
+
const index = Math.floor(Math.random() * arr.length);
|
|
397
|
+
return arr[index].replace('{agent}', agent);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Internal helper that picks a random message from a themed array,
|
|
402
|
+
* replacing the `{name}` placeholder with the agent's full name.
|
|
403
|
+
*
|
|
404
|
+
* @param {string} key - Theme data array key ('claims', 'approvals', 'releases').
|
|
405
|
+
* @param {string} fallback - Fallback message when theme data is missing.
|
|
406
|
+
* @returns {string} The resolved themed message.
|
|
407
|
+
*/
|
|
408
|
+
_themed(key, fallback) {
|
|
409
|
+
const data = themes[this.theme];
|
|
410
|
+
const faction = data?.[side(this.provider)];
|
|
411
|
+
const arr = faction?.[key];
|
|
412
|
+
|
|
413
|
+
if (!arr?.length) return fallback;
|
|
414
|
+
|
|
415
|
+
const index = Math.floor(Math.random() * arr.length);
|
|
416
|
+
return arr[index].replace('{name}', this.name);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Remove the trailing Loreli signature block from a body string.
|
|
421
|
+
* Detects the machine-readable `loreli:signature` marker.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} body - Content that may end with a signature.
|
|
424
|
+
* @returns {string} Content with trailing signature removed.
|
|
425
|
+
*/
|
|
426
|
+
static strip(body) {
|
|
427
|
+
if (has(body, 'signature')) return stripLast(body, 'signature');
|
|
428
|
+
return body;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Generate a full GitHub signature block for this agent.
|
|
433
|
+
*
|
|
434
|
+
* Format:
|
|
435
|
+
* ```
|
|
436
|
+
* ***
|
|
437
|
+
*
|
|
438
|
+
* {signoff} — **{name}**
|
|
439
|
+
*
|
|
440
|
+
* | Agent | Model | Provider | Faction | Role | Version |
|
|
441
|
+
* |-------|-------|----------|---------|------|---------|
|
|
442
|
+
* | {name} | {displayModel} | {provider} | {faction} | {role} | `loreli@{version}` |
|
|
443
|
+
* ```
|
|
444
|
+
*
|
|
445
|
+
* @param {string} role - The agent's current role.
|
|
446
|
+
* @returns {string} Markdown signature block.
|
|
447
|
+
*/
|
|
448
|
+
signature(role) {
|
|
449
|
+
const model = this.model ? display(this.model) : 'unknown';
|
|
450
|
+
const ver = version();
|
|
451
|
+
const msg = this.signoff();
|
|
452
|
+
|
|
453
|
+
return [
|
|
454
|
+
'',
|
|
455
|
+
mark('signature'),
|
|
456
|
+
'',
|
|
457
|
+
'***',
|
|
458
|
+
'',
|
|
459
|
+
`${msg} — **${this.name}**`,
|
|
460
|
+
'',
|
|
461
|
+
'| Agent | Model | Provider | Faction | Role | Version |',
|
|
462
|
+
'|-------|-------|----------|---------|------|---------|',
|
|
463
|
+
`| ${this.name} | ${model} | ${this.provider} | ${this.faction} | ${role} | \`loreli@${ver}\` |`
|
|
464
|
+
].join('\n');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Tracks active/inactive agent names and manages identity assignment.
|
|
470
|
+
*
|
|
471
|
+
* Each Registry instance maintains its own state, making it safe to
|
|
472
|
+
* create per-session registries.
|
|
473
|
+
*/
|
|
474
|
+
export class Registry {
|
|
475
|
+
constructor() {
|
|
476
|
+
/**
|
|
477
|
+
* Active identities keyed by their full name.
|
|
478
|
+
* @type {Map<string, Identity>}
|
|
479
|
+
*/
|
|
480
|
+
this.identities = new Map();
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Tracks which character names have been used per theme+side,
|
|
484
|
+
* and how many times (for instance numbering).
|
|
485
|
+
* Key: `${theme}:${side}:${character}`, Value: use count.
|
|
486
|
+
* @type {Map<string, number>}
|
|
487
|
+
*/
|
|
488
|
+
this.usage = new Map();
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Index of the next character to assign per theme+side.
|
|
492
|
+
* Key: `${theme}:${side}`, Value: index into the names array.
|
|
493
|
+
* @type {Map<string, number>}
|
|
494
|
+
*/
|
|
495
|
+
this.cursors = new Map();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Acquire a new identity for a given theme and provider.
|
|
500
|
+
*
|
|
501
|
+
* Characters are assigned round-robin. When all characters have
|
|
502
|
+
* been used once, the instance counter increments and the cycle
|
|
503
|
+
* starts again (e.g. optimus-0, then optimus-1).
|
|
504
|
+
*
|
|
505
|
+
* When a `taken` set is provided, names already claimed by other
|
|
506
|
+
* participants (discovered from GitHub claim comments and PR
|
|
507
|
+
* branches) are skipped. This prevents identity collisions in a
|
|
508
|
+
* distributed multi-participant setup at zero API cost — the
|
|
509
|
+
* taken set is populated as a side effect of data the reactor
|
|
510
|
+
* already fetches.
|
|
511
|
+
*
|
|
512
|
+
* @param {string} theme - Theme name ('transformers', 'pokemon', etc.).
|
|
513
|
+
* @param {string} provider - AI provider ('openai' | 'anthropic').
|
|
514
|
+
* @param {string} [model] - Model identifier.
|
|
515
|
+
* @param {Set<string>} [taken] - Names already in use by other participants.
|
|
516
|
+
* @returns {Identity} The assigned identity.
|
|
517
|
+
* @throws {Error} If theme or provider is unknown.
|
|
518
|
+
*/
|
|
519
|
+
acquire(theme, provider, model, taken) {
|
|
520
|
+
const data = themes[theme];
|
|
521
|
+
if (!data) throw new Error(`Unknown theme: "${theme}". Valid: ${Object.keys(themes).join(', ')}`);
|
|
522
|
+
|
|
523
|
+
const resolved = side(provider);
|
|
524
|
+
const { faction, names } = data[resolved];
|
|
525
|
+
const cursorKey = `${theme}:${resolved}`;
|
|
526
|
+
let cursor = this.cursors.get(cursorKey) ?? 0;
|
|
527
|
+
|
|
528
|
+
// Try characters round-robin, skipping names taken by other
|
|
529
|
+
// participants. Each skip advances the cursor and bumps the
|
|
530
|
+
// character's usage so its next candidate gets a higher instance.
|
|
531
|
+
const maxAttempts = names.length * 10;
|
|
532
|
+
let character, instance;
|
|
533
|
+
|
|
534
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
535
|
+
const charIndex = cursor % names.length;
|
|
536
|
+
character = names[charIndex];
|
|
537
|
+
|
|
538
|
+
const usageKey = `${theme}:${resolved}:${character}`;
|
|
539
|
+
instance = this.usage.get(usageKey) ?? 0;
|
|
540
|
+
const candidate = `${character}-${instance}`;
|
|
541
|
+
|
|
542
|
+
if (!taken?.has(candidate)) {
|
|
543
|
+
this.usage.set(usageKey, instance + 1);
|
|
544
|
+
this.cursors.set(cursorKey, cursor + 1);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Name taken externally — bump usage so next attempt at this
|
|
549
|
+
// character gets a higher instance, and advance cursor to try
|
|
550
|
+
// a different character first.
|
|
551
|
+
this.usage.set(usageKey, instance + 1);
|
|
552
|
+
cursor++;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const identity = new Identity({
|
|
556
|
+
name: character,
|
|
557
|
+
instance,
|
|
558
|
+
faction,
|
|
559
|
+
provider,
|
|
560
|
+
model,
|
|
561
|
+
theme
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
this.identities.set(identity.name, identity);
|
|
565
|
+
return identity;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Release an identity back to the pool.
|
|
570
|
+
*
|
|
571
|
+
* @param {Identity} identity - The identity to release.
|
|
572
|
+
*/
|
|
573
|
+
release(identity) {
|
|
574
|
+
this.identities.delete(identity.name);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Get all currently active identities.
|
|
579
|
+
*
|
|
580
|
+
* @returns {Identity[]} Array of active identities.
|
|
581
|
+
*/
|
|
582
|
+
active() {
|
|
583
|
+
return [...this.identities.values()];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Resolve the opposing provider and faction for yin/yang pairing.
|
|
588
|
+
*
|
|
589
|
+
* @param {Identity} identity - The identity to find the opposite for.
|
|
590
|
+
* @returns {{provider: string, faction: string}} Opposing provider and faction.
|
|
591
|
+
*/
|
|
592
|
+
opposite(identity) {
|
|
593
|
+
const oppProvider = opposites[identity.provider];
|
|
594
|
+
const oppSide = side(oppProvider);
|
|
595
|
+
const data = themes[identity.theme];
|
|
596
|
+
const { faction } = data[oppSide];
|
|
597
|
+
return { provider: oppProvider, faction };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Find an opposing-side match from a list of candidates.
|
|
602
|
+
*
|
|
603
|
+
* The core design requires cross-provider adversarial review — yin
|
|
604
|
+
* agents review yang work and vice versa. Matching by side instead
|
|
605
|
+
* of exact provider means `cursor-anthropic` naturally pairs with
|
|
606
|
+
* `openai` or `cursor-openai` — any agent on the opposite side works.
|
|
607
|
+
*
|
|
608
|
+
* @param {Identity} identity - The identity to find an opposite for.
|
|
609
|
+
* @param {Array<{identity: Identity}>} candidates - Objects with an identity property.
|
|
610
|
+
* @returns {object|null} The matching candidate, or null if none found.
|
|
611
|
+
*/
|
|
612
|
+
pair(identity, candidates) {
|
|
613
|
+
if (!candidates.length) return null;
|
|
614
|
+
|
|
615
|
+
const mySide = side(identity.provider);
|
|
616
|
+
return candidates.find(function match(c) {
|
|
617
|
+
return side(c.identity?.provider) !== mySide;
|
|
618
|
+
}) ?? null;
|
|
619
|
+
}
|
|
620
|
+
}
|