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.
- package/bin/cli.js +58 -0
- package/lib/catalog.js +134 -0
- package/lib/components.js +144 -0
- package/lib/index.js +51 -0
- package/lib/markdown.js +377 -0
- package/lib/open.js +12 -0
- package/lib/parsers/activity.js +138 -0
- package/lib/parsers/handoff.js +159 -0
- package/lib/parsers/quest.js +134 -0
- package/lib/registry.js +157 -0
- package/lib/render.js +37 -0
- package/lib/shell.js +341 -0
- package/lib/templates/activity.js +285 -0
- package/lib/templates/handoff.js +225 -0
- package/lib/templates/quest.js +112 -0
- package/lib/tokens.js +61 -0
- package/package.json +38 -0
package/lib/markdown.js
ADDED
|
@@ -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
|
+
}
|