egregore-artifacts 0.4.0 → 0.6.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.
@@ -3,28 +3,36 @@
3
3
  // Detects referenced memory files and inlines their content
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { execSync } from 'node:child_process';
6
+ import { execSync, execFileSync } from 'node:child_process';
7
7
 
8
8
  export function parseHandoff(input) {
9
9
  let content;
10
10
  let inputDir = null;
11
+ let inputPath = null;
11
12
 
12
13
  if (typeof input === 'string' && (input.endsWith('.md') || input.includes('/'))) {
13
14
  if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
14
15
  content = fs.readFileSync(input, 'utf-8');
15
16
  inputDir = path.dirname(input);
17
+ inputPath = input;
16
18
  } else if (typeof input === 'string') {
17
19
  content = input;
18
20
  } else {
19
21
  throw new Error('parseHandoff expects a file path or markdown string');
20
22
  }
21
23
 
22
- const title = extractTitle(content);
23
- const meta = extractMeta(content);
24
- const sections = extractSections(content);
24
+ // Resolve graph subgraph first (reads the raw content), then strip the
25
+ // embedded block so it doesn't leak into section bodies or rendered text.
26
+ const gitRoot = findGitRoot();
27
+ const subgraph = resolveSubgraph({ content, inputPath, gitRoot });
28
+ const cleanContent = content.replace(/<!--\s*subgraph[\s\S]*?-->/g, '').trim();
29
+
30
+ const title = extractTitle(cleanContent);
31
+ const meta = extractMeta(cleanContent);
32
+ const sections = extractSections(cleanContent);
25
33
 
26
34
  // Detect referenced files (memory/*.md paths in backticks) and inline them
27
- const attachments = extractReferencedFiles(content, inputDir);
35
+ const attachments = extractReferencedFiles(cleanContent, inputDir);
28
36
 
29
37
  return {
30
38
  title,
@@ -37,6 +45,7 @@ export function parseHandoff(input) {
37
45
  entryPoints: extractBullets(sections['Entry Points'] || ''),
38
46
  context: sections.Context || null,
39
47
  attachments,
48
+ subgraph,
40
49
  // Catch-all for non-standard sections
41
50
  extraSections: Object.entries(sections)
42
51
  .filter(([k]) => !['Summary', 'Briefing', 'Current State', 'Decisions',
@@ -47,6 +56,158 @@ export function parseHandoff(input) {
47
56
  };
48
57
  }
49
58
 
59
+ // ── Subgraph resolution ────────────────────────────────────────────
60
+ //
61
+ // The subgraph shows the handoff's *graph-only* context — things not already
62
+ // in the markdown header (author/recipients/project). Source priority:
63
+ // 1. Embedded <!-- subgraph ... --> block if present (defensive — we don't
64
+ // write one today, but a future caller could snapshot manually).
65
+ // 2. Live Cypher query via bin/graph.sh in connected mode.
66
+ // If neither yields real data, the subgraph is null and the template skips
67
+ // the section entirely — no "degraded" placeholder.
68
+
69
+ function resolveSubgraph({ content, inputPath, gitRoot }) {
70
+ const embedded = extractEmbeddedSubgraph(content);
71
+
72
+ let live = null;
73
+ if (inputPath && gitRoot && isConnectedMode(gitRoot)) {
74
+ const sessionId = deriveSessionIdFromPath(inputPath);
75
+ if (sessionId) live = tryLiveSubgraphQuery(sessionId, gitRoot);
76
+ }
77
+
78
+ const sourced = live || embedded;
79
+ if (sourced && hasAnySubgraphData(sourced)) return sourced;
80
+ return null;
81
+ }
82
+
83
+ function extractEmbeddedSubgraph(content) {
84
+ const m = content.match(/<!--\s*subgraph\s*([\s\S]*?)-->/);
85
+ if (!m) return null;
86
+ try {
87
+ return JSON.parse(m[1].trim());
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function hasAnySubgraphData(sg) {
94
+ if (!sg) return false;
95
+ return (sg.continues && sg.continues.length)
96
+ || (sg.implementsHandoff && sg.implementsHandoff.length)
97
+ || (sg.implementedBy && sg.implementedBy.length)
98
+ || (sg.quests && sg.quests.length)
99
+ || (sg.artifacts && sg.artifacts.length)
100
+ || (sg.prs && sg.prs.length);
101
+ }
102
+
103
+ function findGitRoot() {
104
+ try {
105
+ return execSync('git rev-parse --show-toplevel 2>/dev/null', { encoding: 'utf-8' }).trim();
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function isConnectedMode(gitRoot) {
112
+ try {
113
+ const configPath = path.join(gitRoot, 'egregore.json');
114
+ if (!fs.existsSync(configPath)) return false;
115
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
116
+ if (config.mode === 'local') return false;
117
+ return Boolean(config.api_url) || config.mode === 'connected';
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function deriveSessionIdFromPath(filePath) {
124
+ // memory/handoffs/2026-04/22-author-topic.md → 2026-04-22-author-topic
125
+ const base = path.basename(filePath, '.md');
126
+ const dir = path.basename(path.dirname(filePath));
127
+ if (/^\d{4}-\d{2}$/.test(dir)) return `${dir}-${base}`;
128
+ // Filename already starts with YYYY-MM-DD
129
+ if (/^\d{4}-\d{2}-\d{2}-/.test(base)) return base;
130
+ return null;
131
+ }
132
+
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.
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)
142
+ OPTIONAL MATCH (s)-[:IMPLEMENTS]->(prior:Session)
143
+ OPTIONAL MATCH (prior)-[:BY]->(priorAuthor:Person)
144
+ OPTIONAL MATCH (later:Session)-[:IMPLEMENTS]->(s)
145
+ OPTIONAL MATCH (later)-[:BY]->(laterAuthor:Person)
146
+ OPTIONAL MATCH (s)-[:CONTINUES]->(cont:Session)
147
+ OPTIONAL MATCH (cont)-[:BY]->(contAuthor:Person)
148
+ OPTIONAL MATCH (s)-[:ADVANCED|INVOLVES]->(quest:Quest)
149
+ OPTIONAL MATCH (s)-[:HAS_ACTIVITY]->(art:Artifact)
150
+ OPTIONAL MATCH (art)-[:PART_OF]->(artQuest:Quest)
151
+ OPTIONAL MATCH (s)-[:PRODUCED]->(pr:PR)
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,
156
+ collect(DISTINCT {id:prior.id, topic:prior.topic, author:priorAuthor.name}) AS implementsHandoff,
157
+ collect(DISTINCT {id:later.id, topic:later.topic, author:laterAuthor.name}) AS implementedBy,
158
+ collect(DISTINCT {id:cont.id, topic:cont.topic, author:contAuthor.name}) AS continues,
159
+ collect(DISTINCT {id:quest.id, title:quest.title}) AS quests,
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,
162
+ collect(DISTINCT {id:pr.id, number:pr.number, title:pr.title, repo:pr.repo}) AS prs`;
163
+
164
+ try {
165
+ const out = execFileSync(
166
+ 'bash',
167
+ [path.join(gitRoot, 'bin/graph.sh'), 'query', cypher, JSON.stringify({ sessionId })],
168
+ { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }
169
+ );
170
+ return parseGraphResponse(out);
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function parseGraphResponse(raw) {
177
+ if (!raw || !raw.trim()) return null;
178
+ let body;
179
+ try {
180
+ body = JSON.parse(raw);
181
+ } catch {
182
+ return null;
183
+ }
184
+ const result = body.results?.[0] || body;
185
+ const fields = result.fields;
186
+ const row = result.values?.[0];
187
+ if (!Array.isArray(fields) || !Array.isArray(row)) return null;
188
+
189
+ const obj = {};
190
+ fields.forEach((f, i) => { obj[f] = row[i]; });
191
+
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.
194
+ const clean = (list, ...keys) => Array.isArray(list)
195
+ ? list.filter(x => x && keys.some(k => x[k] != null))
196
+ : [];
197
+ return {
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'),
208
+ };
209
+ }
210
+
50
211
  function extractReferencedFiles(content, inputDir) {
51
212
  const attachments = [];
52
213
  // Find backtick-wrapped paths that look like memory files
package/lib/shell.js CHANGED
@@ -45,6 +45,13 @@ export function htmlShell(bodyHtml, { title = 'Egregore Artifact', type = 'artif
45
45
  --success-bg: #e8f5e9;
46
46
  --success-fg: #2e7d32;
47
47
  --green-p1: #2A7B5B;
48
+ /* Subgraph node fills — subtle tinted backgrounds per node type.
49
+ Strokes/labels use the existing strong tokens (--terracotta,
50
+ --blue-muted, --green-p1, --gold, etc.) for contrast. */
51
+ --quest-fill: rgba(42, 123, 91, 0.12);
52
+ --artifact-fill: rgba(176, 141, 87, 0.14);
53
+ --artifact-stroke: #B08D57;
54
+ --pr-fill: rgba(59, 45, 33, 0.04);
48
55
  }
49
56
 
50
57
  /* Dark mode overrides */
@@ -67,6 +74,10 @@ export function htmlShell(bodyHtml, { title = 'Egregore Artifact', type = 'artif
67
74
  --success-bg: rgba(107, 191, 107, 0.12);
68
75
  --success-fg: #9ed9a0;
69
76
  --green-p1: #6BBF6B;
77
+ --quest-fill: rgba(107, 191, 107, 0.14);
78
+ --artifact-fill: rgba(200, 169, 122, 0.16);
79
+ --artifact-stroke: #C8A97A;
80
+ --pr-fill: rgba(255, 255, 255, 0.04);
70
81
  }
71
82
 
72
83
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -6,6 +6,7 @@ import {
6
6
  } from '../components.js';
7
7
  import { renderMarkdownLite } from '../markdown.js';
8
8
  import { fonts } from '../tokens.js';
9
+ import { renderSubgraph } from './subgraph.js';
9
10
 
10
11
  const h = React.createElement;
11
12
 
@@ -87,6 +88,16 @@ export function handoffTemplate(handoff) {
87
88
  );
88
89
  }
89
90
 
91
+ // Subgraph — handoff's neighborhood in the knowledge graph.
92
+ // Rendered as static SVG; renderSubgraph returns null if there's nothing to show.
93
+ const subgraphNode = renderSubgraph({
94
+ subgraph: handoff.subgraph,
95
+ handoffTopic: handoff.title,
96
+ });
97
+ if (subgraphNode) {
98
+ sections.push(React.cloneElement(subgraphNode, { key: 'subgraph' }));
99
+ }
100
+
90
101
  // Summary / Briefing
91
102
  if (handoff.summary) {
92
103
  sections.push(
@@ -0,0 +1,552 @@
1
+ // Handoff subgraph → inline static SVG
2
+ //
3
+ // Renders the session's knowledge-graph neighborhood AS A GRAPH — typed
4
+ // nodes with labeled edges — rather than as a flat list or zones. Gives
5
+ // the recipient a visual orientation: where this sits, who/what it's
6
+ // connected to, which quests it's part of, what it produced.
7
+ //
8
+ // Layered DAG layout (top-to-bottom):
9
+ //
10
+ // Row 0 (optional) [prior session]
11
+ // │ IMPLEMENTS / CONTINUES
12
+ // Row 1 [ THIS HANDOFF ]
13
+ // ╱ BY │ABOUT ╲ ADVANCED
14
+ // Row 2 [author, recipients] [project] [quests ...]
15
+ // │ ▲
16
+ // │ HAS_ACTIVITY ╭─ PART_OF ──╯
17
+ // ▼ │
18
+ // Row 3 [artifacts ...] [PRs ...] [later sessions]
19
+ // │ PRODUCED ▲
20
+ // │ IMPLEMENTS (incoming)
21
+ //
22
+ // Legend ● Session ● Person ● Project ● Quest ● Artifact ● PR
23
+ //
24
+ // Rows collapse when empty. If the session has no graph-only context
25
+ // (subgraph is null or has only header-duplicating data), the whole
26
+ // section is skipped.
27
+ //
28
+ // Zero runtime JS — pure SSR SVG. All colors via var(--token) so dark
29
+ // mode reaches them.
30
+ import React from 'react';
31
+ import { fonts } from '../tokens.js';
32
+
33
+ const h = React.createElement;
34
+
35
+ // ── Text sizing heuristics ────────────────────────────────────────
36
+ const CHAR_W_SANS_12 = 7.0;
37
+ const CHAR_W_SANS_13 = 7.6;
38
+ const CHAR_W_MONO_9 = 5.4;
39
+
40
+ // ── Node geometry ──────────────────────────────────────────────────
41
+ const PILL_H = 32;
42
+ const PILL_PAD_X = 22;
43
+ const PILL_MIN_W = 110;
44
+ const PILL_MAX_W = 260;
45
+ const SESSION_H = 56;
46
+ const SESSION_MIN_W = 220;
47
+ const SESSION_MAX_W = 360;
48
+
49
+ // ── Row y-coordinates ──────────────────────────────────────────────
50
+ const ROW_H = 90;
51
+ const TOP_PAD = 30;
52
+ const SIDE_PAD = 18;
53
+ const LEGEND_H = 34;
54
+
55
+ // ── Truncation caps ────────────────────────────────────────────────
56
+ const TRUNC_SESSION = 60;
57
+ const TRUNC_PILL = 26;
58
+ const TRUNC_ARTIFACT = 28;
59
+
60
+ // ── Count caps (to keep the SVG within the container width) ────────
61
+ const MAX_AUTHORS = 2;
62
+ const MAX_RECIPIENTS = 2;
63
+ const MAX_QUESTS = 3;
64
+ const MAX_ARTIFACTS = 4;
65
+ const MAX_PRS = 3;
66
+ const MAX_LATER = 2;
67
+
68
+ // ── Colors (CSS vars) ──────────────────────────────────────────────
69
+ const STYLE = {
70
+ session: { fill: 'var(--terracotta-chip)', stroke: 'var(--terracotta)', text: 'var(--black)' },
71
+ person: { fill: 'var(--blue-chip)', stroke: 'var(--blue-muted)', text: 'var(--blue-muted)' },
72
+ project: { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
73
+ quest: { fill: 'var(--quest-fill)', stroke: 'var(--green-p1)', text: 'var(--green-p1)' },
74
+ artifact: { fill: 'var(--artifact-fill)', stroke: 'var(--artifact-stroke)', text: 'var(--artifact-stroke)' },
75
+ pr: { fill: 'var(--pr-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
76
+ };
77
+
78
+ export function renderSubgraph({ subgraph, handoffTopic }) {
79
+ if (!subgraph) return null;
80
+
81
+ const {
82
+ authors = [], recipients = [], project = null,
83
+ continues = [], implementsHandoff = [], implementedBy = [],
84
+ quests = [], artifacts = [], artifactQuestLinks = [], prs = [],
85
+ } = subgraph;
86
+
87
+ // Prior = the most-specific upstream node (implements > continues, pick first).
88
+ const priorNode = implementsHandoff[0] || continues[0] || null;
89
+ const priorRel = implementsHandoff[0] ? 'IMPLEMENTS' : (continues[0] ? 'CONTINUES' : null);
90
+
91
+ const authorList = authors.slice(0, MAX_AUTHORS);
92
+ const recipientList = recipients.slice(0, MAX_RECIPIENTS);
93
+ const questList = quests.slice(0, MAX_QUESTS);
94
+ const artifactList = artifacts.slice(0, MAX_ARTIFACTS);
95
+ const prList = prs.slice(0, MAX_PRS);
96
+ const laterList = implementedBy.slice(0, MAX_LATER);
97
+ const overflowQuests = quests.length - questList.length;
98
+ const overflowArtifacts = artifacts.length - artifactList.length;
99
+ const overflowPrs = prs.length - prList.length;
100
+
101
+ // Gate: we render the section if there's any *graph-only* context —
102
+ // things not already in the header. Author/recipients/project alone
103
+ // aren't enough (they duplicate the metadata row).
104
+ const hasGraphOnlyContext =
105
+ priorNode || laterList.length || questList.length ||
106
+ artifactList.length || prList.length;
107
+ if (!hasGraphOnlyContext) return null;
108
+
109
+ // ── Assign nodes to layout slots ──────────────────────────────────
110
+ // Each node = { id, kind, label, x, y, w }. We compute widths first
111
+ // from label text, then pack rows, then assign y per row.
112
+
113
+ const mkNode = (id, kind, label, sub = null) => ({
114
+ id, kind, label, sub, w: pillWidth(label, kind === 'session'),
115
+ });
116
+
117
+ const nodeSession = mkNode('session', 'session', truncate(handoffTopic || 'this handoff', TRUNC_SESSION));
118
+
119
+ const r1Left = authorList.map((a, i) => mkNode(`author-${i}`, 'person', truncate(a.name || a.github || 'author', TRUNC_PILL)))
120
+ .concat(recipientList.map((r, i) => mkNode(`recip-${i}`, 'person', truncate(r.name || r.github || 'recipient', TRUNC_PILL))));
121
+ const r1Center = project ? [mkNode('project', 'project', truncate(project, TRUNC_PILL))] : [];
122
+ const r1Right = questList.map((q, i) => mkNode(`quest-${q.id || i}`, 'quest', truncate(q.title || q.id || 'quest', TRUNC_PILL)));
123
+
124
+ const r2Left = artifactList.map((a, i) => mkNode(
125
+ `art-${a.id || i}`, 'artifact',
126
+ truncate(a.title || a.id || 'artifact', TRUNC_ARTIFACT),
127
+ a.type || null,
128
+ ));
129
+ const r2Right = prList.map((p, i) => mkNode(
130
+ `pr-${p.id || p.number || i}`, 'pr',
131
+ `PR #${p.number || p.id}${p.repo ? ' · ' + p.repo : ''}`,
132
+ )).concat(laterList.map((l, i) => mkNode(
133
+ `later-${i}`, 'session',
134
+ truncate(l.topic || l.id || 'session', TRUNC_PILL),
135
+ )));
136
+
137
+ // Row 1 (identity): Persons on the left, Project in the center.
138
+ // Row 2 (quests): on its own row so longer quest titles don't push widths.
139
+ // Row 3 (outputs): Artifacts on the left, PRs + later sessions on the right.
140
+ const hasRow0 = Boolean(priorNode);
141
+ const hasRow1 = r1Left.length || r1Center.length; // persons + project only
142
+ const hasQuestsRow = r1Right.length > 0; // quests
143
+ const hasRow2 = r2Left.length || r2Right.length;
144
+
145
+ // ── Compute canvas width from widest row ─────────────────────────
146
+ const GAP = 14;
147
+ const widthOfRow = (items, extra = 0) =>
148
+ items.reduce((a, n) => a + n.w, 0) + Math.max(0, items.length - 1) * GAP + extra;
149
+
150
+ const r1W = widthOfRow(r1Left, r1Center.length ? 32 : 0) + widthOfRow(r1Center);
151
+ const questsW = widthOfRow(r1Right);
152
+ const r2W = widthOfRow(r2Left, r2Right.length ? 32 : 0) + widthOfRow(r2Right);
153
+ const priorW = priorNode ? pillWidth(truncate(priorNode.topic || priorNode.id || '', TRUNC_PILL)) : 0;
154
+ const sessionW = nodeSession.w;
155
+
156
+ const contentW = Math.max(r1W, questsW, r2W, priorW, sessionW, 460);
157
+ const WIDTH = contentW + SIDE_PAD * 2;
158
+ const CX = WIDTH / 2;
159
+
160
+ // ── Assign y coordinates ─────────────────────────────────────────
161
+ let cy = TOP_PAD;
162
+ let yPrior = null, ySession = null, yRow1 = null, yQuests = null, yRow2 = null;
163
+
164
+ if (hasRow0) {
165
+ yPrior = cy + PILL_H / 2;
166
+ cy += ROW_H;
167
+ }
168
+ ySession = cy + SESSION_H / 2;
169
+ cy += SESSION_H + 34;
170
+
171
+ if (hasRow1) {
172
+ yRow1 = cy + PILL_H / 2;
173
+ cy += ROW_H - 6;
174
+ }
175
+ if (hasQuestsRow) {
176
+ yQuests = cy + PILL_H / 2;
177
+ cy += ROW_H - 6;
178
+ }
179
+ if (hasRow2) {
180
+ yRow2 = cy + PILL_H / 2;
181
+ cy += ROW_H - 6;
182
+ }
183
+ const HEIGHT = cy + LEGEND_H + 8;
184
+
185
+ // ── Lay out nodes horizontally per row ───────────────────────────
186
+ let priorPlaced = null;
187
+ if (hasRow0) {
188
+ priorPlaced = mkNode('prior', 'session',
189
+ truncate(priorNode.topic || priorNode.id || 'prior session', TRUNC_PILL),
190
+ priorNode.author ? `by ${priorNode.author}` : null);
191
+ priorPlaced.x = CX - priorPlaced.w / 2;
192
+ priorPlaced.y = yPrior - PILL_H / 2;
193
+ priorPlaced.h = PILL_H;
194
+ }
195
+
196
+ nodeSession.x = CX - nodeSession.w / 2;
197
+ nodeSession.y = ySession - SESSION_H / 2;
198
+ nodeSession.h = SESSION_H;
199
+
200
+ // Row 1 (persons + project): persons left, project centered-right.
201
+ if (hasRow1) layoutRow(r1Left, r1Center, [], yRow1, WIDTH);
202
+ // Quests row: center the whole group.
203
+ if (hasQuestsRow) {
204
+ const total = questsW;
205
+ const startX = Math.max(SIDE_PAD, (WIDTH - total) / 2);
206
+ placeHorizontally(r1Right, startX, yQuests - PILL_H / 2, GAP);
207
+ }
208
+ // Outputs row: artifacts left, PRs + later right.
209
+ if (hasRow2) layoutRow(r2Left, [], r2Right, yRow2, WIDTH);
210
+
211
+ // ── Collect SVG children ─────────────────────────────────────────
212
+ const children = [];
213
+
214
+ // Arrow marker
215
+ children.push(h('defs', { key: 'defs' },
216
+ h('marker', {
217
+ id: 'sg-arrow',
218
+ viewBox: '0 0 10 10',
219
+ refX: 9, refY: 5,
220
+ markerWidth: 6, markerHeight: 6,
221
+ orient: 'auto-start-reverse',
222
+ },
223
+ h('path', { d: 'M0,0 L10,5 L0,10 z', fill: 'var(--border)' })
224
+ ),
225
+ ));
226
+
227
+ // ── Edges (drawn first so they sit behind nodes) ─────────────────
228
+
229
+ // Row 0 → session (IMPLEMENTS or CONTINUES)
230
+ if (hasRow0 && priorPlaced) {
231
+ children.push(edge({
232
+ key: 'e-prior',
233
+ x1: CX, y1: priorPlaced.y + PILL_H,
234
+ x2: CX, y2: nodeSession.y,
235
+ label: priorRel,
236
+ }));
237
+ }
238
+
239
+ // Session → Row 1 nodes
240
+ if (hasRow1) {
241
+ r1Left.forEach((n, i) => {
242
+ const rel = n.id.startsWith('author-') ? 'BY' : 'HANDED_TO';
243
+ children.push(edge({
244
+ key: `e-s-${n.id}`,
245
+ x1: nodeSession.x + sessionW * (0.35 - 0.1 * Math.min(i, 2)),
246
+ y1: nodeSession.y + SESSION_H,
247
+ x2: n.x + n.w / 2,
248
+ y2: n.y,
249
+ label: rel,
250
+ }));
251
+ });
252
+ r1Center.forEach((n) => {
253
+ children.push(edge({
254
+ key: `e-s-${n.id}`,
255
+ x1: CX, y1: nodeSession.y + SESSION_H,
256
+ x2: n.x + n.w / 2, y2: n.y,
257
+ label: 'ABOUT',
258
+ }));
259
+ });
260
+ }
261
+ if (hasQuestsRow) {
262
+ // Quests live on their own row (below persons+project), so these edges
263
+ // need to curve around the Row 1 nodes rather than crossing them.
264
+ r1Right.forEach((n, i) => {
265
+ const sx = nodeSession.x + sessionW * (0.4 + 0.1 * i);
266
+ children.push(edge({
267
+ key: `e-s-${n.id}`,
268
+ x1: sx,
269
+ y1: nodeSession.y + SESSION_H,
270
+ x2: n.x + n.w / 2,
271
+ y2: n.y,
272
+ label: i === 0 ? 'ADVANCED' : null,
273
+ curved: true,
274
+ }));
275
+ });
276
+ }
277
+
278
+ // Session → Row 2 (artifacts and PRs), Later → Session
279
+ if (hasRow2) {
280
+ r2Left.forEach((n, i) => {
281
+ children.push(edge({
282
+ key: `e-s-${n.id}`,
283
+ x1: nodeSession.x + sessionW * 0.3,
284
+ y1: nodeSession.y + SESSION_H,
285
+ x2: n.x + n.w / 2, y2: n.y,
286
+ label: i === 0 ? 'HAS_ACTIVITY' : null,
287
+ curved: true,
288
+ }));
289
+ });
290
+ r2Right.forEach((n, i) => {
291
+ if (n.id.startsWith('later-')) {
292
+ // incoming IMPLEMENTS
293
+ children.push(edge({
294
+ key: `e-${n.id}-s`,
295
+ x1: n.x + n.w / 2, y1: n.y,
296
+ x2: nodeSession.x + sessionW * 0.7, y2: nodeSession.y + SESSION_H,
297
+ label: i === 0 ? 'IMPLEMENTS' : null,
298
+ curved: true,
299
+ }));
300
+ } else {
301
+ children.push(edge({
302
+ key: `e-s-${n.id}`,
303
+ x1: nodeSession.x + sessionW * 0.7,
304
+ y1: nodeSession.y + SESSION_H,
305
+ x2: n.x + n.w / 2, y2: n.y,
306
+ label: n.id.startsWith('pr-') ? (i === 0 ? 'PRODUCED' : null) : null,
307
+ curved: true,
308
+ }));
309
+ }
310
+ });
311
+ }
312
+
313
+ // 2-hop: artifact → quest (PART_OF). Curved lines routed up from Row 2
314
+ // artifacts to their Row 1 quests.
315
+ if (hasRow1 && hasRow2 && artifactQuestLinks.length > 0) {
316
+ const questNodeById = new Map(r1Right.map(n => [n.id.replace(/^quest-/, ''), n]));
317
+ const artNodeById = new Map(r2Left.map(n => [n.id.replace(/^art-/, ''), n]));
318
+ let edgeIdx = 0;
319
+ for (const link of artifactQuestLinks) {
320
+ const artNode = artNodeById.get(link.artifactId);
321
+ const questNode = questNodeById.get(link.questId);
322
+ if (!artNode || !questNode) continue;
323
+ children.push(edge({
324
+ key: `e-a-q-${edgeIdx++}`,
325
+ x1: artNode.x + artNode.w / 2, y1: artNode.y,
326
+ x2: questNode.x + questNode.w / 2, y2: questNode.y + PILL_H,
327
+ label: edgeIdx === 1 ? 'PART_OF' : null,
328
+ curved: true,
329
+ dashed: true,
330
+ }));
331
+ }
332
+ }
333
+
334
+ // ── Nodes ────────────────────────────────────────────────────────
335
+ if (hasRow0 && priorPlaced) children.push(renderNode({ ...priorPlaced, key: 'n-prior' }));
336
+ children.push(renderSessionNode({ ...nodeSession, key: 'n-session' }));
337
+ if (hasRow1) [...r1Left, ...r1Center].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
338
+ if (hasQuestsRow) r1Right.forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
339
+ if (hasRow2) [...r2Left, ...r2Right].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
340
+
341
+ // ── Overflow indicators ──────────────────────────────────────────
342
+ if (overflowQuests > 0 && r1Right.length > 0) {
343
+ const last = r1Right[r1Right.length - 1];
344
+ children.push(overflowLabel({ key: 'of-q', x: last.x + last.w + 10, y: last.y + PILL_H / 2 + 3, n: overflowQuests }));
345
+ }
346
+ if (overflowArtifacts > 0 && r2Left.length > 0) {
347
+ const last = r2Left[r2Left.length - 1];
348
+ children.push(overflowLabel({ key: 'of-a', x: last.x + last.w / 2, y: last.y + PILL_H + 24, n: overflowArtifacts, anchor: 'middle' }));
349
+ }
350
+ if (overflowPrs > 0 && r2Right.length > 0) {
351
+ const lastPr = r2Right.filter(n => n.id.startsWith('pr-')).pop();
352
+ if (lastPr) {
353
+ children.push(overflowLabel({ key: 'of-p', x: lastPr.x + lastPr.w / 2, y: lastPr.y + PILL_H + 24, n: overflowPrs, anchor: 'middle' }));
354
+ }
355
+ }
356
+
357
+ // ── Legend ───────────────────────────────────────────────────────
358
+ const legendTypes = ['session', 'person', 'project', 'quest', 'artifact', 'pr'];
359
+ const legendLabels = { session: 'Session', person: 'Person', project: 'Project', quest: 'Quest', artifact: 'Artifact', pr: 'PR' };
360
+ children.push(h('g', { key: 'legend', transform: `translate(${SIDE_PAD}, ${HEIGHT - LEGEND_H + 4})` },
361
+ ...legendTypes.map((t, i) => {
362
+ const x = i * 88;
363
+ return h('g', { key: `lg-${t}` },
364
+ h('circle', { cx: x + 6, cy: 10, r: 5, fill: STYLE[t].stroke, fillOpacity: 0.9 }),
365
+ h('text', {
366
+ x: x + 17, y: 14,
367
+ fontFamily: fonts.mono, fontSize: 10,
368
+ fill: 'var(--muted)',
369
+ letterSpacing: '0.03em',
370
+ }, legendLabels[t]),
371
+ );
372
+ }),
373
+ ));
374
+
375
+ return h('div', {
376
+ style: {
377
+ marginBottom: '2rem',
378
+ padding: '1rem 0.5rem 0.75rem',
379
+ borderBottom: '1px solid var(--hairline)',
380
+ },
381
+ },
382
+ h('div', {
383
+ style: {
384
+ fontFamily: fonts.mono, fontSize: '11px',
385
+ textTransform: 'uppercase', letterSpacing: '0.08em',
386
+ color: 'var(--muted)',
387
+ marginBottom: '0.75rem',
388
+ paddingLeft: '0.25rem',
389
+ },
390
+ }, 'Subgraph'),
391
+ h('svg', {
392
+ viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
393
+ width: '100%',
394
+ height: 'auto',
395
+ preserveAspectRatio: 'xMidYMid meet',
396
+ style: { display: 'block', maxHeight: '640px' },
397
+ role: 'img',
398
+ 'aria-label': 'Handoff knowledge-graph neighborhood',
399
+ }, ...children),
400
+ );
401
+ }
402
+
403
+ // ── layout helpers ──────────────────────────────────────────────────
404
+
405
+ function layoutRow(leftNodes, centerNodes, rightNodes, yCenter, width) {
406
+ const GAP = 14;
407
+ const leftW = rowSpan(leftNodes, GAP);
408
+ const centerW = rowSpan(centerNodes, GAP);
409
+ const rightW = rowSpan(rightNodes, GAP);
410
+ const total = leftW + centerW + rightW;
411
+
412
+ // If three zones fit comfortably, space them; otherwise compress.
413
+ const available = width - SIDE_PAD * 2;
414
+ const slack = Math.max(0, available - total);
415
+ const padBetween = centerNodes.length > 0 ? slack / 2 : slack;
416
+
417
+ let cursor = SIDE_PAD;
418
+ placeHorizontally(leftNodes, cursor, yCenter - PILL_H / 2, GAP);
419
+ cursor = SIDE_PAD + leftW + (leftNodes.length ? padBetween : 0);
420
+ if (centerNodes.length) {
421
+ // Center the center zone relative to the canvas
422
+ const centerStart = (width - centerW) / 2;
423
+ placeHorizontally(centerNodes, centerStart, yCenter - PILL_H / 2, GAP);
424
+ cursor = centerStart + centerW + padBetween;
425
+ }
426
+ // Right zone flush to right
427
+ const rightStart = width - SIDE_PAD - rightW;
428
+ placeHorizontally(rightNodes, rightStart, yCenter - PILL_H / 2, GAP);
429
+ }
430
+
431
+ function rowSpan(nodes, gap) {
432
+ if (nodes.length === 0) return 0;
433
+ return nodes.reduce((a, n) => a + n.w, 0) + (nodes.length - 1) * gap;
434
+ }
435
+
436
+ function placeHorizontally(nodes, startX, y, gap) {
437
+ let x = startX;
438
+ for (const n of nodes) {
439
+ n.x = x;
440
+ n.y = y;
441
+ n.h = PILL_H;
442
+ x += n.w + gap;
443
+ }
444
+ }
445
+
446
+ function pillWidth(label, isSession) {
447
+ const minW = isSession ? SESSION_MIN_W : PILL_MIN_W;
448
+ const maxW = isSession ? SESSION_MAX_W : PILL_MAX_W;
449
+ const charW = isSession ? CHAR_W_SANS_13 : CHAR_W_SANS_12;
450
+ const raw = Math.ceil((label || '').length * charW) + PILL_PAD_X;
451
+ return clamp(raw, minW, maxW);
452
+ }
453
+
454
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
455
+
456
+ function truncate(s, max) {
457
+ if (!s) return '';
458
+ if (s.length <= max) return s;
459
+ return s.slice(0, max - 1) + '…';
460
+ }
461
+
462
+ // ── rendering helpers ──────────────────────────────────────────────
463
+
464
+ function renderSessionNode({ x, y, w, label, key }) {
465
+ const s = STYLE.session;
466
+ return h('g', { key },
467
+ h('rect', {
468
+ x, y, width: w, height: SESSION_H,
469
+ rx: 10, ry: 10,
470
+ fill: s.fill, stroke: s.stroke, strokeWidth: 1.5,
471
+ }),
472
+ h('text', {
473
+ x: x + w / 2, y: y + 18,
474
+ textAnchor: 'middle',
475
+ fontFamily: fonts.mono, fontSize: 9,
476
+ fill: 'var(--terracotta)',
477
+ letterSpacing: '0.08em',
478
+ }, 'THIS HANDOFF'),
479
+ h('text', {
480
+ x: x + w / 2, y: y + 40,
481
+ textAnchor: 'middle',
482
+ fontFamily: fonts.sans, fontSize: 13, fontWeight: 500,
483
+ fill: s.text,
484
+ }, label),
485
+ );
486
+ }
487
+
488
+ function renderNode({ x, y, w, label, sub, kind, key }) {
489
+ const s = STYLE[kind] || STYLE.project;
490
+ const children = [
491
+ h('rect', {
492
+ key: 'bg',
493
+ x, y, width: w, height: PILL_H,
494
+ rx: PILL_H / 2, ry: PILL_H / 2,
495
+ fill: s.fill, stroke: s.stroke, strokeWidth: 1,
496
+ }),
497
+ h('text', {
498
+ key: 'lbl',
499
+ x: x + w / 2, y: y + PILL_H / 2 + 4,
500
+ textAnchor: 'middle',
501
+ fontFamily: fonts.sans, fontSize: 12, fontWeight: 500,
502
+ fill: s.text,
503
+ }, label),
504
+ ];
505
+ if (sub) {
506
+ children.push(h('text', {
507
+ key: 'sub',
508
+ x: x + w / 2, y: y + PILL_H + 11,
509
+ textAnchor: 'middle',
510
+ fontFamily: fonts.mono, fontSize: 9,
511
+ fill: 'var(--muted)',
512
+ fontStyle: 'italic',
513
+ }, sub));
514
+ }
515
+ return h('g', { key }, ...children);
516
+ }
517
+
518
+ function overflowLabel({ key, x, y, n, anchor = 'start' }) {
519
+ return h('text', {
520
+ key, x, y,
521
+ textAnchor: anchor,
522
+ fontFamily: fonts.mono, fontSize: 10,
523
+ fill: 'var(--muted)',
524
+ }, `+${n} more`);
525
+ }
526
+
527
+ function edge({ key, x1, y1, x2, y2, label, curved, dashed }) {
528
+ const midx = (x1 + x2) / 2;
529
+ const midy = (y1 + y2) / 2;
530
+ const path = curved
531
+ ? `M ${x1} ${y1} C ${x1} ${midy}, ${x2} ${midy}, ${x2} ${y2}`
532
+ : `M ${x1} ${y1} L ${x2} ${y2}`;
533
+ const strokeProps = {
534
+ fill: 'none',
535
+ stroke: 'var(--border)',
536
+ strokeWidth: 1.2,
537
+ markerEnd: 'url(#sg-arrow)',
538
+ };
539
+ if (dashed) strokeProps.strokeDasharray = '3 3';
540
+ const elems = [h('path', { key: 'p', d: path, ...strokeProps })];
541
+ if (label) {
542
+ // Offset label above midpoint for vertical edges, to the right for others
543
+ elems.push(h('text', {
544
+ key: 'lbl',
545
+ x: midx + 4, y: midy + 3,
546
+ fontFamily: fonts.mono, fontSize: 9,
547
+ fill: 'var(--muted)',
548
+ letterSpacing: '0.06em',
549
+ }, label));
550
+ }
551
+ return h('g', { key }, ...elems);
552
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egregore-artifacts",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Generate branded HTML artifacts from Egregore data",
5
5
  "type": "module",
6
6
  "license": "MIT",