SLOPSHOPPER

kemory-panel

Kemory inside Claude Code: a status line that says whether memory works, the memories in context above the prompt, transcript cards, and a panel to review…

panebandrowsguardtoaststatus
v0.20.2Apache-2.0updated 2026-10-21SeKondBrainAILabs/kemory-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kemory-panel
│ ┃ kemory-memories ✕ › fix the failing auth test and add an au╭─────────────────────────╮ │ ┃ Kemory │ kemory-panel │ │ ┃ ● 0 saved ● 0 recalled ● 0 rated this ⏺ Read(src/auth.ts) │ Kemory: API unreachable │ │ ┃ session ⎿ Read 6 lines ╰─────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Suggest memories ] [ Save last reply ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Nothing saved or recalled yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ kemory-panel: ✗ API unreachable, run /kemory:status

Draws

Pane · kemory-memories
Kemory ● 0 saved ● 0 recalled ● 0 rated this session [ Suggest memories ] [ Save last reply ] Nothing saved or recalled yet.
README

kemory-panel

Kemory's interface in Claude Code. See what Kemory recalled and saved while you work, and rate, save or delete a memory without leaving the session.

It is an add-on to the kemory plugin, which provides the memory itself: the MCP server, prompt recall and session capture. kemory-panel uses that plugin's server and credential.

What you get

WhereWhat
Status line✓ SeKondBrain · 3 recalled · 1 saved: the organisation you write to and this session's counts. Otherwise what is wrong (no credential, token expired, API unreachable), ⚠ connector on <name> when the claude.ai connector writes to a different organisation, and ↑ kemory 0.15.3 when an update is out. The band above the prompt and the panel then offer an Update button
Above the promptThe memories the last prompt put in context, each with its namespace. Before the first prompt, what Kemory already knows about the folder you opened
Transcript (terminal)A card for each save and recall, which opens to the full memory. Elsewhere the Kemory server's own cards show these; see Memory cards and this panel
/kemory-panel:memoriesA panel with this session's memories. Rate one Helpful or Outdated, delete it, or open it in Kemory. Suggest memories proposes facts from the session worth keeping, and Save last reply keeps Claude's last answer
Permission promptsRead-only Kemory tools (recall, search, list) run without asking. Every write still asks
Duplicate toolsWhen the claude.ai Kemory connector is also connected, this plugin's copies of the same tools move behind tool search, so each request carries one set

Memory cards and this panel

Two different things show Kemory in Claude, and only one of them needs this plugin.

Memory cardskemory-panel
What you seeA card after each save or recall: the memory, its namespace, and Undo, Forget or a ratingThe status line, the memories in context above the prompt, the session panel, and cards in the terminal
Where it comes fromThe Kemory server, over MCP. It attaches a card (ui://kemory/memory-saved, ui://kemory/recall-results) to its save and recall tools, and the app shows it beside the resultThis plugin, a module Claude Code loads and runs
What you needA connection to Kemory: the claude.ai connector or the kemory plugin. No plugin is requiredClaude Code 2.1.267 or later with this plugin and the kemory plugin
Where it showsApps that support MCP Apps, such as Claude desktop, claude.ai and ChatGPTClaude Code, terminal and desktop

So one save shows one card: the server's card where the app can show it, and this plugin's card in the Claude Code terminal, which cannot.

Requirements

  • Claude Code 2.1.267 or later. Older builds and Grok Build ignore the interface and run only the read-only auto-approval.
  • The kemory plugin, installed and signed in (/kemory:login).

Install

In a Claude Code terminal:

/plugin marketplace add SeKondBrainAILabs/kemory-plugin
/plugin install kemory@kemory
/plugin install kemory-panel@kemory
/reload-plugins

The desktop app has no /plugin command. Run the installs from a terminal instead, then start a new session in the app:

claude plugin marketplace add SeKondBrainAILabs/kemory-plugin
claude plugin install kemory@kemory
claude plugin install kemory-panel@kemory

Commands

CommandWhat it does
/kemory-panel:memoriesOpens the panel
/kemory-panel:remember [text]Saves the text to Kemory, or Claude's last reply when you give none
/kemory-panel:learnedOpens the panel and suggests memories from this session

Settings

Set these as environment variables before Claude Code starts.

VariableDefaultWhat it does
KEMORY_AUTO_SUGGEST=1offAlso suggests memories on its own after a session has been quiet for five minutes. Each run is a model call over the session
KEMORY_ALLOW_DUPLICATE=1offKeeps this plugin's Kemory tools in front even while the claude.ai connector is connected

Why a separate plugin

The Claude plugin directory holds back plugins that ship an interface module or a hook that approves tool calls. Keeping both here lets the kemory plugin be listed on its own, and this one install from the kemory marketplace.

Privacy and security

kemory-panel adds no telemetry or analytics. Apart from your Kemory instance, its one request goes to a public file on GitHub, to check for updates. PRIVACY.md lists every request it makes, and SECURITY.md covers the auto-approval and how to report a vulnerability.

Development

./scripts/check.sh

It validates the manifests and the module, runs the module tests (claude plugin test .) and the approval hook tests, and checks scripts/lib.sh against the kemory plugin's copy on main. See CONTRIBUTING.md.

PathWhat it is
hooks/register.tsxThe interface: status line, band, cards, panel and commands
scripts/recall-approve.shApproves read-only Kemory tools by exact name
scripts/lib.shA copy of the kemory plugin's credential resolution, for the status check
tests/Module tests and approval hook tests

License

Apache-2.0. See LICENSE.

Source 2 files
hooks/register.tsx 1164 lines
1import type { EngineInterface as Engine, McpToolResult, Register } from 'claude-code'
2import { LOGOMARK_PNG } from './logomark'
3
4// Kemory inside Claude Code, loaded by 2.1.267 and later through the "modules"
5// key in hooks.json. Older Claude Code builds and Grok ignore that key and run
6// the command hooks beside it unchanged.
7//
8// The status line says whether Kemory works: the hooks fail open, so a dead
9// credential or a stale install would otherwise look like a working one. It
10// sources the plugin's own lib.sh, so the credential it checks, token refresh
11// included, is the one the hooks use.
12//
13// The rest puts memory where a person can act on it: a brief for the repo and
14// the memories in context above the prompt, a card on each save and recall in
15// the transcript, a panel listing them with Helpful, Outdated and Delete, saving
16// a note or the last reply, and suggested memories when the session goes quiet.
17
18// Each plugin is checked against its own repository. They version
19// independently, so one's version says nothing about the other's.
20const LATEST_URLS = {
21  kemory: 'https://raw.githubusercontent.com/SeKondBrainAILabs/kemory-plugin/main/plugin/.claude-plugin/plugin.json',
22  'kemory-panel': 'https://raw.githubusercontent.com/SeKondBrainAILabs/kemory-panel/main/.claude-plugin/plugin.json',
23} as const
24type PluginName = keyof typeof LATEST_URLS
25const RECHECK_MS = 10 * 60 * 1000
26const RECALL_TOOL = /^mcp__.*__kemory_(recall.*|ask|get_context|find_similar)$/
27const STORE_TOOL = /^mcp__.*__kemory_store_(memory|skill)$/
28// prompt-recall.sh lists each injected memory as one line,
29// "- [<namespace>] <content>", then "  (memory_id: <uuid>)" on the next.
30const INJECTED = /^- (?:\[([^\]\n]+)\] )?(.+)\n {2}\(memory_id: ([0-9a-f-]{36})\)$/gm
31const BAND_ROWS = 3
32const PANE = 'kemory-memories'
33// The plugin's own MCP server, which the panel's actions go through.
34const SERVER = 'plugin:kemory:kemory'
35// How the plugin's own server names its tools to the model.
36const OWN_TOOL_PREFIX = 'mcp__plugin_kemory_kemory__'
37// Another server offering Kemory's tools: the claude.ai connector, named by
38// its display name in a terminal and by its connector id in the desktop app.
39const OTHER_KEMORY = /^mcp__(.+)__kemory_whoami$/
40// The first prompts look again, since a connector can attach after start.
41const CONNECTOR_PROMPT_CHECKS = 3
42const NAMESPACE = 'shared'
43// The repo brief shows only strong matches: a folder name alone is a weak query,
44// and below this a search returns whatever happens to be nearest.
45const BRIEF_MIN_SIMILARITY = 0.65
46// Automatic session summaries say what a session was about, not what is true.
47const BRIEF_SKIP_NAMESPACE = /^user:sessions/
48const IDLE_MS = 5 * 60 * 1000
49// A call made while the plugin's server is still connecting is tried again
50// once after this pause.
51const CONNECT_RETRY_MS = 2000
52const MIN_TURNS_FOR_SUGGESTIONS = 3
53
54const CHECK = `
55. "$1/scripts/lib.sh" 2>/dev/null || { echo nolib; exit 0; }
56if ! kemory_resolve_auth; then
57  if [ -n "\${KEMORY_CREDS_CORRUPT:-}" ]; then echo corrupt; else echo nocreds; fi
58  exit 0
59fi
60[ "\${KEMORY_TOKEN_EXPIRED:-0}" = 1 ] && { echo expired; exit 0; }
61curl -s -o /dev/null -w '%{http_code}' --max-time 6 \\
62  -H "$KEMORY_AUTH_HEADER" \${KEMORY_ORG_HEADER:+-H "$KEMORY_ORG_HEADER"} \\
63  "$KEMORY_BASE_URL/api/v1/namespaces" 2>/dev/null
64`
65
66const PROBLEMS: Record<string, string> = {
67  nolib: 'plugin scripts missing',
68  corrupt: 'credential file is corrupt',
69  nocreds: 'no credential, hooks inert',
70  expired: 'token expired',
71  '401': 'credential rejected (401)',
72  '403': 'credential rejected (403)',
73  '000': 'API unreachable',
74}
75
76type Kind = 'saved' | 'updated' | 'recalled' | 'brief'
77type Entry = {
78  key: string
79  id?: string
80  kind: Kind
81  namespace: string
82  title: string
83  text: string
84  rating?: 'up' | 'down'
85  isDeleted?: boolean
86  isConfirmingDelete?: boolean
87}
88type Suggestion = {
89  key: string
90  text: string
91  state: 'new' | 'saving' | 'saved' | 'failed' | 'skipped'
92  reason?: string
93}
94
95// Module state: a hot reload starts it over, and the next check refills it.
96let problem: string | undefined
97// The tool-name spelling of a Kemory connector that answered a live call in
98// this session, or undefined. Only a live answer counts: a connector that is
99// installed but switched off, or refuses the call, must leave the plugin's own
100// tools in place, or the session ends up with no Kemory tools at all.
101let connector: string | undefined
102let connectorChecks = 0
103// The organisation each Kemory server reads and writes. One account can belong
104// to several, and the plugin (local credential, `kemory use`) and the claude.ai
105// connector (its own sign-in) can each be on a different one.
106let orgName: string | undefined
107let orgId: string | undefined
108let connectorOrg: string | undefined
109let toldOrgMismatch = false
110// The organisations the account belongs to, for the workspace picker, and how
111// the last pick went.
112let orgs: { id: string; name: string }[] = []
113let orgState: 'idle' | 'switching' | 'failed' = 'idle'
114let orgNote = ''
115let stale: string | undefined
116// The notice the update toast last carried, so it appears once per session
117// per release, not on every recheck.
118let toldUpdateFor: string | undefined
119// What the last check found to install or update, for the Update button, and
120// how pressing it went. The new version loads in the next session.
121let pending: { verb: 'install' | 'update'; name: PluginName }[] = []
122let pendingNotice = ''
123let updateState: 'idle' | 'running' | 'done' | 'failed' = 'idle'
124let updateNote = ''
125const entries: Entry[] = []
126const byCall = new Map<string, string[]>()
127const isOpen = new Set<string>()
128// What the last prompt's recall put in front of the model, for the band.
129let inContext: string[] = []
130let isBandHidden = false
131let hasPrompted = false
132let repoName = ''
133let suggestions: Suggestion[] = []
134let isSuggesting = false
135let turnsSinceSuggestion = 0
136let idleTimer: { cancel: () => void } | undefined
137
138async function versionAt($: Engine, url: string): Promise<string | undefined> {
139  try {
140    const text = url.startsWith('/')
141      ? await $.fs.read(url)
142      : await $.http.fetch(url).then(r => (r.ok ? r.text : ''))
143
144    return JSON.parse(text).version
145  } catch {
146    return undefined
147  }
148}
149
150function isOlder(installed: string, latest: string): boolean {
151  const a = installed.split('.').map(Number)
152  const b = latest.split('.').map(Number)
153  for (let i = 0; i < 3; i++) {
154    if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0)
155  }
156  return false
157}
158
159// Memories are written to open with the question they answer, so the first
160// sentence is the best title; a long one is cut.
161function titleOf(content: string): string {
162  const flat = content.split(/\s+/).join(' ').trim()
163  const first = flat.match(/^(.{8,100}?[?.!])(\s|$)/)?.[1] ?? flat
164
165  return first.length > 100 ? `${first.slice(0, 99)}…` : first
166}
167
168function find(key: string): Entry | undefined {
169  return entries.find(e => e.key === key)
170}
171
172function add(entry: Entry): string {
173  if (!find(entry.key)) entries.push(entry)
174
175  return entry.key
176}
177
178function count(kinds: Kind[]): number {
179  return entries.filter(e => kinds.includes(e.kind)).length
180}
181
182// The surface already labels the line with the plugin's name, so the text
183// leads with the state alone.
184// The status line carries only what a person can act on or wants to count:
185// a problem, an update, and this session's numbers. Which server answers is
186// explained in the panel instead.
187function draw($: Engine) {
188  const recalled = count(['recalled'])
189  const saved = count(['saved', 'updated'])
190  const parts = [problem ? `✗ ${problem}, run /kemory:status` : orgName ? `✓ ${orgName}` : '✓']
191  const mismatch = orgMismatch()
192  if (!problem && mismatch) parts.push(`⚠ connector on ${mismatch}`)
193  if (!problem && recalled > 0) parts.push(`${recalled} recalled`)
194  if (!problem && saved > 0) parts.push(`${saved} saved`)
195  if (stale) parts.push(stale)
196  $.ui.status(parts.join(' · '))
197}
198
199// The connector's organisation when it differs from the plugin's, else undefined.
200function orgMismatch(): string | undefined {
201  return connector && orgName && connectorOrg && connectorOrg !== orgName ? connectorOrg : undefined
202}
203
204function orgOf(result: McpToolResult, field: 'org_name' | 'org_id' = 'org_name'): string | undefined {
205  try {
206    const value = JSON.parse(textOf(result))[field]
207
208    return typeof value === 'string' && value ? value : undefined
209  } catch {
210    return undefined
211  }
212}
213
214// Says once per session when the two servers write to different
215// organisations: what Claude saves goes through the connector, and what this
216// panel and the kemory hooks save goes through the plugin.
217async function warnOrgMismatch($: Engine) {
218  const other = orgMismatch()
219  if (!other || toldOrgMismatch) return
220  toldOrgMismatch = true
221  $.ui.toast(
222    `Kemory: the plugin writes to ${orgName} but the claude.ai connector writes to ${other}. ` +
223      `Open /kemory-panel:memories and press "Use ${orgName} for both", or pick another workspace there.`,
224    { timeoutMs: 15000 },
225  )
226}
227
228async function loadOrgs($: Engine) {
229  const run = await $.process
230    .run(['bash', `${$.plugin.root}/scripts/org.sh`, 'list'], { timeoutMs: 15000 })
231    .catch(() => undefined)
232  if (!run || run.exitCode !== 0) return
233  try {
234    const list = JSON.parse(run.stdout)
235    if (!Array.isArray(list)) return
236    orgs = list
237      .filter(o => typeof o?.org_id === 'string')
238      .map(o => ({ id: o.org_id as string, name: typeof o.name === 'string' && o.name ? o.name : (o.org_id as string) }))
239    redraw($)
240  } catch {
241    // An unreadable list leaves the picker out.
242  }
243}
244
245// The workspace picker. A person's pick, never the model's: a switch the model
246// could call would let a stored memory redirect where later memories go.
247// org.sh moves the connector (the account's stored selection) and the plugin
248// (the credential file); both servers are then asked where they now write.
249// Picking the plugin's own organisation still runs while the connector is on
250// another one: that pick is how the connector is brought over.
251async function switchOrg($: Engine, id: string) {
252  if (orgState === 'switching' || (id === orgId && !orgMismatch())) return
253  orgState = 'switching'
254  redraw($)
255  const run = await $.process
256    .run(['bash', `${$.plugin.root}/scripts/org.sh`, 'use', id], { timeoutMs: 20000 })
257    .catch(() => undefined)
258  if (!run || run.exitCode !== 0) {
259    orgState = 'failed'
260    orgNote = `Could not switch: ${run?.stdout.trim() || 'the switch did not run'}.`
261    redraw($)
262    return
263  }
264  const me = await callKemory($, 'kemory_whoami', {})
265  if (!me.isError) {
266    orgName = orgOf(me) ?? orgName
267    orgId = orgOf(me, 'org_id') ?? orgId
268  }
269  if (connector) {
270    const answer = await $.mcp.call(connector, 'kemory_whoami', {}).catch(() => undefined)
271    if (answer && !answer.isError) connectorOrg = orgOf(answer)
272  }
273  orgState = 'idle'
274  toldOrgMismatch = false
275  $.ui.toast(`Kemory: now writing to ${orgName}`)
276  await warnOrgMismatch($)
277  redraw($)
278}
279
280function redraw($: Engine) {
281  draw($)
282  $.ui.invalidate('ui.render')
283}
284
285function toggle($: Engine, key: string) {
286  if (isOpen.has(key)) isOpen.delete(key)
287  else isOpen.add(key)
288  redraw($)
289}
290
291function textOf(result: { content: unknown[] }): string {
292  return (result.content as { type?: string; text?: unknown }[])
293    .map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
294    .join('')
295}
296
297// A recall tool's text is JSON with a top-level `memories` list. Other lists in
298// it (cross_agent_context, shared_chats) are not what was asked for, so only
299// that one counts.
300function memoriesIn(text: string | undefined): { namespace: string; content: string; id: string; similarity: number }[] {
301  try {
302    const memories = JSON.parse(text ?? '').memories
303    if (!Array.isArray(memories)) return []
304
305    return memories
306      .filter(m => typeof m?.memory_id === 'string' && typeof m?.content === 'string')
307      .map(m => ({
308        namespace: String(m.namespace ?? ''),
309        content: m.content as string,
310        id: m.memory_id as string,
311        similarity: typeof m.similarity_score === 'number' ? m.similarity_score : 0,
312      }))
313  } catch {
314    return []
315  }
316}
317
318// The store tools answer "ID: <id>", "Namespace: <ns>" and "Version: <n>"; a
319// version past 1 means a near-duplicate was updated in place.
320function stored(text: string, content: string, fallbackKey: string, fallbackNamespace: string): Entry {
321  const id = text.match(/ID: (\S+)/)?.[1]
322  const version = Number(text.match(/Version: (\d+)/)?.[1] ?? 1)
323
324  return {
325    key: id ?? fallbackKey,
326    id,
327    kind: version > 1 ? 'updated' : 'saved',
328    namespace: text.match(/Namespace: (\S+)/)?.[1] ?? fallbackNamespace,
329    title: titleOf(content || '(no text)'),
330    text: content,
331  }
332}
333
334function saveNotice(e: Entry): string {
335  const what = e.kind === 'updated' ? `Updated in ${e.namespace}` : `Saved to ${e.namespace}`
336
337  return `${what}: "${e.title}"`
338}
339
340// What the last install or update put on disk, per plugin; a plugin that is
341// not installed is absent, and undefined means the list could not be read.
342// The running session keeps the version it loaded until a new session starts,
343// so the two can differ.
344async function versionsOnDisk($: Engine): Promise<Partial<Record<PluginName, string>> | undefined> {
345  try {
346    const home = await $.env.get('HOME')
347    const plugins = JSON.parse(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`)).plugins ?? {}
348    const versions: Partial<Record<PluginName, string>> = {}
349    for (const name of Object.keys(LATEST_URLS) as PluginName[]) {
350      const key = Object.keys(plugins).find(k => k.startsWith(`${name}@`))
351      const installs = key ? plugins[key] : undefined
352      const version = Array.isArray(installs) ? installs[0]?.version : undefined
353      if (typeof version === 'string') versions[name] = version
354    }
355
356    return versions
357  } catch {
358    return undefined
359  }
360}
361
362// `/plugin` is a terminal dialog. The desktop app has no such command, so a
363// session drawn only there is told to use the CLI.
364async function commandHint($: Engine, actions: { verb: 'install' | 'update'; name: PluginName }[]): Promise<string> {
365  const surfaces = await $.session.surfaces().catch(() => [] as string[])
366  const isTerminal = surfaces.includes('terminal')
367  const commands = actions.map(a => (isTerminal ? `/plugin ${a.verb} ${a.name}@kemory` : `'claude plugin ${a.verb} ${a.name}@kemory'`))
368
369  return isTerminal
370    ? `Run ${commands.join(' and ')}, then restart.`
371    : `Run ${commands.join(' and ')} in a terminal, then open a new session.`
372}
373
374async function check($: Engine) {
375  const root = $.plugin.root
376  const [run, running, latestKemory, latestPanel, onDisk, me] = await Promise.all([
377    $.process
378      .run(['bash', '-c', CHECK, 'kemory-status', root], { timeoutMs: 20000 })
379      .catch(() => undefined),
380    versionAt($, `${root}/.claude-plugin/plugin.json`),
381    versionAt($, LATEST_URLS.kemory),
382    versionAt($, LATEST_URLS['kemory-panel']),
383    versionsOnDisk($),
384    callKemory($, 'kemory_whoami', {}),
385  ])
386  if (!me.isError) {
387    orgName = orgOf(me) ?? orgName
388    orgId = orgOf(me, 'org_id') ?? orgId
389  }
390  if (orgs.length === 0) void loadOrgs($)
391  const code = run?.stdout.trim() || '000'
392  const was = problem
393  problem = code === '200' ? undefined : (PROBLEMS[code] ?? `API returned HTTP ${code}`)
394
395  // The status line is narrow and cannot be clicked, so it carries only the
396  // facts; the commands, which do not fit there, go in a toast.
397  const facts: string[] = []
398  const notices: string[] = []
399  const actions: { verb: 'install' | 'update'; name: PluginName }[] = []
400  if (running && latestPanel && isOlder(running, latestPanel)) {
401    const disk = onDisk?.['kemory-panel']
402    if (disk && !isOlder(disk, latestPanel)) {
403      facts.push(`kemory-panel ${disk} installed, new session to apply`)
404    } else {
405      facts.push(`↑ kemory-panel ${latestPanel}`)
406      notices.push(`kemory-panel ${latestPanel} is out (you have ${running})`)
407      actions.push({ verb: 'update', name: 'kemory-panel' })
408    }
409  }
410  // This module cannot see which kemory version the session loaded, only what
411  // is on disk; after an update the kemory plugin's own hooks say a new
412  // session is needed.
413  const kemory = onDisk?.kemory
414  if (onDisk && !kemory) {
415    facts.push('kemory plugin not installed')
416    notices.push('the kemory plugin, which this panel needs, is not installed')
417    actions.push({ verb: 'install', name: 'kemory' })
418  } else if (kemory && latestKemory && isOlder(kemory, latestKemory)) {
419    facts.push(`↑ kemory ${latestKemory}`)
420    notices.push(`kemory ${latestKemory} is out (you have ${kemory})`)
421    actions.push({ verb: 'update', name: 'kemory' })
422  }
423  stale = facts.length > 0 ? facts.join(' · ') : undefined
424  const notice = notices.join('; ')
425  pending = actions
426  pendingNotice = notice
427  if (actions.length > 0 && toldUpdateFor !== notice) {
428    toldUpdateFor = notice
429    $.ui.toast(`Kemory: ${notice}. ${await commandHint($, actions)}`, { timeoutMs: 12000 })
430  }
431
432  if (problem && problem !== was) $.ui.toast(`Kemory: ${problem}`)
433  await warnOrgMismatch($)
434  draw($)
435}
436
437// Finds the claude CLI and runs it with the given arguments, or exits 127.
438// The session's PATH may lack it (a desktop app, a PATH set only in .zshrc),
439// so the usual install locations come next, then the person's own shell.
440const RUN_CLAUDE = `
441c=$(command -v claude 2>/dev/null)
442for p in "$c" "$HOME/.local/bin/claude" "$HOME/.claude/local/claude" /opt/homebrew/bin/claude /usr/local/bin/claude; do
443  [ -n "$p" ] && [ -x "$p" ] && exec "$p" "$@"
444done
445c=$("\${SHELL:-/bin/zsh}" -lic 'command -v claude' 2>/dev/null | tail -1)
446[ -n "$c" ] && [ -x "$c" ] && exec "$c" "$@"
447exit 127
448`
449
450// The Update button. Runs what a person would run in a terminal: refresh the
451// kemory marketplace, then install or update each plugin the check named.
452async function runUpdate($: Engine) {
453  if (updateState === 'running' || pending.length === 0) return
454  updateState = 'running'
455  redraw($)
456  const steps = [
457    ['plugin', 'marketplace', 'update', 'kemory'],
458    ...pending.map(a => ['plugin', a.verb, `${a.name}@kemory`]),
459  ]
460  for (const args of steps) {
461    const run = await $.process
462      .run(['bash', '-c', RUN_CLAUDE, 'claude', ...args], { timeoutMs: 180000 })
463      .catch(() => undefined)
464    if (!run || run.exitCode !== 0) {
465      updateState = 'failed'
466      const manual = pending.map(a => `claude plugin ${a.verb} ${a.name}@kemory`).join(' && ')
467      updateNote =
468        run?.exitCode === 127
469          ? `Could not find the claude command. Run ${manual} in a terminal.`
470          : `Update failed (claude ${args.join(' ')}). Run ${manual} in a terminal.`
471      redraw($)
472      return
473    }
474  }
475  updateState = 'done'
476  pending = []
477  stale = undefined
478  redraw($)
479}
480
481// One row saying an update is out, with the button, or how pressing it went.
482// biome-ignore lint/suspicious/noExplicitAny: the element table differs per surface
483function updateRow($: Engine, els: Record<string, any>, at: string) {
484  const { Box, Button, Text } = els
485  if (updateState === 'idle' && pending.length === 0) return null
486  const text = {
487    idle: `${pendingNotice.charAt(0).toUpperCase()}${pendingNotice.slice(1)}.`,
488    running: 'Updating…',
489    done: 'Updated. Open a new session to use it.',
490    failed: updateNote,
491  }[updateState]
492
493  return (
494    <Box key={`update-${at}`} gap={1}>
495      <Text color={updateState === 'failed' ? BRAND.danger : BRAND.yellow}>●</Text>
496      <Text>{text}</Text>
497      {(updateState === 'idle' || updateState === 'failed') && pending.length > 0 && (
498        <Button
499          key={`update-run-${at}`}
500          label={pending.some(a => a.verb === 'install') ? 'Install' : 'Update'}
501          variant="primary"
502          onPress={() => void runUpdate($)}
503        />
504      )}
505    </Box>
506  )
507}
508
509// The workspace picker, where the surface draws a Select and the account has
510// more than one organisation.
511// biome-ignore lint/suspicious/noExplicitAny: the element table differs per surface
512function orgRow($: Engine, els: Record<string, any>) {
513  const { Box, Select, Text } = els
514  if (!Select || orgs.length < 2) return null
515
516  return (
517    <Box key="org" flexDirection="column">
518      <Select
519        key="pane-org"
520        label="Workspace"
521        options={orgs.map(o => ({ value: o.id, label: o.name }))}
522        value={orgId}
523        onSelect={(id: string) => void switchOrg($, id)}
524      />
525      {orgState === 'switching' && <Text color={BRAND.grey}>Switching…</Text>}
526      {orgState === 'failed' && <Text color={BRAND.danger}>{orgNote}</Text>}
527      <Text color={BRAND.grey}>Applies to every Kemory connector on your account.</Text>
528    </Box>
529  )
530}
531
532// One Kemory server is enough. When the claude.ai connector is live in this
533// session, the plugin's own copies of the same 25 tools move behind tool
534// search and point at the connector's, so requests stop carrying both.
535// The plugin's server keeps running: a module cannot stop it, and the panel's
536// actions still use it. KEMORY_ALLOW_DUPLICATE=1 keeps both in front.
537async function findConnector($: Engine) {
538  if (connector) return
539  connectorChecks += 1
540  if ((await $.env.get('KEMORY_ALLOW_DUPLICATE')) === '1') return
541  const tools = await $.tool.list()
542  const servers = tools
543    .map(t => t.name.match(OTHER_KEMORY)?.[1])
544    .filter((s): s is string => !!s && !`mcp__${s}__`.startsWith(OWN_TOOL_PREFIX))
545  for (const server of new Set(servers)) {
546    const answer = await $.mcp.call(server, 'kemory_whoami', {}).catch(() => undefined)
547    if (answer && !answer.isError) {
548      connector = server
549      connectorOrg = orgOf(answer)
550      $.ui.invalidate('tool.describe')
551      await warnOrgMismatch($)
552      draw($)
553      return
554    }
555  }
556}
557
558// Every call to the plugin's server goes through here. A call made before the
559// server has connected throws at once instead of waiting, so that one is tried
560// again after a pause; any other failure comes back as an errored result whose
561// text says why, so no action is left waiting on a call that threw.
562async function callKemory($: Engine, tool: string, args: Record<string, unknown>): Promise<McpToolResult> {
563  for (let attempt = 0; ; attempt += 1) {
564    try {
565      return await $.mcp.call(SERVER, tool, args)
566    } catch (err) {
567      const message = err instanceof Error ? err.message : String(err)
568      if (attempt === 0 && /no connected MCP tool/.test(message)) {
569        await $.clock.sleep(CONNECT_RETRY_MS)
570        continue
571      }
572      const text = /no connected MCP tool/.test(message)
573        ? "the kemory plugin's server is not connected. Check that the kemory plugin is installed and enabled"
574        : message
575
576      return { content: [{ type: 'text', text }], isError: true }
577    }
578  }
579}
580
581// Why a call failed, short enough for a toast.
582function failureOf(result: McpToolResult): string {
583  const text = textOf(result).split(/\s+/).join(' ').trim()
584
585  return text.length > 140 ? `${text.slice(0, 139)}…` : text || 'no reason given'
586}
587
588// A person's verdict on a memory, sent through the plugin's server.
589async function rate($: Engine, key: string, rating: 'up' | 'down') {
590  const e = find(key)
591  if (!e?.id) return
592  const reason = rating === 'up' ? 'correct' : 'outdated'
593  const result = await callKemory($, 'kemory_rate_memory', { target_id: e.id, rating, reason })
594  if (result.isError) {
595    $.ui.toast(`Kemory: rating failed: ${failureOf(result)}`)
596    return
597  }
598  e.rating = rating
599  $.ui.toast(rating === 'up' ? 'Kemory: marked helpful' : 'Kemory: marked outdated')
600  redraw($)
601}
602
603// Delete takes two presses, the second labelled "Confirm delete".
604async function remove($: Engine, key: string) {
605  const e = find(key)
606  if (!e?.id) return
607  if (!e.isConfirmingDelete) {
608    e.isConfirmingDelete = true
609    redraw($)
610    return
611  }
612  const result = await callKemory($, 'kemory_delete_memory', { memory_id: e.id })
613  e.isConfirmingDelete = false
614  if (result.isError) {
615    $.ui.toast(`Kemory: delete failed: ${failureOf(result)}`)
616  } else {
617    e.isDeleted = true
618    $.ui.toast(`Kemory: deleted "${e.title}"`)
619  }
620  redraw($)
621}
622
623// A save a person asked for: /remember, "Save last reply", or a suggestion.
624// Resolves to why it failed, or undefined once saved.
625async function save($: Engine, content: string): Promise<string | undefined> {
626  const result = await callKemory($, 'kemory_store_memory', {
627    content,
628    namespace: NAMESPACE,
629    content_type: 'fact',
630  })
631  if (result.isError) {
632    const reason = failureOf(result)
633    $.ui.toast(`Kemory: save failed: ${reason}`, { timeoutMs: 8000 })
634    return reason
635  }
636  const entry = stored(textOf(result), content, `save-${entries.length}`, NAMESPACE)
637  add(entry)
638  $.ui.toast(saveNotice(entry), { timeoutMs: 6000 })
639  redraw($)
640  return undefined
641}
642
643async function lastReply($: Engine): Promise<string | undefined> {
644  const messages = await $.session.messages()
645  const last = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim())
646
647  return last?.text.trim().slice(0, 4000)
648}
649
650async function saveLastReply($: Engine) {
651  const text = await lastReply($)
652  if (text) await save($, text)
653}
654
655// The model reads the session it is in (a fork, served from the prompt cache)
656// and proposes memories. Nothing is stored until a person saves one.
657async function suggest($: Engine, isAsked: boolean) {
658  if (isSuggesting) return
659  isSuggesting = true
660  redraw($)
661  const r = await $.model.fork({
662    prompt:
663      'From this conversation, pick at most 3 durable facts worth keeping in long-term memory: decisions, preferences, ' +
664      'non-obvious gotchas with their fix. Skip anything transient or already saved. Write each so it opens with the ' +
665      'question someone would later ask, in plain words, then the answer with exact identifiers. ' +
666      'Reply with only a JSON array of strings, [] if nothing qualifies.',
667  })
668  isSuggesting = false
669  turnsSinceSuggestion = 0
670  if (!r.isAnswered) {
671    if (isAsked) $.ui.toast(`Kemory: no suggestions (${r.reason})`)
672    redraw($)
673    return
674  }
675  let list: unknown = []
676  try {
677    list = JSON.parse(r.text.match(/\[[\s\S]*\]/)?.[0] ?? '[]')
678  } catch {
679    list = []
680  }
681  const fresh = (Array.isArray(list) ? list : []).filter((s): s is string => typeof s === 'string' && s.trim().length > 20)
682  suggestions = [
683    ...suggestions,
684    ...fresh.map((text, i) => ({ key: `s-${suggestions.length + i}`, text: text.trim(), state: 'new' as const })),
685  ]
686  if (fresh.length > 0) {
687    const noun = fresh.length === 1 ? 'memory' : 'memories'
688    $.ui.toast(`Kemory: ${fresh.length} suggested ${noun}, see /kemory-panel:memories`, { timeoutMs: 8000 })
689  } else if (isAsked) {
690    $.ui.toast('Kemory: nothing new worth saving')
691  }
692  redraw($)
693}
694
695async function saveSuggestion($: Engine, key: string) {
696  const s = suggestions.find(x => x.key === key)
697  if (!s || (s.state !== 'new' && s.state !== 'failed')) return
698  s.state = 'saving'
699  s.reason = undefined
700  redraw($)
701  const reason = await save($, s.text)
702  s.state = reason ? 'failed' : 'saved'
703  s.reason = reason
704  redraw($)
705}
706
707function skipSuggestion($: Engine, key: string) {
708  const s = suggestions.find(x => x.key === key)
709  if (s) s.state = 'skipped'
710  redraw($)
711}
712
713// What Kemory already knows about the repo this session runs in.
714async function brief($: Engine) {
715  const root = await $.session.root()
716  repoName = root.split('/').filter(Boolean).pop() ?? ''
717  if (!repoName) return
718  const result = await callKemory($, 'kemory_recall_memory', {
719    query: `${repoName} repository: how it is set up, decisions and gotchas`,
720    limit: 8,
721  })
722  if (result.isError) return
723  const strong = memoriesIn(textOf(result))
724    .filter(m => m.similarity >= BRIEF_MIN_SIMILARITY && !BRIEF_SKIP_NAMESPACE.test(m.namespace))
725    .slice(0, 3)
726  for (const m of strong) {
727    add({ key: m.id, id: m.id, kind: 'brief', namespace: m.namespace, title: titleOf(m.content), text: m.content })
728  }
729  redraw($)
730}
731
732// Look and feel, from the SeKondBrain brand book v6 (brand.s9n.ai): the
733// accent palette, hairline borders rather than coloured ones, pill tags with a
734// coloured dot, status dots, and no decorative glyphs.
735const BRAND = {
736  purple: '#8B78F5',
737  blue: '#1A8AF6',
738  pink: '#E24AF2',
739  orange: '#F28B68',
740  yellow: '#E6B800',
741  mint: '#3DD680',
742  grey: '#969696',
743  danger: '#EF4444',
744  // 8% white on the dark panel (#0E0E10)
745  hairline: '#2A2A2E',
746}
747const ACCENT = BRAND.purple
748// One stable colour per namespace, so the same namespace reads the same everywhere.
749const CHIP_COLORS = [BRAND.purple, BRAND.blue, BRAND.pink, BRAND.mint, BRAND.yellow, BRAND.orange]
750// The dashboard has no page for a single memory; its memories view filters
751// by namespace, so a memory links to its namespace there.
752const DASHBOARD = 'https://app.kemory.ai/memories'
753
754function chipColor(namespace: string): string {
755  let hash = 0
756  for (const c of namespace) hash = (hash * 31 + c.charCodeAt(0)) >>> 0
757
758  return CHIP_COLORS[hash % CHIP_COLORS.length] ?? ACCENT
759}
760
761function dashboardLink(namespace: string): string {
762  return namespace ? `${DASHBOARD}?ns=${encodeURIComponent(namespace)}` : DASHBOARD
763}
764
765function verb(e: Entry): string {
766  if (e.isDeleted) return 'Deleted from Kemory'
767
768  return {
769    saved: 'Saved to Kemory',
770    updated: 'Updated in Kemory',
771    recalled: 'Recalled from Kemory',
772    brief: 'Kemory knows this about the repo',
773  }[e.kind]
774}
775
776// The status dot beside the verb: what happened, at a glance.
777function kindColor(e: Entry): string {
778  if (e.isDeleted) return BRAND.grey
779
780  return { saved: BRAND.mint, updated: BRAND.yellow, recalled: BRAND.blue, brief: BRAND.purple }[e.kind]
781}
782
783// The text after the title, flattened to one line, for the closed card.
784function previewOf(e: Entry): string {
785  const flat = e.text.split(/\s+/).join(' ').trim()
786  const rest = flat.startsWith(e.title) ? flat.slice(e.title.length).trim() : flat
787
788  return rest.length > 160 ? `${rest.slice(0, 159)}…` : rest
789}
790
791// The SeKondBrain mark, drawn where the surface takes SVG.
792function logomark(size: number): string {
793  return (
794    `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 96 96">` +
795    `<image href="data:image/png;base64,${LOGOMARK_PNG}" width="96" height="96"/></svg>`
796  )
797}
798
799// A namespace as a pill tag: its colour as a dot, its name in grey capitals.
800// biome-ignore lint/suspicious/noExplicitAny: the element table differs per surface
801function tag(Text: any, namespace: string) {
802  return (
803    <Text>
804      <Text color={chipColor(namespace)}>●</Text>
805      <Text color={BRAND.grey}> {(namespace || 'kemory').toUpperCase()}</Text>
806    </Text>
807  )
808}
809
810// One memory as a card. Closed: what happened and where, the title, and one
811// line of the memory. Open: the whole memory as Markdown and its actions.
812// Elements are the drawing surface's own, passed in by the hook.
813// biome-ignore lint/suspicious/noExplicitAny: the element table differs per surface
814function memoryCard($: Engine, els: Record<string, any>, m: Entry, at: string) {
815  const { Box, Button, Text, Markdown, Link } = els
816  const svg = 'Svg' in els ? (els.Svg as (p: Record<string, unknown>) => never) : undefined
817  const isShown = isOpen.has(m.key) && !m.isDeleted
818  const preview = previewOf(m)
819
820  return (
821    <Box
822      key={`box-${at}-${m.key}`}
823      flexDirection="column"
824      borderStyle="round"
825      borderColor={BRAND.hairline}
826      paddingX={1}
827    >
828      <Box gap={1} justifyContent="space-between">
829        <Box gap={1} alignItems="center" flexShrink={1}>
830          {svg && svg({ key: `mark-${at}-${m.key}`, source: logomark(14), alt: ' ', width: 14, height: 14 })}
831          <Text color={kindColor(m)}>●</Text>
832          <Text color={m.isDeleted ? BRAND.grey : undefined}>{verb(m)}</Text>
833          {tag(Text, m.namespace)}
834        </Box>
835        {!m.isDeleted && (
836          <Box flexShrink={0}>
837            <Link key={`link-${at}-${m.key}`} href={dashboardLink(m.namespace)} label="Open ↗" />
838          </Box>
839        )}
840      </Box>
841      <Text bold strikethrough={m.isDeleted} color={m.isDeleted ? BRAND.grey : undefined}>
842        {m.title}
843      </Text>
844      {!isShown && preview && (
845        <Text color={BRAND.grey} wrap="truncate-end">
846          {preview}
847        </Text>
848      )}
849      {isShown && <Markdown key={`md-${at}-${m.key}`} text={m.text.slice(0, 9000)} />}
850      <Box gap={1}>
851        {!m.isDeleted && (preview || isShown) && (
852          <Button
853            key={`open-${at}-${m.key}`}
854            plain
855            dimColor
856            label={isShown ? 'Show less' : 'Show more'}
857            onPress={() => toggle($, m.key)}
858          />
859        )}
860        {isShown && m.id && m.kind !== 'saved' && m.kind !== 'updated' && (
861          <Button
862            key={`up-${at}-${m.key}`}
863            label={m.rating === 'up' ? '✓ Helpful' : 'Helpful'}
864            onPress={() => rate($, m.key, 'up')}
865          />
866        )}
867        {isShown && m.id && (
868          <Button
869            key={`down-${at}-${m.key}`}
870            label={m.rating === 'down' ? '✓ Outdated' : 'Outdated'}
871            onPress={() => rate($, m.key, 'down')}
872          />
873        )}
874        {isShown && m.id && (
875          <Button
876            key={`del-${at}-${m.key}`}
877            label={m.isConfirmingDelete ? 'Confirm delete' : 'Delete'}
878            onPress={() => remove($, m.key)}
879          />
880        )}
881      </Box>
882    </Box>
883  )
884}
885
886export const register: Register = on => {
887  on('session.start', async ($, e, next) => {
888    const result = await next(e)
889    $.ui.status('…')
890    void check($)
891    $.clock.every(RECHECK_MS, () => void check($))
892    void brief($)
893    void findConnector($)
894
895    return result
896  })
897
898  // The plugin's own commands (commands/*.md), answered here where the module
899  // runs; elsewhere their files run as prompts. Shipping them as files is what
900  // lists them in the command picker, which runtime-registered commands miss.
901  on('command.run', { command: 'kemory-panel:memories' }, async $ => {
902    await $.ui.open({ id: PANE, title: 'Kemory' })
903
904    return { text: 'Kemory panel opened.' }
905  }).catch(($, e, next) => next(e))
906
907  on('command.run', { command: 'kemory-panel:remember' }, async ($, e) => {
908    const text = e.args.trim() || (await lastReply($))
909    if (!text) return { text: 'Nothing to save: no text given and no reply yet.' }
910
911    const reason = await save($, text)
912
913    return { text: reason ? `Save failed: ${reason}` : `Saved to ${NAMESPACE}: ${titleOf(text)}` }
914  }).catch(($, e, next) => next(e))
915
916  on('command.run', { command: 'kemory-panel:learned' }, async $ => {
917    await $.ui.open({ id: PANE, title: 'Kemory' })
918    await suggest($, true)
919
920    return { text: 'Suggested memories are in the Kemory panel.' }
921  }).catch(($, e, next) => next(e))
922
923  on('prompt.submit', async ($, e, next) => {
924    idleTimer?.cancel()
925    if (!hasPrompted) {
926      hasPrompted = true
927      redraw($)
928    }
929
930    return next(e)
931  }).catch(($, e, next) => next(e))
932
933  // Opt-in (KEMORY_AUTO_SUGGEST=1): each run is a model call over the session
934  // that the person did not ask for. /kemory-panel:learned runs it on request.
935  on('turn.complete', async ($, e, next) => {
936    const result = await next(e)
937    turnsSinceSuggestion += 1
938    idleTimer?.cancel()
939    if ((await $.env.get('KEMORY_AUTO_SUGGEST')) !== '1') return result
940    if (turnsSinceSuggestion >= MIN_TURNS_FOR_SUGGESTIONS) {
941      idleTimer = $.clock.after(IDLE_MS, () => void suggest($, false))
942    }
943
944    return result
945  })
946
947  // A display must never cost the prompt or the tool call it watches: if a
948  // hook below throws, its catch hands back what `next` settled to (replayed,
949  // never run twice).
950  on('classic.UserPromptSubmit', async ($, e, next) => {
951    const result = await next(e)
952    if (!connector && connectorChecks < CONNECTOR_PROMPT_CHECKS) await findConnector($)
953    // A prompt ends the repo brief. Marked here as well as at prompt.submit,
954    // which a session hosted by the desktop app was seen not to raise.
955    hasPrompted = true
956    inContext = []
957    for (const context of result.additionalContext ?? []) {
958      for (const [, namespace = '', content, id] of context.matchAll(INJECTED)) {
959        if (!content || !id) continue
960        inContext.push(add({ key: id, id, kind: 'recalled', namespace, title: titleOf(content), text: content }))
961      }
962    }
963    redraw($)
964
965    return result
966  }).catch(($, e, next) => next(e))
967
968  on('tool.call', async ($, e, next) => {
969    const result = await next(e)
970    if (result.deny !== undefined || result.isError) return result
971    const input = e as Record<string, unknown>
972    const text = result.text ?? ''
973
974    if (STORE_TOOL.test(e.tool)) {
975      const content = [input.content, input.name, input.trigger].find(v => typeof v === 'string') as string | undefined
976      const entry = stored(text, content ?? '', e.tool_use_id, String(input.namespace ?? ''))
977      byCall.set(e.tool_use_id, [add(entry)])
978      $.ui.toast(saveNotice(entry), { timeoutMs: 6000 })
979    } else if (RECALL_TOOL.test(e.tool)) {
980      const keys = memoriesIn(text).map(m =>
981        add({ key: m.id, id: m.id, kind: 'recalled', namespace: m.namespace, title: titleOf(m.content), text: m.content }),
982      )
983      byCall.set(e.tool_use_id, keys)
984    }
985    redraw($)
986
987    return result
988  }).catch(($, e, next) => next(e))
989
990  on('tool.describe', async ($, e, next) => {
991    const described = await next(e)
992    if (!connector || !e.tool.startsWith(OWN_TOOL_PREFIX)) return described
993    const twin = `mcp__${connector}__${e.tool.slice(OWN_TOOL_PREFIX.length)}`
994
995    return {
996      description: `Same tool as ${twin}, which the claude.ai Kemory connector serves in this session; use that one. ${described.description}`,
997      isDeferred: true,
998    }
999  }).catch(($, e, next) => next(e))
1000
1001  // Above the prompt: the repo brief until the first prompt, then what each
1002  // prompt's recall put in context, as a row of namespace chips and titles.
1003  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
1004    if (e.props.hasSurvey || isBandHidden) return next(e)
1005    const els = $.ui.resolve(e)
1006    const { Box, Button, Text } = els
1007    const briefs = entries.filter(m => m.kind === 'brief')
1008    const shown = hasPrompted ? inContext.map(find).filter((x): x is Entry => !!x) : briefs
1009    const update = updateRow($, els, 'band')
1010    if (shown.length === 0 && !update) return next(e)
1011    const noun = shown.length === 1 ? 'memory' : 'memories'
1012    const heading =
1013      shown.length === 0 ? '' : hasPrompted ? `${shown.length} ${noun} in context` : `knows this about ${repoName}`
1014    const more = shown.length - BAND_ROWS
1015
1016    return (
1017      <Box flexDirection="column" borderStyle="round" borderColor={BRAND.hairline} paddingX={1}>
1018        <Box gap={1}>
1019          <Text color={ACCENT} bold>
1020            Kemory
1021          </Text>
1022          <Text dimColor>{heading}</Text>
1023          <Button key="band-open" label="Open" dimColor onPress={() => $.ui.open({ id: PANE, title: 'Kemory' })} />
1024          <Button
1025            key="band-hide"
1026            label="Hide"
1027            dimColor
1028            onPress={() => {
1029              isBandHidden = true
1030              $.ui.invalidate('ui.render')
1031            }}
1032          />
1033        </Box>
1034        {update}
1035        {shown.slice(0, BAND_ROWS).map(m => (
1036          <Box key={`band-${m.key}`} gap={1}>
1037            {tag(Text, m.namespace)}
1038            <Text wrap="truncate-end">{m.title}</Text>
1039          </Box>
1040        ))}
1041        {more > 0 && <Text dimColor>+{more} more in the panel</Text>}
1042      </Box>
1043    )
1044  })
1045
1046  // A group of calls draws as one count line ("Used 2 tools"). Unfolded, each
1047  // call is its own row, and a Kemory call's row can draw as a card.
1048  // Transcript cards are drawn in the terminal only. Elsewhere the Kemory
1049  // server's own MCP App cards (ui://kemory/memory-saved, recall-results) show
1050  // each save and recall, in every app that supports them; one card per call.
1051  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
1052    if (e.surface !== 'terminal') return next(e)
1053    const holdsKemory = e.props.calls.some(c => !!c.tool_use_id && byCall.has(c.tool_use_id))
1054    if (!holdsKemory || e.props.isExpanded) return next(e)
1055
1056    return next({ ...e, props: { ...e.props, isExpanded: true } })
1057  })
1058
1059  // The transcript row of a save or recall becomes one card per memory.
1060  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
1061    if (e.surface !== 'terminal') return next(e)
1062    const keys = byCall.get(e.props.tool_use_id)
1063    if (!keys || keys.length === 0) return next(e)
1064    const els = $.ui.resolve(e)
1065    const { Box } = els
1066    const shown = keys.map(find).filter((x): x is Entry => !!x)
1067
1068    return <Box flexDirection="column">{shown.map(m => memoryCard($, els, m, e.props.tool_use_id))}</Box>
1069  })
1070
1071  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
1072    const els = $.ui.resolve(e)
1073    const { Box, Button, Text } = els
1074    const saved = count(['saved', 'updated'])
1075    const recalled = count(['recalled'])
1076    const rated = entries.filter(m => m.rating).length
1077    const open = suggestions.filter(s => s.state !== 'skipped')
1078    const svg = 'Svg' in els ? (els as unknown as { Svg: (p: Record<string, unknown>) => never }).Svg : undefined
1079
1080    // Plain sections, full width: a small mark beside the title rather than a
1081    // column of its own, one hint line where one is needed, and the primary
1082    // style kept for the one action that fixes something.
1083    return (
1084      <Box flexDirection="column" gap={1}>
1085        <Box flexDirection="column">
1086          <Box gap={1} alignItems="center">
1087            {svg && svg({ key: 'mark', source: logomark(18), alt: 'Kemory', width: 18, height: 18 })}
1088            <Text>
1089              <Text bold>Kemory</Text>
1090              {orgName && <Text color={BRAND.grey}> · {orgName}</Text>}
1091            </Text>
1092          </Box>
1093          <Text>
1094            <Text color={BRAND.purple}>●</Text>
1095            <Text color={BRAND.grey}> {saved} saved   </Text>
1096            <Text color={BRAND.blue}>●</Text>
1097            <Text color={BRAND.grey}> {recalled} recalled   </Text>
1098            <Text color={BRAND.mint}>●</Text>
1099            <Text color={BRAND.grey}> {rated} rated this session</Text>
1100          </Text>
1101        </Box>
1102        {updateRow($, els, 'pane')}
1103        {orgMismatch() && (
1104          <Box flexDirection="column">
1105            <Text color={BRAND.danger}>
1106              This panel and the kemory hooks write to {orgName}, but Claude's own saves go through the claude.ai
1107              connector, which writes to {orgMismatch()}.
1108            </Text>
1109            {orgId && (
1110              <Box gap={1}>
1111                <Button
1112                  key="pane-org-fix"
1113                  variant="primary"
1114                  label={orgState === 'switching' ? 'Switching…' : `Use ${orgName} for both`}
1115                  onPress={() => void switchOrg($, orgId as string)}
1116                />
1117              </Box>
1118            )}
1119          </Box>
1120        )}
1121        {orgRow($, els)}
1122        <Box gap={1}>
1123          <Button
1124            key="pane-learn"
1125            label={isSuggesting ? 'Reading the session…' : 'Suggest memories'}
1126            onPress={() => suggest($, true)}
1127          />
1128          <Button key="pane-save-reply" label="Save last reply" onPress={() => saveLastReply($)} />
1129        </Box>
1130        {open.length > 0 && (
1131          <Box flexDirection="column" borderStyle="round" borderColor={BRAND.hairline} paddingX={1}>
1132            <Text>
1133              <Text color={BRAND.yellow}>●</Text>
1134              <Text color={BRAND.grey}> SUGGESTED · NOT SAVED YET</Text>
1135            </Text>
1136            {open.map(s => (
1137              <Box key={`sug-${s.key}`} flexDirection="column">
1138                <Text color={s.state === 'saved' ? BRAND.grey : undefined}>
1139                  {s.state === 'saved' ? '✓ ' : ''}
1140                  {titleOf(s.text)}
1141                </Text>
1142                {s.state === 'failed' && <Text color={BRAND.danger}>Could not save: {s.reason}</Text>}
1143                {s.state !== 'saved' && (
1144                  <Box gap={1}>
1145                    <Button
1146                      key={`sug-save-${s.key}`}
1147                      label={{ new: 'Save', saving: 'Saving…', failed: 'Retry', skipped: 'Save' }[s.state]}
1148                      variant="primary"
1149                      onPress={() => saveSuggestion($, s.key)}
1150                    />
1151                    <Button key={`sug-skip-${s.key}`} label="Skip" onPress={() => skipSuggestion($, s.key)} />
1152                  </Box>
1153                )}
1154              </Box>
1155            ))}
1156          </Box>
1157        )}
1158        {entries.length === 0 && <Text dimColor>Nothing saved or recalled yet.</Text>}
1159        {[...entries].reverse().map(m => memoryCard($, els, m, 'pane'))}
1160      </Box>
1161    )
1162  })
1163}
1164
hooks/logomark.ts 6 lines
1// The SeKondBrain logomark, 96 px, from the brand book (brand.s9n.ai,
2// 01_SB_Logomark_Color.png), inlined so drawing the panel reads no file.
3// assets/logomark-96.png is the same image.
4export const LOGOMARK_PNG =
5  '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'
6