@agentcairn/mcp-server 0.0.0-stage → 0.1.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/README.md +23 -2
- package/dist/authn.d.ts +38 -0
- package/dist/authn.js +86 -0
- package/dist/authn.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +25 -0
- package/dist/bin.js.map +1 -0
- package/dist/context.d.ts +49 -0
- package/dist/context.js +79 -0
- package/dist/context.js.map +1 -0
- package/dist/dashboard.d.ts +29 -0
- package/dist/dashboard.js +365 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/events.d.ts +120 -0
- package/dist/events.js +161 -0
- package/dist/events.js.map +1 -0
- package/dist/http.d.ts +35 -0
- package/dist/http.js +348 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/security.d.ts +49 -0
- package/dist/security.js +143 -0
- package/dist/security.js.map +1 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +60 -0
- package/dist/serve.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +14 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +199 -0
- package/dist/tools.js.map +1 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +49 -0
- package/dist/util.js.map +1 -0
- package/package.json +55 -4
- package/ui/css/app.css +361 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +17 -0
- package/ui/js/api.js +37 -0
- package/ui/js/app.js +190 -0
- package/ui/js/charts.js +133 -0
- package/ui/js/dom.js +247 -0
- package/ui/js/drawer.js +39 -0
- package/ui/js/json.js +37 -0
- package/ui/js/store.js +77 -0
- package/ui/js/views/access.js +129 -0
- package/ui/js/views/auth.js +102 -0
- package/ui/js/views/eventDrawer.js +106 -0
- package/ui/js/views/graph.js +344 -0
- package/ui/js/views/live.js +203 -0
- package/ui/js/views/memoryDrawer.js +37 -0
- package/ui/js/views/project.js +94 -0
- package/ui/js/views/projects.js +77 -0
- package/ui/js/views/query.js +107 -0
- package/ui/js/views/system.js +73 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { api, qs } from '../api.js'
|
|
2
|
+
import { debounce, fmt, h, icon, KINDS, searchBox } from '../dom.js'
|
|
3
|
+
import { live } from '../store.js'
|
|
4
|
+
import { importance, openMemory } from './memoryDrawer.js'
|
|
5
|
+
|
|
6
|
+
const KIND_VAR = { overview: '--s1', decision: '--s2', convention: '--s3', gotcha: '--s4', checkpoint: '--s5', thread: '--s6', glossary: '--s7' }
|
|
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
|
|
12
|
+
|
|
13
|
+
const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
14
|
+
|
|
15
|
+
function loadCytoscape() {
|
|
16
|
+
if (window.cytoscape) return Promise.resolve()
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const script = document.createElement('script')
|
|
19
|
+
script.src = '/ui/vendor/cytoscape.js'
|
|
20
|
+
script.onload = resolve
|
|
21
|
+
script.onerror = () => reject(new Error('Could not load the graph library.'))
|
|
22
|
+
document.head.append(script)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stylesheet() {
|
|
27
|
+
const text = css('--text')
|
|
28
|
+
const surface = css('--surface')
|
|
29
|
+
const axis = css('--axis')
|
|
30
|
+
const muted = css('--muted')
|
|
31
|
+
const accent = css('--s1')
|
|
32
|
+
const pink = css('--s5')
|
|
33
|
+
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)' } },
|
|
48
|
+
{ 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)' } },
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Layouts that put the project at the root, then its areas, then their memories. */
|
|
54
|
+
function layoutOptions(cy, name) {
|
|
55
|
+
const roots = cy.nodes('[type="project"]')
|
|
56
|
+
switch (name) {
|
|
57
|
+
case 'radial':
|
|
58
|
+
return { name: 'breadthfirst', roots, circle: true, directed: false, spacingFactor: 1.05, avoidOverlap: true, padding: 40, animate: false }
|
|
59
|
+
case 'tree':
|
|
60
|
+
return { name: 'breadthfirst', roots, circle: false, directed: false, spacingFactor: 1.15, avoidOverlap: true, padding: 40, animate: false }
|
|
61
|
+
case 'force':
|
|
62
|
+
return { name: 'cose', animate: false, randomize: true, nodeRepulsion: 12000, idealEdgeLength: 90, nodeOverlap: 24, gravity: 0.45, numIter: 1200, padding: 40 }
|
|
63
|
+
default:
|
|
64
|
+
return { name: 'grid', animate: false, padding: 40, avoidOverlap: true }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LAYOUT_LABELS = { radial: 'Radial', tree: 'Tree', force: 'Force', grid: 'Grid' }
|
|
69
|
+
|
|
70
|
+
export async function mount(container, { query, navigate }) {
|
|
71
|
+
const [{ projects }] = await Promise.all([api('projects'), loadCytoscape()])
|
|
72
|
+
let project = query.project ?? ''
|
|
73
|
+
let layoutName = 'radial'
|
|
74
|
+
let limit = 300
|
|
75
|
+
let pulseOn = true
|
|
76
|
+
let showSuperseded = true
|
|
77
|
+
const hiddenKinds = new Set()
|
|
78
|
+
let cy = null
|
|
79
|
+
let refreshTimer = null
|
|
80
|
+
|
|
81
|
+
// ---- 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}`)))
|
|
83
|
+
projectSelect.value = project
|
|
84
|
+
projectSelect.addEventListener('change', () => navigate(`#/graph${projectSelect.value ? `?project=${encodeURIComponent(projectSelect.value)}` : ''}`))
|
|
85
|
+
|
|
86
|
+
const layoutSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Layout' })
|
|
87
|
+
for (const name of Object.keys(LAYOUT_LABELS)) {
|
|
88
|
+
layoutSeg.append(h('button', { type: 'button', 'aria-pressed': String(name === layoutName), onclick: () => { layoutName = name; syncLayout(); runLayout(true) } }, LAYOUT_LABELS[name]))
|
|
89
|
+
}
|
|
90
|
+
const syncLayout = () => [...layoutSeg.children].forEach((b, i) => b.setAttribute('aria-pressed', String(Object.keys(LAYOUT_LABELS)[i] === layoutName)))
|
|
91
|
+
|
|
92
|
+
const limitSelect = h('select', { 'aria-label': 'How many memories to load' }, [100, 300, 600, 1000].map(n => h('option', { value: n }, `${n} memories`)))
|
|
93
|
+
limitSelect.value = String(limit)
|
|
94
|
+
limitSelect.addEventListener('change', () => { limit = Number(limitSelect.value); load() })
|
|
95
|
+
|
|
96
|
+
const search = searchBox({ placeholder: 'Highlight nodes…', 'aria-label': 'Highlight nodes by title', style: { width: '190px' } })
|
|
97
|
+
search.input.addEventListener('input', debounce(applySearch, 150))
|
|
98
|
+
|
|
99
|
+
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
|
+
pulseBtn.addEventListener('click', () => { pulseOn = !pulseOn; pulseBtn.setAttribute('aria-pressed', String(pulseOn)) })
|
|
101
|
+
|
|
102
|
+
const supBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Show memories that were replaced' }, 'Superseded')
|
|
103
|
+
supBtn.addEventListener('click', () => { showSuperseded = !showSuperseded; supBtn.setAttribute('aria-pressed', String(showSuperseded)); applyFilters() })
|
|
104
|
+
|
|
105
|
+
// ---- canvas --------------------------------------------------------------
|
|
106
|
+
const canvas = h('div', { id: 'cy' })
|
|
107
|
+
const hudEl = h('div', { class: 'graph-hud' })
|
|
108
|
+
const kindChips = project
|
|
109
|
+
? KINDS.map(k => {
|
|
110
|
+
const chip = h('button', { class: `chip kindchip k-${k}`, type: 'button', 'aria-pressed': 'true', title: `Show or hide ${k} memories` }, h('i', { class: 'sw' }), `${KIND_GLYPH[k]} ${k}`)
|
|
111
|
+
chip.addEventListener('click', () => { hiddenKinds.has(k) ? hiddenKinds.delete(k) : hiddenKinds.add(k); chip.setAttribute('aria-pressed', String(!hiddenKinds.has(k))); applyFilters() })
|
|
112
|
+
return chip
|
|
113
|
+
})
|
|
114
|
+
: []
|
|
115
|
+
const zoomBy = factor => cy?.animate({ zoom: { level: cy.zoom() * factor, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } } }, { duration: 160 })
|
|
116
|
+
const controls = h('div', { class: 'canvas-overlay br' },
|
|
117
|
+
h('button', { class: 'btn icon sm', type: 'button', title: 'Zoom in', 'aria-label': 'Zoom in', onclick: () => zoomBy(1.3) }, icon('zoomin')),
|
|
118
|
+
h('button', { class: 'btn icon sm', type: 'button', title: 'Zoom out', 'aria-label': 'Zoom out', onclick: () => zoomBy(1 / 1.3) }, icon('zoomout')),
|
|
119
|
+
h('button', { class: 'btn icon sm', type: 'button', title: 'Fit to screen', 'aria-label': 'Fit to screen', onclick: fit }, icon('fit')),
|
|
120
|
+
)
|
|
121
|
+
const side = h('div', { class: 'card side' })
|
|
122
|
+
|
|
123
|
+
container.append(
|
|
124
|
+
h('div', { class: 'graph-page' },
|
|
125
|
+
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.')),
|
|
127
|
+
),
|
|
128
|
+
h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, pulseBtn, project && supBtn),
|
|
129
|
+
h('div', { class: 'graph-layout' },
|
|
130
|
+
h('div', { class: 'canvas-wrap' }, canvas, kindChips.length > 0 && h('div', { class: 'canvas-overlay tl' }, ...kindChips), controls, hudEl),
|
|
131
|
+
side,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// ---- graph ---------------------------------------------------------------
|
|
137
|
+
const element = n => ({ group: 'nodes', data: { ...n.data, mid: n.data.id, id: n.id, label: n.label, type: n.type } })
|
|
138
|
+
const edgeEl = e => ({ group: 'edges', data: { id: e.id, source: e.source, target: e.target, type: e.type } })
|
|
139
|
+
|
|
140
|
+
function runLayout(doFit) {
|
|
141
|
+
if (!cy) return
|
|
142
|
+
const layout = cy.layout(layoutOptions(cy, layoutName))
|
|
143
|
+
if (doFit) layout.one('layoutstop', fit)
|
|
144
|
+
layout.run()
|
|
145
|
+
}
|
|
146
|
+
function fit() {
|
|
147
|
+
cy?.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 260 })
|
|
148
|
+
}
|
|
149
|
+
function hud() {
|
|
150
|
+
if (cy) hudEl.textContent = `${cy.nodes().length} nodes · ${cy.edges().length} edges · ${cy.zoom().toFixed(2)}×`
|
|
151
|
+
}
|
|
152
|
+
function syncLabels() {
|
|
153
|
+
if (!cy) return
|
|
154
|
+
const show = cy.zoom() >= LABEL_ZOOM
|
|
155
|
+
cy.nodes('[type="memory"]').toggleClass('lbl', show)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyFilters() {
|
|
159
|
+
if (!cy) return
|
|
160
|
+
cy.batch(() => {
|
|
161
|
+
cy.nodes().forEach(n => {
|
|
162
|
+
const hide = (n.data('type') === 'memory' && hiddenKinds.has(n.data('kind'))) || (!showSuperseded && n.data('status') === 'superseded')
|
|
163
|
+
n.toggleClass('hidden', hide)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
function applySearch() {
|
|
168
|
+
if (!cy) return
|
|
169
|
+
const q = search.input.value.trim().toLowerCase()
|
|
170
|
+
cy.batch(() => {
|
|
171
|
+
cy.elements().removeClass('match dim')
|
|
172
|
+
if (!q) return
|
|
173
|
+
const hits = cy.nodes().filter(n => String(n.data('label')).toLowerCase().includes(q))
|
|
174
|
+
cy.elements().not(hits).addClass('dim')
|
|
175
|
+
hits.addClass('match')
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
function pulse(node) {
|
|
179
|
+
if (!pulseOn || node.empty()) return
|
|
180
|
+
node.stop(true)
|
|
181
|
+
node.style({ 'underlay-opacity': 0.6, 'underlay-padding': 4 })
|
|
182
|
+
node.animate({ style: { 'underlay-opacity': 0, 'underlay-padding': 30 } }, { duration: 1200, easing: 'ease-out' })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function load() {
|
|
186
|
+
side.replaceChildren(h('div', { class: 'skeleton', style: { height: '22px', width: '60%' } }), h('div', { class: 'skeleton', style: { height: '90px', marginTop: '14px' } }))
|
|
187
|
+
const graph = await api(`graph?${qs({ project, limit })}`)
|
|
188
|
+
if (cy) cy.destroy()
|
|
189
|
+
cy = window.cytoscape({ container: canvas, elements: [...graph.nodes.map(element), ...graph.edges.map(edgeEl)], style: stylesheet(), wheelSensitivity: 0.22, minZoom: 0.08, maxZoom: 4 })
|
|
190
|
+
runLayout(true)
|
|
191
|
+
applyFilters()
|
|
192
|
+
hud()
|
|
193
|
+
|
|
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) } })
|
|
196
|
+
cy.on('mouseover', 'node', e => {
|
|
197
|
+
if (search.input.value.trim()) return
|
|
198
|
+
const around = e.target.closedNeighborhood()
|
|
199
|
+
cy.elements().not(around).addClass('dim')
|
|
200
|
+
around.nodes('[type="memory"]').addClass('lbl')
|
|
201
|
+
})
|
|
202
|
+
cy.on('mouseout', 'node', () => { if (!search.input.value.trim()) { cy.elements().removeClass('dim'); syncLabels() } })
|
|
203
|
+
cy.on('dbltap', 'node[type="memory"]', e => openMemory(project, e.target.data('mid'), navigate))
|
|
204
|
+
cy.on('zoom', debounce(() => { syncLabels(); hud() }, 60))
|
|
205
|
+
|
|
206
|
+
drawSide(null, graph)
|
|
207
|
+
if (query.focus) {
|
|
208
|
+
const target = cy.nodes('[type="memory"]').filter(n => n.data('mid').startsWith(query.focus))
|
|
209
|
+
if (target.nonempty()) {
|
|
210
|
+
select(target[0])
|
|
211
|
+
target[0].select()
|
|
212
|
+
cy.one('layoutstop', () => cy.animate({ center: { eles: target }, zoom: 1.7 }, { duration: 420 }))
|
|
213
|
+
setTimeout(() => pulse(target), 700)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function refresh() {
|
|
219
|
+
if (!cy || !project) return
|
|
220
|
+
const graph = await api(`graph?${qs({ project, limit })}`)
|
|
221
|
+
const incoming = new Set(graph.nodes.map(n => n.id))
|
|
222
|
+
const fresh = []
|
|
223
|
+
cy.batch(() => {
|
|
224
|
+
for (const n of graph.nodes) {
|
|
225
|
+
const existing = cy.getElementById(n.id)
|
|
226
|
+
if (existing.empty()) {
|
|
227
|
+
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
|
+
const origin = parent ? cy.getElementById(parent).position() : { x: cy.width() / 2, y: cy.height() / 2 }
|
|
229
|
+
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 })
|
|
231
|
+
}
|
|
232
|
+
cy.nodes().forEach(n => { if (!incoming.has(n.id())) n.remove() })
|
|
233
|
+
const ids = new Set(graph.edges.map(e => e.id))
|
|
234
|
+
cy.edges().forEach(e => { if (!ids.has(e.id())) e.remove() })
|
|
235
|
+
for (const e of graph.edges) if (cy.getElementById(e.id).empty()) cy.add(edgeEl(e))
|
|
236
|
+
})
|
|
237
|
+
if (fresh.length) {
|
|
238
|
+
// lay out only the newcomers; everything else stays where the person left it
|
|
239
|
+
const settled = cy.nodes().not(cy.collection(fresh.flatMap(f => f.toArray())))
|
|
240
|
+
settled.lock()
|
|
241
|
+
const layout = cy.layout({ ...layoutOptions(cy, 'force'), animate: true, animationDuration: 600, randomize: false, fit: false, numIter: 250 })
|
|
242
|
+
layout.one('layoutstop', () => settled.unlock())
|
|
243
|
+
layout.run()
|
|
244
|
+
for (const n of fresh) setTimeout(() => pulse(n), 650)
|
|
245
|
+
}
|
|
246
|
+
applyFilters()
|
|
247
|
+
syncLabels()
|
|
248
|
+
hud()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function select(node) {
|
|
252
|
+
cy.elements().removeClass('dim')
|
|
253
|
+
drawSide(node)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What a node is connected to, grouped by role, so the tree reads as a tree. */
|
|
257
|
+
function relations(node) {
|
|
258
|
+
const groups = new Map()
|
|
259
|
+
const add = (label, other) => groups.set(label, [...(groups.get(label) ?? []), other])
|
|
260
|
+
for (const e of node.connectedEdges()) {
|
|
261
|
+
const out = e.source().id() === node.id()
|
|
262
|
+
const other = out ? e.target() : e.source()
|
|
263
|
+
const type = e.data('type')
|
|
264
|
+
if (type === 'HAS_AREA') add(out ? 'Areas' : 'Project', other)
|
|
265
|
+
else if (type === 'HAS_MEMORY') add(out ? 'Memories' : other.data('type') === 'area' ? 'Area' : 'Project', other)
|
|
266
|
+
else if (type === 'SUPERSEDES') add(out ? 'Replaces' : 'Replaced by', other)
|
|
267
|
+
else if (type === 'BELONGS_TO') add(out ? 'Project' : 'Git remote', other)
|
|
268
|
+
}
|
|
269
|
+
return groups
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function drawSide(node, graph) {
|
|
273
|
+
if (!node) {
|
|
274
|
+
const counts = cy ? { project: cy.nodes('[type="project"]').length, area: cy.nodes('[type="area"]').length, memory: cy.nodes('[type="memory"]').length } : {}
|
|
275
|
+
return side.replaceChildren(
|
|
276
|
+
h('div', { class: 'stack' },
|
|
277
|
+
h('div', null, h('div', { class: 'eyebrow' }, project ? 'Project' : 'Overview'), h('h2', { style: { fontSize: '17px', marginTop: '4px' } }, project || 'All projects')),
|
|
278
|
+
graph?.truncated && h('div', { class: 'callout warn' }, `Showing the ${limit} most important and recent memories. Raise the limit to see more.`),
|
|
279
|
+
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.'),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
const d = node.data()
|
|
294
|
+
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')),
|
|
296
|
+
h('h2', { style: { fontSize: '16px', overflowWrap: 'anywhere', lineHeight: 1.35 } }, d.label),
|
|
297
|
+
]
|
|
298
|
+
if (d.type === 'memory') {
|
|
299
|
+
rows.push(h('div', { class: 'row' }, importance(d.importance), h('span', { class: 'muted mono' }, `#${d.mid.slice(0, 8)}`), h('span', { class: 'muted' }, fmt.ago(d.createdAt))), h('button', { class: 'btn', type: 'button', onclick: () => openMemory(project, d.mid, navigate) }, 'Open memory', icon('arrow')))
|
|
300
|
+
} else if (d.type === 'project') {
|
|
301
|
+
rows.push(h('div', { class: 'row' }, h('a', { class: 'btn', href: `#/projects/${encodeURIComponent(d.mid)}` }, 'Open project'), h('a', { class: 'btn', href: `#/graph?project=${encodeURIComponent(d.mid)}` }, 'Explore memories')))
|
|
302
|
+
} else if (d.type === 'area') {
|
|
303
|
+
rows.push(h('div', { class: 'dim' }, `${d.memories} active ${d.memories === 1 ? 'memory' : 'memories'} in ${d.project}`), h('a', { class: 'btn', href: `#/projects/${encodeURIComponent(d.project)}` }, 'Browse the project'))
|
|
304
|
+
} else if (d.type === 'remote') {
|
|
305
|
+
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
|
+
}
|
|
307
|
+
for (const [label, others] of relations(node)) {
|
|
308
|
+
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
|
+
}
|
|
310
|
+
side.replaceChildren(h('div', { class: 'stack' }, ...rows.filter(Boolean)))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- live ----------------------------------------------------------------
|
|
314
|
+
const unsubscribe = live.subscribe((kind, e) => {
|
|
315
|
+
if (kind !== 'event' || !cy) return
|
|
316
|
+
if (project && e.project !== project) return
|
|
317
|
+
if (!project) return pulse(cy.getElementById(`p:${e.project}`))
|
|
318
|
+
pulse(cy.getElementById(`p:${project}`))
|
|
319
|
+
for (const ref of e.memoryRefs) {
|
|
320
|
+
const nodes = cy.nodes('[type="memory"]').filter(n => n.data('mid').startsWith(ref))
|
|
321
|
+
pulse(nodes)
|
|
322
|
+
pulse(nodes.neighborhood('node[type="area"]'))
|
|
323
|
+
}
|
|
324
|
+
if (e.ok && WRITE_TOOLS.has(e.tool)) {
|
|
325
|
+
clearTimeout(refreshTimer)
|
|
326
|
+
refreshTimer = setTimeout(() => refresh().catch(() => undefined), 700)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const restyle = () => cy?.style(stylesheet())
|
|
331
|
+
const observer = new MutationObserver(restyle)
|
|
332
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
333
|
+
const media = matchMedia('(prefers-color-scheme: dark)')
|
|
334
|
+
media.addEventListener('change', restyle)
|
|
335
|
+
|
|
336
|
+
await load()
|
|
337
|
+
return () => {
|
|
338
|
+
unsubscribe()
|
|
339
|
+
observer.disconnect()
|
|
340
|
+
media.removeEventListener('change', restyle)
|
|
341
|
+
clearTimeout(refreshTimer)
|
|
342
|
+
cy?.destroy()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ago, clear, debounce, emptyState, fmt, h, icon, searchBox, statusMark } from '../dom.js'
|
|
2
|
+
import { createThroughput, sparkline, throughputTable } from '../charts.js'
|
|
3
|
+
import { live } from '../store.js'
|
|
4
|
+
import { openEvent } from './eventDrawer.js'
|
|
5
|
+
|
|
6
|
+
const ONLINE_MS = 5 * 60_000
|
|
7
|
+
const FEED_LIMIT = 200
|
|
8
|
+
|
|
9
|
+
function tile(label, value, sub, spark) {
|
|
10
|
+
return h('div', { class: 'tile' }, h('div', { class: 'label' }, label), h('div', { class: 'value' }, value), h('div', { class: 'sub' }, sub), spark && h('div', { class: 'spark' }, spark))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function mount(container, { navigate }) {
|
|
14
|
+
let paused = false
|
|
15
|
+
let view = 'chart'
|
|
16
|
+
const filter = { tool: '', errorsOnly: false, text: '' }
|
|
17
|
+
const throughput = createThroughput()
|
|
18
|
+
|
|
19
|
+
// ---- layout --------------------------------------------------------------
|
|
20
|
+
const pill = h('span', { class: 'livepill' })
|
|
21
|
+
const tiles = h('div', { class: 'tiles' })
|
|
22
|
+
const chartHolder = h('div')
|
|
23
|
+
const toolsBody = h('div')
|
|
24
|
+
const clientsBody = h('div')
|
|
25
|
+
const feedBody = h('tbody')
|
|
26
|
+
const feedCount = h('span', { class: 'hint' })
|
|
27
|
+
|
|
28
|
+
const toggle = h('div', { class: 'seg', role: 'group', 'aria-label': 'Show chart or table' })
|
|
29
|
+
for (const v of ['chart', 'table']) {
|
|
30
|
+
toggle.append(h('button', { type: 'button', 'aria-pressed': String(v === view), onclick: () => { view = v; syncToggle(); drawChart() } }, v === 'chart' ? 'Chart' : 'Table'))
|
|
31
|
+
}
|
|
32
|
+
const syncToggle = () => [...toggle.children].forEach((b, i) => b.setAttribute('aria-pressed', String((i === 0) === (view === 'chart'))))
|
|
33
|
+
|
|
34
|
+
const toolSelect = h('select', { 'aria-label': 'Filter by tool' }, h('option', { value: '' }, 'All calls'))
|
|
35
|
+
const search = searchBox({ placeholder: 'Filter project, client, tool…', 'aria-label': 'Filter the feed', style: { width: '230px' } })
|
|
36
|
+
const errBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false' }, icon('alert'), 'Errors only')
|
|
37
|
+
const pauseBtn = h('button', { class: 'btn sm', type: 'button' }, icon('pause'), 'Pause')
|
|
38
|
+
|
|
39
|
+
container.append(
|
|
40
|
+
h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Live'), h('div', { class: 'sub' }, 'Everything agents and tools send to this server, as it happens.')), pill),
|
|
41
|
+
tiles,
|
|
42
|
+
h(
|
|
43
|
+
'div',
|
|
44
|
+
{ class: 'live-grid' },
|
|
45
|
+
h(
|
|
46
|
+
'div',
|
|
47
|
+
{ class: 'stack', style: { gap: '16px', minWidth: 0 } },
|
|
48
|
+
h(
|
|
49
|
+
'div',
|
|
50
|
+
{ class: 'card' },
|
|
51
|
+
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Requests per minute'), h('div', { class: 'hint' }, 'Last 60 minutes')), h('div', { class: 'tools' }, h('div', { class: 'legend' }, h('span', null, h('i', { class: 'sw', style: { background: 'var(--s1)' } }), 'Requests'), h('span', null, h('i', { class: 'sw', style: { background: 'var(--crit)' } }), 'Errors and denied')), toggle)),
|
|
52
|
+
chartHolder,
|
|
53
|
+
),
|
|
54
|
+
h(
|
|
55
|
+
'div',
|
|
56
|
+
{ class: 'card flush' },
|
|
57
|
+
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Feed'), feedCount), h('div', { class: 'tools' }, toolSelect, errBtn, search.el, pauseBtn)),
|
|
58
|
+
h('div', { class: 'scroll feed', style: { maxHeight: '640px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['', 'Time', 'Call', 'Client', 'Took', 'Tokens'].map((t, i) => h('th', { class: i >= 4 ? 'num' : '' }, t)))), feedBody)),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
h(
|
|
62
|
+
'div',
|
|
63
|
+
{ class: 'rail' },
|
|
64
|
+
h('div', { class: 'card flush' }, h('div', { class: 'head' }, h('div', null, h('h2', null, 'Tools'), h('div', { class: 'hint' }, 'count and p50 / p95 latency'))), toolsBody),
|
|
65
|
+
h('div', { class: 'card flush' }, h('div', { class: 'head' }, h('div', null, h('h2', null, 'Clients'), h('div', { class: 'hint' }, 'online = seen in the last 5 minutes'))), clientsBody),
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// ---- drawing -------------------------------------------------------------
|
|
71
|
+
function drawChart() {
|
|
72
|
+
clear(chartHolder)
|
|
73
|
+
if (view === 'chart') {
|
|
74
|
+
chartHolder.append(throughput.el)
|
|
75
|
+
if (live.stats) throughput.update(live.stats.perMinute)
|
|
76
|
+
} else chartHolder.append(throughputTable(live.stats?.perMinute ?? []))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function drawPill() {
|
|
80
|
+
const s = live.stats
|
|
81
|
+
pill.replaceChildren(h('span', { class: `dot ${live.status === 'live' ? 'live' : live.status === 'down' ? 'down' : ''}` }), s ? `up ${fmt.uptime(s.uptimeSeconds)} · ${s.viewers} watching` : 'connecting…')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function drawTiles() {
|
|
85
|
+
const s = live.stats
|
|
86
|
+
if (!s) return tiles.replaceChildren(...Array.from({ length: 5 }, () => h('div', { class: 'tile' }, h('div', { class: 'skeleton', style: { height: '14px', width: '60%' } }), h('div', { class: 'skeleton', style: { height: '30px', width: '40%', marginTop: '10px' } }))))
|
|
87
|
+
const lastMinute = s.perMinute.at(-1)?.requests ?? 0
|
|
88
|
+
const online = s.clients.filter(c => c.active).length
|
|
89
|
+
const touched = s.projects.filter(p => Date.now() - Date.parse(p.lastSeen) < ONLINE_MS).length
|
|
90
|
+
const rate = s.totals.requests ? (s.totals.errors / s.totals.requests) * 100 : 0
|
|
91
|
+
tiles.replaceChildren(
|
|
92
|
+
tile('Requests', fmt.int(s.totals.requests), `${lastMinute} in the last minute`, sparkline(s.perMinute.slice(-30).map(m => m.requests))),
|
|
93
|
+
tile('Clients online', String(online), `${s.clients.length} seen since start`),
|
|
94
|
+
tile('Projects active', String(touched), `${s.projects.length} seen since start`),
|
|
95
|
+
tile('Tokens served', fmt.num(s.totals.tokensOut), s.totals.requests ? `≈ ${fmt.int(s.totals.tokensOut / s.totals.requests)} per request (est.)` : 'estimated at 4 characters a token', sparkline(s.perMinute.slice(-30).map(m => m.tokensOut))),
|
|
96
|
+
tile('Errors', fmt.int(s.totals.errors), `${rate.toFixed(1)}% of requests · ${s.totals.authFailures} denied`),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function drawTools() {
|
|
101
|
+
const tools = live.stats?.tools ?? []
|
|
102
|
+
if (!tools.length) return toolsBody.replaceChildren(emptyState('zap', 'No tool calls yet', 'They appear here as agents use the brain.'))
|
|
103
|
+
const max = Math.max(...tools.map(t => t.count))
|
|
104
|
+
toolsBody.replaceChildren(
|
|
105
|
+
h('div', { class: 'list' }, tools.map(t => h('div', { class: 'toolrow', role: 'button', tabindex: '0', title: 'Filter the feed to this tool', onclick: () => pickTool(t.name), onkeydown: e => e.key === 'Enter' && pickTool(t.name) },
|
|
106
|
+
h('div', { class: 'row' }, h('b', { class: 'mono' }, t.name), h('span', { class: 'grow' }), t.errors > 0 && h('span', { class: 'badge err' }, icon('x'), String(t.errors)), h('span', { class: 'num' }, fmt.int(t.count))),
|
|
107
|
+
h('div', { class: 'meter' }, h('div', { class: 'track' }, h('div', { class: 'fill', style: { width: `${Math.max(2, (t.count / max) * 100)}%` } })), h('span', { class: 'hint mono nowrap' }, `${fmt.ms(t.p50Ms)} / ${fmt.ms(t.p95Ms)}`)),
|
|
108
|
+
))),
|
|
109
|
+
)
|
|
110
|
+
const have = new Set([...toolSelect.options].map(o => o.value))
|
|
111
|
+
for (const t of tools) if (!have.has(t.name)) toolSelect.append(h('option', { value: t.name }, t.name))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function drawClients() {
|
|
115
|
+
const clients = live.stats?.clients ?? []
|
|
116
|
+
if (!clients.length) return clientsBody.replaceChildren(emptyState('inbox', 'No agent has connected yet', 'Create a token under Access and point Claude at this server.'))
|
|
117
|
+
clientsBody.replaceChildren(
|
|
118
|
+
h('div', { class: 'list' }, clients.map(c => h('div', { class: 'toolrow client', role: 'button', tabindex: '0', title: 'Filter the feed to this client', onclick: () => pickClient(c), onkeydown: e => e.key === 'Enter' && pickClient(c) },
|
|
119
|
+
h('span', { class: `dot ${c.active ? 'live' : ''}`, title: c.active ? 'online' : 'idle' }),
|
|
120
|
+
h('div', { class: 'who' }, h('b', null, c.token ?? 'unnamed'), h('span', null, [c.agent, c.ip, c.lastProject].filter(Boolean).join(' · '))),
|
|
121
|
+
h('div', { style: { textAlign: 'right' } }, h('div', { class: 'hint' }, ago(c.lastSeen)), h('div', { class: 'num dim', style: { fontSize: '12px' } }, `${fmt.int(c.requests)} req`)),
|
|
122
|
+
))),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pickTool(name) {
|
|
127
|
+
filter.tool = name
|
|
128
|
+
toolSelect.value = name
|
|
129
|
+
drawFeed()
|
|
130
|
+
}
|
|
131
|
+
function pickClient(c) {
|
|
132
|
+
search.input.value = c.token ?? c.ip ?? ''
|
|
133
|
+
filter.text = search.input.value.toLowerCase()
|
|
134
|
+
drawFeed()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const matches = e => {
|
|
138
|
+
if (filter.tool && e.tool !== filter.tool && e.method !== filter.tool) return false
|
|
139
|
+
if (filter.errorsOnly && e.ok) return false
|
|
140
|
+
if (filter.text) {
|
|
141
|
+
const hay = `${e.tool ?? ''} ${e.method} ${e.project ?? ''} ${e.client.token ?? ''} ${e.client.agent ?? ''} ${e.client.ip ?? ''}`.toLowerCase()
|
|
142
|
+
if (!hay.includes(filter.text)) return false
|
|
143
|
+
}
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function row(e, fresh) {
|
|
148
|
+
const refs = e.memoryRefs.length
|
|
149
|
+
return h('tr', { class: ['rowlink', fresh && 'fresh'], tabindex: '0', onclick: () => openEvent(e, navigate), onkeydown: ev => ev.key === 'Enter' && openEvent(e, navigate) },
|
|
150
|
+
h('td', { style: { width: '1%', paddingRight: 0 } }, statusMark(e)),
|
|
151
|
+
h('td', { class: 'mono nowrap muted', title: fmt.dateTime(e.ts) }, fmt.clock(e.ts)),
|
|
152
|
+
h('td', null, e.tool ? h('span', { class: 'tool-name mono' }, e.tool) : h('span', { class: 'method dim' }, e.method), refs > 0 && h('span', { class: 'tag', style: { marginLeft: '8px' }, title: `${refs} memories touched` }, `◈ ${refs}`), h('span', { class: 'sub mono' }, e.project ?? '–')),
|
|
153
|
+
h('td', null, e.client.token ?? h('span', { class: 'muted' }, 'unauthenticated'), e.client.agent && h('span', { class: 'sub' }, e.client.agent)),
|
|
154
|
+
h('td', { class: 'num mono' }, fmt.ms(e.durationMs)),
|
|
155
|
+
h('td', { class: 'num' }, e.tokensOut ? fmt.int(e.tokensOut) : h('span', { class: 'muted' }, '–')),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function drawFeed() {
|
|
160
|
+
const shown = live.events.filter(matches).slice(0, FEED_LIMIT)
|
|
161
|
+
feedCount.textContent = live.events.length ? `${shown.length}${shown.length === FEED_LIMIT ? '+' : ''} shown` : ''
|
|
162
|
+
if (!shown.length) {
|
|
163
|
+
return feedBody.replaceChildren(h('tr', null, h('td', { colspan: 6 }, live.events.length ? emptyState('search', 'Nothing matches the filter') : emptyState('live', 'Waiting for the first request…', 'Requests from agents and the CLI appear here the moment they arrive.'))))
|
|
164
|
+
}
|
|
165
|
+
feedBody.replaceChildren(...shown.map(e => row(e, false)))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- wiring --------------------------------------------------------------
|
|
169
|
+
toolSelect.addEventListener('change', () => { filter.tool = toolSelect.value; drawFeed() })
|
|
170
|
+
search.input.addEventListener('input', debounce(() => { filter.text = search.input.value.trim().toLowerCase(); drawFeed() }, 150))
|
|
171
|
+
errBtn.addEventListener('click', () => { filter.errorsOnly = !filter.errorsOnly; errBtn.setAttribute('aria-pressed', String(filter.errorsOnly)); drawFeed() })
|
|
172
|
+
pauseBtn.addEventListener('click', () => {
|
|
173
|
+
paused = !paused
|
|
174
|
+
pauseBtn.replaceChildren(icon(paused ? 'play' : 'pause'), paused ? 'Resume' : 'Pause')
|
|
175
|
+
if (!paused) drawFeed()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const unsubscribe = live.subscribe((kind, payload) => {
|
|
179
|
+
if (kind === 'status') drawPill()
|
|
180
|
+
if (kind === 'stats') {
|
|
181
|
+
drawPill()
|
|
182
|
+
drawTiles()
|
|
183
|
+
drawTools()
|
|
184
|
+
drawClients()
|
|
185
|
+
if (view === 'chart') throughput.update(live.stats.perMinute)
|
|
186
|
+
else drawChart()
|
|
187
|
+
}
|
|
188
|
+
if (kind === 'event' && !paused && matches(payload)) {
|
|
189
|
+
if (feedBody.firstChild?.firstChild?.colSpan > 1) feedBody.replaceChildren()
|
|
190
|
+
feedBody.prepend(row(payload, true))
|
|
191
|
+
while (feedBody.children.length > FEED_LIMIT) feedBody.lastChild.remove()
|
|
192
|
+
feedCount.textContent = `${feedBody.children.length} shown`
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
drawPill()
|
|
197
|
+
drawChart()
|
|
198
|
+
drawTiles()
|
|
199
|
+
drawTools()
|
|
200
|
+
drawClients()
|
|
201
|
+
drawFeed()
|
|
202
|
+
return unsubscribe
|
|
203
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { api, qs } from '../api.js'
|
|
2
|
+
import { copyText, fmt, h, icon, kindLabel } from '../dom.js'
|
|
3
|
+
import { openDrawer } from '../drawer.js'
|
|
4
|
+
|
|
5
|
+
export const importance = n => h('span', { class: 'mono', title: `importance ${n} of 5`, 'aria-label': `importance ${n} of 5` }, '●'.repeat(n) + '○'.repeat(5 - n))
|
|
6
|
+
|
|
7
|
+
/** Opens one memory with its provenance and its supersede history. */
|
|
8
|
+
export function openMemory(project, id, navigate) {
|
|
9
|
+
const title = h('div', { class: 'row' }, h('h2', null, 'Memory'), h('span', { class: 'mono muted' }, `#${id.slice(0, 8)}`))
|
|
10
|
+
const drawer = openDrawer(title, h('div', { class: 'muted' }, 'Loading…'))
|
|
11
|
+
|
|
12
|
+
api(`memories/${encodeURIComponent(id)}?${qs({ project })}`)
|
|
13
|
+
.then(({ memory: m, chain }) => {
|
|
14
|
+
const link = item =>
|
|
15
|
+
h('a', { href: '#', onclick: e => { e.preventDefault(); openMemory(project, item.id, navigate) } }, h('span', { class: 'mono' }, `#${item.id.slice(0, 8)}`), ` ${item.title}`, h('span', { class: 'muted' }, ` · ${fmt.ago(item.createdAt)}`))
|
|
16
|
+
const chainBlock = (heading, items) =>
|
|
17
|
+
items.length && h('div', { class: 'stack', style: { gap: '4px' } }, h('h3', null, heading), ...items.map(i => h('div', null, link(i))))
|
|
18
|
+
drawer.body.replaceChildren(
|
|
19
|
+
h(
|
|
20
|
+
'div',
|
|
21
|
+
{ class: 'stack' },
|
|
22
|
+
h('div', { class: 'row' }, kindLabel(m.kind), m.status === 'superseded' && h('span', { class: 'badge auth' }, 'superseded'), m.area && h('span', { class: 'tag' }, m.area), importance(m.importance)),
|
|
23
|
+
h('h1', { style: { fontSize: '19px' } }, m.title),
|
|
24
|
+
h('pre', { class: 'pre' }, m.body),
|
|
25
|
+
m.paths.length > 0 && h('div', null, h('h3', null, 'Paths'), h('div', { class: 'tags', style: { marginTop: '6px' } }, m.paths.map(p => h('span', { class: 'tag' }, p)))),
|
|
26
|
+
chainBlock('Replaced by (newer)', chain.replacedBy),
|
|
27
|
+
chainBlock('Replaced (older)', chain.replaced),
|
|
28
|
+
h('dl', { class: 'kv' },
|
|
29
|
+
...[['Written', fmt.dateTime(m.createdAt)], ['Author', m.author ?? '–'], ['Agent', m.agent ?? '–'], ['Machine', m.machine ?? '–'], ['Commit', m.commitSha ? h('span', { class: 'mono' }, m.commitSha.slice(0, 12)) : '–'], ['Full id', h('span', { class: 'mono' }, m.id)]].flatMap(([k, v]) => [h('dt', null, k), h('dd', null, v)])),
|
|
30
|
+
h('div', { class: 'row' },
|
|
31
|
+
h('button', { class: 'btn', type: 'button', onclick: () => { drawer.close(); navigate(`#/graph?project=${encodeURIComponent(project)}&focus=${m.id.slice(0, 8)}`) } }, icon('graph'), 'Show in graph'),
|
|
32
|
+
h('button', { class: 'btn', type: 'button', onclick: () => copyText(`#${m.id.slice(0, 8)}`) }, icon('copy'), 'Copy short id')),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
.catch(error => drawer.body.replaceChildren(h('p', { class: 'error' }, error.message)))
|
|
37
|
+
}
|