@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.
- package/dist/authn.d.ts +7 -1
- package/dist/authn.js +34 -8
- package/dist/authn.js.map +1 -1
- package/dist/context.d.ts +9 -2
- package/dist/context.js +48 -10
- package/dist/context.js.map +1 -1
- package/dist/dashboard.js +305 -21
- package/dist/dashboard.js.map +1 -1
- package/dist/http.js +51 -9
- package/dist/http.js.map +1 -1
- package/dist/security.d.ts +2 -0
- package/dist/security.js +6 -0
- package/dist/security.js.map +1 -1
- package/dist/serve.js +3 -3
- package/dist/serve.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +81 -6
- package/dist/tools.js.map +1 -1
- package/package.json +2 -2
- package/ui/css/app.css +40 -2
- package/ui/js/app.js +18 -4
- package/ui/js/dom.js +39 -1
- package/ui/js/views/access.js +10 -4
- package/ui/js/views/audit.js +77 -0
- package/ui/js/views/auth.js +7 -8
- package/ui/js/views/graph.js +215 -54
- package/ui/js/views/invite.js +50 -0
- package/ui/js/views/project.js +162 -10
- package/ui/js/views/projects.js +8 -6
- package/ui/js/views/users.js +235 -0
package/ui/js/views/graph.js
CHANGED
|
@@ -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':
|
|
35
|
-
{ selector: 'node[type="project"]', style: {
|
|
36
|
-
{ selector: 'node[type="area"]', style: {
|
|
37
|
-
{ selector: 'node[type="remote"]', style: {
|
|
38
|
-
{ selector: 'node[type="memory"]', style: { width: 'mapData(importance, 1, 5,
|
|
39
|
-
...KINDS.map(kind => ({ selector: `node[kind="${kind}"]`, style:
|
|
40
|
-
{ selector: 'node[status="superseded"]', style: { opacity: 0.
|
|
41
|
-
{ selector: '
|
|
42
|
-
{ selector: 'edge', style: {
|
|
43
|
-
{ selector: 'edge[type="
|
|
44
|
-
{ selector: '
|
|
45
|
-
{ selector: '
|
|
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: '
|
|
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:
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
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 =>
|
|
195
|
-
|
|
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')
|
|
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(
|
|
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, '
|
|
282
|
-
h('div', { class: '
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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: '
|
|
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 }
|