@agentcairn/mcp-server 0.1.5 → 0.2.1

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,12 +3,11 @@ import { debounce, fmt, h, icon, KINDS, searchBox } from '../dom.js'
3
3
  import { live } from '../store.js'
4
4
  import { importance, openMemory } from './memoryDrawer.js'
5
5
 
6
- const KIND_VAR = { overview: '--s1', decision: '--s2', convention: '--s3', gotcha: '--s4', checkpoint: '--s5', thread: '--s6', glossary: '--s7' }
6
+ const KIND_VAR = { handoff: '--s8', overview: '--s1', decision: '--s2', convention: '--s3', gotcha: '--s4', checkpoint: '--s5', thread: '--s6', glossary: '--s7' }
7
7
  // Seven kinds are more than hue alone can carry safely, so kind is also a shape.
8
- const KIND_SHAPE = { overview: 'star', decision: 'diamond', convention: 'round-rectangle', gotcha: 'triangle', checkpoint: 'ellipse', thread: 'round-tag', glossary: 'pentagon' }
9
- const KIND_GLYPH = { overview: '★', decision: '◆', convention: '▢', gotcha: '▲', checkpoint: '●', thread: '▰', glossary: '⬟' }
10
- const WRITE_TOOLS = new Set(['cairn_remember', 'cairn_checkpoint', 'cairn_forget'])
11
- const LABEL_ZOOM = 1.15
8
+ const KIND_SHAPE = { handoff: 'octagon', overview: 'star', decision: 'diamond', convention: 'round-rectangle', gotcha: 'triangle', checkpoint: 'ellipse', thread: 'round-tag', glossary: 'pentagon' }
9
+ const KIND_GLYPH = { handoff: '◼', overview: '★', decision: '◆', convention: '▢', gotcha: '▲', checkpoint: '●', thread: '▰', glossary: '⬟' }
10
+ const WRITE_TOOLS = new Set(['cairn_remember', 'cairn_checkpoint', 'cairn_handoff', 'cairn_forget'])
12
11
 
13
12
  const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
14
13
 
@@ -23,63 +22,98 @@ function loadCytoscape() {
23
22
  })
24
23
  }
25
24
 
25
+ /** Black or white caption, whichever reads better on this fill. */
26
+ function inkOn(color) {
27
+ const m = /^#?([0-9a-f]{6})$/i.exec(color.trim())
28
+ if (!m) return '#ffffff'
29
+ const [r, g, b] = [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16) / 255).map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
30
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.3 ? '#141415' : '#ffffff'
31
+ }
32
+ /** The fill colour of a kind, project or area: what the chips in the side panel use too. */
33
+ export function nodeColor(type, kind) {
34
+ if (type === 'project') return css('--text')
35
+ if (type === 'area') return css('--text-2')
36
+ if (type === 'remote') return css('--muted')
37
+ return css(KIND_VAR[kind] ?? '--s1')
38
+ }
39
+ /** Mixes two #rrggbb colours (cytoscape does not understand color-mix). */
40
+ function mix(a, b, amount) {
41
+ const p = c => [1, 3, 5].map(i => parseInt(c.replace('#', '').padEnd(6, '0').slice(i - 1, i + 1), 16))
42
+ const [x, y] = [p(a), p(b)]
43
+ return `#${x.map((v, i) => Math.round(v + (y[i] - v) * amount).toString(16).padStart(2, '0')).join('')}`
44
+ }
45
+ const shorten = (text, max) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text)
46
+
26
47
  function stylesheet() {
27
- const text = css('--text')
28
48
  const surface = css('--surface')
29
- const axis = css('--axis')
30
49
  const muted = css('--muted')
31
50
  const accent = css('--s1')
32
51
  const pink = css('--s5')
52
+ const page = css('--page')
53
+ const circle = (type, kind) => {
54
+ const fill = nodeColor(type, kind)
55
+ return { 'background-color': fill, color: type === 'project' || type === 'area' ? page : inkOn(fill), 'border-color': mix(fill, type === 'project' || type === 'area' ? page : '#000000', 0.3) }
56
+ }
33
57
  return [
34
- { selector: 'node', style: { 'font-size': 11, color: text, 'font-family': css('--sans'), 'text-valign': 'bottom', 'text-margin-y': 6, 'text-wrap': 'ellipsis', 'text-max-width': 130, 'text-outline-color': surface, 'text-outline-width': 3, 'border-width': 0, 'overlay-opacity': 0, 'underlay-color': accent, 'underlay-opacity': 0, 'underlay-padding': 6, 'underlay-shape': 'ellipse', label: '' } },
35
- { selector: 'node[type="project"]', style: { label: 'data(label)', shape: 'ellipse', width: 64, height: 64, 'background-color': text, 'border-width': 6, 'border-color': accent, 'font-size': 13, 'font-weight': 700, 'text-max-width': 180, 'text-margin-y': 8, 'z-index': 10 } },
36
- { selector: 'node[type="area"]', style: { label: 'data(label)', shape: 'hexagon', width: 40, height: 40, 'background-color': css('--raised'), 'border-width': 2, 'border-color': muted, 'font-weight': 600, 'z-index': 5 } },
37
- { selector: 'node[type="remote"]', style: { shape: 'round-rectangle', width: 14, height: 14, 'background-color': muted, 'font-size': 10, color: muted } },
38
- { selector: 'node[type="memory"]', style: { width: 'mapData(importance, 1, 5, 15, 32)', height: 'mapData(importance, 1, 5, 15, 32)', 'border-width': 1.5, 'border-color': surface } },
39
- ...KINDS.map(kind => ({ selector: `node[kind="${kind}"]`, style: { 'background-color': css(KIND_VAR[kind]), shape: KIND_SHAPE[kind] } })),
40
- { selector: 'node[status="superseded"]', style: { opacity: 0.42, 'border-style': 'dashed', 'border-color': muted, 'border-width': 2 } },
41
- { selector: 'node.lbl', style: { label: 'data(label)' } },
42
- { selector: 'edge', style: { width: 1.2, 'line-color': axis, 'curve-style': 'bezier', opacity: 0.85 } },
43
- { selector: 'edge[type="HAS_AREA"]', style: { width: 2.4, 'line-color': muted } },
44
- { selector: 'edge[type="BELONGS_TO"]', style: { 'line-style': 'dotted', 'line-color': muted } },
45
- { selector: 'edge[type="SUPERSEDES"]', style: { 'line-style': 'dashed', 'line-color': pink, 'target-arrow-shape': 'triangle', 'target-arrow-color': pink, 'arrow-scale': 1.1, width: 1.8, 'curve-style': 'unbundled-bezier' } },
46
- { selector: '.dim', style: { opacity: 0.1 } },
47
- { selector: '.match', style: { 'border-width': 4, 'border-color': css('--warn-text'), label: 'data(label)' } },
58
+ { selector: 'node', style: { shape: 'ellipse', label: 'data(caption)', 'font-size': 9, 'font-weight': 600, color: '#fff', 'font-family': css('--sans'), 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'wrap', 'text-max-width': 46, 'line-height': 1.15, 'min-zoomed-font-size': 7, 'border-width': 2, 'overlay-opacity': 0, 'underlay-color': accent, 'underlay-opacity': 0, 'underlay-padding': 6, 'underlay-shape': 'ellipse' } },
59
+ { selector: 'node[type="project"]', style: { ...circle('project'), width: 84, height: 84, 'font-size': 12, 'font-weight': 700, 'text-max-width': 70, 'z-index': 10 } },
60
+ { selector: 'node[type="area"]', style: { ...circle('area'), width: 62, height: 62, 'font-size': 10, 'text-max-width': 52, 'z-index': 5 } },
61
+ { selector: 'node[type="remote"]', style: { ...circle('remote'), width: 34, height: 34, 'font-size': 8, 'text-max-width': 30 } },
62
+ { selector: 'node[type="memory"]', style: { width: 'mapData(importance, 1, 5, 44, 66)', height: 'mapData(importance, 1, 5, 44, 66)', 'text-max-width': 48 } },
63
+ ...KINDS.map(kind => ({ selector: `node[kind="${kind}"]`, style: circle('memory', kind) })),
64
+ { selector: 'node[status="superseded"]', style: { opacity: 0.45, 'border-style': 'dashed', 'border-color': muted, 'border-width': 2 } },
65
+ { selector: 'edge', style: { width: 1.1, 'line-color': muted, opacity: 0.75, 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'target-arrow-color': muted, 'arrow-scale': 0.8, label: 'data(type)', 'font-size': 8, color: muted, 'font-family': css('--sans'), 'text-rotation': 'autorotate', 'text-background-color': surface, 'text-background-opacity': 1, 'text-background-padding': 2, 'min-zoomed-font-size': 8, 'text-margin-y': -1 } },
66
+ { selector: 'edge[type="BELONGS_TO"]', style: { 'line-style': 'dotted' } },
67
+ { selector: 'edge[type="SUPERSEDES"]', style: { 'line-style': 'dashed', 'line-color': pink, 'target-arrow-color': pink, color: pink, width: 1.6, 'curve-style': 'unbundled-bezier' } },
68
+ { selector: '.dim', style: { opacity: 0.12 } },
69
+ { selector: '.match', style: { 'border-width': 4, 'border-color': css('--warn-text'), 'underlay-color': css('--warn-text'), 'underlay-opacity': 0.25, 'underlay-padding': 7 } },
48
70
  { selector: '.hidden', style: { display: 'none' } },
49
- { selector: ':selected', style: { 'border-width': 4, 'border-color': accent, 'underlay-opacity': 0.22, 'underlay-padding': 9, label: 'data(label)' } },
71
+ { selector: '.collapsed', style: { display: 'none' } },
72
+ { selector: 'node[type="project"].sized', style: { width: 'mapData(memories, 0, 120, 104, 160)', height: 'mapData(memories, 0, 120, 104, 160)', 'font-size': 12, 'text-max-width': 90 } },
73
+ { selector: 'node[type="project"].quiet', style: { opacity: 0.6 } },
74
+ { selector: 'node[type="project"].away', style: { opacity: 0.25 } },
75
+ { selector: 'node[type="project"].open', style: { 'underlay-opacity': 0.16, 'underlay-padding': 12 } },
76
+ { selector: ':selected', style: { 'border-width': 3, 'border-color': accent, 'underlay-opacity': 0.28, 'underlay-padding': 9 } },
77
+ { selector: 'edge:selected', style: { width: 2.2, 'line-color': accent, 'target-arrow-color': accent, opacity: 1 } },
50
78
  ]
51
79
  }
52
80
 
53
81
  /** Layouts that put the project at the root, then its areas, then their memories. */
54
- function layoutOptions(cy, name) {
82
+ function layoutOptions(cy, name, overview = false) {
55
83
  const roots = cy.nodes('[type="project"]')
56
84
  switch (name) {
57
85
  case 'radial':
86
+ if (overview) return { name: 'concentric', concentric: n => n.data('memories') ?? 0, levelWidth: () => 25, minNodeSpacing: 90, spacingFactor: 1.3, avoidOverlap: true, padding: 40, animate: false }
58
87
  return { name: 'breadthfirst', roots, circle: true, directed: false, spacingFactor: 1.05, avoidOverlap: true, padding: 40, animate: false }
59
88
  case 'tree':
60
89
  return { name: 'breadthfirst', roots, circle: false, directed: false, spacingFactor: 1.15, avoidOverlap: true, padding: 40, animate: false }
61
90
  case 'force':
62
- return { name: 'cose', animate: false, randomize: true, nodeRepulsion: 12000, idealEdgeLength: 90, nodeOverlap: 24, gravity: 0.45, numIter: 1200, padding: 40 }
91
+ return { name: 'cose', animate: false, randomize: true, nodeRepulsion: 16000, idealEdgeLength: 110, nodeOverlap: 30, gravity: 0.35, numIter: 1200, nodeDimensionsIncludeLabels: false, padding: 40 }
63
92
  default:
64
93
  return { name: 'grid', animate: false, padding: 40, avoidOverlap: true }
65
94
  }
66
95
  }
67
96
 
68
97
  const LAYOUT_LABELS = { radial: 'Radial', tree: 'Tree', force: 'Force', grid: 'Grid' }
98
+ const FEW_PROJECTS = 3 // up to this many, their areas are shown straight away
99
+ const QUIET_AFTER_DAYS = 21
69
100
 
70
101
  export async function mount(container, { query, navigate }) {
71
102
  const [{ projects }] = await Promise.all([api('projects'), loadCytoscape()])
72
103
  let project = query.project ?? ''
73
- let layoutName = 'radial'
104
+ let layoutName = query.project ? 'force' : 'radial'
74
105
  let limit = 300
75
106
  let pulseOn = true
76
107
  let showSuperseded = true
108
+ let pinnedAreas = false // overview: keep every project's areas open
109
+ let showRemotes = false
110
+ const expanded = new Set()
77
111
  const hiddenKinds = new Set()
78
112
  let cy = null
79
113
  let refreshTimer = null
80
114
 
81
115
  // ---- toolbar -------------------------------------------------------------
82
- const projectSelect = h('select', { 'aria-label': 'Project' }, h('option', { value: '' }, 'All projects'), projects.map(p => h('option', { value: p.id }, `${p.id} · ${p.active}`)))
116
+ const projectSelect = h('select', { 'aria-label': 'Project' }, h('option', { value: '' }, 'All projects'), projects.map(p => h('option', { value: p.id }, `${p.name} · ${p.active}`)))
83
117
  projectSelect.value = project
84
118
  projectSelect.addEventListener('change', () => navigate(`#/graph${projectSelect.value ? `?project=${encodeURIComponent(projectSelect.value)}` : ''}`))
85
119
 
@@ -99,6 +133,21 @@ export async function mount(container, { query, navigate }) {
99
133
  const pulseBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Flash nodes when an agent reads or writes them' }, icon('zap'), 'Live pulse')
100
134
  pulseBtn.addEventListener('click', () => { pulseOn = !pulseOn; pulseBtn.setAttribute('aria-pressed', String(pulseOn)) })
101
135
 
136
+ const areasBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Open every project to show its areas' }, 'Areas')
137
+ areasBtn.addEventListener('click', () => {
138
+ pinnedAreas = !pinnedAreas
139
+ areasBtn.setAttribute('aria-pressed', String(pinnedAreas))
140
+ pinnedAreas ? expandAll() : collapseAll()
141
+ fit()
142
+ })
143
+ const remotesBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Show the git remotes that map to each project' }, 'Git remotes')
144
+ remotesBtn.addEventListener('click', () => {
145
+ showRemotes = !showRemotes
146
+ remotesBtn.setAttribute('aria-pressed', String(showRemotes))
147
+ cy?.nodes('[type="remote"]').toggleClass('collapsed', !showRemotes)
148
+ hud()
149
+ })
150
+
102
151
  const supBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Show memories that were replaced' }, 'Superseded')
103
152
  supBtn.addEventListener('click', () => { showSuperseded = !showSuperseded; supBtn.setAttribute('aria-pressed', String(showSuperseded)); applyFilters() })
104
153
 
@@ -123,9 +172,9 @@ export async function mount(container, { query, navigate }) {
123
172
  container.append(
124
173
  h('div', { class: 'graph-page' },
125
174
  h('div', { class: 'page-head', style: { marginBottom: '14px' } },
126
- h('div', null, h('h1', null, 'Graph'), h('div', { class: 'sub' }, project ? `The memory tree of ${project}: project, its areas, and the memories filed under each.` : 'Every project, with its areas and git remotes. Pick a project to see its memories.')),
175
+ h('div', null, h('h1', null, 'Graph'), h('div', { class: 'sub' }, project ? `The memory tree of ${project}: project, its areas, and the memories filed under each.` : 'Every project, sized by how much it remembers. Click one to open its areas; double-click to explore its memories.')),
127
176
  ),
128
- h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, pulseBtn, project && supBtn),
177
+ h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, !project && areasBtn, !project && remotesBtn, pulseBtn, project && supBtn),
129
178
  h('div', { class: 'graph-layout' },
130
179
  h('div', { class: 'canvas-wrap' }, canvas, kindChips.length > 0 && h('div', { class: 'canvas-overlay tl' }, ...kindChips), controls, hudEl),
131
180
  side,
@@ -134,12 +183,15 @@ export async function mount(container, { query, navigate }) {
134
183
  )
135
184
 
136
185
  // ---- graph ---------------------------------------------------------------
137
- const element = n => ({ group: 'nodes', data: { ...n.data, mid: n.data.id, id: n.id, label: n.label, type: n.type } })
186
+ const caption = n => (n.type === 'project' ? shorten(n.label, 18) : n.type === 'area' ? shorten(n.label, 14) : n.type === 'remote' ? shorten(n.label, 8) : shorten(n.label, 30))
187
+ const element = n => ({ group: 'nodes', data: { ...n.data, mid: n.data.id, id: n.id, label: n.label, caption: caption(n), type: n.type } })
138
188
  const edgeEl = e => ({ group: 'edges', data: { id: e.id, source: e.source, target: e.target, type: e.type } })
139
189
 
140
190
  function runLayout(doFit) {
141
191
  if (!cy) return
142
- const layout = cy.layout(layoutOptions(cy, layoutName))
192
+ const overview = !project
193
+ const eles = overview ? cy.nodes('[type="project"]') : cy.elements()
194
+ const layout = eles.layout(layoutOptions(cy, layoutName, overview))
143
195
  if (doFit) layout.one('layoutstop', fit)
144
196
  layout.run()
145
197
  }
@@ -147,12 +199,64 @@ export async function mount(container, { query, navigate }) {
147
199
  cy?.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 260 })
148
200
  }
149
201
  function hud() {
150
- if (cy) hudEl.textContent = `${cy.nodes().length} nodes · ${cy.edges().length} edges · ${cy.zoom().toFixed(2)}×`
151
- }
152
- function syncLabels() {
153
202
  if (!cy) return
154
- const show = cy.zoom() >= LABEL_ZOOM
155
- cy.nodes('[type="memory"]').toggleClass('lbl', show)
203
+ // what is drawn: folded-away and filtered nodes are not counted
204
+ const shown = n => !n.is('.collapsed, .hidden')
205
+ const nodes = cy.nodes().filter(shown).length
206
+ const edges = cy.edges().filter(e => shown(e.source()) && shown(e.target())).length
207
+ hudEl.textContent = `${nodes} nodes · ${edges} edges · ${cy.zoom().toFixed(2)}×`
208
+ }
209
+
210
+ /** The areas of a project fan out around it; everything else stays put. */
211
+ function expand(node, animate = true) {
212
+ if (node.data('type') !== 'project') return
213
+ const areas = node.outgoers('node[type="area"]')
214
+ const center = node.position()
215
+ const radius = 84 + areas.length * 8
216
+ areas.forEach((a, i) => {
217
+ const angle = (2 * Math.PI * i) / Math.max(areas.length, 1) - Math.PI / 2
218
+ const spot = { x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle) }
219
+ a.removeClass('collapsed')
220
+ if (animate) {
221
+ a.position(center)
222
+ a.animate({ position: spot }, { duration: 240, easing: 'ease-out' })
223
+ } else a.position(spot)
224
+ })
225
+ node.addClass('open')
226
+ expanded.add(node.id())
227
+ hud()
228
+ }
229
+ function collapse(node) {
230
+ node.outgoers('node[type="area"]').addClass('collapsed')
231
+ node.removeClass('open')
232
+ expanded.delete(node.id())
233
+ hud()
234
+ }
235
+ const expandAll = () => cy?.nodes('[type="project"]').forEach(n => expand(n, false))
236
+ const collapseAll = () => {
237
+ cy?.nodes('[type="project"]').forEach(collapse)
238
+ cy?.nodes('[type="project"]').removeClass('away')
239
+ }
240
+ function toggleExpand(node) {
241
+ if (pinnedAreas) return
242
+ const wasOpen = expanded.has(node.id())
243
+ collapseAll()
244
+ if (!wasOpen) {
245
+ expand(node)
246
+ // the rest step back while one is open
247
+ cy.nodes('[type="project"]').not(node).addClass('away')
248
+ }
249
+ }
250
+ /** Many projects at once is a lot to draw: start with just the projects, and let people open them. */
251
+ function prepareOverview() {
252
+ const cutoff = Date.now() - QUIET_AFTER_DAYS * 86_400_000
253
+ cy.nodes('[type="project"]').forEach(n => {
254
+ n.addClass('sized')
255
+ n.toggleClass('quiet', Date.parse(n.data('lastMemoryAt') ?? n.data('createdAt')) < cutoff)
256
+ })
257
+ cy.nodes('[type="area"]').addClass('collapsed')
258
+ cy.nodes('[type="remote"]').addClass('collapsed')
259
+ expanded.clear()
156
260
  }
157
261
 
158
262
  function applyFilters() {
@@ -187,24 +291,44 @@ export async function mount(container, { query, navigate }) {
187
291
  const graph = await api(`graph?${qs({ project, limit })}`)
188
292
  if (cy) cy.destroy()
189
293
  cy = window.cytoscape({ container: canvas, elements: [...graph.nodes.map(element), ...graph.edges.map(edgeEl)], style: stylesheet(), wheelSensitivity: 0.22, minZoom: 0.08, maxZoom: 4 })
294
+ if (!project) prepareOverview()
190
295
  runLayout(true)
296
+ if (!project && cy.nodes('[type="project"]').length <= FEW_PROJECTS) expandAll()
191
297
  applyFilters()
192
298
  hud()
193
299
 
194
- cy.on('tap', 'node', e => select(e.target))
195
- cy.on('tap', e => { if (e.target === cy) { cy.elements().removeClass('dim'); cy.elements().unselect(); drawSide(null, graph) } })
300
+ cy.on('tap', 'node', e => {
301
+ select(e.target)
302
+ if (!project && e.target.data('type') === 'project') toggleExpand(e.target)
303
+ })
304
+ cy.on('dbltap', 'node[type="project"]', e => { if (!project) navigate(`#/graph?project=${encodeURIComponent(e.target.data('mid'))}`) })
305
+ cy.on('tap', e => {
306
+ if (e.target !== cy) return
307
+ cy.elements().removeClass('dim')
308
+ cy.elements().unselect()
309
+ if (!project && !pinnedAreas) collapseAll()
310
+ drawSide(null, graph)
311
+ })
196
312
  cy.on('mouseover', 'node', e => {
197
313
  if (search.input.value.trim()) return
198
314
  const around = e.target.closedNeighborhood()
199
315
  cy.elements().not(around).addClass('dim')
200
- around.nodes('[type="memory"]').addClass('lbl')
201
316
  })
202
- cy.on('mouseout', 'node', () => { if (!search.input.value.trim()) { cy.elements().removeClass('dim'); syncLabels() } })
317
+ cy.on('mouseout', 'node', () => { if (!search.input.value.trim()) { cy.elements().removeClass('dim') } })
203
318
  cy.on('dbltap', 'node[type="memory"]', e => openMemory(project, e.target.data('mid'), navigate))
204
- cy.on('zoom', debounce(() => { syncLabels(); hud() }, 60))
319
+ cy.on('zoom', debounce(hud, 60))
205
320
 
206
321
  drawSide(null, graph)
207
- if (query.focus) {
322
+ if (!project && query.focus) {
323
+ // a link like #/graph?focus=<project id> opens that project's areas
324
+ const target = cy.getElementById(`p:${query.focus}`)
325
+ if (target.nonempty()) {
326
+ select(target)
327
+ toggleExpand(target)
328
+ target.select()
329
+ }
330
+ }
331
+ if (project && query.focus) {
208
332
  const target = cy.nodes('[type="memory"]').filter(n => n.data('mid').startsWith(query.focus))
209
333
  if (target.nonempty()) {
210
334
  select(target[0])
@@ -227,7 +351,7 @@ export async function mount(container, { query, navigate }) {
227
351
  const parent = graph.edges.map(e => (e.target === n.id ? e.source : e.source === n.id ? e.target : null)).find(id => id && cy.getElementById(id).nonempty())
228
352
  const origin = parent ? cy.getElementById(parent).position() : { x: cy.width() / 2, y: cy.height() / 2 }
229
353
  fresh.push(cy.add({ ...element(n), position: { x: origin.x + (Math.random() - 0.5) * 80, y: origin.y + (Math.random() - 0.5) * 80 } }))
230
- } else existing.data({ ...n.data, mid: n.data.id, label: n.label })
354
+ } else existing.data({ ...n.data, mid: n.data.id, label: n.label, caption: caption(n) })
231
355
  }
232
356
  cy.nodes().forEach(n => { if (!incoming.has(n.id())) n.remove() })
233
357
  const ids = new Set(graph.edges.map(e => e.id))
@@ -244,7 +368,6 @@ export async function mount(container, { query, navigate }) {
244
368
  for (const n of fresh) setTimeout(() => pulse(n), 650)
245
369
  }
246
370
  applyFilters()
247
- syncLabels()
248
371
  hud()
249
372
  }
250
373
 
@@ -269,6 +392,47 @@ export async function mount(container, { query, navigate }) {
269
392
  return groups
270
393
  }
271
394
 
395
+ /** Like Neo4j Browser's overview: one coloured pill per node label and one per relationship type. */
396
+ let focusedLabel = null
397
+ function highlightLabel(name) {
398
+ if (!cy) return
399
+ focusedLabel = focusedLabel === name ? null : name
400
+ cy.batch(() => {
401
+ cy.elements().removeClass('dim')
402
+ if (!focusedLabel) return
403
+ const hits = cy.nodes().filter(n => (n.data('type') === 'memory' ? n.data('kind') : n.data('type')) === focusedLabel)
404
+ cy.elements().not(hits).addClass('dim')
405
+ })
406
+ drawSide(null)
407
+ }
408
+ function labelChips() {
409
+ if (!cy) return []
410
+ const counts = new Map()
411
+ for (const n of cy.nodes()) {
412
+ const name = n.data('type') === 'memory' ? n.data('kind') : n.data('type')
413
+ const entry = counts.get(name) ?? { n: 0, type: n.data('type'), kind: n.data('kind') }
414
+ entry.n += 1
415
+ counts.set(name, entry)
416
+ }
417
+ return [...counts].map(([name, c]) => {
418
+ const fill = nodeColor(c.type, c.kind)
419
+ return h('button', { type: 'button', class: 'nlabel', 'aria-pressed': String(focusedLabel === name), style: { background: fill, color: inkOn(fill) }, onclick: () => highlightLabel(name) }, name, h('span', { class: 'n' }, fmt.int(c.n)))
420
+ })
421
+ }
422
+ function relationshipChips() {
423
+ if (!cy) return []
424
+ const counts = new Map()
425
+ for (const e of cy.edges()) counts.set(e.data('type'), (counts.get(e.data('type')) ?? 0) + 1)
426
+ return [...counts].map(([name, n]) => h('span', { class: 'rlabel' }, name, h('span', { class: 'n' }, fmt.int(n))))
427
+ }
428
+
429
+ /** The node's own fields, the way Neo4j Browser lists properties. */
430
+ function propertyTable(d) {
431
+ const skip = new Set(['id', 'mid', 'label', 'caption', 'type', 'memories', 'areas'])
432
+ const rows = [['id', d.mid], ...Object.entries(d).filter(([k, v]) => !skip.has(k) && v !== null && v !== undefined && v !== '')]
433
+ return h('table', { class: 'props' }, h('tbody', null, rows.map(([k, v]) => h('tr', null, h('th', null, k), h('td', null, typeof v === 'object' ? JSON.stringify(v) : String(v))))))
434
+ }
435
+
272
436
  function drawSide(node, graph) {
273
437
  if (!node) {
274
438
  const counts = cy ? { project: cy.nodes('[type="project"]').length, area: cy.nodes('[type="area"]').length, memory: cy.nodes('[type="memory"]').length } : {}
@@ -277,22 +441,18 @@ export async function mount(container, { query, navigate }) {
277
441
  h('div', null, h('div', { class: 'eyebrow' }, project ? 'Project' : 'Overview'), h('h2', { style: { fontSize: '17px', marginTop: '4px' } }, project || 'All projects')),
278
442
  graph?.truncated && h('div', { class: 'callout warn' }, `Showing the ${limit} most important and recent memories. Raise the limit to see more.`),
279
443
  h('div', { class: 'row' }, ...Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => h('div', { class: 'stat', style: { marginRight: '14px' } }, h('b', null, fmt.int(n)), h('span', null, k === 'memory' ? 'memories' : `${k}${n === 1 ? '' : 's'}`)))),
280
- h('p', { class: 'dim', style: { margin: 0 } }, 'Click a node to inspect it and double-click a memory to open it. Hover to follow its branch; zoom in to read memory titles.'),
281
- h('h3', null, 'How to read it'),
282
- h('div', { class: 'legend-list' },
283
- h('div', null, h('span', { class: 'g' }, '◯'), 'project: the root'),
284
- h('div', null, h('span', { class: 'g' }, '⬢'), 'area: a branch of the project'),
285
- ...KINDS.map(k => h('div', { class: `k-${k}` }, h('span', { class: 'g', style: { color: 'var(--k)' } }, KIND_GLYPH[k]), k)),
286
- h('div', null, h('span', { class: 'g', style: { opacity: 0.45 } }, '◆'), 'faded, dashed: superseded'),
287
- h('div', null, h('span', { class: 'g', style: { color: 'var(--s5)' } }, '⇢'), 'newer replaces older'),
288
- ),
289
- h('p', { class: 'hint', style: { margin: 0 } }, 'Size is importance. With live pulse on, a node glows when an agent reads or writes it.'),
444
+ h('p', { class: 'dim', style: { margin: 0 } }, project ? 'Click a node to inspect it and double-click a memory to open it. Hover to follow its branch; zoom in to read memory titles.' : 'Click a project to open its areas and double-click it to explore its memories. Bigger bubbles remember more; faded ones have been quiet for three weeks. Use Areas to open them all.'),
445
+ h('h3', null, 'Node labels'),
446
+ h('div', { class: 'chips' }, ...labelChips()),
447
+ h('h3', null, 'Relationship types'),
448
+ h('div', { class: 'chips' }, ...relationshipChips()),
449
+ h('p', { class: 'hint', style: { margin: 0 } }, project ? 'Size is importance. Click a label to highlight it. With live pulse on, a node glows when an agent reads or writes it.' : 'Bubble size is how many memories a project holds; faded ones are quiet. With live pulse on, a project glows when an agent reads or writes it.'),
290
450
  ),
291
451
  )
292
452
  }
293
453
  const d = node.data()
294
454
  const rows = [
295
- h('div', { class: 'row tight' }, h('span', { class: 'badge' }, d.type), d.kind && h('span', { class: `kind k-${d.kind}` }, d.kind), d.status === 'superseded' && h('span', { class: 'badge auth' }, 'superseded')),
455
+ h('div', { class: 'row tight' }, h('span', { class: 'nlabel static', style: { background: nodeColor(d.type, d.kind), color: inkOn(nodeColor(d.type, d.kind)) } }, d.kind ?? d.type), d.status === 'superseded' && h('span', { class: 'badge auth' }, 'superseded')),
296
456
  h('h2', { style: { fontSize: '16px', overflowWrap: 'anywhere', lineHeight: 1.35 } }, d.label),
297
457
  ]
298
458
  if (d.type === 'memory') {
@@ -304,6 +464,7 @@ export async function mount(container, { query, navigate }) {
304
464
  } else if (d.type === 'remote') {
305
465
  rows.push(h('div', { class: 'dim' }, 'A git remote that maps to this project, stored as a hash.'), h('div', { class: 'mono muted' }, d.hash))
306
466
  }
467
+ rows.push(h('h3', null, 'Properties'), propertyTable(d))
307
468
  for (const [label, others] of relations(node)) {
308
469
  rows.push(h('h3', null, label), h('div', { class: 'stack', style: { gap: '3px' } }, ...others.slice(0, 8).map(other => h('a', { href: '#', onclick: ev => { ev.preventDefault(); other.select(); select(other); cy.animate({ center: { eles: other } }, { duration: 250 }) } }, other.data('label'))), others.length > 8 && h('span', { class: 'muted' }, `+${others.length - 8} more`)))
309
470
  }
@@ -0,0 +1,50 @@
1
+ import { api } from '../api.js'
2
+ import { clear, h } from '../dom.js'
3
+ import { brand, field, page, submit } from './auth.js'
4
+
5
+ /** The page behind an invitation link: public, so it asks the server what the link is before showing a form. */
6
+ export async function mountInvite(root, token, done) {
7
+ clear(root).append(page(h('p', { class: 'lead' }, 'Checking your invitation…')))
8
+ let info
9
+ try {
10
+ info = await api(`invite/${encodeURIComponent(token)}`)
11
+ } catch (error) {
12
+ return clear(root).append(
13
+ page(
14
+ h('h1', null, 'This link no longer works'),
15
+ h('p', { class: 'lead' }, error.status === 404 || error.status === 410 ? 'It was already used, withdrawn or has expired. Ask the person who invited you for a new one.' : error.message),
16
+ h('a', { class: 'btn', href: '#/' }, 'Go to sign in'),
17
+ ),
18
+ )
19
+ }
20
+
21
+ const reset = info.kind === 'reset'
22
+ const fixed = reset ? info.forUsername : info.username
23
+ const error = h('p', { class: 'error', role: 'alert' })
24
+ const username = h('input', { class: 'input', autocomplete: 'username', required: true, minlength: '3', maxlength: '32', value: fixed ?? '', disabled: Boolean(fixed) })
25
+ const password = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' })
26
+ const confirmField = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true })
27
+ const button = h('button', { class: 'btn primary', type: 'submit', style: { width: '100%', justifyContent: 'center' } }, reset ? 'Set password' : 'Create account')
28
+ const form = h('form', null, field('Username', username, fixed ? undefined : 'Letters, numbers, dot, dash and underscore.'), field('Password', password, 'At least 10 characters.'), field('Repeat password', confirmField), error, button)
29
+ form.addEventListener('submit', e => {
30
+ e.preventDefault()
31
+ submit(form, button, error, async () => {
32
+ if (password.value !== confirmField.value) throw new Error('The passwords do not match.')
33
+ await api(`invite/${encodeURIComponent(token)}/accept`, { body: { username: fixed ? undefined : username.value, password: password.value } })
34
+ history.replaceState(null, '', '#/projects') // leaves the single-use link out of the address bar and history
35
+ done()
36
+ })
37
+ })
38
+
39
+ clear(root).append(
40
+ page(
41
+ h('h1', null, reset ? 'Choose a new password' : 'Join Agent Cairn'),
42
+ h('p', { class: 'lead' }, reset ? `For ${info.forUsername}.` : info.invitedBy ? `${info.invitedBy} invited you as ${info.role === 'admin' ? 'an admin' : 'a member'}.` : `You were invited as ${info.role === 'admin' ? 'an admin' : 'a member'}.`),
43
+ !reset && info.grants.length > 0 && h('div', { class: 'callout', style: { marginBottom: '16px' } }, 'You will start with access to: ', info.grants.map((g, i) => [i > 0 && ', ', h('b', null, g.projectName), ` (${g.role})`])),
44
+ form,
45
+ ),
46
+ )
47
+ ;(fixed ? password : username).focus()
48
+ }
49
+
50
+ export { brand }