egregore-artifacts 0.1.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.
@@ -0,0 +1,377 @@
1
+ // Lightweight markdown → React elements converter
2
+ // Handles: **bold**, *italic*, `code`, ### headings, [links](url), tables, lists
3
+ import React from 'react';
4
+ import { colors, fonts } from './tokens.js';
5
+
6
+ const h = React.createElement;
7
+
8
+ export function renderMarkdown(text) {
9
+ if (!text) return null;
10
+
11
+ const lines = text.split('\n');
12
+ const elements = [];
13
+ let i = 0;
14
+
15
+ while (i < lines.length) {
16
+ const line = lines[i];
17
+
18
+ // Table detection — starts with |
19
+ if (line.trim().startsWith('|') && line.includes('|', 1)) {
20
+ const tableLines = [];
21
+ while (i < lines.length && lines[i].trim().startsWith('|')) {
22
+ tableLines.push(lines[i]);
23
+ i++;
24
+ }
25
+ elements.push(renderTable(tableLines, elements.length));
26
+ continue;
27
+ }
28
+
29
+ // H3 heading
30
+ if (line.startsWith('### ')) {
31
+ elements.push(h('h3', {
32
+ key: elements.length,
33
+ style: {
34
+ fontFamily: fonts.serif,
35
+ fontSize: '18px',
36
+ fontWeight: 600,
37
+ lineHeight: 1.3,
38
+ margin: '1.5rem 0 0.5rem',
39
+ color: colors.black,
40
+ },
41
+ }, inlineMarkdown(line.slice(4))));
42
+ i++;
43
+ continue;
44
+ }
45
+
46
+ // H4 heading
47
+ if (line.startsWith('#### ')) {
48
+ elements.push(h('h4', {
49
+ key: elements.length,
50
+ style: {
51
+ fontFamily: fonts.sans,
52
+ fontSize: '15px',
53
+ fontWeight: 600,
54
+ margin: '1rem 0 0.25rem',
55
+ color: colors.black,
56
+ },
57
+ }, inlineMarkdown(line.slice(5))));
58
+ i++;
59
+ continue;
60
+ }
61
+
62
+ // Unordered list item
63
+ if (line.match(/^[-*] /)) {
64
+ const listItems = [];
65
+ while (i < lines.length && lines[i].match(/^[-*] /)) {
66
+ listItems.push(lines[i].replace(/^[-*] /, ''));
67
+ i++;
68
+ }
69
+ elements.push(h('ul', {
70
+ key: elements.length,
71
+ style: { listStyle: 'none', padding: 0, margin: '0.5rem 0' },
72
+ },
73
+ ...listItems.map((item, j) =>
74
+ h('li', {
75
+ key: j,
76
+ style: {
77
+ position: 'relative',
78
+ paddingLeft: '1.25rem',
79
+ marginBottom: '0.35rem',
80
+ fontSize: '15px',
81
+ lineHeight: 1.55,
82
+ color: colors.dark,
83
+ },
84
+ },
85
+ h('span', {
86
+ style: {
87
+ position: 'absolute',
88
+ left: 0,
89
+ top: '9px',
90
+ width: '5px',
91
+ height: '5px',
92
+ borderRadius: '50%',
93
+ background: colors.terracotta,
94
+ },
95
+ }),
96
+ inlineMarkdown(item),
97
+ )
98
+ ),
99
+ ));
100
+ continue;
101
+ }
102
+
103
+ // Ordered list item
104
+ if (line.match(/^\d+\.\s/)) {
105
+ const listItems = [];
106
+ while (i < lines.length && lines[i].match(/^\d+\.\s/)) {
107
+ listItems.push(lines[i].replace(/^\d+\.\s*/, ''));
108
+ i++;
109
+ }
110
+ elements.push(h('ol', {
111
+ key: elements.length,
112
+ style: { listStyle: 'none', padding: 0, margin: '0.5rem 0', counterReset: 'step' },
113
+ },
114
+ ...listItems.map((item, j) =>
115
+ h('li', {
116
+ key: j,
117
+ style: {
118
+ display: 'flex',
119
+ gap: '8px',
120
+ marginBottom: '0.35rem',
121
+ fontSize: '15px',
122
+ lineHeight: 1.55,
123
+ },
124
+ },
125
+ h('span', {
126
+ style: {
127
+ flexShrink: 0,
128
+ width: '20px',
129
+ height: '20px',
130
+ borderRadius: '50%',
131
+ background: colors.terracotta,
132
+ color: colors.cream,
133
+ display: 'flex',
134
+ alignItems: 'center',
135
+ justifyContent: 'center',
136
+ fontSize: '11px',
137
+ fontFamily: fonts.mono,
138
+ fontWeight: 600,
139
+ marginTop: '2px',
140
+ },
141
+ }, String(j + 1)),
142
+ h('span', null, inlineMarkdown(item)),
143
+ )
144
+ ),
145
+ ));
146
+ continue;
147
+ }
148
+
149
+ // Code block
150
+ if (line.startsWith('```')) {
151
+ const codeLines = [];
152
+ i++;
153
+ while (i < lines.length && !lines[i].startsWith('```')) {
154
+ codeLines.push(lines[i]);
155
+ i++;
156
+ }
157
+ i++; // skip closing ```
158
+ elements.push(h('pre', {
159
+ key: elements.length,
160
+ style: {
161
+ background: colors.terminalBg,
162
+ color: 'rgba(255, 255, 255, 0.85)',
163
+ fontFamily: fonts.mono,
164
+ fontSize: '13px',
165
+ lineHeight: 1.6,
166
+ padding: '1rem',
167
+ borderRadius: '8px',
168
+ overflowX: 'auto',
169
+ margin: '0.75rem 0',
170
+ },
171
+ }, h('code', null, codeLines.join('\n'))));
172
+ continue;
173
+ }
174
+
175
+ // Empty line — spacer
176
+ if (line.trim() === '') {
177
+ i++;
178
+ continue;
179
+ }
180
+
181
+ // Paragraph — collect consecutive non-special lines
182
+ const paraLines = [];
183
+ while (i < lines.length && lines[i].trim() !== '' &&
184
+ !lines[i].startsWith('#') && !lines[i].startsWith('|') &&
185
+ !lines[i].startsWith('```') && !lines[i].match(/^[-*] /) &&
186
+ !lines[i].match(/^\d+\.\s/)) {
187
+ paraLines.push(lines[i]);
188
+ i++;
189
+ }
190
+ if (paraLines.length > 0) {
191
+ elements.push(h('p', {
192
+ key: elements.length,
193
+ style: { margin: '0.5rem 0', fontSize: '15px', lineHeight: 1.6, color: colors.dark },
194
+ }, inlineMarkdown(paraLines.join(' '))));
195
+ }
196
+ }
197
+
198
+ return h('div', null, ...elements);
199
+ }
200
+
201
+ // Inline markdown: **bold**, *italic*, `code`, [link](url)
202
+ // Finds whichever pattern appears earliest in the string
203
+ function inlineMarkdown(text) {
204
+ if (!text) return text;
205
+
206
+ const parts = [];
207
+ let remaining = text;
208
+ let key = 0;
209
+
210
+ const patterns = [
211
+ { re: /`([^`]+)`/, render: (m) => h('code', {
212
+ key: key++,
213
+ style: { fontFamily: fonts.mono, fontSize: '0.88em', background: 'rgba(59, 45, 33, 0.06)', padding: '2px 5px', borderRadius: '3px' },
214
+ }, m[1]) },
215
+ { re: /\*\*(.+?)\*\*/, render: (m) => h('strong', { key: key++, style: { fontWeight: 600 } }, m[1]) },
216
+ { re: /\[([^\]]+)\]\(([^)]+)\)/, render: (m) => h('a', {
217
+ key: key++, href: m[2], style: { color: colors.terracotta, textDecoration: 'underline' },
218
+ }, m[1]) },
219
+ ];
220
+
221
+ let iterations = 0;
222
+ while (remaining.length > 0 && iterations++ < 500) {
223
+ // Find the earliest matching pattern
224
+ let earliest = null;
225
+ let earliestIdx = Infinity;
226
+
227
+ for (const pat of patterns) {
228
+ const m = remaining.match(pat.re);
229
+ if (m && m.index < earliestIdx) {
230
+ earliest = { match: m, pattern: pat };
231
+ earliestIdx = m.index;
232
+ }
233
+ }
234
+
235
+ if (!earliest) {
236
+ parts.push(h(React.Fragment, { key: key++ }, remaining));
237
+ break;
238
+ }
239
+
240
+ // Push text before the match
241
+ if (earliestIdx > 0) {
242
+ parts.push(h(React.Fragment, { key: key++ }, remaining.slice(0, earliestIdx)));
243
+ }
244
+
245
+ // Render the matched pattern
246
+ parts.push(earliest.pattern.render(earliest.match));
247
+
248
+ // Advance past the match
249
+ remaining = remaining.slice(earliestIdx + earliest.match[0].length);
250
+ }
251
+
252
+ return parts.length === 1 ? parts[0] : parts;
253
+ }
254
+
255
+ // Simple text (no nesting, just returns a fragment)
256
+ function inlineMarkdownSimple(text, key) {
257
+ return h(React.Fragment, { key }, text);
258
+ }
259
+
260
+ // Render markdown table
261
+ function renderTable(lines, key) {
262
+ // Parse header
263
+ const headerCells = parseTableRow(lines[0]);
264
+ // Skip separator row (|---|---|)
265
+ const startRow = lines[1]?.match(/^\|[\s-:|]+\|$/) ? 2 : 1;
266
+ const bodyRows = lines.slice(startRow).map(parseTableRow);
267
+
268
+ return h('div', {
269
+ key,
270
+ style: { overflowX: 'auto', margin: '0.75rem 0' },
271
+ },
272
+ h('table', {
273
+ style: {
274
+ width: '100%',
275
+ borderCollapse: 'collapse',
276
+ fontSize: '14px',
277
+ lineHeight: 1.5,
278
+ },
279
+ },
280
+ h('thead', null,
281
+ h('tr', null,
282
+ ...headerCells.map((cell, j) =>
283
+ h('th', {
284
+ key: j,
285
+ style: {
286
+ textAlign: 'left',
287
+ padding: '8px 12px',
288
+ borderBottom: `2px solid ${colors.border}`,
289
+ fontFamily: fonts.mono,
290
+ fontSize: '12px',
291
+ fontWeight: 500,
292
+ color: colors.muted,
293
+ textTransform: 'uppercase',
294
+ letterSpacing: '0.04em',
295
+ whiteSpace: 'nowrap',
296
+ },
297
+ }, cell.trim())
298
+ ),
299
+ ),
300
+ ),
301
+ h('tbody', null,
302
+ ...bodyRows.map((cells, i) =>
303
+ h('tr', { key: i },
304
+ ...cells.map((cell, j) =>
305
+ h('td', {
306
+ key: j,
307
+ style: {
308
+ padding: '6px 12px',
309
+ borderBottom: `1px solid rgba(224, 216, 204, 0.5)`,
310
+ color: colors.dark,
311
+ },
312
+ }, cell.trim())
313
+ ),
314
+ )
315
+ ),
316
+ ),
317
+ ),
318
+ );
319
+ }
320
+
321
+ function parseTableRow(line) {
322
+ return line.replace(/^\|/, '').replace(/\|$/, '').split('|');
323
+ }
324
+
325
+ // Lightweight markdown renderer — tables + paragraphs only, no inline regex
326
+ // Use for large attachments where full inlineMarkdown is too expensive
327
+ export function renderMarkdownLite(text) {
328
+ if (!text) return null;
329
+
330
+ const lines = text.split('\n');
331
+ const elements = [];
332
+ let i = 0;
333
+
334
+ while (i < lines.length) {
335
+ const line = lines[i];
336
+
337
+ // Table
338
+ if (line.trim().startsWith('|') && line.includes('|', 1)) {
339
+ const tableLines = [];
340
+ while (i < lines.length && lines[i].trim().startsWith('|')) {
341
+ tableLines.push(lines[i]);
342
+ i++;
343
+ }
344
+ elements.push(renderTable(tableLines, elements.length));
345
+ continue;
346
+ }
347
+
348
+ // H2/H3 heading
349
+ if (line.startsWith('## ')) {
350
+ elements.push(h('h3', { key: elements.length, style: { fontFamily: fonts.serif, fontSize: '18px', fontWeight: 600, margin: '1.5rem 0 0.5rem', color: colors.black } }, line.slice(3)));
351
+ i++;
352
+ continue;
353
+ }
354
+
355
+ // Bullet
356
+ if (line.match(/^[-*] /)) {
357
+ const items = [];
358
+ while (i < lines.length && lines[i].match(/^[-*] /)) {
359
+ items.push(lines[i].replace(/^[-*] /, ''));
360
+ i++;
361
+ }
362
+ elements.push(h('ul', { key: elements.length, style: { listStyle: 'disc', paddingLeft: '1.5rem', margin: '0.5rem 0' } },
363
+ ...items.map((item, j) => h('li', { key: j, style: { fontSize: '14px', lineHeight: 1.5, marginBottom: '0.25rem' } }, item)),
364
+ ));
365
+ continue;
366
+ }
367
+
368
+ // Empty line
369
+ if (line.trim() === '') { i++; continue; }
370
+
371
+ // Paragraph
372
+ elements.push(h('p', { key: elements.length, style: { margin: '0.5rem 0', fontSize: '14px', lineHeight: 1.6, color: colors.dark } }, line));
373
+ i++;
374
+ }
375
+
376
+ return h('div', null, ...elements);
377
+ }
package/lib/open.js ADDED
@@ -0,0 +1,12 @@
1
+ // Cross-platform browser opening — adapted from packages/create-egregore/lib/auth.js
2
+ import { execFile } from 'node:child_process';
3
+
4
+ export function openInBrowser(url) {
5
+ if (process.platform === 'darwin') {
6
+ execFile('open', [url], () => {});
7
+ } else if (process.platform === 'win32') {
8
+ execFile('cmd', ['/c', 'start', '', url], () => {});
9
+ } else {
10
+ execFile('xdg-open', [url], () => {});
11
+ }
12
+ }
@@ -0,0 +1,138 @@
1
+ // Parse activity data (JSON from bin/activity-data.sh) into structured data
2
+ import fs from 'node:fs';
3
+ import { execSync } from 'node:child_process';
4
+
5
+ export function parseActivity(input) {
6
+ let data;
7
+
8
+ if (typeof input === 'string' && fs.existsSync(input)) {
9
+ data = JSON.parse(fs.readFileSync(input, 'utf-8'));
10
+ } else if (typeof input === 'string' && input.startsWith('{')) {
11
+ data = JSON.parse(input);
12
+ } else if (typeof input === 'object') {
13
+ data = input;
14
+ } else {
15
+ const raw = execSync('bash bin/activity-data.sh 2>/dev/null', { encoding: 'utf-8' });
16
+ data = JSON.parse(raw);
17
+ }
18
+
19
+ // data.date may be pre-formatted ("Apr 03") — normalize to YYYY-MM-DD
20
+ const today = new Date().toISOString().split('T')[0];
21
+
22
+ return {
23
+ title: `Activity Report — ${data.org || 'Egregore'}`,
24
+ date: today,
25
+ me: data.me,
26
+ org: data.org,
27
+
28
+ // Group my sessions: collapse untitled runs, use relative dates
29
+ mySessions: groupSessions(data.my_sessions || [], today),
30
+
31
+ teamSessions: (data.team_sessions || [])
32
+ .filter(s => s.topic) // hide unnamed sessions
33
+ .slice(0, 15)
34
+ .map(s => ({
35
+ date: relativeDate(s.date, today),
36
+ topic: s.topic,
37
+ by: s.by,
38
+ })),
39
+
40
+ quests: (data.quests || []).map(q => ({
41
+ name: q.title || q.quest || q.name || q.id,
42
+ slug: q.quest || q.slug,
43
+ status: q.status || 'active',
44
+ artifacts: q.artifacts || q.artifactCount || 0,
45
+ daysSince: q.daysSince || 0,
46
+ score: q.score || 0,
47
+ })),
48
+
49
+ handoffsToMe: (data.handoffs_to_me || []).map(h => ({
50
+ date: relativeDate(h.date, today),
51
+ topic: h.topic,
52
+ from: h.author || h.from || h.by,
53
+ status: h.status,
54
+ summary: h.summary,
55
+ })),
56
+
57
+ pendingQuestions: (data.pending_questions || []).map(q => ({
58
+ topic: q.topic,
59
+ from: q.from,
60
+ date: relativeDate(q.created?.split('T')[0], today),
61
+ })),
62
+
63
+ prs: (data.prs || []).map(p => ({
64
+ number: p.number,
65
+ title: p.title,
66
+ repo: p.repo,
67
+ state: p.state,
68
+ author: p.author,
69
+ })),
70
+
71
+ todos: data.todos_merged || {},
72
+
73
+ trends: data.trends || null,
74
+
75
+ knowledgeGap: data.knowledge_gap || null,
76
+ orphans: data.orphans || null,
77
+ graphStatus: data.graph_status,
78
+ };
79
+ }
80
+
81
+ function relativeDate(dateStr, today) {
82
+ if (!dateStr) return '';
83
+ if (dateStr === today) return 'Today';
84
+
85
+ const d = new Date(dateStr + 'T00:00:00');
86
+ const t = new Date(today + 'T00:00:00');
87
+ const diffDays = Math.round((t - d) / (1000 * 60 * 60 * 24));
88
+
89
+ if (diffDays === 1) return 'Yesterday';
90
+ if (diffDays < 7) return `${diffDays}d ago`;
91
+ if (diffDays < 30) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
92
+ return dateStr;
93
+ }
94
+
95
+ function groupSessions(sessions, today) {
96
+ const grouped = [];
97
+ const seen = new Set(); // deduplicate by topic+date
98
+ let i = 0;
99
+
100
+ while (i < sessions.length) {
101
+ const s = sessions[i];
102
+ const date = relativeDate(s.date, today);
103
+
104
+ if (s.topic) {
105
+ const key = `${s.date}:${s.topic}`;
106
+ if (seen.has(key)) {
107
+ // Same session handed to multiple people — merge handedTo
108
+ const existing = grouped.find(g => g._key === key);
109
+ if (existing && s.handedTo) {
110
+ existing.handedTo = existing.handedTo
111
+ ? `${existing.handedTo}, ${s.handedTo}`
112
+ : s.handedTo;
113
+ }
114
+ i++;
115
+ } else {
116
+ seen.add(key);
117
+ grouped.push({ date, topic: s.topic, handedTo: s.handedTo, _key: key });
118
+ i++;
119
+ }
120
+ } else {
121
+ // Untitled — count consecutive same-date untitled sessions
122
+ let count = 0;
123
+ const sameDate = s.date;
124
+ while (i < sessions.length && !sessions[i].topic && sessions[i].date === sameDate) {
125
+ count++;
126
+ i++;
127
+ }
128
+ if (count === 1) {
129
+ grouped.push({ date, topic: '(session)', handedTo: null });
130
+ } else {
131
+ grouped.push({ date, topic: `(${count} sessions)`, handedTo: null });
132
+ }
133
+ }
134
+ }
135
+
136
+ // Clean up internal keys
137
+ return grouped.map(({ _key, ...rest }) => rest);
138
+ }
@@ -0,0 +1,159 @@
1
+ // Parse handoff markdown into structured data
2
+ // Handoff format: # title, **Key**: Value metadata, ## sections
3
+ // Detects referenced memory files and inlines their content
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+
8
+ export function parseHandoff(input) {
9
+ let content;
10
+ let inputDir = null;
11
+
12
+ if (typeof input === 'string' && (input.endsWith('.md') || input.includes('/'))) {
13
+ if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
14
+ content = fs.readFileSync(input, 'utf-8');
15
+ inputDir = path.dirname(input);
16
+ } else if (typeof input === 'string') {
17
+ content = input;
18
+ } else {
19
+ throw new Error('parseHandoff expects a file path or markdown string');
20
+ }
21
+
22
+ const title = extractTitle(content);
23
+ const meta = extractMeta(content);
24
+ const sections = extractSections(content);
25
+
26
+ // Detect referenced files (memory/*.md paths in backticks) and inline them
27
+ const attachments = extractReferencedFiles(content, inputDir);
28
+
29
+ return {
30
+ title,
31
+ ...meta,
32
+ summary: sections.Summary || sections.Briefing || null,
33
+ currentState: sections['Current State'] || null,
34
+ decisions: extractBullets(sections.Decisions || sections['Key Decisions'] || ''),
35
+ openThreads: extractThreads(sections['Open Threads'] || sections['Open Questions'] || ''),
36
+ nextSteps: extractOrderedList(sections['Next Steps'] || ''),
37
+ entryPoints: extractBullets(sections['Entry Points'] || ''),
38
+ context: sections.Context || null,
39
+ attachments,
40
+ // Catch-all for non-standard sections
41
+ extraSections: Object.entries(sections)
42
+ .filter(([k]) => !['Summary', 'Briefing', 'Current State', 'Decisions',
43
+ 'Key Decisions', 'Open Threads', 'Open Questions', 'Next Steps',
44
+ 'Entry Points', 'Context'].includes(k))
45
+ .filter(([, v]) => v && v.trim())
46
+ .map(([heading, body]) => ({ heading, body })),
47
+ };
48
+ }
49
+
50
+ function extractReferencedFiles(content, inputDir) {
51
+ const attachments = [];
52
+ // Find backtick-wrapped paths that look like memory files
53
+ const refs = content.matchAll(/`(memory\/[^`]+\.md)`/g);
54
+
55
+ // Resolve git root for file lookup
56
+ let gitRoot = null;
57
+ try {
58
+ gitRoot = execSync('git rev-parse --show-toplevel 2>/dev/null', { encoding: 'utf-8' }).trim();
59
+ } catch {}
60
+
61
+ const seen = new Set();
62
+ for (const ref of refs) {
63
+ const refPath = ref[1];
64
+ if (seen.has(refPath)) continue; // deduplicate
65
+ seen.add(refPath);
66
+
67
+ // Try to find the file
68
+ const candidates = [
69
+ inputDir && path.resolve(inputDir, '..', '..', refPath), // from handoffs/2026-XX/ up to repo root
70
+ gitRoot && path.join(gitRoot, refPath),
71
+ refPath,
72
+ ].filter(Boolean);
73
+
74
+ for (const candidate of candidates) {
75
+ if (fs.existsSync(candidate)) {
76
+ const fileContent = fs.readFileSync(candidate, 'utf-8');
77
+ const fileTitle = extractTitle(fileContent) || path.basename(refPath, '.md');
78
+ attachments.push({ title: fileTitle, path: refPath, content: fileContent });
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ return attachments;
85
+ }
86
+
87
+ function extractTitle(content) {
88
+ const match = content.match(/^# (?:Handoff:\s*)?(.+)/m);
89
+ return match ? match[1].trim() : 'Untitled Handoff';
90
+ }
91
+
92
+ function extractMeta(content) {
93
+ const result = {};
94
+ const metaPatterns = [
95
+ [/\*\*Date\*\*:\s*(.+)/i, 'date'],
96
+ [/\*\*Author\*\*:\s*(.+)/i, 'author'],
97
+ [/\*\*To\*\*:\s*(.+)/i, 'to'],
98
+ [/\*\*Project\*\*:\s*(.+)/i, 'project'],
99
+ [/\*\*Source\*\*:\s*(.+)/i, 'source'],
100
+ [/\*\*Branch\*\*:\s*(.+)/i, 'branch'],
101
+ ];
102
+
103
+ for (const [pattern, key] of metaPatterns) {
104
+ const match = content.match(pattern);
105
+ if (match) result[key] = match[1].trim();
106
+ }
107
+
108
+ // Parse 'to' as array
109
+ if (result.to) {
110
+ result.to = result.to.split(/,\s*/).map(s => s.trim());
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ function extractSections(content) {
117
+ const sections = {};
118
+ // Split on ## headings, capturing the heading text
119
+ const parts = content.split(/^## /m).slice(1); // skip everything before first ##
120
+
121
+ for (const part of parts) {
122
+ const newlineIdx = part.indexOf('\n');
123
+ if (newlineIdx === -1) continue;
124
+ const heading = part.slice(0, newlineIdx).trim();
125
+ // Strip --- horizontal rules from body but keep all other content
126
+ const body = part.slice(newlineIdx + 1).replace(/^---\s*$/gm, '').trim();
127
+ if (heading && body) {
128
+ sections[heading] = body;
129
+ }
130
+ }
131
+
132
+ return sections;
133
+ }
134
+
135
+ function extractBullets(text) {
136
+ if (!text) return [];
137
+ return text.split('\n')
138
+ .filter(l => l.match(/^[-*] /))
139
+ .map(l => l.replace(/^[-*] /, '').trim())
140
+ .filter(Boolean);
141
+ }
142
+
143
+ function extractThreads(text) {
144
+ if (!text) return [];
145
+ return text.split('\n')
146
+ .filter(l => l.match(/^- \[[ x]\]/))
147
+ .map(l => ({
148
+ done: l.includes('[x]'),
149
+ text: l.replace(/^- \[[ x]\]\s*/, '').trim(),
150
+ }));
151
+ }
152
+
153
+ function extractOrderedList(text) {
154
+ if (!text) return [];
155
+ return text.split('\n')
156
+ .filter(l => l.match(/^\d+\.\s/))
157
+ .map(l => l.replace(/^\d+\.\s*/, '').trim())
158
+ .filter(Boolean);
159
+ }