@agentcairn/mcp-server 0.1.5 → 0.2.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/ui/css/app.css CHANGED
@@ -172,7 +172,7 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
172
172
  .badge svg { width: 11px; height: 11px; }
173
173
  .kind { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-2); font-weight: 520; }
174
174
  .kind::before { content: ""; width: 9px; height: 9px; border-radius: 3px; background: var(--k, var(--muted)); }
175
- .k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
175
+ .k-handoff { --k: var(--s8); } .k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
176
176
  .k-checkpoint { --k: var(--s5); } .k-thread { --k: var(--s6); } .k-glossary { --k: var(--s7); }
177
177
 
178
178
  /* ===== data display ===== */
@@ -359,3 +359,29 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
359
359
  .page-head { margin-bottom: 16px; }
360
360
  .toolbar { padding: 10px; }
361
361
  }
362
+
363
+ /* handoff and sessions on the project page */
364
+ .notes { display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; line-height: 1.5; }
365
+ .notes-h { margin: 10px 0 0; font-size: 11.5px; font-weight: 640; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
366
+ .notes-h:first-child { margin-top: 0; }
367
+ .notes-p { margin: 0; color: var(--text-2); }
368
+ .notes-list { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; color: var(--text); }
369
+ .resume-card { border-left: 3px solid var(--s8); margin-bottom: 14px; }
370
+ .resume-card details { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 10px; }
371
+ .resume-card summary { cursor: pointer; font-weight: 560; }
372
+ .sessions { display: flex; flex-direction: column; margin-top: 4px; }
373
+ .session { padding: 12px 0; border-top: 1px solid var(--border); }
374
+ .session:first-child { border-top: 0; padding-top: 4px; }
375
+ .session summary { cursor: pointer; list-style: none; display: flex; flex-direction: column; gap: 4px; }
376
+ .session summary::-webkit-details-marker { display: none; }
377
+ .session .clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-2); font-size: 13px; }
378
+ .session .detail { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
379
+ .session .detail b { font-weight: 600; }
380
+ .session .files { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
381
+
382
+ /* ---- users, sharing, scopes ---- */
383
+ .member { gap: 12px; padding: 4px 0; align-items: center; }
384
+ .member .hint { margin-top: 1px; }
385
+ .scope summary { cursor: pointer; color: var(--dim); font-size: 12.5px; }
386
+ .scope[open] summary { color: var(--text); }
387
+ .tbl .sub { display: block; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
package/ui/js/app.js CHANGED
@@ -3,6 +3,7 @@ import { clear, h, icon, initial, startClock, svg } from './dom.js'
3
3
  import { closeDrawer } from './drawer.js'
4
4
  import { live } from './store.js'
5
5
  import { mountLogin, mountSetup } from './views/auth.js'
6
+ import { mountInvite } from './views/invite.js'
6
7
 
7
8
  const root = document.getElementById('root')
8
9
 
@@ -35,14 +36,20 @@ const ROUTES = [
35
36
  { pattern: /^\/graph$/, nav: 'graph', load: () => import('./views/graph.js') },
36
37
  { pattern: /^\/query$/, nav: 'query', load: () => import('./views/query.js') },
37
38
  { pattern: /^\/access$/, nav: 'access', load: () => import('./views/access.js') },
39
+ { pattern: /^\/users$/, nav: 'users', load: () => import('./views/users.js') },
40
+ { pattern: /^\/audit$/, nav: 'audit', load: () => import('./views/audit.js') },
38
41
  { pattern: /^\/system$/, nav: 'system', load: () => import('./views/system.js') },
39
42
  ]
40
43
 
41
44
  const NAV = [
42
45
  ['Monitor', [['live', 'Live', 'live']]],
43
46
  ['Explore', [['projects', 'Projects', 'folder'], ['graph', 'Graph', 'graph'], ['query', 'Query', 'terminal']]],
44
- ['Manage', [['access', 'Access', 'key'], ['system', 'System', 'server']]],
47
+ ['Manage', [['access', 'Access', 'key'], ['users', 'Users', 'users'], ['audit', 'Audit', 'list'], ['system', 'System', 'server']]],
45
48
  ]
49
+ /** Pages that show the whole server rather than your own projects: admins only. */
50
+ const ADMIN_ONLY = new Set(['live', 'query', 'system', 'users', 'audit'])
51
+ let me = { role: 'admin', solo: true }
52
+
46
53
  const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([key, label]) => [key, label])))
47
54
 
48
55
  export function navigate(hash) {
@@ -72,8 +79,9 @@ let shell = null
72
79
 
73
80
  function mountShell(username, version) {
74
81
  clear(root)
82
+ const visibleNav = NAV.map(([group, items]) => [group, items.filter(([key]) => me.role === 'admin' || !ADMIN_ONLY.has(key))]).filter(([, items]) => items.length)
75
83
  const brand = h('div', { class: 'brand' }, logo(), 'Agent Cairn', version && h('small', null, `v${version}`))
76
- const links = NAV.flatMap(([group, items]) => [
84
+ const links = visibleNav.flatMap(([group, items]) => [
77
85
  h('div', { class: 'label' }, group),
78
86
  ...items.map(([key, label, ic]) => {
79
87
  const count = h('span', { class: 'count', dataset: { count: key } })
@@ -142,6 +150,7 @@ async function route() {
142
150
  const { path, query } = parseHash()
143
151
  const match = ROUTES.map(r => [r, r.pattern.exec(path)]).find(([, m]) => m)
144
152
  const [def, m] = match ?? [ROUTES[0], null]
153
+ if (me.role !== 'admin' && ADMIN_ONLY.has(def.nav)) return navigate('#/projects')
145
154
  const params = Object.fromEntries((def.params ?? []).map((name, i) => [name, decodeURIComponent(m?.[i + 1] ?? '')]))
146
155
 
147
156
  document.title = `${TITLES[def.nav] ?? 'Dashboard'} · Agent Cairn`
@@ -161,7 +170,9 @@ async function route() {
161
170
  }
162
171
  }
163
172
 
164
- window.addEventListener('hashchange', route)
173
+ const inviteToken = () => /^#\/invite\/([^/?]+)/.exec(location.hash)?.[1]
174
+
175
+ window.addEventListener('hashchange', () => (inviteToken() || !shell ? boot() : route()))
165
176
 
166
177
  // ---- boot ------------------------------------------------------------------
167
178
 
@@ -171,6 +182,8 @@ async function boot() {
171
182
  shell?.unsubscribe()
172
183
  shell = null
173
184
  live.stop()
185
+ const invite = inviteToken()
186
+ if (invite) return mountInvite(root, decodeURIComponent(invite), boot)
174
187
  let state
175
188
  try {
176
189
  state = await api('state')
@@ -180,8 +193,9 @@ async function boot() {
180
193
  }
181
194
  if (state.setupRequired) return mountSetup(root, boot)
182
195
  if (!state.authenticated) return mountLogin(root, boot)
196
+ me = { role: state.role ?? 'admin', solo: state.solo ?? true }
183
197
  mountShell(state.username, state.version)
184
- live.start()
198
+ if (me.role === 'admin') live.start()
185
199
  route()
186
200
  }
187
201
 
package/ui/js/dom.js CHANGED
@@ -77,6 +77,8 @@ const ICONS = {
77
77
  fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
78
78
  info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
79
79
  arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
80
+ users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
81
+ list: '<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>',
80
82
  database: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
81
83
  }
82
84
 
@@ -208,7 +210,43 @@ export function debounce(fn, ms) {
208
210
  }
209
211
  }
210
212
 
211
- export const KINDS = ['overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
213
+ export const KINDS = ['handoff', 'overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
214
+
215
+ /**
216
+ * Renders the small Markdown a handoff is written in (`## headings`, `- bullets`, `1. steps`) as elements.
217
+ * Text only goes in through textContent, never as HTML.
218
+ */
219
+ export function renderNotes(body) {
220
+ const out = []
221
+ let list = null
222
+ const flush = () => {
223
+ if (list) out.push(list)
224
+ list = null
225
+ }
226
+ for (const raw of String(body).split('\n')) {
227
+ const line = raw.trimEnd()
228
+ if (!line.trim()) continue
229
+ const heading = /^##\s+(.*)$/.exec(line)
230
+ const bullet = /^-\s+(.*)$/.exec(line)
231
+ const step = /^\d+\.\s+(.*)$/.exec(line)
232
+ if (heading) {
233
+ flush()
234
+ out.push(h('h4', { class: 'notes-h' }, heading[1]))
235
+ } else if (bullet || step) {
236
+ const tag = step ? 'ol' : 'ul'
237
+ if (!list || list.tagName.toLowerCase() !== tag) {
238
+ flush()
239
+ list = h(tag, { class: 'notes-list' })
240
+ }
241
+ list.append(h('li', null, (bullet ?? step)[1]))
242
+ } else {
243
+ flush()
244
+ out.push(h('p', { class: 'notes-p' }, line))
245
+ }
246
+ }
247
+ flush()
248
+ return h('div', { class: 'notes' }, ...out)
249
+ }
212
250
 
213
251
  export function kindLabel(kind) {
214
252
  return h('span', { class: `kind k-${kind}` }, kind)
@@ -13,14 +13,19 @@ export async function mount(container, { query }) {
13
13
 
14
14
  // ---- tokens --------------------------------------------------------------
15
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 readOnly = h('input', { type: 'checkbox', id: 'tok-ro' })
17
+ const scopeBoxes = projects.map(p => ({ p, box: h('input', { type: 'checkbox', value: p.id, 'aria-label': p.name }) }))
18
+ const scope = h('details', { class: 'scope' }, h('summary', null, 'Limit to projects'), h('div', { class: 'stack', style: { gap: '6px', padding: '8px 0' } }, scopeBoxes.length ? scopeBoxes.map(({ p, box }) => h('label', { class: 'row tight' }, box, p.name)) : h('span', { class: 'hint' }, 'No projects yet.'), h('span', { class: 'hint' }, 'None ticked means every project you can reach. A limited token cannot create projects.')))
16
19
  const createBtn = h('button', { class: 'btn primary', type: 'button' }, icon('plus'), 'Create token')
17
20
 
18
21
  async function create() {
19
22
  createBtn.disabled = true
20
23
  try {
21
- const made = await api('tokens', { body: { name: nameInput.value } })
24
+ const made = await api('tokens', { body: { name: nameInput.value, readOnly: readOnly.checked, projects: scopeBoxes.filter(x => x.box.checked).map(x => x.p.id) } })
22
25
  fresh = made.token
23
26
  nameInput.value = ''
27
+ readOnly.checked = false
28
+ scopeBoxes.forEach(x => (x.box.checked = false))
24
29
  tokens = (await api('tokens')).tokens
25
30
  secretBox.replaceChildren(
26
31
  h(
@@ -56,12 +61,13 @@ export async function mount(container, { query }) {
56
61
  function drawTokens() {
57
62
  const active = tokens.filter(t => !t.revokedAt)
58
63
  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)),
64
+ 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, h('label', { class: 'row tight', for: 'tok-ro', title: 'The token can read memories and briefings but never write' }, readOnly, 'Read-only'), createBtn)),
65
+ h('div', { style: { padding: '0 18px 10px' } }, scope),
60
66
  secretBox,
61
67
  tokens.length
62
68
  ? 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
69
  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)),
70
+ h('td', null, h('span', { class: 'tool-name' }, t.name), t.readOnly && h('span', { class: 'badge', style: { marginLeft: '8px' } }, 'read-only'), t.projects?.length > 0 && h('span', { class: 'badge', style: { marginLeft: '6px' }, title: t.projects.map(id => projects.find(p => p.id === id)?.name ?? id).join(', ') }, `${t.projects.length} project${t.projects.length === 1 ? '' : 's'}`)),
65
71
  h('td', { class: 'mono' }, `${t.prefix}…`),
66
72
  h('td', null, ago(t.createdAt), t.createdBy && h('span', { class: 'sub' }, `by ${t.createdBy}`)),
67
73
  h('td', null, t.lastUsedAt ? ago(t.lastUsedAt) : h('span', { class: 'muted' }, 'never')),
@@ -75,7 +81,7 @@ export async function mount(container, { query }) {
75
81
 
76
82
  // ---- connect -------------------------------------------------------------
77
83
  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…'))
84
+ const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.name)), h('option', { value: '' }, 'A project name or id…'))
79
85
  const customProject = h('input', { class: 'input', placeholder: 'project-id', 'aria-label': 'Project id', style: { display: 'none', width: '160px' } })
80
86
  if (query.project) projectSelect.value = query.project
81
87
  else if (!projects.length) projectSelect.value = ''
@@ -0,0 +1,77 @@
1
+ import { api, qs } from '../api.js'
2
+ import { emptyState, fmt, h, toast } from '../dom.js'
3
+
4
+ const GROUPS = [
5
+ ['', 'Everything'],
6
+ ['login', 'Sign-ins'],
7
+ ['user.', 'Accounts'],
8
+ ['invite.', 'Invitations'],
9
+ ['member.', 'Sharing'],
10
+ ['project.', 'Projects'],
11
+ ['token.', 'Tokens'],
12
+ ['console.', 'Query console'],
13
+ ]
14
+ const PAGE = 100
15
+
16
+ export async function mount(container) {
17
+ let events = []
18
+ let prefix = ''
19
+ let more = false
20
+
21
+ const body = h('tbody')
22
+ const table = h('div', { class: 'scroll' }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['When', 'Who', 'What', 'On', 'Detail'].map(t => h('th', null, t)))), body))
23
+ const holder = h('div', { class: 'card flush' })
24
+ const select = h('select', { 'aria-label': 'Kind of event' }, GROUPS.map(([v, label]) => h('option', { value: v }, label)))
25
+ const moreBtn = h('button', { class: 'btn', type: 'button' }, 'Load older')
26
+
27
+ function rows(list) {
28
+ return list.map(e =>
29
+ h(
30
+ 'tr',
31
+ null,
32
+ h('td', { title: e.at }, h('span', { class: 'mono', style: { fontSize: '12px' } }, fmt.dateTime(e.at))),
33
+ h('td', null, e.actor ?? h('span', { class: 'muted' }, 'system')),
34
+ h('td', null, h('span', { class: `badge ${/failed|removed|disabled|revoked|deleted/.test(e.action) ? 'err' : /created|accepted|added|enabled/.test(e.action) ? 'ok' : ''}` }, e.action)),
35
+ h('td', null, e.target ?? ''),
36
+ h('td', { class: 'dim' }, e.detail ?? ''),
37
+ ),
38
+ )
39
+ }
40
+
41
+ async function load(append) {
42
+ moreBtn.disabled = true
43
+ try {
44
+ const { events: got } = await api(`audit?${qs({ limit: PAGE, action: prefix, before: append ? events[events.length - 1]?.at : undefined })}`)
45
+ events = append ? [...events, ...got] : got
46
+ more = got.length === PAGE
47
+ draw(append ? got : events, append)
48
+ } catch (error) {
49
+ toast(error.message)
50
+ } finally {
51
+ moreBtn.disabled = false
52
+ }
53
+ }
54
+
55
+ function draw(list, append) {
56
+ if (!events.length) {
57
+ holder.replaceChildren(emptyState('inbox', 'Nothing recorded', prefix ? 'No events of this kind.' : 'Sign-ins and changes to accounts, sharing, tokens and projects show up here.'))
58
+ return
59
+ }
60
+ if (!holder.contains(table)) holder.replaceChildren(table, h('div', { class: 'row', style: { padding: '12px 18px', justifyContent: 'center' } }, moreBtn))
61
+ if (append) body.append(...rows(list))
62
+ else body.replaceChildren(...rows(list))
63
+ moreBtn.hidden = !more
64
+ }
65
+
66
+ select.addEventListener('change', () => {
67
+ prefix = select.value
68
+ load(false)
69
+ })
70
+ moreBtn.addEventListener('click', () => load(true))
71
+
72
+ container.append(
73
+ h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Audit log'), h('div', { class: 'sub' }, 'Who changed what, kept for 180 days. Secrets and memory content are never recorded.')), h('div', { class: 'row' }, select)),
74
+ holder,
75
+ )
76
+ await load(false)
77
+ }
@@ -1,33 +1,32 @@
1
1
  import { api } from '../api.js'
2
2
  import { clear, h, svg } from '../dom.js'
3
3
 
4
- function brand() {
4
+ export function brand() {
5
5
  return h(
6
6
  'div',
7
7
  { class: 'brand' },
8
8
  svg(
9
9
  'svg',
10
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)' }),
11
+ svg('ellipse', { cx: 16, cy: 23.5, rx: 9, ry: 3.8, fill: 'var(--text)' }),
12
+ svg('ellipse', { cx: 16, cy: 16.2, rx: 6.4, ry: 3.2, fill: 'var(--text)' }),
13
+ svg('ellipse', { cx: 16, cy: 9.6, rx: 3.8, ry: 2.8, fill: 'var(--s1)' }),
15
14
  ),
16
15
  'Agent Cairn',
17
16
  )
18
17
  }
19
18
 
20
- function field(label, input, hint) {
19
+ export function field(label, input, hint) {
21
20
  const id = `f-${Math.random().toString(36).slice(2, 8)}`
22
21
  input.id = id
23
22
  return h('div', { class: 'field' }, h('label', { for: id }, label), input, hint && h('span', { class: 'hint' }, hint))
24
23
  }
25
24
 
26
- function page(...children) {
25
+ export function page(...children) {
27
26
  return h('div', { class: 'auth-page' }, h('div', { class: 'card panel' }, brand(), ...children))
28
27
  }
29
28
 
30
- async function submit(form, button, errorEl, work) {
29
+ export async function submit(form, button, errorEl, work) {
31
30
  errorEl.textContent = ''
32
31
  button.disabled = true
33
32
  try {
@@ -3,11 +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'])
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'])
11
11
  const LABEL_ZOOM = 1.15
12
12
 
13
13
  const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
@@ -46,15 +46,21 @@ function stylesheet() {
46
46
  { selector: '.dim', style: { opacity: 0.1 } },
47
47
  { selector: '.match', style: { 'border-width': 4, 'border-color': css('--warn-text'), label: 'data(label)' } },
48
48
  { selector: '.hidden', style: { display: 'none' } },
49
+ { selector: '.collapsed', style: { display: 'none' } },
50
+ { selector: 'node[type="project"].sized', style: { width: 'mapData(memories, 0, 60, 40, 100)', height: 'mapData(memories, 0, 60, 40, 100)', 'font-size': 12, 'text-max-width': 120 } },
51
+ { selector: 'node[type="project"].quiet', style: { 'border-color': muted, opacity: 0.78 } },
52
+ { selector: 'node[type="project"].away', style: { opacity: 0.3 } },
53
+ { selector: 'node[type="project"].open', style: { 'border-color': css('--s3'), 'underlay-opacity': 0.14, 'underlay-padding': 12 } },
49
54
  { selector: ':selected', style: { 'border-width': 4, 'border-color': accent, 'underlay-opacity': 0.22, 'underlay-padding': 9, label: 'data(label)' } },
50
55
  ]
51
56
  }
52
57
 
53
58
  /** Layouts that put the project at the root, then its areas, then their memories. */
54
- function layoutOptions(cy, name) {
59
+ function layoutOptions(cy, name, overview = false) {
55
60
  const roots = cy.nodes('[type="project"]')
56
61
  switch (name) {
57
62
  case 'radial':
63
+ if (overview) return { name: 'concentric', concentric: n => n.data('memories') ?? 0, levelWidth: () => 25, minNodeSpacing: 70, spacingFactor: 1.2, avoidOverlap: true, padding: 40, animate: false }
58
64
  return { name: 'breadthfirst', roots, circle: true, directed: false, spacingFactor: 1.05, avoidOverlap: true, padding: 40, animate: false }
59
65
  case 'tree':
60
66
  return { name: 'breadthfirst', roots, circle: false, directed: false, spacingFactor: 1.15, avoidOverlap: true, padding: 40, animate: false }
@@ -66,6 +72,8 @@ function layoutOptions(cy, name) {
66
72
  }
67
73
 
68
74
  const LAYOUT_LABELS = { radial: 'Radial', tree: 'Tree', force: 'Force', grid: 'Grid' }
75
+ const FEW_PROJECTS = 3 // up to this many, their areas are shown straight away
76
+ const QUIET_AFTER_DAYS = 21
69
77
 
70
78
  export async function mount(container, { query, navigate }) {
71
79
  const [{ projects }] = await Promise.all([api('projects'), loadCytoscape()])
@@ -74,12 +82,15 @@ export async function mount(container, { query, navigate }) {
74
82
  let limit = 300
75
83
  let pulseOn = true
76
84
  let showSuperseded = true
85
+ let pinnedAreas = false // overview: keep every project's areas open
86
+ let showRemotes = false
87
+ const expanded = new Set()
77
88
  const hiddenKinds = new Set()
78
89
  let cy = null
79
90
  let refreshTimer = null
80
91
 
81
92
  // ---- 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}`)))
93
+ 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
94
  projectSelect.value = project
84
95
  projectSelect.addEventListener('change', () => navigate(`#/graph${projectSelect.value ? `?project=${encodeURIComponent(projectSelect.value)}` : ''}`))
85
96
 
@@ -99,6 +110,21 @@ export async function mount(container, { query, navigate }) {
99
110
  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
111
  pulseBtn.addEventListener('click', () => { pulseOn = !pulseOn; pulseBtn.setAttribute('aria-pressed', String(pulseOn)) })
101
112
 
113
+ const areasBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Open every project to show its areas' }, 'Areas')
114
+ areasBtn.addEventListener('click', () => {
115
+ pinnedAreas = !pinnedAreas
116
+ areasBtn.setAttribute('aria-pressed', String(pinnedAreas))
117
+ pinnedAreas ? expandAll() : collapseAll()
118
+ fit()
119
+ })
120
+ const remotesBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Show the git remotes that map to each project' }, 'Git remotes')
121
+ remotesBtn.addEventListener('click', () => {
122
+ showRemotes = !showRemotes
123
+ remotesBtn.setAttribute('aria-pressed', String(showRemotes))
124
+ cy?.nodes('[type="remote"]').toggleClass('collapsed', !showRemotes)
125
+ hud()
126
+ })
127
+
102
128
  const supBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Show memories that were replaced' }, 'Superseded')
103
129
  supBtn.addEventListener('click', () => { showSuperseded = !showSuperseded; supBtn.setAttribute('aria-pressed', String(showSuperseded)); applyFilters() })
104
130
 
@@ -123,9 +149,9 @@ export async function mount(container, { query, navigate }) {
123
149
  container.append(
124
150
  h('div', { class: 'graph-page' },
125
151
  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.')),
152
+ 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
153
  ),
128
- h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, pulseBtn, project && supBtn),
154
+ h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, !project && areasBtn, !project && remotesBtn, pulseBtn, project && supBtn),
129
155
  h('div', { class: 'graph-layout' },
130
156
  h('div', { class: 'canvas-wrap' }, canvas, kindChips.length > 0 && h('div', { class: 'canvas-overlay tl' }, ...kindChips), controls, hudEl),
131
157
  side,
@@ -139,7 +165,9 @@ export async function mount(container, { query, navigate }) {
139
165
 
140
166
  function runLayout(doFit) {
141
167
  if (!cy) return
142
- const layout = cy.layout(layoutOptions(cy, layoutName))
168
+ const overview = !project
169
+ const eles = overview ? cy.nodes('[type="project"]') : cy.elements()
170
+ const layout = eles.layout(layoutOptions(cy, layoutName, overview))
143
171
  if (doFit) layout.one('layoutstop', fit)
144
172
  layout.run()
145
173
  }
@@ -147,7 +175,12 @@ export async function mount(container, { query, navigate }) {
147
175
  cy?.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 260 })
148
176
  }
149
177
  function hud() {
150
- if (cy) hudEl.textContent = `${cy.nodes().length} nodes · ${cy.edges().length} edges · ${cy.zoom().toFixed(2)}×`
178
+ if (!cy) return
179
+ // what is drawn: folded-away and filtered nodes are not counted
180
+ const shown = n => !n.is('.collapsed, .hidden')
181
+ const nodes = cy.nodes().filter(shown).length
182
+ const edges = cy.edges().filter(e => shown(e.source()) && shown(e.target())).length
183
+ hudEl.textContent = `${nodes} nodes · ${edges} edges · ${cy.zoom().toFixed(2)}×`
151
184
  }
152
185
  function syncLabels() {
153
186
  if (!cy) return
@@ -155,6 +188,58 @@ export async function mount(container, { query, navigate }) {
155
188
  cy.nodes('[type="memory"]').toggleClass('lbl', show)
156
189
  }
157
190
 
191
+ /** The areas of a project fan out around it; everything else stays put. */
192
+ function expand(node, animate = true) {
193
+ if (node.data('type') !== 'project') return
194
+ const areas = node.outgoers('node[type="area"]')
195
+ const center = node.position()
196
+ const radius = 64 + areas.length * 7
197
+ areas.forEach((a, i) => {
198
+ const angle = (2 * Math.PI * i) / Math.max(areas.length, 1) - Math.PI / 2
199
+ const spot = { x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle) }
200
+ a.removeClass('collapsed')
201
+ if (animate) {
202
+ a.position(center)
203
+ a.animate({ position: spot }, { duration: 240, easing: 'ease-out' })
204
+ } else a.position(spot)
205
+ })
206
+ node.addClass('open')
207
+ expanded.add(node.id())
208
+ hud()
209
+ }
210
+ function collapse(node) {
211
+ node.outgoers('node[type="area"]').addClass('collapsed')
212
+ node.removeClass('open')
213
+ expanded.delete(node.id())
214
+ hud()
215
+ }
216
+ const expandAll = () => cy?.nodes('[type="project"]').forEach(n => expand(n, false))
217
+ const collapseAll = () => {
218
+ cy?.nodes('[type="project"]').forEach(collapse)
219
+ cy?.nodes('[type="project"]').removeClass('away')
220
+ }
221
+ function toggleExpand(node) {
222
+ if (pinnedAreas) return
223
+ const wasOpen = expanded.has(node.id())
224
+ collapseAll()
225
+ if (!wasOpen) {
226
+ expand(node)
227
+ // the rest step back while one is open
228
+ cy.nodes('[type="project"]').not(node).addClass('away')
229
+ }
230
+ }
231
+ /** Many projects at once is a lot to draw: start with just the projects, and let people open them. */
232
+ function prepareOverview() {
233
+ const cutoff = Date.now() - QUIET_AFTER_DAYS * 86_400_000
234
+ cy.nodes('[type="project"]').forEach(n => {
235
+ n.addClass('sized')
236
+ n.toggleClass('quiet', Date.parse(n.data('lastMemoryAt') ?? n.data('createdAt')) < cutoff)
237
+ })
238
+ cy.nodes('[type="area"]').addClass('collapsed')
239
+ cy.nodes('[type="remote"]').addClass('collapsed')
240
+ expanded.clear()
241
+ }
242
+
158
243
  function applyFilters() {
159
244
  if (!cy) return
160
245
  cy.batch(() => {
@@ -187,12 +272,24 @@ export async function mount(container, { query, navigate }) {
187
272
  const graph = await api(`graph?${qs({ project, limit })}`)
188
273
  if (cy) cy.destroy()
189
274
  cy = window.cytoscape({ container: canvas, elements: [...graph.nodes.map(element), ...graph.edges.map(edgeEl)], style: stylesheet(), wheelSensitivity: 0.22, minZoom: 0.08, maxZoom: 4 })
275
+ if (!project) prepareOverview()
190
276
  runLayout(true)
277
+ if (!project && cy.nodes('[type="project"]').length <= FEW_PROJECTS) expandAll()
191
278
  applyFilters()
192
279
  hud()
193
280
 
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) } })
281
+ cy.on('tap', 'node', e => {
282
+ select(e.target)
283
+ if (!project && e.target.data('type') === 'project') toggleExpand(e.target)
284
+ })
285
+ cy.on('dbltap', 'node[type="project"]', e => { if (!project) navigate(`#/graph?project=${encodeURIComponent(e.target.data('mid'))}`) })
286
+ cy.on('tap', e => {
287
+ if (e.target !== cy) return
288
+ cy.elements().removeClass('dim')
289
+ cy.elements().unselect()
290
+ if (!project && !pinnedAreas) collapseAll()
291
+ drawSide(null, graph)
292
+ })
196
293
  cy.on('mouseover', 'node', e => {
197
294
  if (search.input.value.trim()) return
198
295
  const around = e.target.closedNeighborhood()
@@ -204,7 +301,16 @@ export async function mount(container, { query, navigate }) {
204
301
  cy.on('zoom', debounce(() => { syncLabels(); hud() }, 60))
205
302
 
206
303
  drawSide(null, graph)
207
- if (query.focus) {
304
+ if (!project && query.focus) {
305
+ // a link like #/graph?focus=<project id> opens that project's areas
306
+ const target = cy.getElementById(`p:${query.focus}`)
307
+ if (target.nonempty()) {
308
+ select(target)
309
+ toggleExpand(target)
310
+ target.select()
311
+ }
312
+ }
313
+ if (project && query.focus) {
208
314
  const target = cy.nodes('[type="memory"]').filter(n => n.data('mid').startsWith(query.focus))
209
315
  if (target.nonempty()) {
210
316
  select(target[0])
@@ -277,16 +383,15 @@ export async function mount(container, { query, navigate }) {
277
383
  h('div', null, h('div', { class: 'eyebrow' }, project ? 'Project' : 'Overview'), h('h2', { style: { fontSize: '17px', marginTop: '4px' } }, project || 'All projects')),
278
384
  graph?.truncated && h('div', { class: 'callout warn' }, `Showing the ${limit} most important and recent memories. Raise the limit to see more.`),
279
385
  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.'),
386
+ 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.'),
281
387
  h('h3', null, 'How to read it'),
282
388
  h('div', { class: 'legend-list' },
283
389
  h('div', null, h('span', { class: 'g' }, '◯'), 'project: the root'),
284
390
  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'),
391
+ ...(project ? KINDS.map(k => h('div', { class: `k-${k}` }, h('span', { class: 'g', style: { color: 'var(--k)' } }, KIND_GLYPH[k]), k)) : [h('div', null, h('span', { class: 'g', style: { opacity: 0.5 } }, '◯'), 'faded project: nothing new in three weeks')]),
392
+ ...(project ? [h('div', null, h('span', { class: 'g', style: { opacity: 0.45 } }, '◆'), 'faded, dashed: superseded'), h('div', null, h('span', { class: 'g', style: { color: 'var(--s5)' } }, '⇢'), 'newer replaces older')] : []),
288
393
  ),
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.'),
394
+ h('p', { class: 'hint', style: { margin: 0 } }, project ? 'Size is importance. With live pulse on, a node glows when an agent reads or writes it.' : 'Bubble size is how many memories a project holds. With live pulse on, a project glows when an agent reads or writes it.'),
290
395
  ),
291
396
  )
292
397
  }
@@ -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 }