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.
Files changed (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,252 @@
1
+ import winston from 'winston';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ /**
7
+ * Module-level state for the logging subsystem.
8
+ * `home` is the base directory for log files (~/.loreli/ by default).
9
+ * `session` is the active session ID (set via bind()).
10
+ * `loggers` caches loggers by package name.
11
+ */
12
+ let home = process.env.LORELI_HOME ?? join(homedir(), '.loreli');
13
+ let session = null;
14
+ let bound = false;
15
+
16
+ /** @type {object|null} loreli/config instance, set via bind(). */
17
+ let cfg = null;
18
+
19
+ const loggers = new Map();
20
+ const agentLoggers = new Map();
21
+
22
+ /**
23
+ * Resolve the effective max file size in bytes.
24
+ * Delegates to loreli/config if bound, else hardcoded default.
25
+ *
26
+ * @returns {number}
27
+ */
28
+ function effectiveMaxSize() {
29
+ return cfg?.get?.('log.maxSize') ?? 10 * 1024 * 1024;
30
+ }
31
+
32
+ /**
33
+ * Resolve the effective max files count.
34
+ * Delegates to loreli/config if bound, else hardcoded default.
35
+ *
36
+ * @returns {number}
37
+ */
38
+ function effectiveMaxFiles() {
39
+ return cfg?.get?.('log.maxFiles') ?? 3;
40
+ }
41
+
42
+ /**
43
+ * Resolve the effective log level for file transports.
44
+ * Delegates to loreli/config if bound, else env > hardcoded default.
45
+ *
46
+ * @returns {string}
47
+ */
48
+ function effectiveLevel() {
49
+ return cfg?.get?.('log.level') ?? process.env.LORELI_LOG_LEVEL ?? 'debug';
50
+ }
51
+
52
+ /**
53
+ * Create the JSON format used for file transports.
54
+ *
55
+ * @returns {winston.Logform.Format} JSON format with timestamp.
56
+ */
57
+ function fileFormat() {
58
+ return winston.format.combine(
59
+ winston.format.timestamp(),
60
+ winston.format.json()
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Ensure a directory exists, creating it recursively if needed.
66
+ *
67
+ * @param {string} dir - Directory path.
68
+ */
69
+ function ensure(dir) {
70
+ mkdirSync(dir, { recursive: true });
71
+ }
72
+
73
+ /**
74
+ * Get the logs directory path for the current session.
75
+ *
76
+ * @returns {string|null} Logs directory or null if no session is bound.
77
+ */
78
+ function logsDir() {
79
+ if (!session) return null;
80
+ return join(home, 'sessions', session, 'logs');
81
+ }
82
+
83
+ /**
84
+ * Create the pre-session fallback file transport.
85
+ * Writes to `~/.loreli/loreli.log` before a session is bound.
86
+ *
87
+ * @returns {winston.transports.FileTransportInstance} File transport.
88
+ */
89
+ function fallbackTransport() {
90
+ ensure(home);
91
+ return new winston.transports.File({
92
+ filename: join(home, 'loreli.log'),
93
+ format: fileFormat(),
94
+ level: effectiveLevel(),
95
+ maxsize: effectiveMaxSize(),
96
+ maxFiles: effectiveMaxFiles()
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Add the appropriate file transport to a logger after bind().
102
+ * When a session is active, writes to the session's orchestrator log.
103
+ * Otherwise falls back to `home/loreli.log`.
104
+ *
105
+ * @param {winston.Logger} log - The winston logger to augment.
106
+ */
107
+ function addBoundTransport(log) {
108
+ const dir = logsDir();
109
+ if (!dir) {
110
+ // No session — add fallback transport at current home
111
+ ensure(home);
112
+ log.add(new winston.transports.File({
113
+ filename: join(home, 'loreli.log'),
114
+ format: fileFormat(),
115
+ level: effectiveLevel(),
116
+ maxsize: effectiveMaxSize(),
117
+ maxFiles: effectiveMaxFiles()
118
+ }));
119
+ return;
120
+ }
121
+
122
+ ensure(dir);
123
+ log.add(new winston.transports.File({
124
+ filename: join(dir, 'orchestrator.log'),
125
+ format: fileFormat(),
126
+ level: effectiveLevel(),
127
+ maxsize: effectiveMaxSize(),
128
+ maxFiles: effectiveMaxFiles()
129
+ }));
130
+ }
131
+
132
+ /**
133
+ * Create a per-agent file-only logger that writes to a dedicated log file.
134
+ *
135
+ * @param {string} name - Agent identity name (e.g. 'optimus-0').
136
+ * @param {string} pkg - Package name for the logger's defaultMeta.
137
+ * @returns {winston.Logger} A logger writing to the agent's log file.
138
+ */
139
+ function createAgentLogger(name, pkg) {
140
+ const dir = logsDir();
141
+ const transports = [fallbackTransport()];
142
+
143
+ if (dir) {
144
+ ensure(dir);
145
+ transports.push(new winston.transports.File({
146
+ filename: join(dir, `${name}.log`),
147
+ format: fileFormat(),
148
+ level: effectiveLevel(),
149
+ maxsize: effectiveMaxSize(),
150
+ maxFiles: effectiveMaxFiles()
151
+ }));
152
+ }
153
+
154
+ return winston.createLogger({
155
+ level: effectiveLevel(),
156
+ defaultMeta: { package: pkg, agent: name },
157
+ transports
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Create or retrieve a logger scoped to a package name.
163
+ *
164
+ * All output goes to file only — no console transports — so stdout
165
+ * and stderr stay clean for protocols like MCP stdio.
166
+ *
167
+ * Before `bind()`, logs write to `~/.loreli/loreli.log`.
168
+ * After `bind()`, a session-scoped transport is added that writes to
169
+ * `~/.loreli/sessions/<session>/logs/orchestrator.log`.
170
+ *
171
+ * @param {string} pkg - The package name (e.g. 'agent', 'hub', 'mcp').
172
+ * @returns {object} A logger with info/warn/error/debug methods and an agent() factory.
173
+ */
174
+ export function logger(pkg) {
175
+ if (loggers.has(pkg)) return loggers.get(pkg);
176
+
177
+ const log = winston.createLogger({
178
+ level: effectiveLevel(),
179
+ defaultMeta: { package: pkg },
180
+ transports: [fallbackTransport()]
181
+ });
182
+
183
+ // Attach bound transport if already bound
184
+ if (bound) addBoundTransport(log);
185
+
186
+ /**
187
+ * Create a per-agent sub-logger that writes to a dedicated log file.
188
+ *
189
+ * @param {string} name - Agent identity name (e.g. 'optimus-0').
190
+ * @returns {object} A logger with info/warn/error/debug methods.
191
+ */
192
+ log.agent = function agent(name) {
193
+ const key = `${pkg}:${name}`;
194
+ if (agentLoggers.has(key)) return agentLoggers.get(key);
195
+
196
+ const agentLog = createAgentLogger(name, pkg);
197
+ agentLoggers.set(key, agentLog);
198
+ return agentLog;
199
+ };
200
+
201
+ loggers.set(pkg, log);
202
+ return log;
203
+ }
204
+
205
+ /**
206
+ * Bind the logging subsystem to a session, home directory, and/or config.
207
+ *
208
+ * After binding, all existing loggers gain an additional file transport.
209
+ * When a session is provided, it writes to the session's logs directory.
210
+ * Without a session, it writes to `home/loreli.log`. New loggers created
211
+ * after bind() also receive the bound transport automatically.
212
+ *
213
+ * Log settings (level, maxSize, maxFiles) are resolved through the config
214
+ * instance's full resolution chain: overrides > loreli.yml > env > defaults.
215
+ *
216
+ * @param {object} opts - Bind options.
217
+ * @param {string} [opts.session] - Session ID for log file path.
218
+ * @param {string} [opts.home] - Override the base directory (~/.loreli/).
219
+ * @param {object} [opts.config] - loreli/config instance with a get() method.
220
+ */
221
+ export function bind(opts = {}) {
222
+ if (opts.home) home = opts.home;
223
+ if (opts.session) session = opts.session;
224
+ if (opts.config) cfg = opts.config;
225
+ bound = true;
226
+
227
+ // Add file transports to all existing loggers
228
+ for (const log of loggers.values()) {
229
+ addBoundTransport(log);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Reset the logging subsystem. Clears all cached loggers and state.
235
+ * Primarily used in tests to ensure a clean slate between runs.
236
+ */
237
+ export function reset() {
238
+ // Close all transports to prevent file handle leaks
239
+ for (const log of loggers.values()) {
240
+ log.close();
241
+ }
242
+ for (const log of agentLoggers.values()) {
243
+ log.close();
244
+ }
245
+
246
+ loggers.clear();
247
+ agentLoggers.clear();
248
+ session = null;
249
+ bound = false;
250
+ cfg = null;
251
+ home = process.env.LORELI_HOME ?? join(homedir(), '.loreli');
252
+ }
@@ -0,0 +1,200 @@
1
+ # loreli/marker
2
+
3
+ Machine-readable HTML comment markers for GitHub content. Decouples workflow-state detection from human-visible text so themed messages can change freely without breaking parsing logic.
4
+
5
+ ## Why
6
+
7
+ Loreli embeds metadata in GitHub comments, PR bodies, and discussion posts. Previously, parsing relied on human-visible strings like `"Claimed by"` or `"| Agent |"`, which broke when text was themed or reformatted. This package standardizes a single marker format across all packages.
8
+
9
+ ## Marker Format
10
+
11
+ ```
12
+ <!-- loreli:TYPE key="value" key="value" -->
13
+ ```
14
+
15
+ Markers are HTML comments — invisible to GitHub readers but reliably parseable. The `loreli:` prefix namespaces them to avoid collisions with other tooling.
16
+
17
+ ## Installation
18
+
19
+ Already available as a workspace export:
20
+
21
+ ```js
22
+ import { mark, has, parse, strip, stripLast, excise } from 'loreli/marker';
23
+ ```
24
+
25
+ ## API Reference
26
+
27
+ ### `mark(type, data?)`
28
+
29
+ Produces a marker string.
30
+
31
+ **Parameters:**
32
+
33
+ | Name | Type | Required | Description |
34
+ |------|------|----------|-------------|
35
+ | `type` | `string` | yes | Marker type (e.g. `'claim'`, `'signature'`, `'agent'`). |
36
+ | `data` | `Object<string, string>` | no | Key-value pairs to embed in the marker. |
37
+
38
+ **Returns:** `string` — an HTML comment marker.
39
+
40
+ **Throws:** `Error` — if `type` is falsy or empty.
41
+
42
+ The following example creates a claim marker with an agent identifier, which is the most common pattern used by the action package to tag issue claims:
43
+
44
+ ```js
45
+ mark('claim', { agent: 'optimus-0' });
46
+ // → '<!-- loreli:claim agent="optimus-0" -->'
47
+ ```
48
+
49
+ Type-only markers (no data) are used for structural markers like signatures, where presence is all that matters:
50
+
51
+ ```js
52
+ mark('signature');
53
+ // → '<!-- loreli:signature -->'
54
+ ```
55
+
56
+ ### `has(body, type)`
57
+
58
+ Checks whether a body contains a marker of the given type.
59
+
60
+ **Parameters:**
61
+
62
+ | Name | Type | Required | Description |
63
+ |------|------|----------|-------------|
64
+ | `body` | `string \| null \| undefined` | yes | GitHub comment/PR/discussion body. |
65
+ | `type` | `string` | yes | Marker type to look for. |
66
+
67
+ **Returns:** `boolean` — `true` if the marker type is present.
68
+
69
+ This is the primary detection function. Use it to branch on workflow state without inspecting visible text. Safe to call on null or empty bodies — always returns `false`:
70
+
71
+ ```js
72
+ has(body, 'claim'); // true if body contains a loreli:claim marker
73
+ has(null, 'claim'); // false
74
+ ```
75
+
76
+ ### `parse(body, type)`
77
+
78
+ Extracts the key-value data from the first marker of the given type.
79
+
80
+ **Parameters:**
81
+
82
+ | Name | Type | Required | Description |
83
+ |------|------|----------|-------------|
84
+ | `body` | `string \| null \| undefined` | yes | GitHub comment/PR/discussion body. |
85
+ | `type` | `string` | yes | Marker type to extract. |
86
+
87
+ **Returns:** `Object<string, string> | null` — parsed key-value data, or `null` if not found. Returns an empty object `{}` for type-only markers.
88
+
89
+ Use `parse` when you need the embedded data, not just presence. For example, extracting which agent claimed an issue:
90
+
91
+ ```js
92
+ parse(body, 'claim');
93
+ // → { agent: 'optimus-0' }
94
+
95
+ parse(body, 'signature');
96
+ // → {} (type-only marker — present but no data)
97
+
98
+ parse('no markers', 'claim');
99
+ // → null
100
+ ```
101
+
102
+ ### `strip(body, type)`
103
+
104
+ Removes the first marker of the given type and everything after it, trimming trailing whitespace.
105
+
106
+ **Parameters:**
107
+
108
+ | Name | Type | Required | Description |
109
+ |------|------|----------|-------------|
110
+ | `body` | `string \| null \| undefined` | yes | GitHub comment/PR/discussion body. |
111
+ | `type` | `string` | yes | Marker type to strip from. |
112
+
113
+ **Returns:** `string | null | undefined` — body with the marker and all trailing content removed. Returns the input unchanged if the marker is not found or the input is null/empty.
114
+
115
+ This is designed for signature stripping — removing the agent signature block that appears at the end of every stamped comment:
116
+
117
+ ```js
118
+ const body = 'Review feedback here\n<!-- loreli:signature -->\n***\n| Agent | Model | ...';
119
+ strip(body, 'signature');
120
+ // → 'Review feedback here'
121
+ ```
122
+
123
+ ### `stripLast(body, type)`
124
+
125
+ Removes the **last** marker of the given type and everything after it, trimming trailing whitespace. Use when multiple markers exist and only the trailing one should be removed (e.g. duplicate signatures at end of body).
126
+
127
+ **Parameters:**
128
+
129
+ | Name | Type | Required | Description |
130
+ |------|------|----------|-------------|
131
+ | `body` | `string \| null \| undefined` | yes | GitHub comment/PR/discussion body. |
132
+ | `type` | `string` | yes | Marker type to strip from. |
133
+
134
+ **Returns:** `string | null | undefined` — body with the last marker and all trailing content removed. Returns the input unchanged if the marker is not found or the input is null/empty.
135
+
136
+ ```js
137
+ const body = '## Content\n<!-- loreli:signature -->\n***\n...\n<!-- loreli:signature -->\n***\n...';
138
+ stripLast(body, 'signature');
139
+ // → '## Content\n<!-- loreli:signature -->\n***\n...' (one signature remains)
140
+ ```
141
+
142
+ ### `excise(body, type)`
143
+
144
+ Removes content between paired markers of the given type. Finds the opening `<!-- loreli:TYPE ... -->` and closing `<!-- loreli:TYPE-end -->`, removes both markers and everything between them, and collapses extra blank lines at the join point.
145
+
146
+ Unlike `strip()`, which removes a marker and everything after it, `excise()` removes only the delimited section and preserves content on both sides. Designed for trace blocks and other self-contained sections embedded within larger bodies.
147
+
148
+ **Parameters:**
149
+
150
+ | Name | Type | Required | Description |
151
+ |------|------|----------|-------------|
152
+ | `body` | `string \| null \| undefined` | yes | GitHub comment/PR/discussion body. |
153
+ | `type` | `string` | yes | Marker type to excise (matches TYPE and TYPE-end). |
154
+
155
+ **Returns:** `string | null | undefined` — body with the marker-delimited section removed. Returns input unchanged if start or end marker is missing.
156
+
157
+ Use `excise` to remove trace blocks from PR bodies before passing content to reviewer prompts or returning it through the `read` MCP tool:
158
+
159
+ ```js
160
+ const body = 'Closes #42\n\n<!-- loreli:trace agent="bee-0" -->\n<details>...</details>\n<!-- loreli:trace-end -->\n\nMore content';
161
+ excise(body, 'trace');
162
+ // → 'Closes #42\nMore content'
163
+ ```
164
+
165
+ When the end marker is missing, the body is returned unchanged to avoid accidental content loss:
166
+
167
+ ```js
168
+ excise('text<!-- loreli:trace -->orphaned', 'trace');
169
+ // → 'text<!-- loreli:trace -->orphaned'
170
+ ```
171
+
172
+ ## Error Behavior
173
+
174
+ | Scenario | Function | Behavior |
175
+ |----------|----------|----------|
176
+ | Falsy/empty `type` | `mark()` | Throws `Error: type is required` |
177
+ | Null/undefined/empty `body` | `has()` | Returns `false` |
178
+ | Null/undefined/empty `body` | `parse()` | Returns `null` |
179
+ | Null/undefined `body` | `strip()` | Returns input unchanged |
180
+ | Null/undefined `body` | `excise()` | Returns input unchanged |
181
+ | Marker not found | `has()` | Returns `false` |
182
+ | Marker not found | `parse()` | Returns `null` |
183
+ | Marker not found | `strip()` | Returns body unchanged |
184
+ | Start marker not found | `excise()` | Returns body unchanged |
185
+ | End marker not found | `excise()` | Returns body unchanged |
186
+
187
+ ## Marker Types in Use
188
+
189
+ | Type | Emitted By | Consumed By | Purpose |
190
+ |------|-----------|-------------|---------|
191
+ | `claim` | action `claim()` | action `claimant()` | Issue claim detection |
192
+ | `signoff` | review `signoff()` | review tests | Approval detection |
193
+ | `release` | action `reassign()` | — | Release tracking (consistency) |
194
+ | `gate` | review `gate()` | — | HITL state tracking (consistency) |
195
+ | `agent` | hub `_stamp()` | gate tool | Agent comment filtering |
196
+ | `signature` | `Identity.signature()` | `Identity.strip()` | Signature block detection/removal |
197
+ | `review-event` | hub `_stamp()` | hub review patching | Review event type embedding |
198
+ | `parent` | planner `link()` | planner `link()` | Parent tracking issue detection for sub-issue linking |
199
+ | `trace` | agent trace `format()` | `excise(body, 'trace')` | Agent reasoning/output block (paired with `trace-end`) |
200
+ | `trace-end` | agent trace `format()` | `excise(body, 'trace')` | Closing marker for trace block |
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Loreli marker system — machine-readable HTML comment metadata for GitHub content.
3
+ *
4
+ * Marker format: `<!-- loreli:TYPE key="value" key="value" -->`
5
+ *
6
+ * Decouples workflow-state detection from human-visible text so themed
7
+ * messages can change freely without breaking parsing logic.
8
+ *
9
+ * @module loreli/marker
10
+ */
11
+
12
+ const PREFIX = 'loreli';
13
+
14
+ /**
15
+ * HTML-entity-encode a marker value so `"`, `&`, `<`, and `>` are safe
16
+ * inside the `key="value"` format within an HTML comment.
17
+ *
18
+ * @param {string} value - Raw value string.
19
+ * @returns {string} Encoded value (no literal `"`, `&`, `<`, or `>`).
20
+ */
21
+ function encode(value) {
22
+ return value
23
+ .replaceAll('&', '&amp;')
24
+ .replaceAll('"', '&quot;')
25
+ .replaceAll('<', '&lt;')
26
+ .replaceAll('>', '&gt;');
27
+ }
28
+
29
+ /**
30
+ * Decode HTML entities produced by {@link encode} back to raw characters.
31
+ *
32
+ * @param {string} value - Encoded value string.
33
+ * @returns {string} Original value with entities resolved.
34
+ */
35
+ function decode(value) {
36
+ return value
37
+ .replaceAll('&quot;', '"')
38
+ .replaceAll('&lt;', '<')
39
+ .replaceAll('&gt;', '>')
40
+ .replaceAll('&amp;', '&');
41
+ }
42
+
43
+ /**
44
+ * Matches a loreli marker with a specific type.
45
+ * Capture group 1: the key-value payload (may be empty).
46
+ *
47
+ * @param {string} type - Marker type to target.
48
+ * @returns {RegExp}
49
+ */
50
+ function regex(type) {
51
+ return new RegExp(`<!-- ${PREFIX}:${type}((?:\\s+\\w+="[^"]*")*) -->`);
52
+ }
53
+
54
+ /**
55
+ * Parses `key="value"` pairs from the raw payload string captured by {@link regex}.
56
+ * Values are HTML-entity-decoded so callers receive the original strings.
57
+ *
58
+ * @param {string} raw - Space-separated key="value" string.
59
+ * @returns {Object<string, string>}
60
+ */
61
+ function pairs(raw) {
62
+ const data = {};
63
+ const re = /(\w+)="([^"]*)"/g;
64
+ let m;
65
+ while ((m = re.exec(raw)) !== null) data[m[1]] = decode(m[2]);
66
+ return data;
67
+ }
68
+
69
+ /**
70
+ * Produces a loreli marker string. Values are HTML-entity-encoded so
71
+ * special characters (`"`, `&`, `<`, `>`) do not break the marker format
72
+ * or prematurely close the HTML comment.
73
+ *
74
+ * @param {string} type - Marker type (e.g. 'claim', 'signature', 'agent').
75
+ * @param {Object<string, string>} [data] - Key-value pairs to embed.
76
+ * @returns {string} An HTML comment marker.
77
+ * @throws {Error} If type is falsy or empty.
78
+ */
79
+ export function mark(type, data) {
80
+ if (!type) throw new Error('type is required');
81
+
82
+ const entries = Object.entries(data ?? {});
83
+ const payload = entries.length
84
+ ? ' ' + entries.map(function pair([k, v]) { return `${k}="${encode(v)}"`; }).join(' ')
85
+ : '';
86
+
87
+ return `<!-- ${PREFIX}:${type}${payload} -->`;
88
+ }
89
+
90
+ /**
91
+ * Checks whether a body contains a marker of the given type.
92
+ *
93
+ * @param {string | null | undefined} body - GitHub comment/PR/discussion body.
94
+ * @param {string} type - Marker type to look for.
95
+ * @returns {boolean}
96
+ */
97
+ export function has(body, type) {
98
+ if (!body) return false;
99
+ return regex(type).test(body);
100
+ }
101
+
102
+ /**
103
+ * Extracts the key-value data from the first marker of the given type.
104
+ *
105
+ * @param {string | null | undefined} body - GitHub comment/PR/discussion body.
106
+ * @param {string} type - Marker type to extract.
107
+ * @returns {Object<string, string> | null} Parsed data, or null if not found.
108
+ */
109
+ export function parse(body, type) {
110
+ if (!body) return null;
111
+ const m = body.match(regex(type));
112
+ if (!m) return null;
113
+ return pairs(m[1]);
114
+ }
115
+
116
+ /**
117
+ * Removes the first marker of the given type and everything after it,
118
+ * trimming trailing whitespace from the remaining content.
119
+ *
120
+ * @param {string | null | undefined} body - GitHub comment/PR/discussion body.
121
+ * @param {string} type - Marker type to strip from.
122
+ * @returns {string | null | undefined} Body with marker and trailing content removed.
123
+ */
124
+ export function strip(body, type) {
125
+ if (body == null || body === '') return body;
126
+ const idx = body.search(regex(type));
127
+ if (idx === -1) return body;
128
+ return body.slice(0, idx).trimEnd();
129
+ }
130
+
131
+ /**
132
+ * Removes the last marker of the given type and everything after it,
133
+ * trimming trailing whitespace from the remaining content.
134
+ *
135
+ * Use when multiple markers exist and only the trailing one should be
136
+ * removed (e.g. duplicate signatures at end of body).
137
+ *
138
+ * @param {string | null | undefined} body - GitHub comment/PR/discussion body.
139
+ * @param {string} type - Marker type to strip from.
140
+ * @returns {string | null | undefined} Body with last marker and trailing content removed.
141
+ */
142
+ export function stripLast(body, type) {
143
+ if (body == null || body === '') return body;
144
+ const re = new RegExp(regex(type).source, 'g');
145
+ let lastIdx = -1;
146
+ let m;
147
+ while ((m = re.exec(body)) !== null) lastIdx = m.index;
148
+ if (lastIdx === -1) return body;
149
+ return body.slice(0, lastIdx).trimEnd();
150
+ }
151
+
152
+ /**
153
+ * Removes content between paired markers of the given type.
154
+ *
155
+ * Finds the opening `<!-- loreli:TYPE ... -->` and the closing
156
+ * `<!-- loreli:TYPE-end -->`, removes both markers and everything
157
+ * between them, and collapses extra blank lines at the join point.
158
+ *
159
+ * Unlike {@link strip}, which removes a marker and everything after it,
160
+ * `excise` removes only the delimited section and preserves content on
161
+ * both sides. Designed for trace blocks and other self-contained sections
162
+ * embedded within larger bodies.
163
+ *
164
+ * @param {string | null | undefined} body - GitHub comment/PR/discussion body.
165
+ * @param {string} type - Marker type to excise (matches TYPE and TYPE-end).
166
+ * @returns {string | null | undefined} Body with the marker-delimited section removed.
167
+ */
168
+ export function excise(body, type) {
169
+ if (body == null || body === '') return body;
170
+ const startIdx = body.search(regex(type));
171
+ if (startIdx === -1) return body;
172
+
173
+ const endMarker = new RegExp(`<!-- ${PREFIX}:${type}-end -->`);
174
+ const after = body.slice(startIdx);
175
+ const endMatch = endMarker.exec(after);
176
+ if (!endMatch) return body;
177
+
178
+ const endIdx = startIdx + endMatch.index + endMatch[0].length;
179
+ const before = body.slice(0, startIdx);
180
+ const rest = body.slice(endIdx);
181
+
182
+ // Collapse extra blank lines at the join point
183
+ return (before.replace(/\n+$/, '') + rest.replace(/^\n+/, '\n')).trim();
184
+ }