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…

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.
| Where | What |
|---|---|
| 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 prompt | The 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:memories | A 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 prompts | Read-only Kemory tools (recall, search, list) run without asking. Every write still asks |
| Duplicate tools | When 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 |
Two different things show Kemory in Claude, and only one of them needs this plugin.
| Memory cards | kemory-panel | |
|---|---|---|
| What you see | A card after each save or recall: the memory, its namespace, and Undo, Forget or a rating | The status line, the memories in context above the prompt, the session panel, and cards in the terminal |
| Where it comes from | The 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 result | This plugin, a module Claude Code loads and runs |
| What you need | A connection to Kemory: the claude.ai connector or the kemory plugin. No plugin is required | Claude Code 2.1.267 or later with this plugin and the kemory plugin |
| Where it shows | Apps that support MCP Apps, such as Claude desktop, claude.ai and ChatGPT | Claude 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.
/kemory:login).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
| Command | What it does |
|---|---|
/kemory-panel:memories | Opens the panel |
/kemory-panel:remember [text] | Saves the text to Kemory, or Claude's last reply when you give none |
/kemory-panel:learned | Opens the panel and suggests memories from this session |
Set these as environment variables before Claude Code starts.
| Variable | Default | What it does |
|---|---|---|
KEMORY_AUTO_SUGGEST=1 | off | Also 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=1 | off | Keeps this plugin's Kemory tools in front even while the claude.ai connector is connected |
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.
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.
./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.
| Path | What it is |
|---|---|
hooks/register.tsx | The interface: status line, band, cards, panel and commands |
scripts/recall-approve.sh | Approves read-only Kemory tools by exact name |
scripts/lib.sh | A copy of the kemory plugin's credential resolution, for the status check |
tests/ | Module tests and approval hook tests |
Apache-2.0. See LICENSE.
hooks/register.tsx 1164 lines1import 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}
1164hooks/logomark.ts 6 lines1// 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