egregore-artifacts 0.5.0 → 0.9.5

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,31 @@ 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
+ // Types that participate in the comments system. Skipped: board (interactive
28
+ // editor with its own UI), activity/network (live feeds without a stable
29
+ // parent_id).
30
+ const COMMENTABLE_TYPES = new Set(['quest', 'handoff', 'document']);
31
+
32
+ export async function generateArtifact(type, input, options = {}) {
24
33
  const template = TEMPLATES[type];
25
34
  if (!template) throw new Error(`Unknown artifact type: ${type}. Available: ${Object.keys(TEMPLATES).join(', ')}`);
26
35
 
@@ -33,8 +42,28 @@ export async function generateArtifact(type, input) {
33
42
  data = input;
34
43
  }
35
44
 
36
- const element = template(data);
37
- return renderToHtml(element, { title: data.title || 'Untitled', type });
45
+ // Comments only when rendering from a real file path and the type supports them.
46
+ let parent = null;
47
+ let comments = [];
48
+ if (typeof input === 'string' && COMMENTABLE_TYPES.has(type)) {
49
+ parent = derivedParent(type, input);
50
+ if (parent) comments = loadComments(parent);
51
+ }
52
+
53
+ // Link context for inlineMarkdown — only enabled when both values are supplied.
54
+ // Missing either falls through to plain <code> rendering (OSS mode).
55
+ setLinkContext({ viewBase: options.viewBase, orgSlug: options.orgSlug });
56
+ try {
57
+ const element = template(data);
58
+ return renderToHtml(element, {
59
+ title: data.title || 'Untitled',
60
+ type,
61
+ parent,
62
+ comments,
63
+ });
64
+ } finally {
65
+ clearLinkContext();
66
+ }
38
67
  }
39
68
 
40
69
  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
+ }
@@ -131,7 +131,14 @@ function deriveSessionIdFromPath(filePath) {
131
131
  }
132
132
 
133
133
  function tryLiveSubgraphQuery(sessionId, gitRoot) {
134
+ // 1-hop neighbors + 2-hop artifact→quest linkage. The artifactQuestLinks
135
+ // collection lets the renderer draw the :PART_OF edges from artifacts up
136
+ // to the quests they belong to — the feature that makes this a graph
137
+ // instead of a list.
134
138
  const cypher = `MATCH (s:Session {id: $sessionId})
139
+ OPTIONAL MATCH (s)-[:BY]->(author:Person)
140
+ OPTIONAL MATCH (s)-[:HANDED_TO]->(recipient:Person)
141
+ OPTIONAL MATCH (s)-[:ABOUT]->(project:Project)
135
142
  OPTIONAL MATCH (s)-[:IMPLEMENTS]->(prior:Session)
136
143
  OPTIONAL MATCH (prior)-[:BY]->(priorAuthor:Person)
137
144
  OPTIONAL MATCH (later:Session)-[:IMPLEMENTS]->(s)
@@ -140,13 +147,18 @@ OPTIONAL MATCH (s)-[:CONTINUES]->(cont:Session)
140
147
  OPTIONAL MATCH (cont)-[:BY]->(contAuthor:Person)
141
148
  OPTIONAL MATCH (s)-[:ADVANCED|INVOLVES]->(quest:Quest)
142
149
  OPTIONAL MATCH (s)-[:HAS_ACTIVITY]->(art:Artifact)
150
+ OPTIONAL MATCH (art)-[:PART_OF]->(artQuest:Quest)
143
151
  OPTIONAL MATCH (s)-[:PRODUCED]->(pr:PR)
144
152
  RETURN
153
+ collect(DISTINCT {name:author.name, github:author.github}) AS authors,
154
+ collect(DISTINCT {name:recipient.name, github:recipient.github}) AS recipients,
155
+ project.name AS project,
145
156
  collect(DISTINCT {id:prior.id, topic:prior.topic, author:priorAuthor.name}) AS implementsHandoff,
146
157
  collect(DISTINCT {id:later.id, topic:later.topic, author:laterAuthor.name}) AS implementedBy,
147
158
  collect(DISTINCT {id:cont.id, topic:cont.topic, author:contAuthor.name}) AS continues,
148
159
  collect(DISTINCT {id:quest.id, title:quest.title}) AS quests,
149
- collect(DISTINCT {id:art.id, title:art.title, type:art.type, path:art.path}) AS artifacts,
160
+ collect(DISTINCT {id:art.id, title:art.title, type:art.type}) AS artifacts,
161
+ collect(DISTINCT {artifactId:art.id, questId:artQuest.id, questTitle:artQuest.title}) AS artifactQuestLinks,
150
162
  collect(DISTINCT {id:pr.id, number:pr.number, title:pr.title, repo:pr.repo}) AS prs`;
151
163
 
152
164
  try {
@@ -177,24 +189,31 @@ function parseGraphResponse(raw) {
177
189
  const obj = {};
178
190
  fields.forEach((f, i) => { obj[f] = row[i]; });
179
191
 
180
- // OPTIONAL MATCH + collect() emits sentinel {id: null, ...} entries strip.
192
+ // OPTIONAL MATCH + collect() emits sentinel entries when nothing matched
193
+ // filter them out by requiring at least one of the key fields to be set.
181
194
  const clean = (list, ...keys) => Array.isArray(list)
182
195
  ? list.filter(x => x && keys.some(k => x[k] != null))
183
196
  : [];
184
197
  return {
185
- implementsHandoff: clean(obj.implementsHandoff, 'id'),
186
- implementedBy: clean(obj.implementedBy, 'id'),
187
- continues: clean(obj.continues, 'id'),
188
- quests: clean(obj.quests, 'id', 'title'),
189
- artifacts: clean(obj.artifacts, 'id', 'title', 'path'),
190
- prs: clean(obj.prs, 'id', 'number'),
198
+ authors: clean(obj.authors, 'name', 'github'),
199
+ recipients: clean(obj.recipients, 'name', 'github'),
200
+ project: obj.project || null,
201
+ implementsHandoff: clean(obj.implementsHandoff, 'id'),
202
+ implementedBy: clean(obj.implementedBy, 'id'),
203
+ continues: clean(obj.continues, 'id'),
204
+ quests: clean(obj.quests, 'id', 'title'),
205
+ artifacts: clean(obj.artifacts, 'id', 'title'),
206
+ artifactQuestLinks: clean(obj.artifactQuestLinks, 'artifactId', 'questId'),
207
+ prs: clean(obj.prs, 'id', 'number'),
191
208
  };
192
209
  }
193
210
 
194
211
  function extractReferencedFiles(content, inputDir) {
195
212
  const attachments = [];
196
- // Find backtick-wrapped paths that look like memory files
197
- 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);
198
217
 
199
218
  // Resolve git root for file lookup
200
219
  let gitRoot = null;
@@ -218,7 +237,7 @@ function extractReferencedFiles(content, inputDir) {
218
237
  for (const candidate of candidates) {
219
238
  if (fs.existsSync(candidate)) {
220
239
  const fileContent = fs.readFileSync(candidate, 'utf-8');
221
- const fileTitle = extractTitle(fileContent) || path.basename(refPath, '.md');
240
+ const fileTitle = extractTitle(fileContent) || path.basename(refPath, path.extname(refPath));
222
241
  attachments.push({ title: fileTitle, path: refPath, content: fileContent });
223
242
  break;
224
243
  }