@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 +13 -3
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +1 -1
- package/mod/peer-thread-mark/hooks/register.tsx +162 -16
- package/mod/peer-thread-mark/tests/band.test.tsx +42 -4
- package/mod/peer-thread-mark/tests/modes.test.tsx +98 -0
- package/mod/peer-thread-mark/tests/recall.test.tsx +6 -1
- package/mod/peer-thread-mark/types/index.d.ts +4 -1
- package/package.json +1 -1
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
|
-
-
|
|
22
|
-
|
|
23
|
-
the
|
|
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.
|
|
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
|
-
|
|
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
|
|
590
|
-
<
|
|
591
|
-
<
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(['
|
|
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
|
-
|
|
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
|
}
|