SLOPSHOPPER

muninn

Muninn, Odin's raven of memory: your ai-memory in Claude Code. A /muninn pane (and a /mods tab) with the project's memory, the last handoff, search and recent…

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-15tauantcamargo/claude-mods/plugins/muninn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · muninn
│ ┃ Muninn ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Muninn · a memória do ai-memory ● fora d │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ ! fora do ar │ ⏺ Update(src/auth.ts) │ ┃ │ O servidor do ai-memory não respondeu em │ ⎿ Added 2 lines, removed 1 line │ ┃ │ http://127.0.0.1:49374. │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ [ Atualizar ] [ Reiniciar servidor ] │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Esc fecha · /muninn <termo> busca direto ✻ Worked for 42s · done 4:20 PM │ │ › /muninn │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ muninn: Muninn · fora do ar

Draws

Pane · Muninn
◆ Muninn · a memória do ai-memory ● fora do ar ╭──────────────────────────────────────────────────────────╮ │ ! fora do ar │ │ O servidor do ai-memory não respondeu em │ │ http://127.0.0.1:49374. │ │ │ │ [ Atualizar ] [ Reiniciar servidor ] │ ╰──────────────────────────────────────────────────────────╯ Esc fecha · /muninn <termo> busca direto
README

tauan-mods

My Claude Code mods in one marketplace. /mods opens one pane with a tab per mod: no per-mod slash commands to remember.

ModSourceWhat it does
modstauantcamargo/claude-mods-hub (fork of mvacoimbra's, plus a Workers tab)The /mods pane
workersplugins/workersSalão dos Deuses: every subagent becomes a Norse or Roman god, in a carved-stone pane (/workers) with runes, laurel, Roman-numeral progress, an oracle check and a war band above the prompt
muninnplugins/muninnMuninn, Odin's raven of memory: your ai-memory in a /muninn pane and a /mods tab (the project's memory, the last handoff, search, recent pages), a count in the status line and a toast when a handoff arrives. Also for the Codex app, below
cache-taxmvacoimbra forkKeeps the prompt cache warm (costs tokens)
skinsmvacoimbra forkThemes and richer tool rows
filetreemvacoimbra forkFile tree of what Claude touches
savvy-progressmvacoimbra forkSubagent progress and agents panel
replay-theatermvacoimbra forkReplay the last turn's edits
blast-radiusmvacoimbra/claude-code-playgroundHolds risky shell commands for confirmation
reflect-modmvacoimbra/claude-reflectSaves your corrections to CLAUDE.md

Install

claude plugin marketplace add tauantcamargo/claude-mods
claude plugin install mods@tauan-mods
claude plugin install workers@tauan-mods
claude plugin install muninn@tauan-mods
claude plugin install cache-tax@tauan-mods
claude plugin install skins@tauan-mods
claude plugin install filetree@tauan-mods
claude plugin install savvy-progress@tauan-mods
claude plugin install replay-theater@tauan-mods
claude plugin install blast-radius@tauan-mods
claude plugin install reflect-mod@tauan-mods

Restart Claude Code, then open /mods. Uninstall the same mods from their original marketplaces first so nothing loads twice.

Codex

The same marketplace serves Codex (.agents/plugins/marketplace.json). Muninn there is an MCP App (codex/muninn): the Codex app draws its panel, and the $muninn skill points Codex at it.

codex plugin marketplace add tauantcamargo/claude-mods
codex plugin add muninn@tauan-mods
Source 7 files
hooks/register.tsx 968 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { MuninnActArgs, MuninnActResult, MuninnHit, MuninnItem, MuninnLang, MuninnPage, MuninnPageRef, MuninnPanel, MuninnPanelArgs, MuninnScope, MuninnSection, MuninnSnapshot } from '../types'
5import { WORDS } from './i18n'
6import type { Words } from './i18n'
7import { action, empty, field, icon, kitOf, notice, picker, row, section, stack, status, text, toggle, value } from './kit/kit'
8import type { Kit } from './kit/kit'
9import { toneOf } from './kit/tone'
10import type { KitSkin, KitTone } from './kit/tone'
11import { baseOf, briefed, clip, DEFAULT_SERVER, isFresh, kindOf as errorKindOf, MARKER, matchProject, MemoryError, messageOf, pagePath, parentOf, parseMarker, plain, projectPath, projectsOf, refOf, sinceOf, webUrl } from './memory'
12import type { Briefing, Conn, HandoffRow, HitRow, Marker, PageBody, ProjectRow } from './memory'
13
14// Muninn, Odin's raven of memory: the ai-memory server in Claude Code. A pane (/muninn), a tab
15// in the /mods hub, a count in the status line and a toast when a session starts with a
16// handoff. It only reads the server (memory.ts); capture stays with ai-memory's own hooks.
17
18const PANE = 'muninn'
19// A snapshot younger than this is drawn as it is; older, the hub's tab fetches afresh.
20const STALE_MS = 15_000
21const POLL_MS = 120_000
22
23const snapshotAtom = atom({ plugin: 'muninn', key: 'snapshot' } as const, null)
24const busyAtom = atom({ plugin: 'muninn', key: 'isBusy' } as const, false)
25const viewAtom = atom({ plugin: 'muninn', key: 'view' } as const, 'home')
26const queryAtom = atom({ plugin: 'muninn', key: 'query' } as const, '')
27const everywhereAtom = atom({ plugin: 'muninn', key: 'everywhere' } as const, false)
28const resultsAtom = atom({ plugin: 'muninn', key: 'results' } as const, [])
29const pageAtom = atom({ plugin: 'muninn', key: 'page' } as const, null)
30const langAtom = atom({ plugin: 'muninn', key: 'lang' } as const, 'pt')
31
32const wordsOf = (lang: MuninnLang): Words => WORDS[lang === 'en' ? 'en' : 'pt']
33
34// --- Settings: the user's, in the store every session shares
35
36async function langOf($: EngineInterface): Promise<MuninnLang> {
37  return (await $.store.get('lang')) === 'en' ? 'en' : 'pt'
38}
39
40async function settingOf($: EngineInterface, key: 'statusLine' | 'toast'): Promise<boolean> {
41  return (await $.store.get(key)) !== false
42}
43
44async function pinsOf($: EngineInterface): Promise<Record<string, string>> {
45  const pins = await $.store.get('pins')
46
47  return pins !== null && typeof pins === 'object' && !Array.isArray(pins) ? (pins as Record<string, string>) : {}
48}
49
50// The skin every mod of the pack wears, from skins; Claude Code's own colours without it.
51async function skinOf($: EngineInterface): Promise<KitSkin | null> {
52  try {
53    return await $.skins.palette()
54  } catch {
55    return null
56  }
57}
58
59// --- Reading the ai-memory server: its read-only `/api/v1` (and `/identity`), with the root
60// bearer `ai-memory setup` wrote to the data directory. Every function that calls `$` is in
61// this file: the engine follows `$` into a function of the hooks' own file, never an import.
62
63// The server and its credential: AI_MEMORY_SERVER_URL and AI_MEMORY_AUTH_TOKEN when set, else
64// loopback and the token file under the data directory (macOS, then the XDG path on Linux).
65async function connOf($: EngineInterface): Promise<Conn> {
66  const url = (await $.env.get('AI_MEMORY_SERVER_URL')) ?? ''
67  const base = (url.trim() || DEFAULT_SERVER).replace(/\/+$/, '').replace(/\/mcp$/, '')
68  const fromEnv = (await $.env.get('AI_MEMORY_AUTH_TOKEN'))?.trim()
69
70  if (fromEnv) {
71    return { base, token: fromEnv }
72  }
73
74  const home = (await $.env.get('HOME')) ?? ''
75  const dataDir = (await $.env.get('AI_MEMORY_DATA_DIR'))?.trim()
76  const xdg = (await $.env.get('XDG_DATA_HOME'))?.trim()
77  const files = [
78    ...(dataDir ? [`${dataDir}/auth-tokens/default`] : []),
79    `${home}/Library/Application Support/ai-memory/auth-tokens/default`,
80    `${xdg || `${home}/.local/share`}/ai-memory/auth-tokens/default`,
81  ]
82
83  for (const file of files) {
84    try {
85      const found = await $.fs.read(file)
86
87      if (typeof found === 'string' && found.trim() !== '') {
88        return { base, token: found.trim() }
89      }
90    } catch {
91      // Not this one.
92    }
93  }
94
95  return { base }
96}
97
98async function getJson<T>($: EngineInterface, conn: Conn, path: string): Promise<T> {
99  const headers: Record<string, string> = { Accept: 'application/json' }
100
101  if (conn.token) {
102    headers.Authorization = `Bearer ${conn.token}`
103  }
104
105  let response
106
107  try {
108    response = await $.http.fetch(`${conn.base}${path}`, { headers })
109  } catch (error) {
110    throw new MemoryError('offline', messageOf(error))
111  }
112
113  if (response.status === 401 || response.status === 403) {
114    throw new MemoryError('unauthorized', `HTTP ${response.status}`, response.status)
115  }
116
117  if (!response.ok) {
118    throw new MemoryError('http', `HTTP ${response.status}`, response.status)
119  }
120
121  return JSON.parse(response.text) as T
122}
123
124// Walks up from the session's folder to home: the nearest marker that names a scope, and the
125// repository root (the nearest folder holding `.git`), whose name the project is matched by.
126async function localOf($: EngineInterface, cwd: string, home: string): Promise<{ marker: Marker; root: string }> {
127  let marker: Marker = {}
128  let root: string | undefined
129  let dir = cwd
130
131  for (let depth = 0; depth < 32; depth++) {
132    if (root === undefined && (await $.fs.exists(`${dir}/.git`))) {
133      root = dir
134    }
135
136    if (marker.workspace === undefined && marker.project === undefined && (await $.fs.exists(`${dir}/${MARKER}`))) {
137      try {
138        marker = parseMarker(String(await $.fs.read(`${dir}/${MARKER}`)))
139      } catch {
140        marker = {}
141      }
142    }
143
144    const done = (marker.workspace !== undefined || marker.project !== undefined) && root !== undefined
145
146    if (done || dir === home || dir === '/') {
147      break
148    }
149
150    dir = parentOf(dir)
151  }
152
153  return { marker, root: root ?? cwd }
154}
155
156async function snapshotOf($: EngineInterface, pins: Readonly<Record<string, string>> = {}): Promise<MuninnSnapshot> {
157  const at = await $.clock.now()
158  const cwd = await $.session.cwd()
159  const home = (await $.env.get('HOME')) ?? ''
160  const { marker, root } = await localOf($, cwd, home)
161  const repo = baseOf(root)
162  const blank: MuninnSnapshot = { at, online: false, cwd, root, repo, scope: null, handoff: null, recent: [], projects: [] }
163
164  try {
165    const conn = await connOf($)
166    const [identity, rows] = await Promise.all([
167      getJson<{ version?: string }>($, conn, '/identity').catch((): { version?: string } => ({})),
168      getJson<ProjectRow[]>($, conn, '/api/v1/projects'),
169    ])
170    const projects = projectsOf(rows)
171    const scope = matchProject(projects, marker, repo, pins[root] ?? pins[cwd])
172    const online: MuninnSnapshot = { ...blank, online: true, ...(identity.version ? { version: identity.version } : {}), scope, projects }
173
174    if (scope === null) {
175      return online
176    }
177
178    const [briefing, handoffs] = await Promise.all([
179      getJson<Briefing>($, conn, `${projectPath(scope)}/briefing?limit=8`).catch((error: unknown): Briefing => {
180        if (error instanceof MemoryError && error.status === 404) {
181          return {}
182        }
183
184        throw error
185      }),
186      getJson<{ handoffs?: HandoffRow[] }>($, conn, `${projectPath(scope)}/handoffs?limit=5`).catch((): { handoffs?: HandoffRow[] } => ({})),
187    ])
188
189    return briefed(online, scope, briefing, handoffs.handoffs ?? [])
190  } catch (error) {
191    return { ...blank, error: errorKindOf(error) }
192  }
193}
194
195// A project's hybrid search (the ranker `memory_query` uses), or FTS5 over every project.
196async function searchOf($: EngineInterface, scope: MuninnScope | null, query: string): Promise<MuninnHit[]> {
197  const conn = await connOf($)
198  const params = new URLSearchParams({ q: query, limit: '12' })
199
200  if (scope) {
201    params.set('workspace', scope.workspace)
202    params.set('project', scope.project)
203  }
204
205  const rows = await getJson<HitRow[]>($, conn, `/api/v1/search?${params.toString()}`)
206
207  return rows.map(row => ({ ...refOf(row, row), snippet: plain(row.snippet ?? '') }))
208}
209
210async function pageOf($: EngineInterface, ref: MuninnPageRef): Promise<MuninnPage> {
211  const conn = await connOf($)
212  const row = await getJson<PageBody>($, conn, `${projectPath(ref)}/pages/${pagePath(ref.path)}`)
213
214  return { ...ref, title: row.title || ref.title, kind: row.kind || ref.kind, ...(row.updated_at ? { updatedAt: row.updated_at } : {}), body: row.body_markdown ?? '' }
215}
216
217// --- Keeping a snapshot
218
219let inflight: Promise<MuninnSnapshot> | undefined
220
221// A fresh snapshot, kept for the session and shown in the status line. One at a time: a call
222// while one is in flight gets that one. Never from a render, which may not write state.
223function refresh($: EngineInterface): Promise<MuninnSnapshot> {
224  inflight ??= (async () => {
225    await update($, busyAtom, () => true)
226
227    try {
228      const snap = await snapshotOf($, await pinsOf($))
229      await keep($, snap)
230
231      return snap
232    } finally {
233      inflight = undefined
234      await update($, busyAtom, () => false)
235    }
236  })()
237
238  return inflight
239}
240
241async function keep($: EngineInterface, snap: MuninnSnapshot): Promise<void> {
242  await update($, snapshotAtom, () => snap)
243  await showStatus($, snap)
244}
245
246// The hub draws its tab from inside its own render, where nothing may be written: what is held
247// when it is fresh, else a fetch, kept from a timer.
248async function snapshotForPanel($: EngineInterface): Promise<MuninnSnapshot> {
249  const held = await read($, snapshotAtom)
250
251  if (held !== null && (await $.clock.now()) - held.at < STALE_MS) {
252    return held
253  }
254
255  const snap = await snapshotOf($, await pinsOf($))
256  $.clock.after(0, () => void keep($, snap))
257
258  return snap
259}
260
261async function showStatus($: EngineInterface, snap: MuninnSnapshot): Promise<void> {
262  if (!(await settingOf($, 'statusLine'))) {
263    $.ui.status(undefined)
264
265    return
266  }
267
268  const w = wordsOf(await langOf($))
269
270  if (!snap.online) {
271    $.ui.status(w.statusOffline)
272  } else if (snap.scope === null) {
273    $.ui.status(undefined)
274  } else {
275    $.ui.status(w.status(snap.repo, snap.counts?.pages ?? 0, snap.handoff?.state === 'open'))
276  }
277}
278
279// The toast a session starts with when it was handed a handoff: once per handoff.
280async function greet($: EngineInterface): Promise<void> {
281  const snap = await refresh($)
282  const handoff = snap.handoff
283  const now = await $.clock.now()
284
285  if (handoff === null || !isFresh(handoff, now) || !(await settingOf($, 'toast'))) {
286    return
287  }
288
289  const seen = await $.store.get('toasted')
290  const ids = Array.isArray(seen) ? (seen as string[]) : []
291
292  if (ids.includes(handoff.id)) {
293    return
294  }
295
296  await $.store.set('toasted', [...ids, handoff.id].slice(-20))
297  const w = wordsOf(await langOf($))
298  const summary = handoff.summary ? clip(handoff.summary, 90) : undefined
299  $.ui.toast(w.toast(handoff.agent, w.ago(sinceOf(now, handoff.at) ?? 0), summary, handoff.state === 'open'), { timeoutMs: 8000 })
300}
301
302async function runSearch($: EngineInterface, query: string, everywhere: boolean): Promise<void> {
303  await update($, queryAtom, () => query)
304  await update($, everywhereAtom, () => everywhere)
305  await update($, viewAtom, () => 'search')
306  await update($, busyAtom, () => true)
307
308  try {
309    const snap = (await read($, snapshotAtom)) ?? (await refresh($))
310    // With no project for this folder yet, a search covers every project.
311    const hits = await searchOf($, everywhere ? null : snap.scope, query)
312    await update($, resultsAtom, () => hits)
313  } catch (error) {
314    await update($, resultsAtom, () => [])
315    $.ui.toast(`Muninn: ${messageOf(error)}`)
316  } finally {
317    await update($, busyAtom, () => false)
318  }
319}
320
321async function openPage($: EngineInterface, ref: MuninnPageRef): Promise<void> {
322  await update($, busyAtom, () => true)
323
324  try {
325    const page = await pageOf($, ref)
326    await update($, pageAtom, () => page)
327    await update($, viewAtom, () => 'page')
328  } catch (error) {
329    $.ui.toast(`Muninn: ${messageOf(error)}`)
330  } finally {
331    await update($, busyAtom, () => false)
332  }
333}
334
335async function openPane($: EngineInterface, w: Words): Promise<boolean> {
336  const opened = await $.ui.open({ id: PANE, title: w.title, focus: true, closeOnEscape: true })
337
338  return opened.isPlaced
339}
340
341// The wiki in the browser: macOS `open`, then `xdg-open`; the address on the clipboard when
342// neither runs here.
343async function openWeb($: EngineInterface, ref?: MuninnPageRef | { workspace: string; project: string }): Promise<boolean> {
344  const url = webUrl((await connOf($)).base, ref)
345
346  for (const argv of [['open', url], ['xdg-open', url]]) {
347    try {
348      if ((await $.process.run(argv, { timeoutMs: 5000 })).exitCode === 0) {
349        return true
350      }
351    } catch {
352      // Not on this host.
353    }
354  }
355
356  return (await $.ui.copy({ text: url })).isCopied
357}
358
359// The web UI's password on a fresh install is the root token itself (any user name).
360async function copyPassword($: EngineInterface, surface?: RenderSurface): Promise<boolean> {
361  const { token } = await connOf($)
362
363  if (!token) {
364    return false
365  }
366
367  return (await $.ui.copy(surface === undefined ? { text: token } : { text: token, surface })).isCopied
368}
369
370// The per-user service `ai-memory setup` installed: the LaunchAgent on macOS, systemd on Linux.
371async function restartServer($: EngineInterface): Promise<boolean> {
372  const tries: string[][] = []
373
374  try {
375    const uid = (await $.process.run(['id', '-u'], { timeoutMs: 5000 })).stdout.trim()
376
377    if (/^\d+$/.test(uid)) {
378      tries.push(['launchctl', 'kickstart', '-k', `gui/${uid}/com.github.akitaonrails.ai-memory`])
379    }
380  } catch {
381    // No `id` here.
382  }
383
384  tries.push(['systemctl', '--user', 'restart', 'ai-memory.service'])
385
386  for (const argv of tries) {
387    try {
388      if ((await $.process.run(argv, { timeoutMs: 15_000 })).exitCode === 0) {
389        await $.clock.sleep(1500)
390        await refresh($)
391
392        return true
393      }
394    } catch {
395      // Try the next.
396    }
397  }
398
399  return false
400}
401
402async function setPin($: EngineInterface, choice: string): Promise<void> {
403  const snap = (await read($, snapshotAtom)) ?? (await refresh($))
404  const pins = { ...(await pinsOf($)) }
405
406  if (choice === 'auto' || choice === '') {
407    delete pins[snap.root]
408  } else {
409    pins[snap.root] = choice
410  }
411
412  await $.store.set('pins', pins)
413  await refresh($)
414}
415
416// --- Words for one snapshot
417
418const kindOf = (w: Words, kind: string): string => w.kinds[kind] ?? kind
419
420const agoOf = (w: Words, now: number, iso: string | null | undefined): string => {
421  const since = sinceOf(now, iso)
422
423  return since === null ? w.never : w.ago(since)
424}
425
426const whereOf = (snap: MuninnSnapshot): string => (snap.scope === null ? snap.repo : `${snap.scope.workspace} / ${snap.scope.project}`)
427
428const handoffLine = (w: Words, snap: MuninnSnapshot, now: number): string | null => {
429  const h = snap.handoff
430
431  return h === null ? null : w.handoffFrom(h.agent, w.ago(sinceOf(now, h.at) ?? 0), w.handoffState(h.state, isFresh(h, now)))
432}
433
434// What /muninn prints where no surface draws a pane (VS Code, a headless run).
435export function dashboardText(w: Words, snap: MuninnSnapshot, now: number): string {
436  const lines = [`**${w.title}** · ${w.tagline}`]
437
438  if (!snap.online) {
439    lines.push(snap.error === 'unauthorized' ? w.unauthorizedHint : w.offlineHint(DEFAULT_SERVER))
440
441    return lines.join('\n')
442  }
443
444  if (snap.scope === null) {
445    lines.push(w.nothingHere(snap.repo), w.nothingHereHint)
446
447    return lines.join('\n')
448  }
449
450  lines.push(`- ${w.project}: ${whereOf(snap)}`)
451
452  if (snap.counts) {
453    lines.push(`- ${w.memory}: ${w.counts(snap.counts.pages, snap.counts.sessions, snap.counts.observations)}`)
454  }
455
456  lines.push(`- ${w.last}: ${agoOf(w, now, snap.lastAt)}`)
457
458  const h = snap.handoff
459
460  if (h !== null) {
461    lines.push('', `**${w.handoff}** ${handoffLine(w, snap, now)}`)
462
463    if (h.summary) {
464      lines.push(clip(h.summary, 400))
465    }
466
467    for (const step of h.nextSteps.slice(0, 5)) {
468      lines.push(`- ${w.nextSteps}: ${clip(step, 200)}`)
469    }
470
471    for (const question of h.openQuestions.slice(0, 3)) {
472      lines.push(`- ${w.openQuestions}: ${clip(question, 200)}`)
473    }
474  }
475
476  if (snap.recent.length > 0) {
477    lines.push('', `**${w.recent}**`)
478
479    for (const ref of snap.recent.slice(0, 6)) {
480      lines.push(`- ${clip(ref.title, 90)} (${kindOf(w, ref.kind)}, ${agoOf(w, now, ref.updatedAt)})`)
481    }
482  }
483
484  return lines.join('\n')
485}
486
487export function searchText(w: Words, query: string, hits: readonly MuninnHit[], everywhere: boolean): string {
488  if (hits.length === 0) {
489    return w.noResults(query)
490  }
491
492  return [
493    `**${w.results(hits.length, query, everywhere)}**`,
494    ...hits.slice(0, 8).map(hit => `- **${clip(hit.title, 90)}** (${kindOf(w, hit.kind)}${everywhere ? ` · ${hit.project}` : ''}): ${clip(hit.snippet, 220)}`),
495  ].join('\n')
496}
497
498// --- The /mods hub tab (claude-mods-hub, PROTOCOL.md)
499
500export type PanelInput = {
501  lang: MuninnLang
502  snap: MuninnSnapshot
503  now: number
504  query: string
505  hits: readonly MuninnHit[]
506  everywhere: boolean
507  pinned: string | undefined
508  statusLine: boolean
509  toast: boolean
510}
511
512async function hubPanel($: EngineInterface, args: MuninnPanelArgs): Promise<MuninnPanel> {
513  const lang: MuninnLang = args.lang === 'en' ? 'en' : 'pt'
514
515  if ((await langOf($)) !== lang) {
516    $.clock.after(0, () => void $.store.set('lang', lang))
517  }
518
519  const snap = await snapshotForPanel($)
520
521  return panelOf({
522    lang,
523    snap,
524    now: await $.clock.now(),
525    query: await read($, queryAtom),
526    hits: await read($, resultsAtom),
527    everywhere: await read($, everywhereAtom),
528    pinned: (await pinsOf($))[snap.root],
529    statusLine: await settingOf($, 'statusLine'),
530    toast: await settingOf($, 'toast'),
531  })
532}
533
534// The tab, from one snapshot: what the hub draws, in the hub's own items.
535export function panelOf({ lang, snap, now, query, hits, everywhere, pinned, statusLine, toast }: PanelInput): MuninnPanel {
536  const w = wordsOf(lang)
537  const sections: MuninnSection[] = []
538
539  if (!snap.online) {
540    sections.push({
541      id: 'now',
542      title: lang === 'pt' ? 'Agora' : 'Now',
543      items: [
544        { kind: 'text', text: snap.error === 'unauthorized' ? w.unauthorizedHint : w.offlineHint(DEFAULT_SERVER), tone: 'err' },
545        { kind: 'action', id: 'refresh', label: w.refresh },
546        { kind: 'action', id: 'restart', label: w.restart, primary: true },
547      ],
548    })
549
550    return { title: w.title, summary: snap.error === 'unauthorized' ? w.unauthorized : w.offline, tone: 'err', sections, refreshMs: 30_000 }
551  }
552
553  const nowItems: MuninnItem[] = [{ kind: 'info', label: w.project, value: snap.scope === null ? w.nothingHere(snap.repo) : whereOf(snap), ...(snap.scope === null ? { tone: 'muted' as const } : {}) }]
554
555  if (snap.counts) {
556    nowItems.push({ kind: 'info', label: w.memory, value: w.counts(snap.counts.pages, snap.counts.sessions, snap.counts.observations) })
557  }
558
559  if (snap.week) {
560    nowItems.push({ kind: 'info', label: w.week, value: w.weekLine(snap.week.sessions, snap.week.observations) })
561  }
562
563  if (snap.scope !== null) {
564    nowItems.push({ kind: 'info', label: w.last, value: agoOf(w, now, snap.lastAt) })
565  }
566
567  if (snap.handoff !== null) {
568    nowItems.push({ kind: 'info', label: w.handoff, value: handoffLine(w, snap, now) ?? '', tone: snap.handoff.state === 'open' ? 'warn' : 'ok' })
569
570    if (snap.handoff.summary) {
571      nowItems.push({ kind: 'text', text: clip(snap.handoff.summary, 240) })
572    }
573  }
574
575  nowItems.push({ kind: 'action', id: 'refresh', label: w.refresh }, { kind: 'action', id: 'web', label: w.web }, { kind: 'action', id: 'open', label: lang === 'pt' ? 'Abrir o painel' : 'Open the pane', primary: true })
576  sections.push({ id: 'now', title: lang === 'pt' ? 'Agora' : 'Now', items: nowItems })
577
578  const searchItems: MuninnItem[] = [{ kind: 'input', id: 'search', label: w.search, help: w.searchHelp, placeholder: w.placeholder, submitLabel: w.go }]
579
580  if (query !== '') {
581    searchItems.push({ kind: 'text', text: hits.length === 0 ? w.noResults(query) : w.results(hits.length, query, everywhere) })
582
583    hits.slice(0, 8).forEach((hit, i) => {
584      searchItems.push({ kind: 'action', id: `hit:${i}`, label: w.open, title: clip(hit.title, 70), help: clip(`${kindOf(w, hit.kind)} · ${hit.snippet}`, 140) })
585    })
586  }
587
588  sections.push({ id: 'search', title: w.search, items: searchItems })
589  sections.push({
590    id: 'recent',
591    title: w.recent,
592    items:
593      snap.recent.length === 0
594        ? [{ kind: 'text', text: snap.scope === null ? w.nothingHereHint : w.noRecent }]
595        : snap.recent.map((ref, i): MuninnItem => ({ kind: 'action', id: `recent:${i}`, label: w.open, title: clip(ref.title, 70), help: `${kindOf(w, ref.kind)} · ${agoOf(w, now, ref.updatedAt)}` })),
596  })
597  sections.push({
598    id: 'settings',
599    title: w.settings,
600    items: [
601      {
602        kind: 'choice',
603        id: 'project',
604        label: w.pickProject,
605        help: w.projectHelp,
606        value: pinned ?? 'auto',
607        options: [{ value: 'auto', label: w.auto(snap.repo) }, ...snap.projects.map(p => ({ value: `${p.workspace}/${p.project}`, label: `${p.workspace} / ${p.project}` }))],
608      },
609      { kind: 'toggle', id: 'statusLine', label: w.statusLine, help: w.statusLineHelp, value: statusLine },
610      { kind: 'toggle', id: 'toast', label: w.toastSetting, help: w.toastSettingHelp, value: toast },
611      { kind: 'action', id: 'copy', label: w.copyPassword, title: w.web, help: w.webHelp },
612    ],
613  })
614
615  const handoffWord = snap.handoff === null ? null : `${w.handoff.toLowerCase()} ${w.handoffState(snap.handoff.state, isFresh(snap.handoff, now))}`
616  const summary = snap.scope === null ? w.nothingHere(snap.repo) : w.summary(snap.scope.project, snap.counts?.pages ?? 0, handoffWord)
617  const tone = snap.scope === null ? 'muted' : snap.handoff?.state === 'open' ? 'warn' : 'ok'
618
619  return { title: w.title, summary, tone, sections, refreshMs: 30_000 }
620}
621
622async function hubAct($: EngineInterface, args: MuninnActArgs): Promise<MuninnActResult> {
623  const w = wordsOf(await langOf($))
624  const [id, at] = args.id.split(':')
625  const index = Number(at)
626
627  switch (id) {
628    case 'open':
629      await update($, viewAtom, () => 'home')
630      await openPane($, w)
631
632      return {}
633    case 'refresh':
634      await refresh($)
635
636      return { message: w.refreshed }
637    case 'web':
638      return (await openWeb($)) ? {} : { message: w.notCopied }
639    case 'copy':
640      return { message: (await copyPassword($)) ? w.copied : w.notCopied }
641    case 'restart':
642      return { message: (await restartServer($)) ? w.restarted : w.notRestarted }
643    case 'search':
644      if (args.value) {
645        await runSearch($, args.value, false)
646      }
647
648      return {}
649    case 'hit':
650    case 'recent': {
651      const list: readonly MuninnPageRef[] = id === 'hit' ? await read($, resultsAtom) : ((await read($, snapshotAtom))?.recent ?? [])
652      const ref = list[index]
653
654      if (ref !== undefined) {
655        await openPage($, ref)
656        await openPane($, w)
657      }
658
659      return {}
660    }
661    case 'project':
662      await setPin($, args.value ?? 'auto')
663
664      return {}
665    case 'statusLine':
666    case 'toast': {
667      await $.store.set(id, args.value === 'on')
668      const snap = await read($, snapshotAtom)
669
670      if (snap !== null) {
671        await showStatus($, snap)
672      }
673
674      return {}
675    }
676    default:
677      return {}
678  }
679}
680
681// --- The pane
682
683type Press = (work: () => Promise<unknown>) => () => void
684
685function header(k: Kit, w: Words, snap: MuninnSnapshot | null, busy: boolean): RenderElement {
686  const { Box } = k.ui
687  const state: [string, KitTone] | null =
688    snap === null ? null : snap.online ? [`${w.online}${snap.version ? ` · ${snap.version}` : ''}`, 'ok'] : [snap.error === 'unauthorized' ? w.unauthorized : w.offline, 'err']
689
690  return (
691    <Box key="muninn-header" flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
692      {icon(k, 'raven', 'accent', 18, '◆')}
693      {text(k, w.title, undefined, { bold: true })}
694      {text(k, `· ${w.tagline}`, 'faint')}
695      <Box flexGrow={1} />
696      {busy ? text(k, w.loading, 'faint') : null}
697      {state === null ? null : status(k, state[0], state[1])}
698    </Box>
699  )
700}
701
702function handoffNotice(k: Kit, w: Words, snap: MuninnSnapshot, now: number): RenderElement | null {
703  const h = snap.handoff
704
705  if (h === null) {
706    return null
707  }
708
709  const body = [
710    ...(h.summary ? [text(k, clip(h.summary, 600), undefined, { key: 'handoff-summary' })] : []),
711    ...h.nextSteps.slice(0, 5).map((step, i) => text(k, `→ ${clip(step, 300)}`, 'muted', { key: `handoff-next-${i}` })),
712    ...h.openQuestions.slice(0, 3).map((question, i) => text(k, `? ${clip(question, 300)}`, 'muted', { key: `handoff-open-${i}` })),
713  ]
714  const tone: KitTone = h.state === 'open' ? 'warn' : isFresh(h, now) ? 'ok' : 'muted'
715
716  return notice(k, 'muninn-handoff', tone, `${w.handoff} ${handoffLine(w, snap, now)}`, body.length > 0 ? body : undefined, undefined, 'replay')
717}
718
719function homeView($: EngineInterface, k: Kit, w: Words, snap: MuninnSnapshot, now: number, pinned: string | undefined, settings: { statusLine: boolean; toast: boolean }, press: Press, surface: RenderSurface): RenderElement {
720  if (!snap.online) {
721    return notice(
722      k,
723      'muninn-offline',
724      'err',
725      snap.error === 'unauthorized' ? w.unauthorized : w.offline,
726      snap.error === 'unauthorized' ? w.unauthorizedHint : w.offlineHint(DEFAULT_SERVER),
727      [
728        action(k, 'muninn-retry', w.refresh, press(() => refresh($))),
729        action(k, 'muninn-restart', w.restart, press(async () => $.ui.toast((await restartServer($)) ? w.restarted : w.notRestarted)), { primary: true }),
730      ],
731    )
732  }
733
734  const search = section(k, 'muninn-search', w.search, [
735    field(k, 'muninn-q', w.searchHelp, w.placeholder, query => query !== '' && press(() => runSearch($, query, false))(), { submitLabel: w.go }),
736  ])
737
738  const memory =
739    snap.scope === null
740      ? notice(k, 'muninn-nothing', 'muted', w.nothingHere(snap.repo), w.nothingHereHint)
741      : section(
742          k,
743          'muninn-memory',
744          w.memory,
745          [
746            value(k, w.project, whereOf(snap), undefined, 'muninn-v-project'),
747            ...(snap.counts ? [value(k, w.memory, w.counts(snap.counts.pages, snap.counts.sessions, snap.counts.observations), undefined, 'muninn-v-counts')] : []),
748            ...(snap.week ? [value(k, w.week, w.weekLine(snap.week.sessions, snap.week.observations), undefined, 'muninn-v-week')] : []),
749            value(k, w.last, agoOf(w, now, snap.lastAt), undefined, 'muninn-v-last'),
750          ],
751          undefined,
752          true,
753        )
754
755  const recent =
756    snap.scope === null
757      ? null
758      : section(
759          k,
760          'muninn-recent',
761          w.recent,
762          snap.recent.length === 0
763            ? [empty(k, 'muninn-no-recent', w.noRecent)]
764            : snap.recent.map((ref, i) =>
765                row(k, {
766                  key: `muninn-recent-${i}`,
767                  label: clip(ref.title, 90),
768                  help: `${kindOf(w, ref.kind)} · ${agoOf(w, now, ref.updatedAt)}`,
769                  icon: ref.kind === 'session' ? 'clock' : 'file',
770                  end: action(k, `muninn-recent-open-${i}`, w.open, press(() => openPage($, ref))),
771                }),
772              ),
773        )
774
775  const options = [{ value: 'auto', label: w.auto(snap.repo) }, ...snap.projects.map(p => ({ value: `${p.workspace}/${p.project}`, label: `${p.workspace} / ${p.project} (${p.pages})` }))]
776  const toggleWords = { on: w.on, off: w.off, turnOn: w.turnOn, turnOff: w.turnOff }
777
778  const more = section(k, 'muninn-more', w.more, [
779    row(k, { key: 'muninn-project', label: w.pickProject, help: w.projectHelp, icon: 'folder', below: picker(k, 'muninn-pick', pinned ?? 'auto', options, choice => press(() => setPin($, choice))()) }),
780    row(k, {
781      key: 'muninn-web',
782      label: w.web,
783      help: w.webHelp,
784      icon: 'external',
785      end: [action(k, 'muninn-copy', w.copyPassword, press(async () => $.ui.toast((await copyPassword($, surface)) ? w.copied : w.notCopied))), action(k, 'muninn-open-web', w.web, press(() => openWeb($)))],
786    }),
787    row(k, { key: 'muninn-status', label: w.statusLine, help: w.statusLineHelp, end: toggle(k, 'muninn-t-status', settings.statusLine, next => press(() => hubAct($, { id: 'statusLine', value: next ? 'on' : 'off' }))(), toggleWords) }),
788    row(k, { key: 'muninn-toast', label: w.toastSetting, help: w.toastSettingHelp, end: toggle(k, 'muninn-t-toast', settings.toast, next => press(() => hubAct($, { id: 'toast', value: next ? 'on' : 'off' }))(), toggleWords) }),
789    row(k, { key: 'muninn-refresh', label: w.refresh, icon: 'refresh', end: action(k, 'muninn-refresh-now', w.refresh, press(() => refresh($))) }),
790  ])
791
792  return stack(k, 'muninn-home', [handoffNotice(k, w, snap, now), search, memory, recent, more])
793}
794
795function searchView($: EngineInterface, k: Kit, w: Words, query: string, hits: readonly MuninnHit[], everywhere: boolean, now: number, press: Press): RenderElement {
796  const { Box } = k.ui
797  const list =
798    hits.length === 0
799      ? empty(k, 'muninn-no-hits', w.noResults(query))
800      : section(
801          k,
802          'muninn-hits',
803          w.results(hits.length, query, everywhere),
804          hits.map((hit, i) =>
805            row(k, {
806              key: `muninn-hit-${i}`,
807              label: clip(hit.title, 90),
808              help: clip(`${kindOf(w, hit.kind)}${everywhere ? ` · ${hit.workspace} / ${hit.project}` : ''}${hit.updatedAt ? ` · ${agoOf(w, now, hit.updatedAt)}` : ''} · ${hit.snippet}`, 260),
809              icon: 'search',
810              end: action(k, `muninn-hit-open-${i}`, w.open, press(() => openPage($, hit))),
811            }),
812          ),
813        )
814
815  return stack(k, 'muninn-search-view', [
816    <Box key="muninn-search-nav" flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
817      {action(k, 'muninn-back', `← ${w.back}`, press(() => update($, viewAtom, () => 'home')))}
818      {action(k, 'muninn-scope', everywhere ? w.here : w.everywhere, press(() => runSearch($, query, !everywhere)))}
819    </Box>,
820    field(k, 'muninn-q2', w.search, w.placeholder, next => next !== '' && press(() => runSearch($, next, everywhere))(), { submitLabel: w.go, value: query }),
821    list,
822  ])
823}
824
825export const register: Register = on => {
826  // Adds `$.muninn`: its tab in the /mods pane.
827  on('engine.create', async ($, e, next) => {
828    const built = await next(e)
829
830    return { ...built, muninn: { panel: async () => ({ title: '', sections: [] }), act: async () => ({}) } }
831  })
832  on('muninn.panel', async ($, e) => ({ value: await hubPanel($, e) }))
833  on('muninn.act', async ($, e) => ({ value: await hubAct($, e) }))
834
835  on('session.start', async ($, e, next) => {
836    const started = await next(e)
837    const lang = await langOf($)
838    await update($, langAtom, () => lang)
839    await $.command.register({ name: 'muninn', description: wordsOf(lang).command, argumentHint: wordsOf(lang).argHint, immediate: true })
840    // After the session's own start, so ai-memory's hook has handed the handoff over.
841    $.clock.after(800, () => void greet($).catch(() => undefined))
842    $.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
843
844    return started
845  })
846
847  // A turn may have written memory: look again once it settles, at most every few seconds.
848  on('turn.complete', async ($, e, next) => {
849    const done = await next(e)
850
851    if (e.agentId === undefined) {
852      $.clock.after(2000, () =>
853        void (async () => {
854          const held = await read($, snapshotAtom)
855
856          if (held === null || (await $.clock.now()) - held.at > 5000) {
857            await refresh($)
858          }
859        })().catch(() => undefined),
860      )
861    }
862
863    return done
864  })
865
866  on('command.run', { command: 'muninn' }, async ($, e) => {
867    const w = wordsOf(await langOf($))
868    const query = e.args.trim()
869
870    if (query === '') {
871      const open = (await $.ui.panes()).find(pane => pane.id === PANE)
872
873      if (open?.isShown) {
874        await $.ui.close({ id: PANE })
875
876        return {}
877      }
878
879      await update($, viewAtom, () => 'home')
880    }
881
882    const isPlaced = await openPane($, w)
883    // VS Code and headless runs attach no surface that draws panes, whatever the open answered.
884    const surfaces = await $.session.surfaces()
885    const drawn = isPlaced && surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
886    const snap = await refresh($)
887
888    if (query !== '') {
889      await runSearch($, query, snap.scope === null)
890    }
891
892    if (drawn) {
893      return {}
894    }
895
896    // No surface draws the pane here: the same, as text.
897    return {
898      text: query === '' ? dashboardText(w, snap, await $.clock.now()) : searchText(w, query, await read($, resultsAtom), await read($, everywhereAtom)),
899    }
900  })
901
902  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
903    const ui = $.ui.resolve(e)
904    const lang = await langOf($)
905    const w = wordsOf(lang)
906
907    if (!('Input' in ui)) {
908      return <ui.Text>{w.textOnly}</ui.Text>
909    }
910
911    const k = kitOf(ui, toneOf(await skinOf($)), e.surface, e.props.bodyColumns)
912    const snap = await read($, snapshotAtom)
913    const busy = await read($, busyAtom)
914    const view = await read($, viewAtom)
915    const now = await $.clock.now()
916
917    const press: Press = work => () =>
918      void work().catch(error => {
919        $.ui.toast(`Muninn: ${messageOf(error)}`)
920      })
921
922    const top = header(k, w, snap, busy)
923
924    if (snap === null) {
925      return stack(k, 'muninn', [top, empty(k, 'muninn-waiting', w.loading)])
926    }
927
928    if (view === 'page') {
929      const page = await read($, pageAtom)
930
931      if (page !== null) {
932        const { Box, Markdown } = ui
933
934        return stack(k, 'muninn', [
935          top,
936          <Box key="muninn-page-nav" flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
937            {action(
938              k,
939              'muninn-page-back',
940              `← ${w.back}`,
941              press(async () => {
942                const query = await read($, queryAtom)
943                await update($, viewAtom, () => (query === '' ? 'home' : 'search'))
944              }),
945            )}
946            {action(k, 'muninn-page-web', w.web, press(() => openWeb($, page)))}
947          </Box>,
948          <Box key="muninn-page-meta" flexDirection="column" paddingX={1}>
949            {text(k, page.title, undefined, { bold: true })}
950            {text(k, `${kindOf(w, page.kind)} · ${page.workspace} / ${page.project} · ${agoOf(w, now, page.updatedAt)}`, 'muted')}
951          </Box>,
952          <Box key="muninn-page-body" flexDirection="column" paddingX={1}>
953            <Markdown key="muninn-page-md" text={page.body} />
954          </Box>,
955        ])
956      }
957    }
958
959    if (view === 'search') {
960      return stack(k, 'muninn', [top, searchView($, k, w, await read($, queryAtom), await read($, resultsAtom), await read($, everywhereAtom), now, press)])
961    }
962
963    const settings = { statusLine: await settingOf($, 'statusLine'), toast: await settingOf($, 'toast') }
964
965    return stack(k, 'muninn', [top, homeView($, k, w, snap, now, (await pinsOf($))[snap.root], settings, press, e.surface), text(k, w.footer, 'faint', { key: 'muninn-footer' })])
966  })
967}
968
hooks/i18n.ts 246 lines
1import type { MuninnLang } from '../types'
2
3// Every word Muninn draws or says. Portuguese is the default; the /mods hub asks for English
4// when the person picks it there, and Muninn remembers the last language asked.
5export type Words = {
6  title: string
7  tagline: string
8  command: string
9  argHint: string
10  online: string
11  offline: string
12  unauthorized: string
13  loading: string
14  server: string
15  project: string
16  folder: string
17  memory: string
18  counts: (pages: number, sessions: number, observations: number) => string
19  week: string
20  weekLine: (sessions: number, observations: number) => string
21  last: string
22  never: string
23  ago: (ms: number) => string
24  handoff: string
25  handoffState: (state: string, fresh: boolean) => string
26  handoffFrom: (agent: string, when: string, state: string) => string
27  nextSteps: string
28  openQuestions: string
29  noHandoff: string
30  search: string
31  searchHelp: string
32  placeholder: string
33  go: string
34  results: (count: number, query: string, everywhere: boolean) => string
35  noResults: (query: string) => string
36  everywhere: string
37  here: string
38  recent: string
39  noRecent: string
40  open: string
41  back: string
42  pickProject: string
43  auto: (repo: string) => string
44  projectHelp: string
45  refresh: string
46  refreshed: string
47  web: string
48  webHelp: string
49  copyPassword: string
50  copied: string
51  notCopied: string
52  restart: string
53  restarted: string
54  notRestarted: string
55  more: string
56  settings: string
57  statusLine: string
58  statusLineHelp: string
59  toastSetting: string
60  toastSettingHelp: string
61  turnOn: string
62  turnOff: string
63  on: string
64  off: string
65  nothingHere: (repo: string) => string
66  nothingHereHint: string
67  offlineHint: (url: string) => string
68  unauthorizedHint: string
69  status: (repo: string, pages: number, pending: boolean) => string
70  statusOffline: string
71  toast: (agent: string, when: string, summary: string | undefined, pending: boolean) => string
72  footer: string
73  kinds: Readonly<Record<string, string>>
74  summary: (where: string, pages: number, handoff: string | null) => string
75  closed: string
76  textOnly: string
77}
78
79const units = (ms: number, now: string, m: string, h: string, d: string, wrap: (x: string) => string): string => {
80  const minutes = Math.round(ms / 60_000)
81
82  if (minutes < 1) {
83    return now
84  }
85
86  if (minutes < 60) {
87    return wrap(`${minutes} ${m}`)
88  }
89
90  const hours = Math.round(minutes / 60)
91
92  return hours < 48 ? wrap(`${hours} ${h}`) : wrap(`${Math.round(hours / 24)} ${d}`)
93}
94
95const pt: Words = {
96  title: 'Muninn',
97  tagline: 'a memória do ai-memory',
98  command: 'Muninn: a memória do ai-memory num painel. /muninn <termo> busca',
99  argHint: '[termo]',
100  online: 'online',
101  offline: 'fora do ar',
102  unauthorized: 'sem acesso',
103  loading: 'buscando…',
104  server: 'Servidor',
105  project: 'Projeto',
106  folder: 'Pasta',
107  memory: 'Memória',
108  counts: (p, s, o) => `${p} ${p === 1 ? 'página' : 'páginas'} · ${s} ${s === 1 ? 'sessão' : 'sessões'} · ${o} ${o === 1 ? 'observação' : 'observações'}`,
109  week: 'Últimos 7 dias',
110  weekLine: (s, o) => `${s} ${s === 1 ? 'sessão' : 'sessões'} · ${o} ${o === 1 ? 'observação' : 'observações'}`,
111  last: 'Última atividade',
112  never: 'nenhuma ainda',
113  ago: ms => units(ms, 'agora', 'min', 'h', 'dias', x => `há ${x}`),
114  handoff: 'Handoff',
115  handoffState: (state, fresh) => (state === 'open' ? 'pendente' : state === 'accepted' ? (fresh ? 'entregue a esta sessão' : 'entregue') : state === 'expired' ? 'expirado' : state),
116  handoffFrom: (agent, when, state) => `de ${agent} · ${when} · ${state}`,
117  nextSteps: 'Próximos passos',
118  openQuestions: 'Em aberto',
119  noHandoff: 'Nenhum handoff neste projeto ainda.',
120  search: 'Buscar na memória',
121  searchHelp: 'Decisões, erros, nomes, o que você lembrar',
122  placeholder: 'termo, decisão, erro…',
123  go: 'Buscar',
124  results: (n, q, all) => `${n} ${n === 1 ? 'resultado' : 'resultados'} para “${q}”${all ? ' em todos os projetos' : ''}`,
125  noResults: q => `Nada encontrado para “${q}”.`,
126  everywhere: 'Buscar em todos os projetos',
127  here: 'Só neste projeto',
128  recent: 'Recentes',
129  noRecent: 'Nada consolidado ainda: as páginas aparecem quando uma sessão termina.',
130  open: 'Abrir',
131  back: 'Voltar',
132  pickProject: 'Projeto deste painel',
133  auto: repo => `Automático (${repo})`,
134  projectHelp: 'Muninn acha o projeto pela pasta; escolha outro para fixar aqui',
135  refresh: 'Atualizar',
136  refreshed: 'Muninn voltou com notícias frescas.',
137  web: 'Abrir Web UI',
138  webHelp: 'O wiki completo no navegador; a senha é o token copiado abaixo',
139  copyPassword: 'Copiar senha',
140  copied: 'Senha da Web UI copiada.',
141  notCopied: 'Não deu para copiar aqui.',
142  restart: 'Reiniciar servidor',
143  restarted: 'Servidor do ai-memory reiniciado.',
144  notRestarted: 'Não consegui reiniciar o servidor por aqui.',
145  more: 'Mais',
146  settings: 'Ajustes',
147  statusLine: 'Linha de status',
148  statusLineHelp: 'Páginas do projeto e handoff pendente na barra de status',
149  toastSetting: 'Aviso de handoff',
150  toastSettingHelp: 'Um aviso quando a sessão começa com um handoff',
151  turnOn: 'Ligar',
152  turnOff: 'Desligar',
153  on: 'Ligado',
154  off: 'Desligado',
155  nothingHere: repo => `Nada capturado ainda para “${repo}”.`,
156  nothingHereHint: 'A primeira sessão nesta pasta cria o projeto; até lá, a busca vale para todos os projetos.',
157  offlineHint: url => `O servidor do ai-memory não respondeu em ${url}.`,
158  unauthorizedHint: 'O servidor recusou o token de ~/Library/Application Support/ai-memory/auth-tokens/default.',
159  status: (repo, pages, pending) => `Muninn · ${repo} · ${pages} ${pages === 1 ? 'pág' : 'págs'}${pending ? ' · handoff' : ''}`,
160  statusOffline: 'Muninn · fora do ar',
161  toast: (agent, when, summary, pending) =>
162    `Muninn ${pending ? 'trouxe um handoff pendente' : 'entregou o handoff'} de ${agent} (${when})${summary ? `: ${summary}` : ''}`,
163  footer: 'Esc fecha · /muninn <termo> busca direto',
164  kinds: { session: 'sessão', decision: 'decisão', gotcha: 'armadilha', concept: 'conceito', procedure: 'procedimento', rule: 'regra', note: 'nota', fact: 'fato', slot: 'slot' },
165  summary: (where, pages, handoff) => `${where} · ${pages} ${pages === 1 ? 'pág' : 'págs'}${handoff ? ` · ${handoff}` : ''}`,
166  closed: 'Muninn voltou para o ombro de Odin.',
167  textOnly: 'Este app não desenha painéis de mods; aqui vai o resumo em texto.',
168}
169
170const en: Words = {
171  title: 'Muninn',
172  tagline: 'your ai-memory',
173  command: 'Muninn: your ai-memory in a pane. /muninn <term> searches',
174  argHint: '[term]',
175  online: 'online',
176  offline: 'offline',
177  unauthorized: 'no access',
178  loading: 'loading…',
179  server: 'Server',
180  project: 'Project',
181  folder: 'Folder',
182  memory: 'Memory',
183  counts: (p, s, o) => `${p} ${p === 1 ? 'page' : 'pages'} · ${s} ${s === 1 ? 'session' : 'sessions'} · ${o} ${o === 1 ? 'observation' : 'observations'}`,
184  week: 'Last 7 days',
185  weekLine: (s, o) => `${s} ${s === 1 ? 'session' : 'sessions'} · ${o} ${o === 1 ? 'observation' : 'observations'}`,
186  last: 'Last activity',
187  never: 'none yet',
188  ago: ms => units(ms, 'just now', 'min', 'h', 'days', x => `${x} ago`),
189  handoff: 'Handoff',
190  handoffState: (state, fresh) => (state === 'open' ? 'pending' : state === 'accepted' ? (fresh ? 'delivered to this session' : 'delivered') : state),
191  handoffFrom: (agent, when, state) => `from ${agent} · ${when} · ${state}`,
192  nextSteps: 'Next steps',
193  openQuestions: 'Open questions',
194  noHandoff: 'No handoff in this project yet.',
195  search: 'Search memory',
196  searchHelp: 'Decisions, errors, names, whatever you remember',
197  placeholder: 'a term, a decision, an error…',
198  go: 'Search',
199  results: (n, q, all) => `${n} ${n === 1 ? 'result' : 'results'} for “${q}”${all ? ' in every project' : ''}`,
200  noResults: q => `Nothing found for “${q}”.`,
201  everywhere: 'Search every project',
202  here: 'This project only',
203  recent: 'Recent',
204  noRecent: 'Nothing compiled yet: pages appear when a session ends.',
205  open: 'Open',
206  back: 'Back',
207  pickProject: 'Project in this pane',
208  auto: repo => `Automatic (${repo})`,
209  projectHelp: 'Muninn finds the project from the folder; pick another to pin it here',
210  refresh: 'Refresh',
211  refreshed: 'Muninn is back with fresh news.',
212  web: 'Open web UI',
213  webHelp: 'The whole wiki in the browser; the password is the token copied below',
214  copyPassword: 'Copy password',
215  copied: 'Web UI password copied.',
216  notCopied: 'Could not copy here.',
217  restart: 'Restart server',
218  restarted: 'ai-memory server restarted.',
219  notRestarted: 'Could not restart the server from here.',
220  more: 'More',
221  settings: 'Settings',
222  statusLine: 'Status line',
223  statusLineHelp: "The project's pages and a pending handoff in the status line",
224  toastSetting: 'Handoff notice',
225  toastSettingHelp: 'A notice when a session starts with a handoff',
226  turnOn: 'Turn on',
227  turnOff: 'Turn off',
228  on: 'On',
229  off: 'Off',
230  nothingHere: repo => `Nothing captured yet for “${repo}”.`,
231  nothingHereHint: 'The first session in this folder creates the project; until then search covers every project.',
232  offlineHint: url => `The ai-memory server did not answer at ${url}.`,
233  unauthorizedHint: 'The server refused the token in ~/Library/Application Support/ai-memory/auth-tokens/default.',
234  status: (repo, pages, pending) => `Muninn · ${repo} · ${pages} ${pages === 1 ? 'page' : 'pages'}${pending ? ' · handoff' : ''}`,
235  statusOffline: 'Muninn · offline',
236  toast: (agent, when, summary, pending) =>
237    `Muninn ${pending ? 'brought a pending handoff' : 'delivered the handoff'} from ${agent} (${when})${summary ? `: ${summary}` : ''}`,
238  footer: 'Esc closes · /muninn <term> searches straight away',
239  kinds: { session: 'session', decision: 'decision', gotcha: 'gotcha', concept: 'concept', procedure: 'procedure', rule: 'rule', note: 'note', fact: 'fact', slot: 'slot' },
240  summary: (where, pages, handoff) => `${where} · ${pages} ${pages === 1 ? 'page' : 'pages'}${handoff ? ` · ${handoff}` : ''}`,
241  closed: "Muninn flew back to Odin's shoulder.",
242  textOnly: 'This app draws no mod panes; here is the summary as text.',
243}
244
245export const WORDS: Readonly<Record<MuninnLang, Words>> = { pt, en }
246
hooks/kit/kit.tsx 468 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { iconSvg } from './icons'
4import type { IconName } from './icons'
5import { hexOf, inkOf } from './tone'
6import type { KitTone, Tone } from './tone'
7
8// The pack's components: one grammar for every pane, band and dialog the mods draw, in
9// Claude Code's own idiom. Native Buttons, the app's text colours, hairline groups that
10// never nest, one accent for the current choice and the one main action, stroke icons
11// instead of glyphs, and the ring meter for anything with a percent.
12//
13// Pure functions of the resolved elements, the tone and the surface: nothing here takes `$`.
14// Shared kit: edit in claude-mods-hub/kit, then run dev/sync-kit.sh; never edit a copy.
15
16// The elements the kit draws with; `Svg` only where the surface draws vectors.
17export type KitUi = {
18  Box: any
19  Text: any
20  Button: any
21  Input?: any
22  Select?: any
23  Svg?: any
24}
25
26export type Kit = {
27  readonly ui: KitUi
28  readonly t: Tone
29  readonly surface: RenderSurface
30  // The desktop app, or another surface that draws Svg: icons, switches and rings.
31  readonly vector: boolean
32  // Columns the drawing may take.
33  readonly columns: number
34}
35
36export const kitOf = (ui: KitUi, t: Tone, surface: RenderSurface, columns: number): Kit => ({
37  ui,
38  t,
39  surface,
40  vector: surface !== 'terminal' && ui.Svg !== undefined,
41  columns,
42})
43
44type Child = RenderElement | string | null | undefined | false
45
46// An icon, 16px; nothing on the terminal unless a fallback glyph is given.
47export function icon(k: Kit, name: IconName, tone?: KitTone | 'faint', size = 16, fallback?: string): RenderElement | null {
48  if (k.vector) {
49    const { Svg } = k.ui
50    const color = tone === 'faint' ? k.t.hex.faint : hexOf(k.t, tone ?? 'muted')
51
52    return <Svg source={iconSvg(name, color, size)} alt={name} width={size} height={size} />
53  }
54
55  return fallback === undefined ? null : <k.ui.Text color={tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone ?? 'muted')}>{fallback}</k.ui.Text>
56}
57
58// A small filled circle in a state's colour: the dot beside a live state.
59export function dot(k: Kit, tone: KitTone, size = 8): RenderElement {
60  if (k.vector) {
61    const { Svg } = k.ui
62    const r = size / 2
63
64    return (
65      <Svg
66        source={`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${r}" cy="${r}" r="${r - 0.5}" fill="${hexOf(k.t, tone)}"/></svg>`}
67        alt={tone}
68        width={size}
69        height={size}
70      />
71    )
72  }
73
74  return <k.ui.Text color={inkOf(k.t, tone)}>●</k.ui.Text>
75}
76
77// Text in a role: body, muted (secondary), faint (tertiary), or a state.
78export function text(k: Kit, content: Child | Child[], tone?: KitTone | 'faint', opts: { bold?: boolean; wrap?: string; key?: string } = {}): RenderElement {
79  const color = tone === 'faint' ? k.t.ink.faint : inkOf(k.t, tone)
80
81  return (
82    <k.ui.Text {...(opts.key ? { key: opts.key } : {})} {...(color === undefined ? {} : { color })} {...(opts.bold ? { bold: true } : {})} {...(opts.wrap ? { wrap: opts.wrap } : {})}>
83      {content}
84    </k.ui.Text>
85  )
86}
87
88// A group's title, with optional muted detail and a control at the right:
89// `Finished              2  [Hide]`.
90export function heading(k: Kit, title: string, detail?: string, key?: string, end?: Child | Child[]): RenderElement {
91  const { Box } = k.ui
92
93  return (
94    <Box {...(key ? { key } : {})} flexDirection="row" columnGap={2} paddingX={1} alignItems="center">
95      <Box flexGrow={1} flexShrink={1}>
96        {text(k, title, undefined, { bold: true })}
97      </Box>
98      {detail ? text(k, detail, 'muted') : null}
99      {end ?? null}
100    </Box>
101  )
102}
103
104// Rows held in one hairline rounded outline, a line apart. Groups never nest. A dense group
105// (values, a list of readings) keeps its rows together.
106export function group(k: Kit, key: string, rows: Child[], dense = false): RenderElement {
107  const { Box } = k.ui
108
109  return (
110    <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingY={1} rowGap={dense ? 0 : 1}>
111      {rows.filter(Boolean)}
112    </Box>
113  )
114}
115
116// A titled group: the heading, then the outlined rows. `end` puts a control in the heading.
117export function section(k: Kit, key: string, title: string, rows: Child[], detail?: string, dense = false, end?: Child | Child[]): RenderElement {
118  const { Box } = k.ui
119
120  return (
121    <Box key={`section-box-${key}`} flexDirection="column">
122      {heading(k, title, detail, undefined, end)}
123      {group(k, key, rows, dense)}
124    </Box>
125  )
126}
127
128export type RowArgs = {
129  key: string
130  label: string
131  help?: string | undefined
132  icon?: IconName | undefined
133  iconTone?: KitTone | undefined
134  // Any leading element in place of the icon: a mascot, an avatar, a ring.
135  lead?: Child
136  // Right-aligned: a control, a value, a state.
137  end?: Child | Child[]
138  // A long value at the end wraps instead of pushing the row wider.
139  endWraps?: boolean
140  // Under the label: a full-width control, a list of options that wraps.
141  below?: Child | Child[]
142  tone?: KitTone | undefined
143  // The help line in a state's colour: a mod's live state under its name.
144  helpTone?: KitTone | undefined
145  muted?: boolean
146}
147
148// One line of a group: icon, label and help at the left, the control at the right. The
149// row lights under the pointer, as the app's own list rows do.
150export function row(k: Kit, args: RowArgs): RenderElement {
151  const { Box } = k.ui
152  const narrow = k.columns < 44
153  const end = args.end === undefined || args.end === null ? null : (
154    <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={args.endWraps ? 1 : 0} {...(args.endWraps ? { justifyContent: 'flex-end' } : {})}>
155      {args.end}
156    </Box>
157  )
158
159  return (
160    <Box key={`row-${args.key}`} flexDirection="column" paddingX={1} hover={{ backgroundColor: k.t.ink.hover }}>
161      <Box flexDirection={narrow && end !== null && args.help ? 'column' : 'row'} columnGap={2} alignItems={narrow ? 'flex-start' : 'center'}>
162        <Box flexDirection="row" columnGap={1} flexGrow={1} flexShrink={1} alignItems="flex-start">
163          {args.lead ?? (args.icon ? icon(k, args.icon, args.iconTone) : null)}
164          <Box flexDirection="column" flexShrink={1}>
165            {text(k, args.label, args.muted ? 'muted' : args.tone)}
166            {args.help ? text(k, args.help, args.helpTone ?? 'muted') : null}
167          </Box>
168        </Box>
169        {end}
170      </Box>
171      {args.below === undefined || args.below === null ? null : (
172        <Box flexDirection="column" paddingTop={0} paddingLeft={(args.icon || args.lead) && k.vector ? 3 : 0}>
173          {args.below}
174        </Box>
175      )}
176    </Box>
177  )
178}
179
180export type Option = { value: string; label: string }
181
182// A choice among a few: a row of native buttons, the current one in the accent. The
183// options wrap under the label when there are many.
184export function choice(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void): RenderElement {
185  const { Box, Button } = k.ui
186
187  return (
188    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
189      {options.map(option =>
190        option.value === value ? (
191          <Button key={`${key}-${option.value}`} label={option.label} variant="primary" onPress={() => pick(option.value)} />
192        ) : (
193          <Button key={`${key}-${option.value}`} label={option.label} dimColor onPress={() => pick(option.value)} />
194        ),
195      )}
196    </Box>
197  )
198}
199
200// A choice of any size: a few options as native buttons, more than three as the native
201// select, so a long list never becomes a wall of buttons. `value` '' selects nothing.
202// With nothing selected, `prompt` is the first entry and the one shown ("Choose a length"),
203// never the select's generic placeholder; picking it does nothing.
204export function picker(k: Kit, key: string, value: string, options: readonly Option[], pick: (value: string) => void, prompt?: string): RenderElement {
205  const { Select } = k.ui
206
207  if (options.length <= 3 || Select === undefined) {
208    return choice(k, key, value, options, pick)
209  }
210
211  const none = value === '' && prompt !== undefined
212
213  return (
214    <Select
215      key={key}
216      options={[...(none ? [{ value: '', label: prompt }] : []), ...options.map(option => ({ value: option.value, label: option.label }))]}
217      {...(value === '' && !none ? {} : { value })}
218      onSelect={(next: string) => next !== '' && pick(next)}
219    />
220  )
221}
222
223const SWITCH_W = 28
224const SWITCH_H = 16
225
226// The drawn state of a switch: a pill track, the knob at the right and the track in the
227// accent when on. A picture only; the Button beside it does the switching.
228export function switchSvg(k: Kit, on: boolean): string {
229  const track = on ? k.t.hex.accent : k.t.hex.line
230  const knob = on ? '#ffffff' : k.t.hex.muted
231  const x = on ? SWITCH_W - SWITCH_H / 2 : SWITCH_H / 2
232
233  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SWITCH_W}" height="${SWITCH_H}" viewBox="0 0 ${SWITCH_W} ${SWITCH_H}"><rect width="${SWITCH_W}" height="${SWITCH_H}" rx="${SWITCH_H / 2}" fill="${track}"/><circle cx="${x}" cy="${SWITCH_H / 2}" r="${SWITCH_H / 2 - 2.5}" fill="${knob}"/></svg>`
234}
235
236// An on/off setting: the switch shows the state, the button names what pressing does. On
237// the terminal, one plain button with a filled or hollow mark: `● On`, `○ Off`.
238export function toggle(k: Kit, key: string, on: boolean, flip: (next: boolean) => void, words: { on: string; off: string; turnOn: string; turnOff: string }): RenderElement {
239  const { Box, Button, Svg } = k.ui
240
241  if (!k.vector) {
242    return <Button key={key} plain dimColor={!on} label={on ? `● ${words.on}` : `○ ${words.off}`} onPress={() => flip(!on)} />
243  }
244
245  return (
246    <Box flexDirection="row" columnGap={1} alignItems="center">
247      <Svg source={switchSvg(k, on)} alt={on ? words.on : words.off} width={SWITCH_W} height={SWITCH_H} />
248      <Button key={key} label={on ? words.turnOff : words.turnOn} dimColor onPress={() => flip(!on)} />
249    </Box>
250  )
251}
252
253// A press: the group's one main action in the accent, the rest native and quiet.
254// `focus` starts the keyboard there (Enter presses it): the safe choice of a dialog. `plain`
255// draws the terminal's `1: Label` survey row; the desktop draws its native button either way.
256export function action(
257  k: Kit,
258  key: string,
259  label: string,
260  press: () => void,
261  opts: { primary?: boolean; hotkey?: string; dismiss?: boolean; focus?: boolean; plain?: boolean; dim?: boolean } = {},
262): RenderElement {
263  const { Button } = k.ui
264
265  return (
266    <Button
267      key={key}
268      label={label}
269      {...(opts.primary ? { variant: 'primary' } : {})}
270      {...(opts.hotkey ? { hotkey: opts.hotkey } : {})}
271      {...(opts.dismiss ? { role: 'dismiss' } : {})}
272      {...(opts.focus ? { autoFocus: true } : {})}
273      {...(opts.plain ? { plain: true } : {})}
274      {...(opts.dim ? { dimColor: true } : {})}
275      onPress={press}
276    />
277  )
278}
279
280// A value with its label, the value in its state's colour: `Cache   warm · 42m`.
281export function value(k: Kit, label: string, shown: string, tone?: KitTone, key?: string): RenderElement {
282  return row(k, { key: key ?? `value-${label}`, label, muted: true, end: text(k, shown, tone) })
283}
284
285const RING_R = 7
286
287// A percent as a ring, the pack's one data glyph, with its label beside it: `◔ 42% context`.
288// `detail` follows in muted text, such as a reset countdown. The terminal draws a bar.
289// A usage fills toward warn (80%) and err (95%); a progress passes `tone` and keeps it, so
290// finishing never reads as a warning.
291export function meter(k: Kit, key: string, label: string, percent: number, detail?: string, fixed?: KitTone): RenderElement {
292  const { Box, Svg } = k.ui
293  const p = Math.max(0, Math.min(100, Math.round(percent)))
294  const tone: KitTone = fixed ?? (p >= 95 ? 'err' : p >= 80 ? 'warn' : 'accent')
295
296  if (!k.vector) {
297    const filled = Math.round(p / 12.5)
298
299    return (
300      <Box key={key} flexDirection="row" columnGap={1}>
301        {text(k, '█'.repeat(filled) + '░'.repeat(8 - filled), tone)}
302        {text(k, `${p}%`, undefined, { bold: true })}
303        {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
304      </Box>
305    )
306  }
307
308  const size = RING_R * 2 + 4
309  const c = 2 * Math.PI * RING_R
310  const ring = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${k.t.hex.muted}" stroke-opacity=".25" stroke-width="2.5"/><circle cx="${size / 2}" cy="${size / 2}" r="${RING_R}" fill="none" stroke="${hexOf(k.t, tone)}" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="${(c * p) / 100} ${c}" transform="rotate(-90 ${size / 2} ${size / 2})"/></svg>`
311
312  return (
313    <Box key={key} flexDirection="row" columnGap={1} alignItems="center">
314      <Svg source={ring} alt={`${label} ${p}%`} width={size} height={size} />
315      {text(k, `${p}%`, undefined, { bold: true })}
316      {text(k, detail ? `${label} · ${detail}` : label, 'muted')}
317    </Box>
318  )
319}
320
321// A short state word in its colour, with its dot: `● warm`.
322export function status(k: Kit, word: string, tone: KitTone): RenderElement {
323  const { Box } = k.ui
324
325  return (
326    <Box flexDirection="row" columnGap={1} alignItems="center">
327      {dot(k, tone)}
328      {text(k, word, tone === 'muted' ? 'muted' : tone)}
329    </Box>
330  )
331}
332
333// A notice inside a pane or dialog: an icon in the state's colour, a title, a body. No
334// coloured side bar: the outline and the icon carry the state.
335export function notice(k: Kit, key: string, tone: KitTone, title: string, body?: Child | Child[], actions?: Child[], iconName?: IconName): RenderElement {
336  const { Box } = k.ui
337  const name: IconName = iconName ?? (tone === 'err' || tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info')
338
339  return (
340    <Box key={key} flexDirection="column" borderStyle="round" borderColor={k.t.ink.line} paddingX={1} rowGap={0}>
341      <Box flexDirection="row" columnGap={1} alignItems="center">
342        {icon(k, name, tone, 16, tone === 'err' || tone === 'warn' ? '!' : 'i')}
343        {text(k, title, tone === 'muted' ? undefined : tone, { bold: true })}
344      </Box>
345      {body === undefined ? null : <Box flexDirection="column">{typeof body === 'string' ? text(k, body, 'muted') : body}</Box>}
346      {actions && actions.length > 0 ? (
347        <Box flexDirection="row" columnGap={1} paddingTop={1} justifyContent="flex-end">
348          {actions}
349        </Box>
350      ) : null}
351    </Box>
352  )
353}
354
355// What a list shows when it has nothing yet: what it will hold, and how it gets there.
356export function empty(k: Kit, key: string, title: string, hint?: string): RenderElement {
357  const { Box } = k.ui
358
359  return (
360    <Box key={key} flexDirection="column" paddingX={1}>
361      {text(k, title, 'muted')}
362      {hint ? text(k, hint, 'faint') : null}
363    </Box>
364  )
365}
366
367// A colour sample beside its name and value, as a palette editor lists them.
368export function swatch(k: Kit, key: string, label: string, color: string, selected: boolean, press: () => void): RenderElement {
369  const { Box, Button, Svg } = k.ui
370  const chip = k.vector ? (
371    <Svg
372      source={`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect x=".5" y=".5" width="15" height="15" rx="4" fill="${color}" stroke="${k.t.hex.line}"/></svg>`}
373      alt={color}
374      width={16}
375      height={16}
376    />
377  ) : (
378    <k.ui.Text backgroundColor={color}>{'  '}</k.ui.Text>
379  )
380
381  return (
382    <Box key={`swatch-${key}`} flexDirection="row" columnGap={1} alignItems="center" width={26}>
383      {chip}
384      {selected ? <Button key={key} label={label} variant="primary" onPress={press} /> : <Button key={key} label={label} dimColor onPress={press} />}
385      {text(k, color, 'faint')}
386    </Box>
387  )
388}
389
390// A single-line field with its label above, as the app's own forms set them.
391export function field(k: Kit, key: string, label: string, placeholder: string, submit: (value: string) => void, opts: { submitLabel?: string; help?: string; value?: string } = {}): RenderElement {
392  const { Box, Input } = k.ui
393
394  if (Input === undefined) {
395    return text(k, label, 'muted')
396  }
397
398  return (
399    <Box key={`field-${key}`} flexDirection="column" paddingX={1}>
400      {text(k, label)}
401      {opts.help ? text(k, opts.help, 'muted') : null}
402      <Input key={key} placeholder={placeholder} value={opts.value ?? ''} submitLabel={opts.submitLabel ?? 'OK'} onSubmit={(v: string) => submit(v.trim())} />
403    </Box>
404  )
405}
406
407// Space between groups: one empty line, the pack's whole vertical rhythm.
408export function stack(k: Kit, key: string, children: Child[], gap = 1): RenderElement {
409  return (
410    <k.ui.Box key={key} flexDirection="column" rowGap={gap}>
411      {children.filter(Boolean)}
412    </k.ui.Box>
413  )
414}
415
416// A drawing made plain data, for a catalog another mod draws: a press cannot cross mods, so
417// handlers are dropped and each control is drawn as its shape without its press. A Button is
418// a round hairline box around its label (filled with the accent when primary), a field or a
419// select a hairline box around its placeholder or value, a Client its props' summary.
420export function staticTree(node: unknown, t?: Tone): unknown {
421  if (Array.isArray(node)) {
422    return node.map(child => staticTree(child, t))
423  }
424
425  if (node === null || typeof node !== 'object') {
426    return typeof node === 'function' ? undefined : node
427  }
428
429  const element = node as { type?: unknown; props?: Record<string, unknown> }
430  const p = element.props ?? {}
431  const line = t?.ink.line ?? 'subtle'
432
433  if (element.type === 'Button') {
434    const label = String(p.label ?? p.key ?? '')
435    const primary = p.variant === 'primary' && p.plain !== true
436
437    return {
438      type: 'Box',
439      props: { borderStyle: 'round', borderColor: primary ? (t?.ink.accent ?? 'claude') : line, paddingX: 1, ...(primary ? { backgroundColor: t?.hex.accent ?? 'claude' } : {}) },
440      children: [{ type: 'Text', props: primary ? { color: '#ffffff', bold: true } : p.dimColor === true ? { color: t?.ink.muted ?? 'inactive' } : {}, children: [label] }],
441    }
442  }
443
444  if (element.type === 'Input' || element.type === 'Select') {
445    const options = Array.isArray(p.options) ? (p.options as { value: string; label?: string }[]) : []
446    const shown = element.type === 'Select' ? (options.find(option => option.value === p.value)?.label ?? String(p.value ?? options[0]?.label ?? '')) : String(p.value || p.placeholder || '')
447
448    return {
449      type: 'Box',
450      props: { flexDirection: 'row', columnGap: 1 },
451      children: [
452        ...(p.label ? [{ type: 'Text', props: {}, children: [String(p.label)] }] : []),
453        { type: 'Box', props: { borderStyle: 'round', borderColor: line, paddingX: 1 }, children: [{ type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [element.type === 'Select' ? `${shown} ⌄` : shown] }] },
454      ],
455    }
456  }
457
458  if (element.type === 'Client') {
459    return { type: 'Text', props: { color: t?.ink.muted ?? 'inactive' }, children: [String(p.key ?? p.module ?? '')] }
460  }
461
462  return Object.fromEntries(
463    Object.entries(element)
464      .filter(([key, value]) => key !== 'press' && typeof value !== 'function')
465      .map(([key, value]) => [key, key === 'props' && value !== null && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([, prop]) => typeof prop !== 'function')) : staticTree(value, t)]),
466  )
467}
468
hooks/kit/tone.ts 131 lines
1// The pack's colours. One skin themes every mod: skins hands its palette out through
2// `$.skins.palette()`, and a mod without skins installed falls back to Claude Code's own.
3//
4// Two forms of every colour: `hex`, for SVG markup and mixing, and `ink`, for an element's
5// `color`. On the native skin `ink` holds Claude Code's theme keys, so text follows the app
6// exactly, light or dark; on any other skin it holds the skin's hex.
7//
8// This file is the pack's shared kit: the source lives in claude-mods-hub/kit and
9// `dev/sync-kit.sh` copies it into every mod. Edit it there, never in a copy.
10
11export type KitSlot =
12  | 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
13  | 'user' | 'fg' | 'muted' | 'surface' | 'zebra' | 'ok' | 'err' | 'warn'
14
15export type KitPalette = Readonly<Record<KitSlot, string>>
16
17// What `$.skins.palette()` answers.
18export type KitSkin = { name: string; palette: KitPalette; isLight: boolean }
19
20export type KitTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
21
22export type Tone = {
23  readonly name: string
24  readonly isNative: boolean
25  readonly isLight: boolean
26  // For SVG and mixing.
27  readonly hex: KitPalette & { readonly accent: string; readonly line: string; readonly hover: string; readonly faint: string; readonly selected: string }
28  // For an element's colour. `fg` is undefined on the native skin: the app's own text colour.
29  readonly ink: {
30    readonly fg: string | undefined
31    readonly muted: string
32    readonly faint: string
33    readonly accent: string
34    readonly ok: string
35    readonly warn: string
36    readonly err: string
37    readonly line: string
38    readonly hover: string
39    // The background of the selected row: a tint of the accent.
40    readonly selected: string
41  }
42}
43
44// Claude Code's own colours, as the native skin carries them.
45export const NATIVE: { readonly dark: KitPalette; readonly light: KitPalette } = {
46  dark: {
47    read: '#c9c7bd', write: '#c9c7bd', run: '#c9c7bd', search: '#c9c7bd', web: '#c9c7bd', mcp: '#c9c7bd', other: '#c9c7bd',
48    user: '#d97757', fg: '#e8e6dc', muted: '#9c9a92', surface: '#262624', zebra: '#2d2c2a',
49    ok: '#4eba65', err: '#ff6b80', warn: '#ffc107',
50  },
51  light: {
52    read: '#3d3c38', write: '#3d3c38', run: '#3d3c38', search: '#3d3c38', web: '#3d3c38', mcp: '#3d3c38', other: '#3d3c38',
53    user: '#c96442', fg: '#1f1e1d', muted: '#6f6d66', surface: '#faf9f5', zebra: '#f0eee6',
54    ok: '#2c7a39', err: '#ab2b3f', warn: '#966c1e',
55  },
56}
57
58export const NATIVE_NAME = 'claude'
59
60const channels = (hex: string): [number, number, number] => {
61  const h = /^#[0-9a-f]{6}$/i.test(hex) ? hex : '#808080'
62
63  return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
64}
65
66// `amount` of the way from `from` to `to`.
67export function mix(from: string, to: string, amount: number): string {
68  const a = channels(from)
69  const b = channels(to)
70
71  return `#${a.map((value, i) => Math.round(value + (b[i]! - value) * amount).toString(16).padStart(2, '0')).join('')}`
72}
73
74export function toneOf(skin: KitSkin | null): Tone {
75  const isLight = skin?.isLight ?? false
76  const palette = skin?.palette ?? (isLight ? NATIVE.light : NATIVE.dark)
77  const name = skin?.name ?? NATIVE_NAME
78  const isNative = name === NATIVE_NAME
79  const line = mix(palette.surface, palette.fg, isLight ? 0.14 : 0.16)
80  const hover = mix(palette.surface, palette.fg, isLight ? 0.05 : 0.07)
81  const faint = mix(palette.muted, palette.surface, 0.35)
82  const selected = mix(palette.surface, palette.user, isLight ? 0.14 : 0.22)
83  const hex = { ...palette, accent: palette.user, line, hover, faint, selected }
84
85  return {
86    name,
87    isNative,
88    isLight,
89    hex,
90    ink: isNative
91      ? { fg: undefined, muted: 'inactive', faint: 'subtle', accent: 'claude', ok: 'success', warn: 'warning', err: 'error', line, hover, selected }
92      : { fg: palette.fg, muted: palette.muted, faint, accent: palette.user, ok: palette.ok, warn: palette.warn, err: palette.err, line, hover, selected },
93  }
94}
95
96// A tone word as an element colour; no tone is the body text.
97export function inkOf(t: Tone, tone: KitTone | undefined): string | undefined {
98  switch (tone) {
99    case 'ok':
100      return t.ink.ok
101    case 'warn':
102      return t.ink.warn
103    case 'err':
104      return t.ink.err
105    case 'muted':
106      return t.ink.muted
107    case 'accent':
108      return t.ink.accent
109    default:
110      return t.ink.fg
111  }
112}
113
114// The same, as hex for markup.
115export function hexOf(t: Tone, tone: KitTone | undefined): string {
116  switch (tone) {
117    case 'ok':
118      return t.hex.ok
119    case 'warn':
120      return t.hex.warn
121    case 'err':
122      return t.hex.err
123    case 'muted':
124      return t.hex.muted
125    case 'accent':
126      return t.hex.accent
127    default:
128      return t.hex.fg
129  }
130}
131
hooks/memory.ts 215 lines
1import type { MuninnHandoff, MuninnPageRef, MuninnProject, MuninnScope, MuninnSnapshot } from '../types'
2
3// The pure half of reading ai-memory: its shapes, where a folder's memory lives, and the words
4// a snapshot is built from. Everything that calls `$` lives in register.tsx, since the engine
5// follows `$` only into functions of the file that holds the hooks.
6
7export const DEFAULT_SERVER = 'http://127.0.0.1:49374'
8export const MARKER = '.ai-memory.toml'
9
10export type Conn = { base: string; token?: string }
11
12export type MemoryErrorKind = 'offline' | 'unauthorized' | 'http'
13
14export class MemoryError extends Error {
15  constructor(
16    readonly kind: MemoryErrorKind,
17    message: string,
18    readonly status?: number,
19  ) {
20    super(message)
21  }
22}
23
24export const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
25
26// What a snapshot says went wrong: 'offline', 'unauthorized' or the server's own words.
27export const kindOf = (error: unknown): string => (error instanceof MemoryError ? (error.kind === 'http' ? error.message : error.kind) : messageOf(error))
28
29// --- Where a folder's memory lives
30
31export type Marker = { workspace?: string; project?: string }
32
33// The top-level `workspace` and `project` of a marker; a marker that only holds `[capture]`
34// names neither and is passed by, as ai-memory's own walk does.
35export function parseMarker(text: string): Marker {
36  const marker: Marker = {}
37
38  for (const raw of text.split('\n')) {
39    const line = raw.trim()
40
41    if (line.startsWith('[')) {
42      break
43    }
44
45    const found = /^(workspace|project)\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(line)
46
47    if (found) {
48      const value = (found[2] ?? found[3] ?? '').trim()
49
50      if (value !== '') {
51        marker[found[1] as keyof Marker] = value
52      }
53    }
54  }
55
56  return marker
57}
58
59export const parentOf = (dir: string): string => {
60  const trimmed = dir.replace(/\/+$/, '')
61  const cut = trimmed.lastIndexOf('/')
62
63  return cut <= 0 ? '/' : trimmed.slice(0, cut)
64}
65
66export const baseOf = (dir: string): string => dir.replace(/\/+$/, '').split('/').pop() || dir
67
68// The project for this folder: a pinned one, the marker's, else the one whose name is the
69// repository's (`lazyreview`) or ends with it (`acme-lazyreview`, ai-memory's remote-path name),
70// most recently updated first.
71export function matchProject(projects: readonly MuninnProject[], marker: Marker, repo: string, pinned?: string): MuninnScope | null {
72  if (pinned) {
73    const cut = pinned.indexOf('/')
74    const workspace = pinned.slice(0, cut)
75    const project = pinned.slice(cut + 1)
76
77    if (cut > 0 && projects.some(p => p.workspace === workspace && p.project === project)) {
78      return { workspace, project, isGuess: false }
79    }
80  }
81
82  const workspace = marker.workspace ?? 'default'
83
84  if (marker.project) {
85    return { workspace, project: marker.project, isGuess: false }
86  }
87
88  const name = repo.toLowerCase()
89  const found = projects
90    .filter(p => p.workspace === workspace && (p.project.toLowerCase() === name || p.project.toLowerCase().endsWith(`-${name}`)))
91    .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))[0]
92
93  return found ? { workspace: found.workspace, project: found.project, isGuess: true } : null
94}
95
96// --- The server's shapes, and the snapshot built from them
97
98export type ProjectRow = { workspace_name: string; project_name: string; page_count: number; last_updated: string | null }
99
100export type PageRow = { path: string; title?: string | null; kind?: string | null; updated_at?: string | null }
101
102export type PageBody = PageRow & { body_markdown?: string }
103
104export type Briefing = {
105  counts?: { pages_latest?: number; sessions?: number; observations?: number }
106  activity_7d?: { sessions?: number; observations?: number; pages_updated?: number }
107  last_observation_at?: string | null
108  recent_pages?: PageRow[]
109}
110
111export type HandoffRow = {
112  id: string
113  agent?: string
114  at: string
115  state?: string
116  accepted_at?: string | null
117  summary?: string | null
118  next_steps?: string[] | null
119  open_questions?: string[] | null
120}
121
122export type HitRow = { workspace: string; project: string; path: string; title?: string | null; kind?: string | null; snippet?: string | null }
123
124const enc = encodeURIComponent
125
126export const projectPath = (scope: { workspace: string; project: string }): string => `/api/v1/workspaces/${enc(scope.workspace)}/projects/${enc(scope.project)}`
127
128export const pagePath = (path: string): string => path.split('/').map(enc).join('/')
129
130export const refOf = (scope: { workspace: string; project: string }, row: PageRow): MuninnPageRef => ({
131  workspace: scope.workspace,
132  project: scope.project,
133  path: row.path,
134  title: row.title || row.path,
135  kind: row.kind || 'fact',
136  ...(row.updated_at ? { updatedAt: row.updated_at } : {}),
137})
138
139export const projectsOf = (rows: readonly ProjectRow[]): MuninnProject[] =>
140  rows.map(row => ({ workspace: row.workspace_name, project: row.project_name, pages: row.page_count, updatedAt: row.last_updated }))
141
142export function handoffOf(rows: readonly HandoffRow[]): MuninnHandoff | null {
143  const newest = [...rows].sort((a, b) => b.at.localeCompare(a.at))[0]
144
145  if (newest === undefined) {
146    return null
147  }
148
149  return {
150    id: newest.id,
151    agent: newest.agent || 'agent',
152    at: newest.at,
153    state: newest.state || 'open',
154    ...(newest.accepted_at ? { acceptedAt: newest.accepted_at } : {}),
155    ...(newest.summary ? { summary: newest.summary } : {}),
156    nextSteps: newest.next_steps ?? [],
157    openQuestions: newest.open_questions ?? [],
158  }
159}
160
161// A snapshot with its project's briefing and handoffs folded in.
162export function briefed(online: MuninnSnapshot, scope: MuninnScope, briefing: Briefing, handoffs: readonly HandoffRow[]): MuninnSnapshot {
163  return {
164    ...online,
165    counts: { pages: briefing.counts?.pages_latest ?? 0, sessions: briefing.counts?.sessions ?? 0, observations: briefing.counts?.observations ?? 0 },
166    week: { sessions: briefing.activity_7d?.sessions ?? 0, observations: briefing.activity_7d?.observations ?? 0, pages: briefing.activity_7d?.pages_updated ?? 0 },
167    lastAt: briefing.last_observation_at ?? null,
168    handoff: handoffOf(handoffs),
169    recent: (briefing.recent_pages ?? []).map(row => refOf(scope, row)),
170  }
171}
172
173export const webUrl = (base: string, ref?: { workspace: string; project: string; path?: string }): string =>
174  ref === undefined ? `${base}/web` : `${base}/web/w/${enc(ref.workspace)}/${enc(ref.project)}${ref.path ? `/p/${pagePath(ref.path)}` : ''}`
175
176// --- Text
177
178const ENTITIES: Readonly<Record<string, string>> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&#x27;': "'" }
179
180// A search snippet as plain text: no `<mark>`, no markdown headings or bullets, one line.
181export function plain(snippet: string): string {
182  return snippet
183    .replace(/<\/?mark>/g, '')
184    .replace(/&(amp|lt|gt|quot|#39|#x27);/g, m => ENTITIES[m] ?? m)
185    .replace(/^#+\s*/gm, '')
186    .replace(/^\s*[-*]\s+/gm, '')
187    .replace(/\*\*/g, '')
188    .replace(/\s+/g, ' ')
189    .trim()
190}
191
192export const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1)).trimEnd()}…`)
193
194// Milliseconds since an ISO time, never negative; null when there is none.
195export const sinceOf = (now: number, iso: string | null | undefined): number | null => {
196  if (!iso) {
197    return null
198  }
199
200  const at = Date.parse(iso)
201
202  return Number.isNaN(at) ? null : Math.max(0, now - at)
203}
204
205// A handoff delivered to this session: accepted within the last few minutes.
206export const isFresh = (handoff: MuninnHandoff, now: number): boolean => {
207  if (handoff.state === 'open') {
208    return true
209  }
210
211  const since = sinceOf(now, handoff.acceptedAt ?? handoff.at)
212
213  return handoff.state === 'accepted' && since !== null && since < 5 * 60_000
214}
215
hooks/kit/icons.ts 63 lines
1// The pack's icons: outline paths on a 24-unit grid, drawn with one stroke (1.8, round caps
2// and joins), the set skins' tool rows started. An icon is a whole SVG document, for the
3// `Svg` element; the terminal draws no Svg, so callers pass a fallback there or nothing.
4//
5// Shared kit: edit in claude-mods-hub/kit, never in a copy.
6
7export const ICON_PATHS = {
8  // Tool kinds, as skins draws them.
9  run: '<path d="M5 7l5 5-5 5"/><path d="M12 18h7"/>',
10  write: '<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
11  read: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/><path d="M9 13h6M9 17h4"/>',
12  search: '<circle cx="10" cy="10" r="6.5"/><path d="M20 20l-5.2-5.2"/>',
13  web: '<circle cx="12" cy="12" r="8.5"/><path d="M3.8 9.5h16.4M3.8 14.5h16.4"/><path d="M11.5 3.5a15 15 0 0 0 0 17M12.5 3.5a15 15 0 0 1 0 17"/>',
14  mcp: '<path d="M9.8 6.2l8 8-2 2a5.66 5.66 0 0 1-8-8z"/><path d="M4 20l3.6-3.6M15 4l-3.4 3.4M20 9l-3.4 3.4"/>',
15  other: '<circle cx="12" cy="12" r="3"/>',
16
17  // Navigation and state.
18  chevronRight: '<path d="M9 5l7 7-7 7"/>',
19  chevronLeft: '<path d="M15 5l-7 7 7 7"/>',
20  chevronDown: '<path d="M5 9l7 7 7-7"/>',
21  check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
22  close: '<path d="M6 6l12 12M18 6L6 18"/>',
23  alert: '<path d="M12 4l9 16H3z"/><path d="M12 10v4.5"/><path d="M12 17.5v.01"/>',
24  info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5"/><path d="M12 7.5v.01"/>',
25  clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
26  refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
27  eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>',
28  eyeOff: '<path d="M4 4l16 16"/><path d="M9.9 6A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.4 7.6A15.5 15.5 0 0 0 2.5 12S6 18.5 12 18.5a9.4 9.4 0 0 0 4.2-1"/>',
29  copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2"/><path d="M15.5 8.5V6.5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2"/>',
30  external: '<path d="M13.5 4.5h6v6"/><path d="M19.5 4.5l-8 8"/><path d="M17.5 14v4a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 18V8A1.5 1.5 0 0 1 6 6.5h4"/>',
31  play: '<path d="M8 5.5v13l10-6.5z"/>',
32  pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
33  stepBack: '<path d="M16 6l-7 6 7 6"/><path d="M7 6v12"/>',
34  stepForward: '<path d="M8 6l7 6-7 6"/><path d="M17 6v12"/>',
35
36  // The mods.
37  cache: '<path d="M12 3.5c3.5 3.2 5.5 6 5.5 9a5.5 5.5 0 0 1-11 0c0-1.6.6-3 1.6-4.3.4 1.4 1.3 2.3 2.4 2.6-.4-2.8.2-5 1.5-7.3z"/>',
38  skins: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.2 0 1.8-.8 1.8-1.7 0-1.1-.9-1.5-.9-2.4 0-.9.7-1.6 1.6-1.6h2a4 4 0 0 0 4-4C20.5 6.8 16.7 3.5 12 3.5z"/><circle cx="7.8" cy="11" r=".9"/><circle cx="10.5" cy="7.5" r=".9"/><circle cx="15" cy="7.8" r=".9"/>',
39  folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2.5h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>',
40  file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/>',
41  branch: '<circle cx="7" cy="5.5" r="2"/><circle cx="7" cy="18.5" r="2"/><circle cx="17" cy="8" r="2"/><path d="M7 7.5v9"/><path d="M17 10c0 4-10 2.5-10 6.5"/>',
42  agents: '<rect x="4.5" y="8" width="15" height="11" rx="3"/><path d="M12 8V4.5"/><circle cx="12" cy="4" r=".8"/><path d="M9.5 13v1.5M14.5 13v1.5"/><path d="M2.5 12.5v2.5M21.5 12.5v2.5"/>',
43  replay: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3"/><path d="M4.5 4.5v4h4"/><path d="M11 9.5v5l3.5-2.5z"/>',
44  shield: '<path d="M12 3.5l7 2.8v5.2c0 4.3-3 7.6-7 9-4-1.4-7-4.7-7-9V6.3z"/><path d="M12 8.5v4"/><path d="M12 15.5v.01"/>',
45  components: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/>',
46  settings: '<circle cx="12" cy="12" r="2.8"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
47  mods: '<path d="M4 7h16M4 12h16M4 17h10"/>',
48  reflect: '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2V16h5v-.1c0-.8.4-1.5 1-2A6 6 0 0 0 12 3z"/>',
49  // Muninn, Odin's raven of memory: ai-memory.
50  raven: '<path d="M21.5 8.2l-3.2-.9a4.3 4.3 0 0 0-8.1 1.6c-.3 2.4-1.8 4.3-4.2 5.4L3 15.5c2.6 1.6 6 2.1 9 1.3 4-1.1 6.3-4.4 6.5-8z"/><path d="M15.2 7.8v.01"/><path d="M10.5 17l-1 3.5M13.5 16.4l.5 3.6"/>',
51} as const
52
53export type IconName = keyof typeof ICON_PATHS
54
55// The whole SVG document: `size` CSS pixels, stroked in `color`.
56export function iconSvg(name: IconName, color: string, size = 16): string {
57  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><g fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g></svg>`
58}
59
60// The same paths placed inside a larger drawing, as skins' cards place theirs.
61export const iconAt = (name: IconName, x: number, y: number, size: number, color: string): string =>
62  `<g transform="translate(${x} ${y}) scale(${size / 24})" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g>`
63
types/index.d.ts 96 lines
1// Muninn's contract: the ai-memory snapshot it keeps for the session, and its noun on `$`,
2// the tab it hands the /mods hub (claude-mods-hub, PROTOCOL.md).
3
4export type MuninnLang = 'en' | 'pt'
5
6export type MuninnScope = {
7  workspace: string
8  project: string
9  // True when the project was matched from the folder's name, not pinned or named in a marker.
10  isGuess: boolean
11}
12
13export type MuninnHandoff = {
14  id: string
15  agent: string
16  at: string
17  state: string
18  acceptedAt?: string
19  summary?: string
20  nextSteps: string[]
21  openQuestions: string[]
22}
23
24export type MuninnPageRef = { workspace: string; project: string; path: string; title: string; kind: string; updatedAt?: string }
25
26export type MuninnHit = MuninnPageRef & { snippet: string }
27
28export type MuninnProject = { workspace: string; project: string; pages: number; updatedAt: string | null }
29
30export type MuninnSnapshot = {
31  // When it was fetched, ms since the epoch.
32  at: number
33  online: boolean
34  // What went wrong, when something did: 'offline', 'unauthorized' or the server's words.
35  error?: string
36  version?: string
37  // The folder the session runs in, its repository root (where a pin is kept) and the
38  // repository's name, which Muninn matches the project by.
39  cwd: string
40  root: string
41  repo: string
42  // Where the session's memory lives; null while the server knows no project for this folder.
43  scope: MuninnScope | null
44  counts?: { pages: number; sessions: number; observations: number }
45  week?: { sessions: number; observations: number; pages: number }
46  lastAt?: string | null
47  handoff: MuninnHandoff | null
48  recent: MuninnPageRef[]
49  projects: MuninnProject[]
50}
51
52export type MuninnPage = MuninnPageRef & { body: string }
53
54export type MuninnView = 'home' | 'search' | 'page'
55
56// The /mods hub protocol: one tab, drawn by the hub.
57export type MuninnPanelArgs = { lang: MuninnLang; surface: 'terminal' | 'desktop' | 'vscode' | 'mobile'; columns: number }
58export type MuninnTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
59export type MuninnScopeOf = 'user' | 'session'
60export type MuninnItem =
61  | { kind: 'toggle'; id: string; label: string; help?: string; value: boolean; scope?: MuninnScopeOf }
62  | { kind: 'choice'; id: string; label: string; help?: string; value: string; options: { value: string; label: string }[]; scope?: MuninnScopeOf }
63  | { kind: 'action'; id: string; label: string; title?: string; help?: string; primary?: boolean; scope?: MuninnScopeOf }
64  | { kind: 'input'; id: string; label: string; help?: string; placeholder?: string; submitLabel?: string; scope?: MuninnScopeOf }
65  | { kind: 'info'; label: string; value: string; tone?: MuninnTone }
66  | { kind: 'text'; text: string; tone?: MuninnTone }
67  | { kind: 'swatch'; id: string; label: string; color: string; selected: boolean }
68  | { kind: 'tree'; tree: unknown }
69export type MuninnSection = { id: string; title: string; items: MuninnItem[] }
70export type MuninnPanel = { title: string; summary?: string; tone?: MuninnTone; percent?: number; sections: MuninnSection[]; refreshMs?: number }
71export type MuninnActArgs = { id: string; value?: string }
72export type MuninnActResult = { message?: string }
73
74declare module 'claude-code' {
75  interface EngineInterface {
76    muninn: {
77      panel: (args: MuninnPanelArgs) => Promise<MuninnPanel>
78      act: (args: MuninnActArgs) => Promise<MuninnActResult>
79    }
80  }
81  interface PluginState {
82    muninn: {
83      snapshot: MuninnSnapshot | null
84      // A fetch in flight: the pane says so instead of looking stale.
85      isBusy: boolean
86      view: MuninnView
87      query: string
88      // Whether the last search covered every project, not just this folder's.
89      everywhere: boolean
90      results: MuninnHit[]
91      page: MuninnPage | null
92      lang: MuninnLang
93    }
94  }
95}
96