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…

My Claude Code mods in one marketplace. /mods opens one pane with a tab per mod: no per-mod slash commands to remember.
| Mod | Source | What it does |
|---|---|---|
| mods | tauantcamargo/claude-mods-hub (fork of mvacoimbra's, plus a Workers tab) | The /mods pane |
| workers | plugins/workers | Salã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 |
| muninn | plugins/muninn | Muninn, 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-tax | mvacoimbra fork | Keeps the prompt cache warm (costs tokens) |
| skins | mvacoimbra fork | Themes and richer tool rows |
| filetree | mvacoimbra fork | File tree of what Claude touches |
| savvy-progress | mvacoimbra fork | Subagent progress and agents panel |
| replay-theater | mvacoimbra fork | Replay the last turn's edits |
| blast-radius | mvacoimbra/claude-code-playground | Holds risky shell commands for confirmation |
| reflect-mod | mvacoimbra/claude-reflect | Saves your corrections to CLAUDE.md |
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.
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-modshooks/register.tsx 968 lines1import { 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}
968hooks/i18n.ts 246 lines1import 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 }
246hooks/kit/kit.tsx 468 lines1import 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}
468hooks/kit/tone.ts 131 lines1// 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}
131hooks/memory.ts 215 lines1import 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>> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" }
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}
215hooks/kit/icons.ts 63 lines1// 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>`
63types/index.d.ts 96 lines1// 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