@webinson/peer 0.4.14 → 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.
package/README.md CHANGED
@@ -18,9 +18,19 @@ In a Claude Code thread:
18
18
  - `/peer <message>` shares it and passes the message on to the agent as your prompt.
19
19
  - `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
20
20
  is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
21
- - In a shared thread a band above the prompt says `Peer <workspace>` with `/peer stop` to click; after a stop it
22
- turns gray with `/peer start`. It is a Claude Code plugin in this package (`mod/`) that `peer install` adds;
23
- the desktop app draws it only while you type or a turn runs.
21
+ - A band above the prompt shows the thread's memories (`17 memories`), `Use Elephant` and `Use Jellyfish`, and
22
+ `● Active` (shared) or `○ Inactive` at the right; hovering a control explains it. The modes decide what memory
23
+ the thread uses:
24
+ - **Use Elephant** (shared): your OpenViking memory, recalled with each prompt and saved as the thread goes, on
25
+ top of Claude Code's own memory. It saves only the turns from the moment it was chosen.
26
+ - **Inactive** (not shared): Claude Code's own memory alone; OpenViking stays off.
27
+ - **Use Jellyfish** (shared): no memory at all, neither OpenViking nor Claude Code's own (`MEMORY.md`).
28
+ `CLAUDE.md` instructions stay.
29
+
30
+ Pressing the lit mode again stops sharing (Inactive); pressing either while Inactive shares again. Every thread
31
+ that is not shared runs without OpenViking. What a thread was already given stays in it after a switch. The
32
+ band is a Claude Code plugin in this package (`mod/`) that `peer install` adds; the desktop app draws it only
33
+ while you type or a turn runs.
24
34
  - A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
25
35
  back if Claude Code renames the thread, and `/peer stop` takes it off.
26
36
  - `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-thread-mark",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt, with /peer stop and /peer start; panes for /peer-list, /peer-thread and /peer-all (search your memories)",
5
5
  "license": "MIT",
6
6
  "types": "./types/index.d.ts"
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register, RenderChildren, RenderNode, RenderSurface } from 'claude-code'
3
3
 
4
- import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, MemoryUse, Recall, RecallItem, 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`;
@@ -28,6 +28,8 @@ const RECALLS_KEPT = 30
28
28
  const recalls = atom({ plugin: 'peer-thread-mark', key: 'recalls' } as const, [], { shape: 'recalls-1' })
29
29
  // The one prompt whose recall line is open, by its message id.
30
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')
31
33
 
32
34
  // Peer's team memory block: `- [verified pitfall] Title`, a line of text, then `(who, date, uri)`.
33
35
  function teamItems(context: string): RecallItem[] {
@@ -190,8 +192,73 @@ async function refresh($: EngineInterface) {
190
192
  if (before?.workspace !== now?.workspace || before?.isShared !== now?.isShared) {
191
193
  await update($, shared, () => now)
192
194
  }
195
+ await applyMemory($)
193
196
  }
194
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
+
195
262
  // The memory badge's color: an explicit value, as a surface may not know a theme key; it reads on light and dark.
196
263
  const SAVED_COLOR = '#8b5cf6'
197
264
  // The soft badge where a surface draws one (desktop, VS Code, mobile): a pale tint and the color as its text, both
@@ -440,10 +507,53 @@ export const register: Register = on => {
440
507
  return next(e)
441
508
  })
442
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
+ }
524
+ return next(e)
525
+ })
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
+
443
547
  // What reached the model with a prompt: the hooks beneath (Peer's from settings, OpenViking's plugin) answer
444
548
  // first, then the mod reads their additionalContext and keeps it for the prompt's row. It changes nothing.
445
549
  on('classic.UserPromptSubmit', async ($, e, next) => {
446
- const result = await next(e)
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
+ }
447
557
  try {
448
558
  const context = (result?.additionalContext ?? []).join('\n')
449
559
  const items = [...teamItems(context), ...mineItems(context)]
@@ -583,23 +693,59 @@ export const register: Register = on => {
583
693
  const total = saved && 'saves' in saved ? memoryCount(saved.saves) : undefined
584
694
  if (e.props.hasSurvey || (now === null && total === undefined)) return next(e)
585
695
  const isShared = now?.isShared ?? false
696
+ const kind = await read($, mode)
586
697
 
587
698
  const { Box, Button, Text } = $.ui.resolve(e)
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.'}`
588
724
  return (
589
- <Box gap={2}>
590
- <Button key="memory" label={total === undefined ? 'Memory' : `Memory, ${total}`} plain onPress={async () => { memoryOpen = await openThread($) }}>
591
- <Text bold>{'Memory '}</Text>
592
- <Text backgroundColor={total ? 'suggestion' : 'inactive'} color="inverseText" bold tabular>
593
- {` ${total ?? '–'} `}
594
- </Text>
595
- </Button>
596
- <Button
597
- key="toggle"
598
- label={isShared ? '/peer stop' : '/peer start'}
599
- plain
600
- dimColor
601
- onPress={() => send($, isShared ? '/peer stop' : '/peer')}
602
- />
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).')}
603
749
  </Box>
604
750
  )
605
751
  })
@@ -26,11 +26,25 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
26
26
  on('fs.read', (_, e) => {
27
27
  const path = String((e as { path?: string }).path ?? '')
28
28
  if (path.endsWith('settings.json')) return { value: JSON.stringify(settings) } as never
29
- if (path.endsWith('/sessions/s1')) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
29
+ if (path.endsWith('/sessions/s1') && !stopped) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
30
30
  throw new Error('none')
31
31
  })
32
- on('process.run', () => ({ value: { stdout: JSON.stringify(SAVES), stderr: '', exitCode: 0 } }) as never)
32
+ // The CLI's hook: `/peer stop` removes the session file, `/peer` writes it back.
33
+ let stopped = false
34
+ const sent: string[] = []
35
+ on('process.run', (_, e) => {
36
+ const stdin = (e as { init?: { stdin?: string } }).init?.stdin
37
+ const prompt = stdin ? (JSON.parse(stdin) as { prompt?: string }).prompt : undefined
38
+ if (prompt) {
39
+ sent.push(prompt)
40
+ stopped = prompt === '/peer stop'
41
+ return { value: { stdout: '{}', stderr: '', exitCode: 0 } } as never
42
+ }
43
+ return { value: { stdout: JSON.stringify(SAVES), stderr: '', exitCode: 0 } } as never
44
+ })
33
45
  on('session.id', () => ({ value: 's1' }) as never)
46
+ on('env.set', () => ({ value: {} }) as never)
47
+ on('ui.invalidate', () => ({ value: undefined }) as never)
34
48
  on('clock.now', () => ({ value: 1 }) as never)
35
49
  const opened: string[] = []
36
50
  on('ui.open', (_, e) => { opened.push((e as { id: string }).id); return { value: { isPlaced: true } } as never })
@@ -43,7 +57,9 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
43
57
 
44
58
  // Before the thread's memories have loaded the square holds a dash; a press opens the pane and loads them.
45
59
  const before = await $.ui.mount({ ...BAND, surface: 'desktop' })
46
- expect((await before.findAll({ type: 'Text' })).map(t => t.text)).toEqual(['Memory ', ' – '])
60
+ const first = (await before.findAll({ type: 'Text' })).map(t => t.text)
61
+ expect(first.slice(0, 2)).toEqual(['–', ' memories'])
62
+ expect(first).toContain('●')
47
63
  await before.press({ key: 'memory' } as never)
48
64
  await before.unmount()
49
65
  expect(opened).toEqual(['peer-thread-memories'])
@@ -52,8 +68,30 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
52
68
  const ui = await $.ui.mount({ ...BAND, surface })
53
69
  const texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
54
70
  // New, updated once (two saves of one memory), the removed one gone: 2.
55
- expect(texts.slice(0, 2)).toEqual(['Memory ', ' 2 '])
71
+ expect(texts.slice(0, 2)).toEqual(['2', ' memories'])
56
72
  expect(texts.some(t => t.includes('hotovo'))).toBe(false)
57
73
  await ui.unmount()
58
74
  }
75
+
76
+ // Elephant is lit while shared; pressing Jellyfish only switches, pressing the lit one again stops sharing,
77
+ // and pressing it while Inactive shares again.
78
+ const lit = async (ui: { findAll: (q: { type: 'Button' }) => Promise<{ props?: { label?: string; variant?: string } }[]> }) =>
79
+ (await ui.findAll({ type: 'Button' })).filter(b => b.props?.variant === 'primary').map(b => b.props?.label)
80
+ const modes = await $.ui.mount({ ...BAND, surface: 'desktop' })
81
+ expect(await lit(modes as never)).toEqual(['Use Elephant'])
82
+ await modes.press({ key: 'jellyfish' } as never)
83
+ expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
84
+ expect(sent).toEqual([])
85
+ await modes.press({ key: 'jellyfish' } as never)
86
+ expect(sent).toEqual(['/peer stop'])
87
+ let texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
88
+ expect(texts).toContain('○')
89
+ expect(await modes.findAll({ type: 'Button' } as never)).toContainEqual(expect.objectContaining({ props: expect.objectContaining({ label: 'Inactive' }) }))
90
+ expect(await lit(modes as never)).toEqual([])
91
+ await modes.press({ key: 'jellyfish' } as never)
92
+ expect(sent).toEqual(['/peer stop', '/peer'])
93
+ texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
94
+ expect(texts).toContain('●')
95
+ expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
96
+ await modes.unmount()
59
97
  })
@@ -0,0 +1,98 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ const BAND = {
4
+ plugin: 'peer-thread-mark',
5
+ component: 'AbovePrompt',
6
+ props: { hasSurvey: false, isWorking: false, maxRows: 12 },
7
+ } as const
8
+
9
+ const MEMORY_FILE = '/home/.claude/projects/p/memory/MEMORY.md'
10
+ const CLAUDE_MD = `Contents of /home/.claude/CLAUDE.md (user's private global instructions):\n\nBe brief.\n\nContents of ${MEMORY_FILE} (user's auto-memory):\n\n- a fact`
11
+
12
+ test('Elephant turns OpenViking on, Inactive keeps Claude Code memory alone, Jellyfish keeps none', async ($, on) => {
13
+ const settings = { hooks: { UserPromptSubmit: [{ hooks: [{ command: '"/n/node" "/p/peer.mjs" hook prompt # peer-hook' }] }] } }
14
+ // The CLI's hook: `/peer stop` removes the session file, `/peer` writes it back.
15
+ let stopped = false
16
+ on('env.get', () => ({ value: '/home' }) as never)
17
+ on('fs.read', (_, e) => {
18
+ const path = String((e as { path?: string }).path ?? '')
19
+ if (path.endsWith('settings.json')) return { value: JSON.stringify(settings) } as never
20
+ if (path.endsWith('/sessions/s1') && !stopped) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
21
+ throw new Error('none')
22
+ })
23
+ const runs: string[][] = []
24
+ on('process.run', (_, e) => {
25
+ runs.push([...(e as { argv: string[] }).argv])
26
+ const stdin = (e as { init?: { stdin?: string } }).init?.stdin
27
+ const prompt = stdin ? (JSON.parse(stdin) as { prompt?: string }).prompt : undefined
28
+ if (prompt) stopped = prompt === '/peer stop'
29
+ return { value: { stdout: prompt ? '{}' : JSON.stringify({ connected: true, saves: [] }), stderr: '', exitCode: 0 } } as never
30
+ })
31
+ const env: Record<string, string | undefined> = {}
32
+ on('env.set', (_, e) => {
33
+ const { name, value } = e as { name: string; value?: string }
34
+ env[name] = value
35
+ return { value: {} } as never
36
+ })
37
+ on('session.id', () => ({ value: 's1' }) as never)
38
+ on('session.cwd', () => ({ value: '/repo' }) as never)
39
+ on('clock.now', () => ({ value: 1 }) as never)
40
+ on('command.register', (_, e) => ({ value: { command: (e as { name: string }).name } }) as never)
41
+ on('ui.status', () => ({ value: undefined }) as never)
42
+ on('ui.invalidate', () => ({ value: undefined }) as never)
43
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
44
+ // Beneath the mod: OpenViking's recall and a hook of the person's own, a tool that answers, core's memory.
45
+ on('classic.UserPromptSubmit', () => ({ additionalContext: ['<openviking-context>- [memory 80%] x</openviking-context>', 'kept'] }) as never)
46
+ let toolRuns = 0
47
+ on('tool.call', () => { toolRuns++; return { result: { content: 'ok' } } as never })
48
+ on('prompt.section', () => ({ text: 'how memory works' }) as never)
49
+ on('prompt.context', (_, e) => ({ blocks: (e as { blocks: unknown[] }).blocks }) as never)
50
+
51
+ const context = () =>
52
+ $.prompt.context({
53
+ blocks: [{ name: 'claudeMd', text: CLAUDE_MD }],
54
+ instructionFiles: [{ path: MEMORY_FILE, kind: 'memory', content: '- a fact' }],
55
+ })
56
+ const search = () => $.tool.call({ tool: 'mcp__plugin_openviking-memory_openviking__search', query: 'x' } as never)
57
+ const recalled = async () => (await $.classic.UserPromptSubmit({ prompt: 'hi', transcript_path: '/t/s1.jsonl' } as never)).additionalContext
58
+ const cursorMoves = () => runs.filter(argv => argv.includes('--input-type=module'))
59
+
60
+ await $.session.start({ cwd: '/', surface: 'desktop', isInteractive: true })
61
+
62
+ // Shared, with Elephant: OpenViking on, its recall reaches the model, its tools run, Claude Code's memory stays.
63
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
64
+ expect(env.OPENVIKING_AUTO_CAPTURE).toBe('1')
65
+ expect(await recalled()).toHaveLength(2)
66
+ await search()
67
+ expect(toolRuns).toBe(1)
68
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
69
+
70
+ // Jellyfish: OpenViking off, its recall and tools held back, and no Claude Code memory either.
71
+ const band = await $.ui.mount({ ...BAND, surface: 'desktop' })
72
+ await band.press({ key: 'jellyfish' } as never)
73
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
74
+ expect(env.OPENVIKING_AUTO_CAPTURE).toBeUndefined()
75
+ expect(await recalled()).toEqual(['kept'])
76
+ await search()
77
+ expect(toolRuns).toBe(1)
78
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBeNull()
79
+ const kept = (await context()).blocks.find(b => b.name === 'claudeMd')?.text ?? ''
80
+ expect(kept).toContain('Be brief.')
81
+ expect(kept).not.toContain('a fact')
82
+
83
+ // Pressing the lit Jellyfish stops sharing: Inactive, OpenViking still off, Claude Code's own memory back.
84
+ await band.press({ key: 'jellyfish' } as never)
85
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
86
+ expect(await recalled()).toEqual(['kept'])
87
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
88
+ expect((await context()).blocks.find(b => b.name === 'claudeMd')?.text).toContain('a fact')
89
+
90
+ // Use Elephant from Inactive shares again, with OpenViking on, and its capture starts at the turns there are now,
91
+ // so what was said under Jellyfish and Inactive is never saved. Starting with Elephant moved nothing.
92
+ expect(cursorMoves()).toHaveLength(0)
93
+ await band.press({ key: 'elephant' } as never)
94
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
95
+ expect(cursorMoves()).toHaveLength(1)
96
+ expect(cursorMoves()[0]?.slice(-3)).toEqual(['s1', '/t/s1.jsonl', '/repo'])
97
+ await band.unmount()
98
+ })
@@ -29,7 +29,12 @@ test('a prompt shows under it what Peer and OpenViking recalled for it, and chan
29
29
  on('classic.UserPromptSubmit', () => ({ additionalContext: context }) as never)
30
30
  on('ui.render', (_, e) => ({ type: 'Text', props: {}, children: [String((e as { props: { text: string } }).props.text)] }) as never)
31
31
  on('env.get', () => ({ value: '/home' }) as never)
32
- on('fs.read', () => { throw new Error('none') })
32
+ // A thread shared with Use Elephant: OpenViking's recall and teammates' pages reach the model.
33
+ on('fs.read', (_, e) => {
34
+ if (String((e as { path?: string }).path ?? '').endsWith('/sessions/s1')) return { value: JSON.stringify({ workspace: 'acme' }) } as never
35
+ throw new Error('none')
36
+ })
37
+ on('env.set', () => ({ value: {} }) as never)
33
38
  on('session.id', () => ({ value: 's1' }) as never)
34
39
  on('clock.now', () => ({ value: 1 }) as never)
35
40
  let copied = ''
@@ -63,8 +63,11 @@ export type AllMemories = { ask: AllQuery; loading: boolean; found?: AllFound; e
63
63
  export type RecallItem = { from: 'team' | 'mine'; title: string; detail: string; uri: string }
64
64
  export type Recall = { prompt: string; items: RecallItem[] }
65
65
 
66
+ // The band's memory mode for a thread: Elephant saves and recalls, Jellyfish neither.
67
+ export type Mode = 'elephant' | 'jellyfish'
68
+
66
69
  declare module 'claude-code' {
67
70
  interface PluginState {
68
- 'peer-thread-mark': { shared: Shared; board: Shaped<Board>; memories: Shaped<Memories>; all: Shaped<AllMemories>; openMemory: string | null; openAll: string | null; recalls: Shaped<Recall[]>; openRecall: string | null }
71
+ 'peer-thread-mark': { shared: Shared; board: Shaped<Board>; memories: Shaped<Memories>; all: Shaped<AllMemories>; openMemory: string | null; openAll: string | null; recalls: Shaped<Recall[]>; openRecall: string | null; mode: Mode }
69
72
  }
70
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webinson/peer",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Share chosen Claude Code threads with your team: who has agents running and on what.",
5
5
  "license": "MIT",
6
6
  "type": "module",