egregore-artifacts 0.6.0 → 0.9.6

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 CHANGED
@@ -4,7 +4,7 @@ import { execSync } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- const KNOWN_TYPES = ['quest', 'handoff', 'activity', 'document', 'board', 'network'];
7
+ const KNOWN_TYPES = ['quest', 'handoff', 'handoff-v1', 'activity', 'document', 'board', 'network'];
8
8
  const args = process.argv.slice(2);
9
9
 
10
10
  let type, filePath;
@@ -26,17 +26,27 @@ if (args.length === 0) {
26
26
  // Parse flags
27
27
  let outputFile = null;
28
28
  let noOpen = false;
29
+ let orgSlug = null;
30
+ let viewBase = null;
29
31
  const positional = [];
30
32
  for (let i = 0; i < args.length; i++) {
31
33
  if (args[i] === '--output' || args[i] === '-o') {
32
34
  outputFile = args[++i];
33
35
  } else if (args[i] === '--no-open') {
34
36
  noOpen = true;
37
+ } else if (args[i] === '--org-slug') {
38
+ orgSlug = args[++i];
39
+ } else if (args[i] === '--view-base') {
40
+ viewBase = args[++i];
35
41
  } else {
36
42
  positional.push(args[i]);
37
43
  }
38
44
  }
39
45
 
46
+ // Default view base; slug is intentionally left null unless supplied so OSS
47
+ // mode (which cannot predict URLs) falls through to plain <code> rendering.
48
+ if (!viewBase) viewBase = 'https://egregore.xyz/view';
49
+
40
50
  // If first positional is a known type, use it. Otherwise treat it as a file path.
41
51
  if (KNOWN_TYPES.includes(positional[0])) {
42
52
  type = positional[0];
@@ -83,7 +93,7 @@ function resolveFile(fp) {
83
93
 
84
94
  try {
85
95
  const input = (type === 'activity' || type === 'board' || type === 'network') ? (filePath || 'live') : resolveFile(filePath);
86
- const html = await generateArtifact(type, input);
96
+ const html = await generateArtifact(type, input, { orgSlug, viewBase });
87
97
  const slug = filePath ? filePath.split('/').pop().replace('.md', '') : new Date().toISOString().split('T')[0];
88
98
 
89
99
  if (outputFile) {
@@ -0,0 +1,28 @@
1
+ // Deterministic artifact IDs for memory files.
2
+ //
3
+ // Emits `{prefix}-{12 hex}` where prefix is derived from the file extension
4
+ // (m = .md, h = .html) and the hex is the first 12 chars of sha256(canonical_path).
5
+ //
6
+ // Kept in parity with bin/lib/artifact-id.sh — both MUST produce identical
7
+ // output for the same input. Parity is enforced by test/artifact-id.test.js.
8
+ import { createHash } from 'node:crypto';
9
+
10
+ const EXT_PREFIX = { '.md': 'm', '.html': 'h' };
11
+
12
+ function canonicalize(path) {
13
+ return path.replace(/^\.\//, '').replace(/\/{2,}/g, '/');
14
+ }
15
+
16
+ function extension(path) {
17
+ const m = path.match(/\.[a-z0-9]+$/i);
18
+ return m ? m[0].toLowerCase() : '';
19
+ }
20
+
21
+ export function artifactIdFromPath(rawPath) {
22
+ if (!rawPath || typeof rawPath !== 'string') return null;
23
+ const canonical = canonicalize(rawPath);
24
+ const prefix = EXT_PREFIX[extension(canonical)];
25
+ if (!prefix) return null;
26
+ const hash = createHash('sha256').update(canonical).digest('hex').slice(0, 12);
27
+ return `${prefix}-${hash}`;
28
+ }
@@ -0,0 +1,248 @@
1
+ // Comment thread + composer — renders inline at the bottom of any
2
+ // `/view`-rendered artifact. Reads markdown files from
3
+ // `memory/comments/{kind}/{id}/*.md`. No graph access — filesystem only,
4
+ // so it works in both connected and local mode.
5
+ import React from 'react';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execSync } from 'node:child_process';
9
+ import { renderMarkdown } from './markdown.js';
10
+
11
+ const h = React.createElement;
12
+
13
+ // ── Path resolution ─────────────────────────────────────────────
14
+
15
+ function findGitRoot(startPath) {
16
+ try {
17
+ return execSync('git rev-parse --show-toplevel 2>/dev/null', {
18
+ encoding: 'utf-8',
19
+ cwd: startPath || process.cwd(),
20
+ }).trim();
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ // Derive (parentKind, parentId) for a rendered artifact.
27
+ // Mirrors the indexer's parent_kind enum: quest|handoff|card|decision|document.
28
+ export function derivedParent(type, filePath) {
29
+ if (!filePath || typeof filePath !== 'string') return null;
30
+
31
+ const base = path.basename(filePath, '.md');
32
+
33
+ if (type === 'quest') {
34
+ return { kind: 'quest', id: base };
35
+ }
36
+ if (type === 'handoff') {
37
+ // sessionId can be either YYYY-MM-DD-author-topic (flat) or DD-author-topic
38
+ // inside a YYYY-MM/ subdir. Mirror bin/index-handoff.sh's id derivation.
39
+ const dir = path.basename(path.dirname(filePath));
40
+ if (/^\d{4}-\d{2}$/.test(dir)) {
41
+ return { kind: 'handoff', id: `${dir}-${base}` };
42
+ }
43
+ return { kind: 'handoff', id: base };
44
+ }
45
+ if (type === 'document') {
46
+ if (filePath.includes('/knowledge/decisions/')) {
47
+ return { kind: 'decision', id: base };
48
+ }
49
+ return { kind: 'document', id: base };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // ── Loader ──────────────────────────────────────────────────────
55
+
56
+ export function loadComments(parent, gitRootHint) {
57
+ if (!parent || !parent.kind || !parent.id) return [];
58
+
59
+ const gitRoot = gitRootHint || findGitRoot();
60
+ if (!gitRoot) return [];
61
+
62
+ const dir = path.join(gitRoot, 'memory', 'comments', parent.kind, parent.id);
63
+ if (!fs.existsSync(dir)) return [];
64
+
65
+ const files = fs.readdirSync(dir)
66
+ .filter(f => f.endsWith('.md'))
67
+ .sort();
68
+
69
+ return files.map(f => parseCommentFile(path.join(dir, f))).filter(Boolean);
70
+ }
71
+
72
+ function parseCommentFile(filePath) {
73
+ const raw = fs.readFileSync(filePath, 'utf-8');
74
+ const id = path.basename(filePath, '.md');
75
+
76
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
77
+ if (!fmMatch) return null;
78
+
79
+ const fm = parseSimpleYaml(fmMatch[1]);
80
+ const body = fmMatch[2].trim();
81
+
82
+ return {
83
+ id,
84
+ parentKind: fm.parent_kind,
85
+ parentId: fm.parent_id,
86
+ author: fm.author,
87
+ to: fm.to || [],
88
+ replyTo: fm.reply_to || null,
89
+ createdAt: fm.created || null,
90
+ body,
91
+ };
92
+ }
93
+
94
+ function parseSimpleYaml(text) {
95
+ const result = {};
96
+ for (const line of text.split('\n')) {
97
+ const m = line.match(/^([\w_-]+):\s*(.*)$/);
98
+ if (!m) continue;
99
+ const [, key, rawVal] = m;
100
+ let val = rawVal.trim();
101
+ if (val.startsWith('[') && val.endsWith(']')) {
102
+ val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
103
+ } else if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
104
+ val = val.slice(1, -1);
105
+ } else if (val === 'null' || val === '') {
106
+ val = null;
107
+ }
108
+ result[key] = val;
109
+ }
110
+ return result;
111
+ }
112
+
113
+ // ── Threading ───────────────────────────────────────────────────
114
+
115
+ // Build a tree from a flat list using replyTo. Top-level items keep their order;
116
+ // each child renders inline under its parent. Orphaned replies (parent missing)
117
+ // are promoted to top-level so nothing disappears.
118
+ function buildThreads(comments) {
119
+ const byId = new Map(comments.map(c => [c.id, { ...c, children: [] }]));
120
+ const roots = [];
121
+ for (const c of byId.values()) {
122
+ if (c.replyTo && byId.has(c.replyTo)) {
123
+ byId.get(c.replyTo).children.push(c);
124
+ } else {
125
+ roots.push(c);
126
+ }
127
+ }
128
+ return roots;
129
+ }
130
+
131
+ function formatDate(iso) {
132
+ if (!iso) return '';
133
+ try {
134
+ return new Date(iso).toLocaleDateString('en-US', {
135
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
136
+ });
137
+ } catch {
138
+ return iso;
139
+ }
140
+ }
141
+
142
+ // ── Components ──────────────────────────────────────────────────
143
+
144
+ function Comment({ comment, depth }) {
145
+ const indent = depth > 0 ? { marginLeft: `${Math.min(depth, 3) * 1.5}rem` } : {};
146
+ const recipients = (comment.to || []).filter(Boolean);
147
+
148
+ return h('div', { className: 'eg-comment', style: indent, 'data-comment-id': comment.id },
149
+ h('div', { className: 'eg-comment-meta' },
150
+ h('span', { className: 'eg-comment-avatar' },
151
+ (comment.author || '?')[0].toUpperCase()
152
+ ),
153
+ h('span', { className: 'eg-comment-author' }, comment.author || 'unknown'),
154
+ comment.createdAt && h('span', { className: 'eg-comment-date' }, formatDate(comment.createdAt)),
155
+ recipients.length > 0 && h('span', { className: 'eg-comment-to' },
156
+ '→ ', recipients.join(', ')
157
+ ),
158
+ ),
159
+ h('div', { className: 'eg-comment-body' }, renderMarkdown(comment.body)),
160
+ h('div', { className: 'eg-comment-actions' },
161
+ h('button', {
162
+ type: 'button',
163
+ className: 'eg-comment-reply',
164
+ 'data-reply-to': comment.id,
165
+ 'data-reply-author': comment.author || '',
166
+ }, '↩ reply'),
167
+ ),
168
+ ...(comment.children || []).map(child =>
169
+ h(Comment, { key: child.id, comment: child, depth: depth + 1 })
170
+ ),
171
+ );
172
+ }
173
+
174
+ function CommentThread({ comments }) {
175
+ if (!comments || comments.length === 0) {
176
+ return h('div', { className: 'eg-comments-empty' },
177
+ 'No comments yet. Be the first.'
178
+ );
179
+ }
180
+ const roots = buildThreads(comments);
181
+ return h('div', { className: 'eg-comments-thread' },
182
+ ...roots.map(c => h(Comment, { key: c.id, comment: c, depth: 0 }))
183
+ );
184
+ }
185
+
186
+ function CommentComposer({ parent }) {
187
+ return h('div', {
188
+ className: 'eg-comments-composer',
189
+ 'data-parent-kind': parent.kind,
190
+ 'data-parent-id': parent.id,
191
+ },
192
+ h('div', { className: 'eg-card-title' },
193
+ h('span', { className: 'eg-live-only' }, 'Add a comment to '),
194
+ h('span', { className: 'eg-paste-only' }, 'Compose a comment on '),
195
+ h('code', { className: 'eg-comment-target' }, `${parent.kind}/${parent.id}`),
196
+ ),
197
+ h('textarea', {
198
+ className: 'eg-comment-textarea',
199
+ placeholder: 'Type your comment. @handle to ping someone. Markdown is supported.',
200
+ rows: 4,
201
+ }),
202
+
203
+ h('div', { className: 'eg-comment-preview-label' },
204
+ h('span', { className: 'eg-live-only' }, 'Preview:'),
205
+ h('span', { className: 'eg-paste-only' }, 'Paste this in your Claude terminal:'),
206
+ ),
207
+ h('div', { className: 'eg-comment-preview-frame' },
208
+ h('pre', { className: 'eg-comment-preview' },
209
+ h('code', { className: 'eg-comment-preview-code' }, ''),
210
+ ),
211
+ h('button', {
212
+ type: 'button',
213
+ className: 'eg-comment-copy-btn',
214
+ 'aria-label': 'Copy snippet',
215
+ }, 'Copy'),
216
+ ),
217
+ h('div', { className: 'eg-comment-status', 'aria-live': 'polite' }),
218
+
219
+ // Primary action — only visible when the loopback server is reachable.
220
+ h('button', {
221
+ type: 'button',
222
+ className: 'eg-comment-post-btn eg-live-only',
223
+ }, 'Post comment'),
224
+
225
+ h('div', { className: 'eg-comment-help' },
226
+ h('span', { className: 'eg-live-only' },
227
+ 'Posts directly via the local /view server. Writes the file, indexes the graph, fires Telegram for any @mentions, and commits to memory.',
228
+ ),
229
+ h('span', { className: 'eg-paste-only' },
230
+ 'The browser can’t write to your filesystem or run git, so the snippet is the handoff. Claude recognizes the marker and runs the publish flow.',
231
+ ),
232
+ ),
233
+ );
234
+ }
235
+
236
+ export function CommentSection({ parent, comments }) {
237
+ if (!parent) return null;
238
+ return h('section', { className: 'eg-comments-section' },
239
+ h('div', { className: 'eg-comments-heading' },
240
+ h('span', null, 'Comments'),
241
+ comments && comments.length > 0 && h('span', { className: 'eg-comments-count' },
242
+ ` (${comments.length})`
243
+ ),
244
+ ),
245
+ h(CommentThread, { comments }),
246
+ h(CommentComposer, { parent }),
247
+ );
248
+ }
package/lib/components.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Branded Egregore React components — no JSX, uses React.createElement
2
2
  import React from 'react';
3
3
  import { colors, fonts, typography } from './tokens.js';
4
- import { renderMarkdown } from './markdown.js';
4
+ import { renderMarkdown, inlineMarkdown } from './markdown.js';
5
5
 
6
6
  const h = React.createElement;
7
7
 
@@ -105,7 +105,7 @@ export function BulletList({ items }) {
105
105
 
106
106
  return h('ul', { className: 'eg-bullet-list' },
107
107
  ...items.map((item, i) =>
108
- h('li', { key: i }, item)
108
+ h('li', { key: i }, inlineMarkdown(item))
109
109
  ),
110
110
  );
111
111
  }
package/lib/index.js CHANGED
@@ -5,22 +5,27 @@ import os from 'node:os';
5
5
  import { renderToHtml, renderSpecToHtml } from './render.js';
6
6
  import { parseQuest } from './parsers/quest.js';
7
7
  import { parseHandoff } from './parsers/handoff.js';
8
+ import { parseHandoffV1 } from './parsers/handoff-v1.js';
8
9
  import { parseActivity } from './parsers/activity.js';
9
10
  import { parseDocument } from './parsers/document.js';
10
11
  import { parseBoard } from './parsers/board.js';
11
12
  import { parseNetwork } from './parsers/network.js';
12
13
  import { questTemplate } from './templates/quest.js';
13
14
  import { handoffTemplate } from './templates/handoff.js';
15
+ import { handoffV1Template } from './templates/handoff-v1.js';
14
16
  import { activityTemplate } from './templates/activity.js';
15
17
  import { documentTemplate } from './templates/document.js';
16
18
  import { boardTemplate } from './templates/board.js';
17
19
  import { networkTemplate } from './templates/network.js';
18
20
  import { openInBrowser } from './open.js';
21
+ import { setLinkContext, clearLinkContext } from './markdown.js';
22
+ import { derivedParent, loadComments } from './comments.js';
19
23
 
20
- const PARSERS = { quest: parseQuest, handoff: parseHandoff, activity: parseActivity, document: parseDocument, board: parseBoard, network: parseNetwork };
21
- const TEMPLATES = { quest: questTemplate, handoff: handoffTemplate, activity: activityTemplate, document: documentTemplate, board: boardTemplate, network: networkTemplate };
24
+ const PARSERS = { quest: parseQuest, handoff: parseHandoff, 'handoff-v1': parseHandoffV1, activity: parseActivity, document: parseDocument, board: parseBoard, network: parseNetwork };
25
+ const TEMPLATES = { quest: questTemplate, handoff: handoffTemplate, 'handoff-v1': handoffV1Template, activity: activityTemplate, document: documentTemplate, board: boardTemplate, network: networkTemplate };
22
26
 
23
- export async function generateArtifact(type, input) {
27
+ const COMMENTABLE_TYPES = new Set(['quest', 'handoff', 'document']);
28
+ export async function generateArtifact(type, input, options = {}) {
24
29
  const template = TEMPLATES[type];
25
30
  if (!template) throw new Error(`Unknown artifact type: ${type}. Available: ${Object.keys(TEMPLATES).join(', ')}`);
26
31
 
@@ -33,8 +38,26 @@ export async function generateArtifact(type, input) {
33
38
  data = input;
34
39
  }
35
40
 
36
- const element = template(data);
37
- return renderToHtml(element, { title: data.title || 'Untitled', type });
41
+ let parent = null;
42
+ let comments = [];
43
+ if (typeof input === 'string' && COMMENTABLE_TYPES.has(type)) {
44
+ parent = derivedParent(type, input);
45
+ if (parent) comments = loadComments(parent);
46
+ }
47
+ // Link context for inlineMarkdown — only enabled when both values are supplied.
48
+ // Missing either falls through to plain <code> rendering (OSS mode).
49
+ setLinkContext({ viewBase: options.viewBase, orgSlug: options.orgSlug });
50
+ try {
51
+ const element = template(data);
52
+ return renderToHtml(element, {
53
+ title: data.title || 'Untitled',
54
+ type,
55
+ parent,
56
+ comments,
57
+ });
58
+ } finally {
59
+ clearLinkContext();
60
+ }
38
61
  }
39
62
 
40
63
  export async function openArtifact(html, title = 'Egregore Artifact') {
package/lib/markdown.js CHANGED
@@ -2,9 +2,34 @@
2
2
  // Handles: **bold**, *italic*, `code`, ### headings, [links](url), tables, lists
3
3
  import React from 'react';
4
4
  import { colors, fonts } from './tokens.js';
5
+ import { artifactIdFromPath } from './artifact-id.js';
5
6
 
6
7
  const h = React.createElement;
7
8
 
9
+ // Module-level link context. Set by generateArtifact() before rendering, read
10
+ // by inlineMarkdown() when deciding whether to rewrite `memory/…` code spans
11
+ // and bare egregore.xyz URLs into <a> tags. Renders are synchronous so this
12
+ // is safe; missing context falls through to plain-<code> behaviour (OSS mode).
13
+ let linkContext = { viewBase: null, orgSlug: null };
14
+
15
+ export function setLinkContext(ctx) {
16
+ linkContext = {
17
+ viewBase: (ctx && ctx.viewBase) || null,
18
+ orgSlug: (ctx && ctx.orgSlug) || null,
19
+ };
20
+ }
21
+
22
+ export function clearLinkContext() {
23
+ linkContext = { viewBase: null, orgSlug: null };
24
+ }
25
+
26
+ function memoryPathHref(memoryPath) {
27
+ if (!linkContext.viewBase || !linkContext.orgSlug) return null;
28
+ const id = artifactIdFromPath(memoryPath);
29
+ if (!id) return null;
30
+ return `${linkContext.viewBase}/${linkContext.orgSlug}/${id}`;
31
+ }
32
+
8
33
  export function renderMarkdown(text) {
9
34
  if (!text) return null;
10
35
 
@@ -237,7 +262,7 @@ export function renderMarkdown(text) {
237
262
 
238
263
  // Inline markdown: **bold**, *italic*, `code`, [link](url)
239
264
  // Finds whichever pattern appears earliest in the string
240
- function inlineMarkdown(text) {
265
+ export function inlineMarkdown(text) {
241
266
  if (!text) return text;
242
267
 
243
268
  const parts = [];
@@ -245,6 +270,27 @@ function inlineMarkdown(text) {
245
270
  let key = 0;
246
271
 
247
272
  const patterns = [
273
+ // Backtick-wrapped memory/*.{md,html} → link to its own published view
274
+ // when linkContext is available (connected mode). Falls through to plain
275
+ // <code> otherwise via the generic `code` pattern below.
276
+ { re: /`(memory\/[^`\s]+\.(?:md|html))`/, render: (m) => {
277
+ const href = memoryPathHref(m[1]);
278
+ if (!href) return h('code', { key: key++, className: 'eg-code' }, m[1]);
279
+ return h('a', {
280
+ key: key++,
281
+ href,
282
+ style: { color: 'var(--terracotta)', textDecoration: 'none' },
283
+ }, h('code', { className: 'eg-code' }, m[1]));
284
+ } },
285
+ // Bare http(s) URLs → auto-linked <a>. URLs inside `code`, **bold**, or
286
+ // [text](url) spans are unaffected because those patterns match at an
287
+ // earlier index and win the "earliest wins" race. Regex excludes common
288
+ // trailing punctuation so "See https://foo.com." doesn't eat the period,
289
+ // and excludes quote characters so "href=\"https://x.com\"" or inline
290
+ // strings like 'see https://x.com' terminate cleanly at the quote.
291
+ { re: /https?:\/\/[^\s)`<>"']*[^\s)`<>"'.,;:!?]/, render: (m) => h('a', {
292
+ key: key++, href: m[0], style: { color: 'var(--terracotta)', textDecoration: 'underline' },
293
+ }, m[0]) },
248
294
  { re: /`([^`]+)`/, render: (m) => h('code', {
249
295
  key: key++,
250
296
  className: 'eg-code',
@@ -359,8 +405,10 @@ function parseTableRow(line) {
359
405
  return line.replace(/^\|/, '').replace(/\|$/, '').split('|');
360
406
  }
361
407
 
362
- // Lightweight markdown renderer — tables + paragraphs only, no inline regex
363
- // Use for large attachments where full inlineMarkdown is too expensive
408
+ // Lightweight markdown renderer — tables, bullets, and paragraphs. Pipes
409
+ // items and paragraph text through inlineMarkdown so attachments pick up
410
+ // auto-linking of memory paths and bare URLs. Skips the full block grammar
411
+ // (headings beyond H2, ordered lists, code fences) for speed on large inputs.
364
412
  export function renderMarkdownLite(text) {
365
413
  if (!text) return null;
366
414
 
@@ -397,7 +445,7 @@ export function renderMarkdownLite(text) {
397
445
  i++;
398
446
  }
399
447
  elements.push(h('ul', { key: elements.length, style: { listStyle: 'disc', paddingLeft: '1.5rem', margin: '0.5rem 0' } },
400
- ...items.map((item, j) => h('li', { key: j, style: { fontSize: '14px', lineHeight: 1.5, marginBottom: '0.25rem' } }, item)),
448
+ ...items.map((item, j) => h('li', { key: j, style: { fontSize: '14px', lineHeight: 1.5, marginBottom: '0.25rem' } }, inlineMarkdown(item))),
401
449
  ));
402
450
  continue;
403
451
  }
@@ -406,7 +454,7 @@ export function renderMarkdownLite(text) {
406
454
  if (line.trim() === '') { i++; continue; }
407
455
 
408
456
  // Paragraph
409
- elements.push(h('p', { key: elements.length, style: { margin: '0.5rem 0', fontSize: '14px', lineHeight: 1.6, color: 'var(--dark)' } }, line));
457
+ elements.push(h('p', { key: elements.length, style: { margin: '0.5rem 0', fontSize: '14px', lineHeight: 1.6, color: 'var(--dark)' } }, inlineMarkdown(line)));
410
458
  i++;
411
459
  }
412
460
 
@@ -0,0 +1,65 @@
1
+ // Parse v1 handoff JSON (canonical format per docs/specs/handoff-format-v1.md).
2
+ // Input: file path to a JSON file, OR a JSON string, OR a parsed object.
3
+ // Output: the canonical v1 object normalized for the template.
4
+ import fs from 'node:fs';
5
+
6
+ export function parseHandoffV1(input) {
7
+ let obj;
8
+
9
+ if (typeof input === 'string') {
10
+ if (input.trim().startsWith('{')) {
11
+ obj = JSON.parse(input);
12
+ } else {
13
+ if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
14
+ const raw = fs.readFileSync(input, 'utf-8');
15
+ obj = JSON.parse(raw);
16
+ }
17
+ } else if (typeof input === 'object' && input !== null) {
18
+ obj = input;
19
+ } else {
20
+ throw new Error('parseHandoffV1 expects a file path, JSON string, or object');
21
+ }
22
+
23
+ // Light validation — surface clear errors.
24
+ if (obj['@context'] !== 'https://egregore.xyz/spec/handoff/v1') {
25
+ throw new Error(`Unexpected @context: ${obj['@context']} (expected handoff/v1)`);
26
+ }
27
+ if (!obj.topic || !obj.claim || !obj.author || !obj.body) {
28
+ throw new Error('Missing required v1 fields (topic, claim, author, body)');
29
+ }
30
+
31
+ // Normalize: ensure arrays exist; preserve everything else as-is.
32
+ return {
33
+ '@context': obj['@context'],
34
+ id: obj.id || null,
35
+ version: obj.version || '1.0',
36
+ kind: obj.kind || 'handoff',
37
+ topic: obj.topic,
38
+ claim: obj.claim,
39
+ ask: obj.ask || null,
40
+ created_at: obj.created_at || null,
41
+ updated_at: obj.updated_at || null,
42
+ author: obj.author,
43
+ audience: obj.audience || { addressed_to: [], visible_to: 'public', extendable_by: 'anyone' },
44
+ parents: Array.isArray(obj.parents) ? obj.parents : [],
45
+ body: obj.body,
46
+ references: Array.isArray(obj.references) ? obj.references : [],
47
+ repo_state: Array.isArray(obj.repo_state) ? obj.repo_state : null,
48
+ depth: obj.depth || 'shallow',
49
+ deep_context: obj.deep_context || null,
50
+ receiver_instructions: obj.receiver_instructions || null,
51
+ extensions: Array.isArray(obj.extensions) ? obj.extensions : [],
52
+ // Convenience for the template:
53
+ title: obj.topic,
54
+ canonical_url: obj.id || null,
55
+ extension_endpoint: obj.id ? deriveExtensionEndpoint(obj.id) : null,
56
+ };
57
+ }
58
+
59
+ function deriveExtensionEndpoint(canonicalUrl) {
60
+ // Canonical URL pattern: https://egregore.xyz/view/_/{id}
61
+ // Extension endpoint: https://egregore.xyz/api/artifacts/handoff/{id}/extend
62
+ const m = canonicalUrl.match(/\/(?:view\/_|h)\/([a-zA-Z0-9_-]+)/);
63
+ if (!m) return null;
64
+ return `https://egregore.xyz/api/artifacts/handoff/${m[1]}/extend`;
65
+ }
@@ -210,8 +210,10 @@ function parseGraphResponse(raw) {
210
210
 
211
211
  function extractReferencedFiles(content, inputDir) {
212
212
  const attachments = [];
213
- // Find backtick-wrapped paths that look like memory files
214
- const refs = content.matchAll(/`(memory\/[^`]+\.md)`/g);
213
+ // Find backtick-wrapped paths that look like memory files (.md or .html).
214
+ // Must stay in sync with the corresponding pattern in lib/markdown.js and
215
+ // with bin/publish-references.sh.
216
+ const refs = content.matchAll(/`(memory\/[^`\s]+\.(?:md|html))`/g);
215
217
 
216
218
  // Resolve git root for file lookup
217
219
  let gitRoot = null;
@@ -235,7 +237,7 @@ function extractReferencedFiles(content, inputDir) {
235
237
  for (const candidate of candidates) {
236
238
  if (fs.existsSync(candidate)) {
237
239
  const fileContent = fs.readFileSync(candidate, 'utf-8');
238
- const fileTitle = extractTitle(fileContent) || path.basename(refPath, '.md');
240
+ const fileTitle = extractTitle(fileContent) || path.basename(refPath, path.extname(refPath));
239
241
  attachments.push({ title: fileTitle, path: refPath, content: fileContent });
240
242
  break;
241
243
  }
package/lib/render.js CHANGED
@@ -4,12 +4,22 @@ import { renderToStaticMarkup } from 'react-dom/server';
4
4
  import { Renderer, JSONUIProvider, createStateStore } from '@json-render/react';
5
5
  import { registry } from './registry.js';
6
6
  import { htmlShell } from './shell.js';
7
+ import { CommentSection } from './comments.js';
7
8
 
8
9
  const h = React.createElement;
9
10
 
10
- // Render a React element tree (from direct templates)
11
+ // Render a React element tree (from direct templates).
12
+ // If `options.parent` is set, append a CommentSection so every commentable
13
+ // artifact gets the same thread + composer treatment without per-template work.
11
14
  export function renderToHtml(element, options = {}) {
12
- const bodyHtml = renderToStaticMarkup(element);
15
+ const finalElement = options.parent
16
+ ? h('div', null, element, h(CommentSection, {
17
+ parent: options.parent,
18
+ comments: options.comments || [],
19
+ }))
20
+ : element;
21
+
22
+ const bodyHtml = renderToStaticMarkup(finalElement);
13
23
  return htmlShell(bodyHtml, {
14
24
  title: options.title || 'Egregore Artifact',
15
25
  type: options.type || 'artifact',