@webinson/peer 0.4.5 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from 'claude-code'
2
- import type { EngineInterface, Register, RenderChildren, RenderNode } from 'claude-code'
2
+ import type { EngineInterface, Register, RenderChildren, RenderNode, RenderSurface } from 'claude-code'
3
3
 
4
- import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, Thread } from '../types'
4
+ import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, MemoryUse, Mode, Recall, RecallItem, Thread } from '../types'
5
5
  import type { Shared } from '../types'
6
6
 
7
7
  // The peer CLI's hook writes ~/.peer/sessions/<session id> on `/peer` and removes it on `/peer stop`;
@@ -23,6 +23,45 @@ const openMemory = atom({ plugin: 'peer-thread-mark', key: 'openMemory' } as con
23
23
  // The search pane: /peer-all lists all of the person's own memories, newest first, and searches their text.
24
24
  const ALL_PANE = 'peer-all-memories'
25
25
  const all = atom({ plugin: 'peer-thread-mark', key: 'all' } as const, null, { shape: 'all-2' })
26
+ // What the hooks added to the session's last prompts, newest last; a prompt's row in the thread finds its own here.
27
+ const RECALLS_KEPT = 30
28
+ const recalls = atom({ plugin: 'peer-thread-mark', key: 'recalls' } as const, [], { shape: 'recalls-1' })
29
+ // The one prompt whose recall line is open, by its message id.
30
+ const openRecall = atom({ plugin: 'peer-thread-mark', key: 'openRecall' } as const, null)
31
+ // The band's memory mode for this thread: Elephant remembers, Jellyfish forgets.
32
+ const mode = atom({ plugin: 'peer-thread-mark', key: 'mode' } as const, 'elephant')
33
+
34
+ // Peer's team memory block: `- [verified pitfall] Title`, a line of text, then `(who, date, uri)`.
35
+ function teamItems(context: string): RecallItem[] {
36
+ const block = /<teammates-project-knowledge[^>]*>([\s\S]*?)<\/teammates-project-knowledge>/.exec(context)?.[1]
37
+ if (!block) return []
38
+ return block
39
+ .split(/\n(?=- \[)/)
40
+ .filter(entry => entry.startsWith('- ['))
41
+ .map(entry => {
42
+ const [head = '', ...rest] = entry.split('\n')
43
+ const kind = /^- \[([^\]]+)\]/.exec(head)?.[1] ?? ''
44
+ const by = /\(([^,]+), (\d{4}-\d{2}-\d{2})?, (viking:\/\/\S+)\)\s*$/.exec(entry)
45
+ return {
46
+ from: 'team' as const,
47
+ title: head.replace(/^- \[[^\]]+\]\s*/, '').trim(),
48
+ detail: [kind.split(' · ')[0], by?.[1], by?.[2]].filter(Boolean).join(' · '),
49
+ uri: by?.[3] ?? '',
50
+ }
51
+ })
52
+ }
53
+
54
+ // OpenViking's recall block: `- [memory 82%] <a memory's text or its address>`, one line each.
55
+ function mineItems(context: string): RecallItem[] {
56
+ const block = /<openviking-context(?![^>]*source="startup")[^>]*>([\s\S]*?)<\/openviking-context>/.exec(context)?.[1]
57
+ if (!block) return []
58
+ return [...block.matchAll(/^- \[(\S+) (\d+)%\] (.+)$/gm)].map(([, kind, score, text = '']) => {
59
+ const isUri = text.startsWith('viking://')
60
+ const name = isUri ? (text.replace(/\/+$/, '').split('/').pop() ?? text).replace(/\.md$/, '').replace(/[_-]+/g, ' ') : text
61
+ return { from: 'mine' as const, title: snippet(name).slice(0, 120), detail: `${kind} · ${score}%`, uri: isUri ? text : '' }
62
+ })
63
+ }
64
+
26
65
  // The one memory the search pane shows open, by its address.
27
66
  const openAll = atom({ plugin: 'peer-thread-mark', key: 'openAll' } as const, null)
28
67
 
@@ -153,8 +192,130 @@ async function refresh($: EngineInterface) {
153
192
  if (before?.workspace !== now?.workspace || before?.isShared !== now?.isShared) {
154
193
  await update($, shared, () => now)
155
194
  }
195
+ await applyMemory($)
196
+ }
197
+
198
+ // Which memory a thread works with. Elephant (shared) adds OpenViking, its recall and its saving, to Claude Code's own
199
+ // memory; Inactive keeps Claude Code's own memory alone; Jellyfish (shared) keeps none at all.
200
+ type MemoryMode = 'openviking' | 'own' | 'none'
201
+ async function memoryUse($: EngineInterface): Promise<MemoryMode> {
202
+ if (!(await read($, shared))?.isShared) return 'own'
203
+ return (await read($, mode)) === 'elephant' ? 'openviking' : 'none'
204
+ }
205
+ let appliedMemory: MemoryMode | undefined
206
+ // The thread's transcript, as the classic hooks hand it over; OpenViking counts its turns from it.
207
+ let transcriptPath: string | undefined
208
+
209
+ // OpenViking keeps how many of a thread's turns it has captured ($TMPDIR/openviking-cc-capture-state/<session>.json)
210
+ // and takes everything after them at the next turn end. Turned on with Use Elephant, it would take the turns from
211
+ // Inactive or Jellyfish too, so the count moves to the turns there are now, counted by OpenViking's own reader.
212
+ const SKIP_TO_NOW = `
213
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
214
+ import { homedir, tmpdir } from 'node:os'
215
+ import { join } from 'node:path'
216
+ const [id, transcript, cwd] = process.argv.slice(1)
217
+ const installed = JSON.parse(readFileSync(join(homedir(), '.claude/plugins/installed_plugins.json'), 'utf-8'))
218
+ const root = (installed.plugins?.['openviking-memory@openviking'] ?? []).map(e => e.installPath).find(p => p && existsSync(join(p, 'scripts/cc-transcript.mjs')))
219
+ if (!root || !existsSync(transcript)) process.exit(0)
220
+ const { parseTranscript, extractCaptureTurns } = await import(join(root, 'scripts/cc-transcript.mjs'))
221
+ const { loadConfig } = await import(join(root, 'scripts/config.mjs'))
222
+ const turns = extractCaptureTurns(parseTranscript(readFileSync(transcript, 'utf-8')), loadConfig(cwd)).length
223
+ const dir = join(tmpdir(), 'openviking-cc-capture-state')
224
+ const file = join(dir, id.replace(/[^a-zA-Z0-9_-]/g, '_') + '.json')
225
+ let state = {}
226
+ try { state = JSON.parse(readFileSync(file, 'utf-8')) } catch {}
227
+ if ((state.capturedTurnCount ?? 0) < turns) {
228
+ mkdirSync(dir, { recursive: true })
229
+ writeFileSync(file, JSON.stringify({ ...state, capturedTurnCount: turns, baselinedBy: 'peer-thread-mark', ts: Date.now() }))
230
+ }
231
+ `
232
+ async function skipToNow($: EngineInterface) {
233
+ if (!transcriptPath) return
234
+ // The node that runs Peer's own hooks, from Claude Code's settings; any node on PATH where there is none.
235
+ const node = (await promptHook($).catch(() => undefined))?.[0]
236
+ const argv = [node && /node$/.test(node) ? node : 'node', '--input-type=module', '-e', SKIP_TO_NOW, await $.session.id(), transcriptPath, await $.session.cwd()]
237
+ await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => {})
238
+ }
239
+ // The OpenViking plugin's hooks read these on every run, and $.env.set reaches every process the session starts
240
+ // after it, so the switch holds for this thread's next prompt, turn end and compaction. Its MCP server, started
241
+ // with the session, is held back by the tool.call hook instead.
242
+ async function applyMemory($: EngineInterface) {
243
+ const use = await memoryUse($)
244
+ if (use === appliedMemory) return use
245
+ const isOn = use === 'openviking'
246
+ if (isOn && appliedMemory !== undefined) await skipToNow($)
247
+ await $.env.set('OPENVIKING_MEMORY_ENABLED', isOn ? '1' : '0')
248
+ await $.env.set('OPENVIKING_AUTO_RECALL', isOn ? '1' : undefined)
249
+ await $.env.set('OPENVIKING_AUTO_CAPTURE', isOn ? '1' : undefined)
250
+ // Claude Code's own memory goes out of the system prompt and the first message with Jellyfish, back without it.
251
+ if (appliedMemory !== undefined && (appliedMemory === 'none' || use === 'none')) {
252
+ $.ui.invalidate('prompt.section')
253
+ $.ui.invalidate('prompt.context')
254
+ }
255
+ appliedMemory = use
256
+ return use
257
+ }
258
+ // What OpenViking's recall and Peer's team memory add to a prompt, left out where the thread keeps OpenViking off.
259
+ const OPENVIKING_BLOCK = /<openviking-context|<teammates-project-knowledge/
260
+ const OPENVIKING_TOOL = 'mcp__plugin_openviking-memory_'
261
+
262
+ // The memory badge's color: an explicit value, as a surface may not know a theme key; it reads on light and dark.
263
+ const SAVED_COLOR = '#8b5cf6'
264
+ // The soft badge where a surface draws one (desktop, VS Code, mobile): a pale tint and the color as its text, both
265
+ // plain six-digit colors (a surface may refuse a whole drawing over a color with alpha).
266
+ const SOFT_BACKGROUND = '#ede9fe'
267
+ const SOFT_COLOR = '#6d28d9'
268
+ // A memory's kind in a word, and a day as the card says it.
269
+ const KIND_WORD: Record<string, string> = { events: 'event', entities: 'entity', preferences: 'preference', profile: 'profile' }
270
+ function fullDay(at: string) {
271
+ const date = new Date(at)
272
+ return Number.isNaN(date.getTime()) ? '' : `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`
273
+ }
274
+
275
+ // Show in thread: scroll the thread to the message where the surface lets a plugin scroll it (the terminal). The
276
+ // desktop does not, so its own Find gets the message's words (picked by the CLI to be rare in the thread) by ⌘F, ⌘V,
277
+ // and ⌘G for a later match; without the Mac's permission for that, the person is told the keys.
278
+ async function showInThread($: EngineInterface, place: { messageId?: string; messageText?: string; messageMatch?: number }, surface: RenderSurface) {
279
+ const findIt = async () => {
280
+ if (!place.messageText) return $.ui.toast('Peer: this surface cannot scroll the thread to a message')
281
+ await $.ui.copy({ text: place.messageText, surface })
282
+ const match = place.messageMatch ?? 1
283
+ if (await findInApp($, match)) return
284
+ $.ui.toast(
285
+ match > 1
286
+ ? `Copied your message. Press ⌘F, ⌘V, then ⌘G ${match - 1}× (it is match ${match}).`
287
+ : 'Copied your message. Press ⌘F, then ⌘V: the thread scrolls to it.',
288
+ )
289
+ }
290
+ if (!place.messageId) return findIt()
291
+ try {
292
+ const result = await $.ui.scroll({ to: { requestId: place.messageId }, block: 'center' })
293
+ if (result?.deny) await findIt()
294
+ } catch {
295
+ await findIt()
296
+ }
297
+ }
298
+
299
+ // The desktop's Find in page, opened and filled from the clipboard by the keys a person would press: ⌘F, then ⌘V.
300
+ // macOS only (osascript); false when it could not (no Accessibility permission, another system), so the caller
301
+ // falls back to telling the person the two keys.
302
+ async function findInApp($: EngineInterface, match: number) {
303
+ // ⌘G steps to the next match, as Enter would in the find bar, but never sends a message if the bar is not open.
304
+ const next = Array.from({ length: Math.min(Math.max(match - 1, 0), 20) }, () => ['-e', 'delay 0.15', '-e', 'tell application "System Events" to keystroke "g" using command down']).flat()
305
+ try {
306
+ const { exitCode } = await $.process.run(
307
+ ['osascript', '-e', 'tell application "System Events" to keystroke "f" using command down', '-e', 'delay 0.25', '-e', 'tell application "System Events" to keystroke "v" using command down', ...next],
308
+ { timeoutMs: 8_000 },
309
+ )
310
+ return exitCode === 0
311
+ } catch {
312
+ return false
313
+ }
156
314
  }
157
315
 
316
+ // Where a row is the person's own message: typed in the terminal, sent from the desktop or through Remote Control.
317
+ const PERSON = new Set<string | undefined>(['composer', 'sdk', 'bridge'])
318
+
158
319
  // The prompt hook exactly as `peer install` put it in Claude Code's settings, split into its arguments.
159
320
  async function promptHook($: EngineInterface): Promise<string[] | undefined> {
160
321
  const claude = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
@@ -205,7 +366,8 @@ async function loadMemories($: EngineInterface) {
205
366
 
206
367
  // One row of the memory pane. New memories read as their sentence under a name; updates of one memory across
207
368
  // saves are one row, newest first, saying what was added last.
208
- type Entry = { id: string; title: string; when: string; lines: string[] }
369
+ // messageId: the thread's row the memory came from, when it is known; the pane can scroll the thread to it.
370
+ type Entry = { id: string; title: string; when: string; lines: string[]; messageId?: string; messageText?: string; messageMatch?: number }
209
371
 
210
372
  const titled = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
211
373
  const KIND: Record<string, string> = { preferences: 'your preference', profile: 'your profile' }
@@ -221,16 +383,16 @@ function sections(saves: { at: string; items: MemoryItem[] }[]) {
221
383
  const when = clock(save.at)
222
384
  // A memory that holds Claude Code's text is listed once, where it asks for a look.
223
385
  if (item.systemText) {
224
- check.push({ id, title: titled(item.name), when, lines: item.lines })
386
+ check.push({ id, title: titled(item.name), when, lines: item.lines, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch })
225
387
  return
226
388
  }
227
389
  if (item.change === 'updated') {
228
390
  const name = KIND[item.type] ? `${titled(KIND[item.type]!)}: ${item.name}` : titled(item.name)
229
391
  const known = updated.get(name)
230
392
  if (known) known.times += 1
231
- else updated.set(name, { id, title: name, when, lines: item.lines, times: 1 })
393
+ else updated.set(name, { id, title: name, when, lines: item.lines, times: 1, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch })
232
394
  } else {
233
- const entry = { id, title: titled(item.name), when, lines: item.lines }
395
+ const entry = { id, title: titled(item.name), when, lines: item.lines, messageId: item.messageId, messageText: item.messageText, messageMatch: item.messageMatch }
234
396
  ;(item.change === 'removed' ? removed : added).push(entry)
235
397
  }
236
398
  })
@@ -239,6 +401,20 @@ function sections(saves: { at: string; items: MemoryItem[] }[]) {
239
401
  return { check, added, updated: merged, removed }
240
402
  }
241
403
 
404
+ // /peer-thread and the band's Memory button open the same pane; says whether a surface seated it.
405
+ async function openThread($: EngineInterface) {
406
+ await loadMemories($)
407
+ const opened = await $.ui.open({ id: MEMORY_PANE, title: 'This thread' })
408
+ return opened.isPlaced
409
+ }
410
+
411
+ // How many memories the thread holds, as the pane lists them: each new one, each updated one once, each to check;
412
+ // removed ones are gone.
413
+ function memoryCount(saves: { at: string; items: MemoryItem[] }[]) {
414
+ const groups = sections(saves)
415
+ return groups.check.length + groups.added.length + groups.updated.length
416
+ }
417
+
242
418
  const clock = (at: string) => {
243
419
  const date = new Date(at)
244
420
  return Number.isNaN(date.getTime()) ? at : `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
@@ -311,6 +487,8 @@ export const register: Register = on => {
311
487
  // Nothing under the prompt: clear the line an earlier version pinned there.
312
488
  $.ui.status(undefined)
313
489
  await refresh($).catch(() => {})
490
+ // A resumed thread, or one that loaded the mod later, has its Saved lines at once.
491
+ void loadMemories($).catch(() => {})
314
492
  return result
315
493
  })
316
494
 
@@ -323,35 +501,251 @@ export const register: Register = on => {
323
501
 
324
502
  on('turn.complete', async ($, e, next) => {
325
503
  await refresh($).catch(() => {})
326
- // OpenViking saves a thread's memories when a turn ends past its threshold, so the open pane looks again.
327
- if (memoryOpen) await loadMemories($).catch(() => {})
504
+ // OpenViking saves a thread's memories when a turn ends past its threshold, so the open pane and the Saved lines
505
+ // under the thread's messages look again.
506
+ await loadMemories($).catch(() => {})
507
+ return next(e)
508
+ })
509
+
510
+ // A session's first hooks run with the switch already set: OpenViking's start hook lists memories unless Elephant.
511
+ on('classic.SessionStart', async ($, e, next) => {
512
+ transcriptPath = e.transcript_path || transcriptPath
513
+ await refresh($).catch(() => {})
514
+ const use = await memoryUse($)
515
+ const result = await next(e)
516
+ if (use === 'openviking' || !result?.additionalContext) return result
517
+ return { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
518
+ })
519
+
520
+ on('tool.call', async ($, e, next) => {
521
+ if (e.tool.startsWith(OPENVIKING_TOOL) && (await memoryUse($)) !== 'openviking') {
522
+ return { deny: 'OpenViking is off in this thread: only Use Elephant in the Peer band turns it on.' }
523
+ }
328
524
  return next(e)
329
525
  })
330
526
 
527
+ // Jellyfish: no memory of Claude Code's own either, neither its instructions nor the memory files it loads.
528
+ on('prompt.section', { name: 'memory' }, async ($, e, next) => ((await memoryUse($)) === 'none' ? { text: null } : next(e)))
529
+ on('prompt.context', async ($, e, next) => {
530
+ const result = await next(e)
531
+ const files = (e.instructionFiles ?? []).filter(file => file.kind === 'memory')
532
+ if (!files.length || (await memoryUse($)) !== 'none') return result
533
+ const blocks = result.blocks.map(block => {
534
+ if (block.name !== 'claudeMd') return block
535
+ let text = block.text
536
+ for (const file of files) {
537
+ const at = text.indexOf(`Contents of ${file.path}`)
538
+ if (at < 0) continue
539
+ const end = text.indexOf('\n\nContents of ', at + 1)
540
+ text = text.slice(0, at) + (end < 0 ? '' : text.slice(end + 2))
541
+ }
542
+ return { ...block, text }
543
+ })
544
+ return { ...result, blocks }
545
+ })
546
+
547
+ // What reached the model with a prompt: the hooks beneath (Peer's from settings, OpenViking's plugin) answer
548
+ // first, then the mod reads their additionalContext and keeps it for the prompt's row. It changes nothing.
549
+ on('classic.UserPromptSubmit', async ($, e, next) => {
550
+ transcriptPath = e.transcript_path || transcriptPath
551
+ await refresh($).catch(() => {})
552
+ const use = await memoryUse($)
553
+ let result = await next(e)
554
+ if (use !== 'openviking' && result?.additionalContext) {
555
+ result = { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
556
+ }
557
+ try {
558
+ const context = (result?.additionalContext ?? []).join('\n')
559
+ const items = [...teamItems(context), ...mineItems(context)]
560
+ if (items.length) {
561
+ const recall: Recall = { prompt: String(e.prompt ?? '').trim(), items }
562
+ await update($, recalls, was => [...(was ?? []), recall].slice(-RECALLS_KEPT))
563
+ }
564
+ } catch {}
565
+ return result
566
+ })
567
+
568
+ // Under the person's prompt, quiet lines: what was recalled for it, and what OpenViking saved from it. A press
569
+ // opens either list; nothing shows for a prompt that neither touched.
570
+ on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
571
+ // The person's own messages only: the terminal stamps them composer, the desktop (an SDK host) sdk, Remote
572
+ // Control bridge; notifications and other sessions' messages draw as they are.
573
+ if (!PERSON.has(e.props.origin?.kind)) return next(e)
574
+ const drawn = await next(e)
575
+ const text = String(e.props.text ?? '').trim()
576
+ const recall = [...((await read($, recalls)) ?? [])].reverse().find(r => r.prompt === text)
577
+ const thread = await read($, memories)
578
+ const saved = thread && 'saves' in thread ? thread.saves.flatMap(save => save.items.map(item => ({ ...item, at: save.at }))).filter(item => item.messageId === e.requestId) : []
579
+ if (drawn === undefined) return drawn
580
+ // What the transcript says came with this message: recalls a hook added, and the agent asking OpenViking. It
581
+ // covers messages from before the mod loaded; the live record covers a prompt the transcript has not caught up on.
582
+ const usedHere = thread && 'saves' in thread ? (thread.uses ?? []).filter(use => use.messageId === e.requestId) : []
583
+ const fromTranscript: RecallItem[] = usedHere
584
+ .filter(use => use.where === 'prompt')
585
+ .flatMap(use => use.items.map(title => ({ from: use.from === 'team' ? ('team' as const) : ('mine' as const), title, detail: use.from === 'team' ? "teammate's page" : 'your memory', uri: '' })))
586
+ const asked = usedHere.filter(use => use.where === 'tool')
587
+ if (!recall && !saved.length && !fromTranscript.length && !asked.length) return drawn
588
+ const { Box, Button, Markdown, Text } = $.ui.resolve(e)
589
+ const id = `memory-${e.requestId}`
590
+ const count = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n} ${many}`)
591
+ const isOpen = (await read($, openRecall)) === id
592
+
593
+ // What happened, said once: new memories by name; updates and recalls as counts.
594
+ const fromHere = saved.filter(item => item.exact)
595
+ const nearHere = saved.filter(item => !item.exact)
596
+ const recalled = fromTranscript.length ? fromTranscript : recall?.items ?? []
597
+ // The badge names the main thing (saved, else updated, else recalled); the words beside it name the rest.
598
+ const headline = fromHere.length ? 'Memory saved' : nearHere.length ? 'Memory updated' : recalled.length ? 'Memory recalled' : 'Memory asked'
599
+ const summary = [
600
+ fromHere.length && fromHere.map(item => item.name).join(', '),
601
+ nearHere.length && (fromHere.length ? `updated ${nearHere.length}` : nearHere.map(item => item.name).join(', ')),
602
+ recalled.length && (fromHere.length || nearHere.length ? `recalled ${recalled.length}` : count(recalled.length, 'memory', 'memories')),
603
+ asked.length && (fromHere.length || nearHere.length || recalled.length ? `asked ${asked.length}` : asked.map(use => use.kind).join(', ')),
604
+ ]
605
+ .filter(Boolean)
606
+ .join(' · ')
607
+
608
+ // A badge: on the terminal solid, elsewhere a soft tint of the memory color with the color as its text.
609
+ const pill = (word: string) =>
610
+ e.surface === 'terminal' ? (
611
+ <Text backgroundColor={SAVED_COLOR} color="#ffffff" bold size="caption">{` ${word} `}</Text>
612
+ ) : (
613
+ <Text backgroundColor={SOFT_BACKGROUND} color={SOFT_COLOR} bold size="caption">{` ${word} `}</Text>
614
+ )
615
+ // One memory as a card: its badge and name, the time right; the day and its kind under them; then its own
616
+ // text as written, headings, lists and code included.
617
+ const card = (key: string, word: string, title: RenderChildren, time: string, meta: string, body?: string) => (
618
+ <Box key={key} flexDirection="column" marginTop={1}>
619
+ <Box justifyContent="space-between" gap={1}>
620
+ <Box gap={1}>
621
+ {pill(word)}
622
+ {title}
623
+ </Box>
624
+ <Text dimColor tabular size="footnote">{time}</Text>
625
+ </Box>
626
+ <Text dimColor size="footnote">{meta}</Text>
627
+ {body ? <Markdown key={`${key}-body`} text={body} /> : null}
628
+ </Box>
629
+ )
630
+ const savedCard = (memory: MemoryItem & { at: string }, index: number) =>
631
+ card(
632
+ `s-${e.requestId}-${index}`,
633
+ memory.change === 'added' ? 'Saved' : memory.change === 'removed' ? 'Forgotten' : 'Updated',
634
+ <Text bold wrap="truncate-end">{titled(memory.name)}</Text>,
635
+ clock(memory.at),
636
+ [fullDay(memory.at), KIND_WORD[memory.type] ?? memory.type, memory.exact ? '' : 'near this message'].filter(Boolean).join(' · '),
637
+ memory.body || memory.lines.join('\n'),
638
+ )
639
+ // A recalled memory's name is its button (a button holds text only): a press copies its address.
640
+ const recallCard = (memory: RecallItem, index: number) =>
641
+ card(
642
+ `r-${e.requestId}-${index}`,
643
+ 'Recalled',
644
+ <Button
645
+ key={`r-${e.requestId}-${index}`}
646
+ plain
647
+ onPress={press => {
648
+ if (!memory.uri) return
649
+ void $.ui.copy({ text: memory.uri, surface: press.surface })
650
+ $.ui.toast('Copied its address')
651
+ }}
652
+ >
653
+ <Text bold wrap="truncate-end">{memory.title}</Text>
654
+ </Button>,
655
+ '',
656
+ `${memory.from === 'team' ? 'From a teammate' : 'From your memory'} · ${memory.detail}`,
657
+ )
658
+
659
+ return (
660
+ <Box flexDirection="column">
661
+ {drawn}
662
+ {/* The one accent: the badge, beside the button, as a surface draws a button's own text plainly. */}
663
+ <Box gap={1}>
664
+ {pill(headline)}
665
+ <Button key={id} plain onPress={() => void update($, openRecall, was => (was === id ? null : id))}>
666
+ <Text dimColor size="footnote" wrap="truncate-end">{`${summary} ${isOpen ? '▾' : '▸'}`}</Text>
667
+ </Button>
668
+ </Box>
669
+ {isOpen && (
670
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
671
+ {fromHere.map(savedCard)}
672
+ {nearHere.map((memory, index) => savedCard(memory, fromHere.length + index))}
673
+ {recalled.map(recallCard)}
674
+ {asked.map((use, index) =>
675
+ card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `OpenViking ${use.kind}`),
676
+ )}
677
+ </Box>
678
+ )}
679
+ </Box>
680
+ )
681
+ })
682
+
331
683
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332
684
  if (!looked) {
333
685
  looked = true
334
686
  await refresh($).catch(() => {})
335
687
  }
336
688
  const now = await read($, shared)
337
- if (e.props.hasSurvey || now === null) return next(e)
689
+ // The band leads with this thread's memory: its count in a square, a press away from the /peer-thread pane. It
690
+ // shows once the memory answers or the thread was shared here; the workspace goes unnamed (the thread is in one),
691
+ // and the one control changes in place, stop to start and back.
692
+ const saved = await read($, memories)
693
+ const total = saved && 'saves' in saved ? memoryCount(saved.saves) : undefined
694
+ if (e.props.hasSurvey || (now === null && total === undefined)) return next(e)
695
+ const isShared = now?.isShared ?? false
696
+ const kind = await read($, mode)
338
697
 
339
698
  const { Box, Button, Text } = $.ui.resolve(e)
340
- // One band in both states: the label's own surface says the state (accent when shared, gray when not),
341
- // and the one control changes in place, stop to start and back.
699
+ // A hint is the band's tooltip: a line under the controls, hidden until the pointer is on its control's scope.
700
+ const hint = (scope: string, text: string) => (
701
+ <Box key={`hint-${scope}`} display="none" hover={{ scope, display: 'flex' }}>
702
+ <Text dimColor size="footnote" wrap="truncate-end">{text}</Text>
703
+ </Box>
704
+ )
705
+ const count = total === undefined ? '–' : String(total)
706
+ const noun = total === 1 ? 'memory' : 'memories'
707
+ // A mode is lit only while the thread is shared: pressing the lit one stops sharing (Inactive), pressing
708
+ // either while Inactive picks it and shares again (Active), pressing the other while Active only switches.
709
+ const pick = async (key: Mode) => {
710
+ if (isShared && kind === key) return send($, '/peer stop')
711
+ await update($, mode, () => key)
712
+ if (isShared) await applyMemory($)
713
+ else await send($, '/peer')
714
+ }
715
+ // A desktop draws a Button's label as its own native button and leaves Text inside it unstyled, so the lit
716
+ // mode is the primary button and the other the secondary; the terminal draws the primary in the accent color.
717
+ const animal = (key: Mode, name: string) => (
718
+ <Box key={key} hover={{ scope: `peer-${key}` }}>
719
+ <Button key={key} label={name} variant={isShared && kind === key ? 'primary' : 'secondary'} onPress={() => void pick(key)} />
720
+ </Box>
721
+ )
722
+ const modeHint = (key: Mode, about: string) =>
723
+ `${about} ${isShared && kind === key ? 'Press again to turn Peer off for this thread.' : isShared ? 'Press to switch.' : 'Press to turn Peer on with it.'}`
342
724
  return (
343
- <Box>
344
- <Text backgroundColor={now.isShared ? 'suggestion' : 'inactive'} color="inverseText" bold>
345
- {' Peer '}
346
- </Text>
347
- <Text bold={now.isShared} dimColor={!now.isShared}>{now.workspace ? ` ${now.workspace} ` : ' '}</Text>
348
- <Button
349
- key="toggle"
350
- label={now.isShared ? '/peer stop' : '/peer start'}
351
- plain
352
- dimColor
353
- onPress={() => send($, now.isShared ? '/peer stop' : '/peer')}
354
- />
725
+ <Box flexDirection="column">
726
+ <Box width="100%" justifyContent="space-between" gap={2}>
727
+ <Box gap={2}>
728
+ <Box key="memory" hover={{ scope: 'peer-memory' }}>
729
+ <Button key="memory" label={`${count} ${noun}`} plain onPress={async () => { memoryOpen = await openThread($) }}>
730
+ <Text bold tabular>{count}</Text>
731
+ <Text>{` ${noun}`}</Text>
732
+ </Button>
733
+ </Box>
734
+ <Box gap={1}>
735
+ {animal('elephant', 'Use Elephant')}
736
+ {animal('jellyfish', 'Use Jellyfish')}
737
+ </Box>
738
+ </Box>
739
+ {/* The dot is Text beside the Button, where both surfaces keep its color. */}
740
+ <Box key="toggle" gap={1} alignItems="center" hover={{ scope: 'peer-share' }}>
741
+ <Text color={isShared ? 'success' : 'inactive'}>{isShared ? '●' : '○'}</Text>
742
+ <Button key="toggle" label={isShared ? 'Active' : 'Inactive'} plain dimColor={!isShared} onPress={() => send($, isShared ? '/peer stop' : '/peer')} />
743
+ </Box>
744
+ </Box>
745
+ {hint('peer-memory', total ? `What this thread has saved to your memory. Open the list.` : 'Nothing saved from this thread yet. Open the list.')}
746
+ {hint('peer-elephant', modeHint('elephant', 'Elephant never forgets: this thread saves what it learns and recalls your memories.'))}
747
+ {hint('peer-jellyfish', modeHint('jellyfish', 'Jellyfish lives in the moment: this thread saves nothing and recalls nothing.'))}
748
+ {hint('peer-share', isShared ? 'Shared with your Peer workspace: teammates see this thread. Press to stop sharing (/peer stop).' : 'Not shared. Press to share this thread with your Peer workspace (/peer).')}
355
749
  </Box>
356
750
  )
357
751
  })
@@ -368,10 +762,9 @@ export const register: Register = on => {
368
762
  })
369
763
 
370
764
  on('command.run', { command: 'peer-thread' }, async ($, e, next) => {
371
- await loadMemories($)
372
- const opened = await $.ui.open({ id: MEMORY_PANE, title: 'This thread' })
373
- memoryOpen = opened.isPlaced
374
- return { text: opened.isPlaced ? "Peer: this thread's memories are in the pane." : 'Peer: no room for a pane here; `peer thread <session id>` prints the same list.' }
765
+ const placed = await openThread($)
766
+ memoryOpen = placed
767
+ return { text: placed ? "Peer: this thread's memories are in the pane." : 'Peer: no room for a pane here; `peer thread <session id>` prints the same list.' }
375
768
  })
376
769
 
377
770
  // /peer-all opens the search pane on the words typed after it; without room for a pane, the hook answers.
@@ -639,16 +1032,29 @@ export const register: Register = on => {
639
1032
  <Text dimColor wrap="wrap">
640
1033
  {entry.lines.join(' ') || '(no text)'}
641
1034
  </Text>
642
- <Button
643
- key={`${id}-copy`}
644
- label="Copy"
645
- plain
646
- dimColor
647
- onPress={press => {
648
- void $.ui.copy({ text: [entry.title, ...entry.lines].join('\n'), surface: press.surface })
649
- $.ui.toast('Copied')
650
- }}
651
- />
1035
+ <Box gap={2}>
1036
+ {entry.messageId && (
1037
+ // The thread scrolls to the message the memory came from, where the surface lets a plugin scroll
1038
+ // it (the terminal). The desktop does not: there the message's words go to the clipboard, and
1039
+ // the app's own Find in page (⌘F) scrolls to them.
1040
+ <Button
1041
+ key={`${id}-show`}
1042
+ label="Show in thread"
1043
+ plain
1044
+ onPress={press => void showInThread($, entry, press.surface)}
1045
+ />
1046
+ )}
1047
+ <Button
1048
+ key={`${id}-copy`}
1049
+ label="Copy"
1050
+ plain
1051
+ dimColor
1052
+ onPress={press => {
1053
+ void $.ui.copy({ text: [entry.title, ...entry.lines].join('\n'), surface: press.surface })
1054
+ $.ui.toast('Copied')
1055
+ }}
1056
+ />
1057
+ </Box>
652
1058
  </Box>
653
1059
  )}
654
1060
  </Box>
@@ -665,6 +1071,39 @@ export const register: Register = on => {
665
1071
  </Box>
666
1072
  )
667
1073
 
1074
+ // Where memory went into the thread, oldest first: one line each, what and when; a press opens which memories,
1075
+ // and Show in thread where it came with a message.
1076
+ const uses = now.uses ?? []
1077
+ const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked OpenViking' }
1078
+ const useRow = (use: MemoryUse, index: number) => {
1079
+ const id = `use-${index}`
1080
+ const isOpen = open === id
1081
+ const n = use.items.length
1082
+ const amount =
1083
+ use.from === 'team'
1084
+ ? n === 1 ? "1 teammate's page" : `${n} teammates' pages`
1085
+ : use.kind === 'listed' ? `${n === 1 ? '1 memory' : `${n} memories`} listed` : `${n} recalled`
1086
+ const what = use.where === 'tool' ? `${USE_WORDS.tool}: ${use.kind}` : `${USE_WORDS[use.where] ?? use.where} · ${amount}`
1087
+ return (
1088
+ <Box key={id} flexDirection="column">
1089
+ <Box justifyContent="space-between" gap={1}>
1090
+ <Button key={id} plain onPress={() => void update($, openMemory, was => (was === id ? null : id))}>
1091
+ <Text bold={isOpen} wrap="truncate-end">{what}</Text>
1092
+ </Button>
1093
+ <Text dimColor tabular size="footnote">{clock(use.at)}</Text>
1094
+ </Box>
1095
+ {isOpen && (
1096
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
1097
+ {use.items.map((item, i) => (
1098
+ <Text key={`${id}-${i}`} dimColor size="footnote" wrap="truncate-end">{`· ${item}`}</Text>
1099
+ ))}
1100
+ {use.messageId && <Button key={`${id}-show`} label="Show in thread" plain onPress={press => void showInThread($, use, press.surface)} />}
1101
+ </Box>
1102
+ )}
1103
+ </Box>
1104
+ )
1105
+ }
1106
+
668
1107
  return (
669
1108
  <Box flexDirection="column">
670
1109
  <Box justifyContent="space-between" gap={1}>
@@ -674,6 +1113,15 @@ export const register: Register = on => {
674
1113
  {reload}
675
1114
  </Box>
676
1115
  </Box>
1116
+ {uses.length > 0 && (
1117
+ <Box flexDirection="column" marginTop={1}>
1118
+ <Box justifyContent="space-between">
1119
+ <Text dimColor size="caption">Used</Text>
1120
+ <Text dimColor tabular size="caption">{String(uses.length)}</Text>
1121
+ </Box>
1122
+ {uses.map(useRow)}
1123
+ </Box>
1124
+ )}
677
1125
  {total === 0 && <Text dimColor>Nothing saved yet. OpenViking saves after longer turns.</Text>}
678
1126
  {section('Check', groups.check, true)}
679
1127
  {section('New', groups.added)}