@agentcairn/mcp-server 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/ui/js/store.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { api } from './api.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The live connection to the server: recent events, rolling stats, and a
|
|
5
|
+
* server-sent stream that keeps both current. Views subscribe and re-draw.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_EVENTS = 500
|
|
8
|
+
|
|
9
|
+
export const live = {
|
|
10
|
+
status: 'idle', // idle | connecting | live | down
|
|
11
|
+
stats: null,
|
|
12
|
+
events: [],
|
|
13
|
+
listeners: new Set(),
|
|
14
|
+
source: null,
|
|
15
|
+
|
|
16
|
+
subscribe(fn) {
|
|
17
|
+
this.listeners.add(fn)
|
|
18
|
+
return () => this.listeners.delete(fn)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
emit(kind, payload) {
|
|
22
|
+
for (const fn of this.listeners) {
|
|
23
|
+
try {
|
|
24
|
+
fn(kind, payload)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('live listener failed', error)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async start() {
|
|
32
|
+
if (this.source) return
|
|
33
|
+
this.status = 'connecting'
|
|
34
|
+
this.emit('status')
|
|
35
|
+
await this.backfill()
|
|
36
|
+
const source = new EventSource('/admin/api/stream')
|
|
37
|
+
this.source = source
|
|
38
|
+
source.addEventListener('open', () => {
|
|
39
|
+
this.status = 'live'
|
|
40
|
+
this.emit('status')
|
|
41
|
+
})
|
|
42
|
+
source.addEventListener('error', () => {
|
|
43
|
+
this.status = source.readyState === EventSource.CLOSED ? 'down' : 'connecting'
|
|
44
|
+
this.emit('status')
|
|
45
|
+
})
|
|
46
|
+
source.addEventListener('stats', e => {
|
|
47
|
+
this.stats = JSON.parse(e.data)
|
|
48
|
+
this.emit('stats', this.stats)
|
|
49
|
+
})
|
|
50
|
+
source.addEventListener('event', e => {
|
|
51
|
+
const event = JSON.parse(e.data)
|
|
52
|
+
if (this.events.some(x => x.id === event.id)) return
|
|
53
|
+
this.events.unshift(event)
|
|
54
|
+
if (this.events.length > MAX_EVENTS) this.events.length = MAX_EVENTS
|
|
55
|
+
this.emit('event', event)
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async backfill() {
|
|
60
|
+
try {
|
|
61
|
+
const { events } = await api('events?limit=300')
|
|
62
|
+
const known = new Set(this.events.map(e => e.id))
|
|
63
|
+
this.events = [...this.events, ...events.filter(e => !known.has(e.id))].sort((a, b) => b.id - a.id).slice(0, MAX_EVENTS)
|
|
64
|
+
} catch {
|
|
65
|
+
// the stream will still deliver new events
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
stop() {
|
|
70
|
+
this.source?.close()
|
|
71
|
+
this.source = null
|
|
72
|
+
this.status = 'idle'
|
|
73
|
+
this.stats = null
|
|
74
|
+
this.events = []
|
|
75
|
+
this.emit('status')
|
|
76
|
+
},
|
|
77
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { api } from '../api.js'
|
|
2
|
+
import { ago, codeBox, copyText, emptyState, h, icon, toast } from '../dom.js'
|
|
3
|
+
|
|
4
|
+
export async function mount(container, { query }) {
|
|
5
|
+
const [{ projects }, tokensReply] = await Promise.all([api('projects'), api('tokens')])
|
|
6
|
+
let tokens = tokensReply.tokens
|
|
7
|
+
let fresh = null // the token just created, kept only in this page's memory
|
|
8
|
+
let method = 'claude'
|
|
9
|
+
|
|
10
|
+
const tokenCard = h('div', { class: 'card flush' })
|
|
11
|
+
const connectCard = h('div', { class: 'card' })
|
|
12
|
+
const secretBox = h('div')
|
|
13
|
+
|
|
14
|
+
// ---- tokens --------------------------------------------------------------
|
|
15
|
+
const nameInput = h('input', { class: 'input', placeholder: 'Name, e.g. laptop or ci-runner', maxlength: '60', 'aria-label': 'Token name', style: { width: '250px' } })
|
|
16
|
+
const createBtn = h('button', { class: 'btn primary', type: 'button' }, icon('plus'), 'Create token')
|
|
17
|
+
|
|
18
|
+
async function create() {
|
|
19
|
+
createBtn.disabled = true
|
|
20
|
+
try {
|
|
21
|
+
const made = await api('tokens', { body: { name: nameInput.value } })
|
|
22
|
+
fresh = made.token
|
|
23
|
+
nameInput.value = ''
|
|
24
|
+
tokens = (await api('tokens')).tokens
|
|
25
|
+
secretBox.replaceChildren(
|
|
26
|
+
h(
|
|
27
|
+
'div',
|
|
28
|
+
{ class: 'callout warn stack', style: { gap: '10px', margin: '0 18px 16px' } },
|
|
29
|
+
h('b', null, `Token "${made.info.name}" created. Copy it now: it is shown only once.`),
|
|
30
|
+
h('div', { class: 'secret' }, made.token),
|
|
31
|
+
h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => copyText(made.token, 'Token copied') }, icon('copy'), 'Copy token'), h('span', { class: 'hint' }, 'Only a hash is stored on the server.')),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
drawTokens()
|
|
35
|
+
drawConnect()
|
|
36
|
+
} catch (error) {
|
|
37
|
+
toast(error.message)
|
|
38
|
+
} finally {
|
|
39
|
+
createBtn.disabled = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
createBtn.addEventListener('click', create)
|
|
43
|
+
nameInput.addEventListener('keydown', e => e.key === 'Enter' && create())
|
|
44
|
+
|
|
45
|
+
async function revoke(t) {
|
|
46
|
+
if (!confirm(`Revoke "${t.name}"? Anything using it stops working immediately.`)) return
|
|
47
|
+
try {
|
|
48
|
+
await api(`tokens/${t.id}`, { method: 'DELETE' })
|
|
49
|
+
tokens = (await api('tokens')).tokens
|
|
50
|
+
drawTokens()
|
|
51
|
+
} catch (error) {
|
|
52
|
+
toast(error.message)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function drawTokens() {
|
|
57
|
+
const active = tokens.filter(t => !t.revokedAt)
|
|
58
|
+
tokenCard.replaceChildren(
|
|
59
|
+
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Access tokens'), h('div', { class: 'hint' }, `${active.length} active · agents and the cairn CLI send one as a bearer token`)), h('div', { class: 'tools' }, nameInput, createBtn)),
|
|
60
|
+
secretBox,
|
|
61
|
+
tokens.length
|
|
62
|
+
? h('div', { class: 'scroll', style: { maxHeight: '340px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['Name', 'Token', 'Created', 'Last used', 'Status', ''].map(t => h('th', null, t)))),
|
|
63
|
+
h('tbody', null, tokens.map(t => h('tr', { style: { opacity: t.revokedAt ? 0.55 : 1 } },
|
|
64
|
+
h('td', null, h('span', { class: 'tool-name' }, t.name)),
|
|
65
|
+
h('td', { class: 'mono' }, `${t.prefix}…`),
|
|
66
|
+
h('td', null, ago(t.createdAt), t.createdBy && h('span', { class: 'sub' }, `by ${t.createdBy}`)),
|
|
67
|
+
h('td', null, t.lastUsedAt ? ago(t.lastUsedAt) : h('span', { class: 'muted' }, 'never')),
|
|
68
|
+
h('td', null, t.revokedAt ? h('span', { class: 'badge err' }, icon('x'), 'revoked') : h('span', { class: 'badge ok' }, icon('check'), 'active')),
|
|
69
|
+
h('td', { class: 'num' }, !t.revokedAt && h('button', { class: 'btn sm danger', type: 'button', onclick: () => revoke(t) }, icon('trash'), 'Revoke')),
|
|
70
|
+
)))))
|
|
71
|
+
: emptyState('key', 'No tokens yet', 'Create one, then connect an agent below.'),
|
|
72
|
+
tokens.length > 0 && active.length === 0 && h('div', { class: 'hint', style: { padding: '0 18px 16px' } }, 'All tokens are revoked: agents cannot connect until you create a new one.'),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- connect -------------------------------------------------------------
|
|
77
|
+
const origin = h('input', { class: 'input mono', value: location.origin, 'aria-label': 'Server address', style: { width: '280px' } })
|
|
78
|
+
const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.id)), h('option', { value: '' }, 'A new project id…'))
|
|
79
|
+
const customProject = h('input', { class: 'input', placeholder: 'project-id', 'aria-label': 'Project id', style: { display: 'none', width: '160px' } })
|
|
80
|
+
if (query.project) projectSelect.value = query.project
|
|
81
|
+
else if (!projects.length) projectSelect.value = ''
|
|
82
|
+
projectSelect.addEventListener('change', () => {
|
|
83
|
+
customProject.style.display = projectSelect.value === '' ? '' : 'none'
|
|
84
|
+
drawConnect()
|
|
85
|
+
})
|
|
86
|
+
for (const el of [origin, customProject]) el.addEventListener('input', drawConnect)
|
|
87
|
+
|
|
88
|
+
const METHODS = [
|
|
89
|
+
['claude', 'Claude Code', 'One command, once per machine. It works in every folder: the project comes from .cairn.json or the git remote. Needs Node 20+.'],
|
|
90
|
+
['http', 'Direct HTTP', 'No Node needed. Run it inside the project folder; the project header tells the server which brain to use.'],
|
|
91
|
+
['mcp', '.mcp.json', 'Commit this with the repo. Each person sets CAIRN_URL and CAIRN_TOKEN in their environment, so no secret is committed.'],
|
|
92
|
+
['cli', 'cairn CLI', 'Also installs the Claude Code hooks (briefing at session start, checkpoint reminder) and a CLAUDE.md block.'],
|
|
93
|
+
['check', 'Check it', 'Two requests: the server is up and can reach Neo4j, and your token and project are accepted.'],
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
function drawConnect() {
|
|
97
|
+
const base = origin.value.replace(/\/+$/, '')
|
|
98
|
+
const project = projectSelect.value || customProject.value.trim() || 'my-project'
|
|
99
|
+
const token = fresh ?? '<your-token>'
|
|
100
|
+
const known = projects.some(p => p.id === project)
|
|
101
|
+
const snippets = {
|
|
102
|
+
claude: `claude mcp add cairn --scope user -e CAIRN_URL=${base} -e CAIRN_TOKEN=${token} -- npx -y agentcairn mcp`,
|
|
103
|
+
http: `claude mcp add --transport http cairn ${base}/mcp --header "Authorization: Bearer ${token}" --header "X-Cairn-Project: ${project}"`,
|
|
104
|
+
mcp: JSON.stringify({ mcpServers: { 'cairn': { type: 'http', url: '${CAIRN_URL}/mcp', headers: { Authorization: 'Bearer ${CAIRN_TOKEN}', 'X-Cairn-Project': project } } } }, null, 2),
|
|
105
|
+
cli: `export CAIRN_URL=${base}\nexport CAIRN_TOKEN=${token}\nbrain init ${project}`,
|
|
106
|
+
check: `curl ${base}/healthz\ncurl -H "Authorization: Bearer ${token}" -H "X-Cairn-Project: ${project}" ${base}/api/project`,
|
|
107
|
+
}
|
|
108
|
+
const [, , blurb] = METHODS.find(([k]) => k === method)
|
|
109
|
+
const tabs = h('div', { class: 'seg', role: 'tablist', 'aria-label': 'Connection method' }, ...METHODS.map(([k, label]) => h('button', { type: 'button', 'aria-pressed': String(k === method), onclick: () => { method = k; drawConnect() } }, label)))
|
|
110
|
+
connectCard.replaceChildren(
|
|
111
|
+
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Connect an agent'), h('div', { class: 'hint' }, 'Pick a project, then copy the snippet for your tool.')), tabs),
|
|
112
|
+
h('div', { class: 'row', style: { marginBottom: '14px', alignItems: 'flex-end', gap: '14px' } },
|
|
113
|
+
h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'Server address, as your computers see it'), origin),
|
|
114
|
+
h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'Project'), h('div', { class: 'row tight' }, projectSelect, customProject)),
|
|
115
|
+
),
|
|
116
|
+
!known && h('div', { class: 'callout warn', style: { marginBottom: '12px' } }, `"${project}" does not exist yet. Create it under Projects → New project before an agent writes its first memory.`),
|
|
117
|
+
!fresh && h('div', { class: 'hint', style: { marginBottom: '10px' } }, 'Create a token above and it is filled in here automatically. Tokens are never shown again, so these snippets use a placeholder until then.'),
|
|
118
|
+
codeBox(snippets[method]),
|
|
119
|
+
h('p', { class: 'hint', style: { margin: '10px 0 0' } }, blurb),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
container.append(
|
|
124
|
+
h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Access'), h('div', { class: 'sub' }, 'Who can talk to this server, and how to connect an agent in under a minute.'))),
|
|
125
|
+
h('div', { class: 'stack', style: { gap: '16px' } }, tokenCard, connectCard),
|
|
126
|
+
)
|
|
127
|
+
drawTokens()
|
|
128
|
+
drawConnect()
|
|
129
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { api } from '../api.js'
|
|
2
|
+
import { clear, h, svg } from '../dom.js'
|
|
3
|
+
|
|
4
|
+
function brand() {
|
|
5
|
+
return h(
|
|
6
|
+
'div',
|
|
7
|
+
{ class: 'brand' },
|
|
8
|
+
svg(
|
|
9
|
+
'svg',
|
|
10
|
+
{ viewBox: '0 0 32 32', 'aria-hidden': 'true' },
|
|
11
|
+
svg('g', { stroke: 'var(--s1)', 'stroke-width': 2, 'stroke-linecap': 'round' }, svg('path', { d: 'M9 21 16 10M16 10l8 11M9 21h15' })),
|
|
12
|
+
svg('circle', { cx: 9, cy: 21, r: 3, fill: 'var(--text)' }),
|
|
13
|
+
svg('circle', { cx: 16, cy: 10, r: 3.4, fill: 'var(--s1)' }),
|
|
14
|
+
svg('circle', { cx: 24, cy: 21, r: 3, fill: 'var(--text)' }),
|
|
15
|
+
),
|
|
16
|
+
'Agent Cairn',
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function field(label, input, hint) {
|
|
21
|
+
const id = `f-${Math.random().toString(36).slice(2, 8)}`
|
|
22
|
+
input.id = id
|
|
23
|
+
return h('div', { class: 'field' }, h('label', { for: id }, label), input, hint && h('span', { class: 'hint' }, hint))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function page(...children) {
|
|
27
|
+
return h('div', { class: 'auth-page' }, h('div', { class: 'card panel' }, brand(), ...children))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function submit(form, button, errorEl, work) {
|
|
31
|
+
errorEl.textContent = ''
|
|
32
|
+
button.disabled = true
|
|
33
|
+
try {
|
|
34
|
+
await work()
|
|
35
|
+
} catch (error) {
|
|
36
|
+
errorEl.textContent = error.message
|
|
37
|
+
button.disabled = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function mountSetup(root, done) {
|
|
42
|
+
const error = h('p', { class: 'error', role: 'alert' })
|
|
43
|
+
const code = h('input', { class: 'input mono', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false', placeholder: 'XXXX-XXXX-XXXX', maxlength: '14', required: true })
|
|
44
|
+
code.addEventListener('input', () => {
|
|
45
|
+
const raw = code.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 12)
|
|
46
|
+
code.value = raw.replace(/(.{4})(?=.)/g, '$1-')
|
|
47
|
+
})
|
|
48
|
+
const username = h('input', { class: 'input', autocomplete: 'username', required: true, minlength: '3', maxlength: '32', value: 'admin' })
|
|
49
|
+
const password = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' })
|
|
50
|
+
const confirm = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true })
|
|
51
|
+
const button = h('button', { class: 'btn primary', type: 'submit', style: { width: '100%', justifyContent: 'center' } }, 'Create admin account')
|
|
52
|
+
const form = h(
|
|
53
|
+
'form',
|
|
54
|
+
null,
|
|
55
|
+
field('One-time setup code', code, 'Printed in the server log when it started.'),
|
|
56
|
+
field('Admin username', username),
|
|
57
|
+
field('Password', password, 'At least 10 characters.'),
|
|
58
|
+
field('Repeat password', confirm),
|
|
59
|
+
error,
|
|
60
|
+
button,
|
|
61
|
+
)
|
|
62
|
+
form.addEventListener('submit', e => {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
submit(form, button, error, async () => {
|
|
65
|
+
if (password.value !== confirm.value) throw new Error('The passwords do not match.')
|
|
66
|
+
await api('setup', { body: { code: code.value, username: username.value, password: password.value } })
|
|
67
|
+
done()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
clear(root).append(
|
|
71
|
+
page(
|
|
72
|
+
h('h1', null, 'Set up Agent Cairn'),
|
|
73
|
+
h('p', { class: 'lead' }, 'Create the admin account for this dashboard.'),
|
|
74
|
+
h(
|
|
75
|
+
'div',
|
|
76
|
+
{ class: 'callout', style: { marginBottom: '16px' } },
|
|
77
|
+
'The one-time setup code is printed in the server log when it starts. With Docker: ',
|
|
78
|
+
h('code', null, 'docker logs <container>'),
|
|
79
|
+
' (or open the container’s Logs in your Docker UI).',
|
|
80
|
+
),
|
|
81
|
+
form,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
code.focus()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mountLogin(root, done) {
|
|
88
|
+
const error = h('p', { class: 'error', role: 'alert' })
|
|
89
|
+
const username = h('input', { class: 'input', autocomplete: 'username', required: true })
|
|
90
|
+
const password = h('input', { class: 'input', type: 'password', autocomplete: 'current-password', required: true })
|
|
91
|
+
const button = h('button', { class: 'btn primary', type: 'submit', style: { width: '100%', justifyContent: 'center' } }, 'Sign in')
|
|
92
|
+
const form = h('form', null, field('Username', username), field('Password', password), error, button)
|
|
93
|
+
form.addEventListener('submit', e => {
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
submit(form, button, error, async () => {
|
|
96
|
+
await api('login', { body: { username: username.value, password: password.value } })
|
|
97
|
+
done()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
clear(root).append(page(h('h1', null, 'Sign in'), h('p', { class: 'lead' }, 'The looking glass for your project memory.'), form))
|
|
101
|
+
username.focus()
|
|
102
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { api } from '../api.js'
|
|
2
|
+
import { copyText, fmt, h, icon, statusBadge, codeBox } from '../dom.js'
|
|
3
|
+
import { openDrawer } from '../drawer.js'
|
|
4
|
+
import { jsonView } from '../json.js'
|
|
5
|
+
|
|
6
|
+
const label = e => e.tool ?? e.method
|
|
7
|
+
|
|
8
|
+
/** Opens the full record of one request: what came in, what went back, and which memories it touched. */
|
|
9
|
+
export function openEvent(summary, navigate) {
|
|
10
|
+
const body = h('div', null, h('div', { class: 'muted' }, 'Loading…'))
|
|
11
|
+
const title = h('div', { class: 'row', style: { minWidth: 0 } }, h('h2', { class: 'mono' }, label(summary)), statusBadge(summary))
|
|
12
|
+
const drawer = openDrawer(title, body)
|
|
13
|
+
|
|
14
|
+
api(`events/${summary.id}`)
|
|
15
|
+
.then(event => drawer.body.replaceChildren(render(event, navigate, drawer.close)))
|
|
16
|
+
.catch(error => drawer.body.replaceChildren(h('p', { class: 'error' }, error.message)))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function kv(rows) {
|
|
20
|
+
return h('dl', { class: 'kv' }, rows.filter(Boolean).flatMap(([k, v]) => [h('dt', null, k), h('dd', null, v)]))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function render(event, navigate, close) {
|
|
24
|
+
const tabs = ['Overview', 'Request', 'Response', 'Raw']
|
|
25
|
+
const panels = {
|
|
26
|
+
Overview: overview(event, navigate, close),
|
|
27
|
+
Request: panel(event.request, 'No request payload was captured (capture mode is "meta" or the request had no body).'),
|
|
28
|
+
Response: responsePanel(event),
|
|
29
|
+
Raw: codeBox(JSON.stringify(event, null, 2)),
|
|
30
|
+
}
|
|
31
|
+
const content = h('div')
|
|
32
|
+
const bar = h('div', { class: 'tabs', role: 'tablist' })
|
|
33
|
+
const show = name => {
|
|
34
|
+
for (const b of bar.children) b.setAttribute('aria-selected', String(b.textContent === name))
|
|
35
|
+
content.replaceChildren(panels[name])
|
|
36
|
+
}
|
|
37
|
+
for (const name of tabs) bar.append(h('button', { type: 'button', role: 'tab', onclick: () => show(name) }, name))
|
|
38
|
+
show(event.tool || event.request ? 'Overview' : 'Overview')
|
|
39
|
+
return h('div', null, bar, content)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function panel(value, empty) {
|
|
43
|
+
if (value === undefined) return h('div', { class: 'muted' }, empty)
|
|
44
|
+
return h('div', { class: 'stack' }, h('div', { class: 'row' }, h('button', { class: 'btn sm', type: 'button', onclick: () => copyText(JSON.stringify(value, null, 2)) }, icon('copy'), 'Copy JSON')), jsonView(value))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function responsePanel(event) {
|
|
48
|
+
const r = event.response
|
|
49
|
+
if (r === undefined) return h('div', { class: 'muted' }, 'No response payload was captured.')
|
|
50
|
+
if (typeof r?.text === 'string') {
|
|
51
|
+
return h(
|
|
52
|
+
'div',
|
|
53
|
+
{ class: 'stack' },
|
|
54
|
+
h('div', { class: 'row' }, h('span', { class: 'muted' }, `${fmt.int(event.tokensOut)} tokens (est.) sent to the agent`), h('span', { class: 'grow' }), h('button', { class: 'btn sm', type: 'button', onclick: () => copyText(r.text) }, icon('copy'), 'Copy text')),
|
|
55
|
+
h('pre', { class: 'pre' }, r.text),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return panel(r, '')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function overview(event, navigate, close) {
|
|
62
|
+
const client = event.client
|
|
63
|
+
const refs = event.memoryRefs.length
|
|
64
|
+
? h(
|
|
65
|
+
'span',
|
|
66
|
+
{ class: 'tags' },
|
|
67
|
+
event.memoryRefs.map(ref =>
|
|
68
|
+
h(
|
|
69
|
+
'span',
|
|
70
|
+
{
|
|
71
|
+
class: 'tag ref',
|
|
72
|
+
role: 'link',
|
|
73
|
+
tabindex: '0',
|
|
74
|
+
title: 'Show in the graph',
|
|
75
|
+
onclick: () => {
|
|
76
|
+
close()
|
|
77
|
+
navigate(`#/graph?project=${encodeURIComponent(event.project ?? '')}&focus=${ref}`)
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
`#${ref}`,
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
: h('span', { class: 'muted' }, 'none')
|
|
85
|
+
return h(
|
|
86
|
+
'div',
|
|
87
|
+
{ class: 'stack' },
|
|
88
|
+
event.error && h('div', { class: 'callout warn' }, event.error),
|
|
89
|
+
kv([
|
|
90
|
+
['Time', fmt.dateTime(event.ts)],
|
|
91
|
+
['Source', event.source === 'mcp' ? 'MCP (agent)' : event.source === 'api' ? 'JSON API (CLI / hooks)' : 'Rejected request'],
|
|
92
|
+
['Call', h('span', { class: 'mono' }, event.tool ? `${event.method} → ${event.tool}` : event.method)],
|
|
93
|
+
['Project', event.project ?? h('span', { class: 'muted' }, 'not specified')],
|
|
94
|
+
['Token', client.token ?? h('span', { class: 'muted' }, 'none')],
|
|
95
|
+
['Agent', client.agent ?? '–'],
|
|
96
|
+
['IP address', client.ip ?? '–'],
|
|
97
|
+
['User-Agent', client.userAgent ? h('span', { class: 'mono' }, client.userAgent) : '–'],
|
|
98
|
+
['Duration', fmt.ms(event.durationMs)],
|
|
99
|
+
['HTTP status', String(event.httpStatus)],
|
|
100
|
+
['Request size', fmt.bytes(event.bytesIn)],
|
|
101
|
+
['Response size', fmt.bytes(event.bytesOut)],
|
|
102
|
+
['Tokens out (est.)', fmt.int(event.tokensOut)],
|
|
103
|
+
['Memories touched', refs],
|
|
104
|
+
]),
|
|
105
|
+
)
|
|
106
|
+
}
|