@webinson/peer 0.4.14 → 0.4.16
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 +195 -23
- package/mod/peer-thread-mark/tests/band.test.tsx +47 -4
- package/mod/peer-thread-mark/tests/modes.test.tsx +99 -0
- package/mod/peer-thread-mark/tests/recall.test.tsx +6 -1
- package/mod/peer-thread-mark/tests/saved.test.tsx +2 -2
- 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
|
-
|
|
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; a press says in a toast what the thread now uses. The modes
|
|
23
|
+
decide what memory the thread uses:
|
|
24
|
+
- **Use Elephant** (shared): PeerMemory (your personal OpenViking), recalled with each prompt and saved as the
|
|
25
|
+
thread goes, on 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; PeerMemory stays off.
|
|
27
|
+
- **Use Jellyfish** (shared): no memory at all, neither PeerMemory 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.16",
|
|
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
|
|
@@ -386,7 +453,8 @@ function projects(people: { email: string; threads: Thread[] }[]) {
|
|
|
386
453
|
|
|
387
454
|
// A press does what typing the command does: the CLI's own hook shares or stops the thread and says so.
|
|
388
455
|
// (A plugin may not submit a prompt that starts with `/`, so the hook is run directly.)
|
|
389
|
-
|
|
456
|
+
// `said`, when given, is the toast in place of the CLI's own line, once the press is done; a failure still says why.
|
|
457
|
+
async function send($: EngineInterface, prompt: string, said?: () => Promise<string>) {
|
|
390
458
|
try {
|
|
391
459
|
const argv = await promptHook($)
|
|
392
460
|
if (!argv) {
|
|
@@ -396,11 +464,23 @@ async function send($: EngineInterface, prompt: string) {
|
|
|
396
464
|
const input = { session_id: await $.session.id(), cwd: await $.env.get('PWD'), prompt }
|
|
397
465
|
const { stdout } = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: 10_000 })
|
|
398
466
|
const reason = JSON.parse(stdout.trim().split('\n').pop() || '{}').reason
|
|
399
|
-
if (reason) $.ui.toast(String(reason))
|
|
467
|
+
if (reason && !said) $.ui.toast(String(reason))
|
|
400
468
|
} catch (error) {
|
|
401
469
|
$.ui.toast(`Peer: ${error instanceof Error ? error.message : String(error)}`)
|
|
470
|
+
await refresh($)
|
|
471
|
+
return
|
|
402
472
|
}
|
|
403
473
|
await refresh($)
|
|
474
|
+
if (said) $.ui.toast(await said())
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// What a press of the band leaves the thread with, in a toast that stands in for a tooltip: the desktop does not
|
|
478
|
+
// follow the pointer over the band, so a hover hint cannot show there.
|
|
479
|
+
async function memoryNote($: EngineInterface) {
|
|
480
|
+
const use = await memoryUse($)
|
|
481
|
+
if (use === 'openviking') return 'Elephant: PeerMemory recalls and saves from now on.'
|
|
482
|
+
if (use === 'none') return 'Jellyfish: no memory in this thread.'
|
|
483
|
+
return 'Inactive: not shared, Claude Code memory only.'
|
|
404
484
|
}
|
|
405
485
|
|
|
406
486
|
export const register: Register = on => {
|
|
@@ -440,10 +520,53 @@ export const register: Register = on => {
|
|
|
440
520
|
return next(e)
|
|
441
521
|
})
|
|
442
522
|
|
|
523
|
+
// A session's first hooks run with the switch already set: OpenViking's start hook lists memories unless Elephant.
|
|
524
|
+
on('classic.SessionStart', async ($, e, next) => {
|
|
525
|
+
transcriptPath = e.transcript_path || transcriptPath
|
|
526
|
+
await refresh($).catch(() => {})
|
|
527
|
+
const use = await memoryUse($)
|
|
528
|
+
const result = await next(e)
|
|
529
|
+
if (use === 'openviking' || !result?.additionalContext) return result
|
|
530
|
+
return { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
on('tool.call', async ($, e, next) => {
|
|
534
|
+
if (e.tool.startsWith(OPENVIKING_TOOL) && (await memoryUse($)) !== 'openviking') {
|
|
535
|
+
return { deny: 'PeerMemory is off in this thread. Use Elephant turns it on.' }
|
|
536
|
+
}
|
|
537
|
+
return next(e)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// Jellyfish: no memory of Claude Code's own either, neither its instructions nor the memory files it loads.
|
|
541
|
+
on('prompt.section', { name: 'memory' }, async ($, e, next) => ((await memoryUse($)) === 'none' ? { text: null } : next(e)))
|
|
542
|
+
on('prompt.context', async ($, e, next) => {
|
|
543
|
+
const result = await next(e)
|
|
544
|
+
const files = (e.instructionFiles ?? []).filter(file => file.kind === 'memory')
|
|
545
|
+
if (!files.length || (await memoryUse($)) !== 'none') return result
|
|
546
|
+
const blocks = result.blocks.map(block => {
|
|
547
|
+
if (block.name !== 'claudeMd') return block
|
|
548
|
+
let text = block.text
|
|
549
|
+
for (const file of files) {
|
|
550
|
+
const at = text.indexOf(`Contents of ${file.path}`)
|
|
551
|
+
if (at < 0) continue
|
|
552
|
+
const end = text.indexOf('\n\nContents of ', at + 1)
|
|
553
|
+
text = text.slice(0, at) + (end < 0 ? '' : text.slice(end + 2))
|
|
554
|
+
}
|
|
555
|
+
return { ...block, text }
|
|
556
|
+
})
|
|
557
|
+
return { ...result, blocks }
|
|
558
|
+
})
|
|
559
|
+
|
|
443
560
|
// What reached the model with a prompt: the hooks beneath (Peer's from settings, OpenViking's plugin) answer
|
|
444
561
|
// first, then the mod reads their additionalContext and keeps it for the prompt's row. It changes nothing.
|
|
445
562
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
446
|
-
|
|
563
|
+
transcriptPath = e.transcript_path || transcriptPath
|
|
564
|
+
await refresh($).catch(() => {})
|
|
565
|
+
const use = await memoryUse($)
|
|
566
|
+
let result = await next(e)
|
|
567
|
+
if (use !== 'openviking' && result?.additionalContext) {
|
|
568
|
+
result = { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
|
|
569
|
+
}
|
|
447
570
|
try {
|
|
448
571
|
const context = (result?.additionalContext ?? []).join('\n')
|
|
449
572
|
const items = [...teamItems(context), ...mineItems(context)]
|
|
@@ -562,7 +685,7 @@ export const register: Register = on => {
|
|
|
562
685
|
{nearHere.map((memory, index) => savedCard(memory, fromHere.length + index))}
|
|
563
686
|
{recalled.map(recallCard)}
|
|
564
687
|
{asked.map((use, index) =>
|
|
565
|
-
card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `
|
|
688
|
+
card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `PeerMemory ${use.kind}`),
|
|
566
689
|
)}
|
|
567
690
|
</Box>
|
|
568
691
|
)}
|
|
@@ -583,23 +706,72 @@ export const register: Register = on => {
|
|
|
583
706
|
const total = saved && 'saves' in saved ? memoryCount(saved.saves) : undefined
|
|
584
707
|
if (e.props.hasSurvey || (now === null && total === undefined)) return next(e)
|
|
585
708
|
const isShared = now?.isShared ?? false
|
|
709
|
+
const kind = await read($, mode)
|
|
586
710
|
|
|
587
711
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
712
|
+
// A hint is the band's tooltip: one line in the free space of the band's own row, between the modes and the
|
|
713
|
+
// status, hidden until the pointer is on its control, so the band never has to grow for it. The control's zone
|
|
714
|
+
// Box and its Button both join the hint's scope, as a desktop's native button may take the pointer from the Box
|
|
715
|
+
// around it; the zones carry keys of their own, apart from their Buttons'.
|
|
716
|
+
const hint = (scope: string, text: string) => (
|
|
717
|
+
<Box key={`hint-${scope}`} display="none" flexGrow={1} hover={{ scope, display: 'flex' }}>
|
|
718
|
+
<Text dimColor size="footnote" wrap="truncate-end">{text}</Text>
|
|
719
|
+
</Box>
|
|
720
|
+
)
|
|
721
|
+
const count = total === undefined ? '–' : String(total)
|
|
722
|
+
const noun = total === 1 ? 'memory' : 'memories'
|
|
723
|
+
// A mode is lit only while the thread is shared: pressing the lit one stops sharing (Inactive), pressing
|
|
724
|
+
// either while Inactive picks it and shares again (Active), pressing the other while Active only switches.
|
|
725
|
+
const pick = async (key: Mode) => {
|
|
726
|
+
const said = () => memoryNote($)
|
|
727
|
+
if (isShared && kind === key) return send($, '/peer stop', said)
|
|
728
|
+
await update($, mode, () => key)
|
|
729
|
+
if (!isShared) return send($, '/peer', said)
|
|
730
|
+
await applyMemory($)
|
|
731
|
+
$.ui.toast(await said())
|
|
732
|
+
}
|
|
733
|
+
// A desktop draws a Button's label as its own native button and leaves Text inside it unstyled, so the lit
|
|
734
|
+
// mode is the primary button and the other the secondary; the terminal draws the primary in the accent color.
|
|
735
|
+
const animal = (key: Mode, name: string) => (
|
|
736
|
+
<Box key={`zone-${key}`} gap={1} alignItems="center" hover={{ scope: `peer-${key}` }}>
|
|
737
|
+
<Button key={key} label={name} variant={isShared && kind === key ? 'primary' : 'secondary'} hover={{ scope: `peer-${key}` }} onPress={() => void pick(key)} />
|
|
738
|
+
</Box>
|
|
739
|
+
)
|
|
740
|
+
const modeHint = (key: Mode, about: string) =>
|
|
741
|
+
`${about} ${isShared && kind === key ? 'Press again to stop.' : isShared ? 'Press to switch.' : 'Press to share with it.'}`
|
|
742
|
+
const hints = {
|
|
743
|
+
memory: total ? 'What this thread saved.' : 'Nothing saved yet.',
|
|
744
|
+
elephant: modeHint('elephant', 'Recall and save with PeerMemory.'),
|
|
745
|
+
jellyfish: modeHint('jellyfish', 'No memory at all.'),
|
|
746
|
+
share: isShared ? 'Shared with your workspace. Press to stop.' : 'Not shared. Press to share.',
|
|
747
|
+
}
|
|
588
748
|
return (
|
|
589
|
-
<Box
|
|
590
|
-
<
|
|
591
|
-
<
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
749
|
+
<Box flexDirection="column">
|
|
750
|
+
<Box width="100%" justifyContent="space-between" gap={2}>
|
|
751
|
+
<Box gap={2}>
|
|
752
|
+
<Box key="zone-memory" gap={1} alignItems="center" hover={{ scope: 'peer-memory' }}>
|
|
753
|
+
<Button key="memory" label={`${count} ${noun}`} plain hover={{ scope: 'peer-memory' }} onPress={async () => { memoryOpen = await openThread($) }}>
|
|
754
|
+
<Text bold tabular>{count}</Text>
|
|
755
|
+
<Text>{` ${noun}`}</Text>
|
|
756
|
+
</Button>
|
|
757
|
+
</Box>
|
|
758
|
+
<Box gap={1}>
|
|
759
|
+
{animal('elephant', 'Use Elephant')}
|
|
760
|
+
{animal('jellyfish', 'Use Jellyfish')}
|
|
761
|
+
</Box>
|
|
762
|
+
</Box>
|
|
763
|
+
<Box flexGrow={1} flexShrink={1} paddingX={2}>
|
|
764
|
+
{e.surface === 'terminal' && hint('peer-memory', hints.memory)}
|
|
765
|
+
{e.surface === 'terminal' && hint('peer-elephant', hints.elephant)}
|
|
766
|
+
{e.surface === 'terminal' && hint('peer-jellyfish', hints.jellyfish)}
|
|
767
|
+
{e.surface === 'terminal' && hint('peer-share', hints.share)}
|
|
768
|
+
</Box>
|
|
769
|
+
{/* The dot is Text beside the Button, where both surfaces keep its color. */}
|
|
770
|
+
<Box key="zone-toggle" gap={1} alignItems="center" hover={{ scope: 'peer-share' }}>
|
|
771
|
+
<Text color={isShared ? 'success' : 'inactive'}>{isShared ? '●' : '○'}</Text>
|
|
772
|
+
<Button key="toggle" label={isShared ? 'Active' : 'Inactive'} plain dimColor={!isShared} hover={{ scope: 'peer-share' }} onPress={() => send($, isShared ? '/peer stop' : '/peer', () => memoryNote($))} />
|
|
773
|
+
</Box>
|
|
774
|
+
</Box>
|
|
603
775
|
</Box>
|
|
604
776
|
)
|
|
605
777
|
})
|
|
@@ -755,7 +927,7 @@ export const register: Register = on => {
|
|
|
755
927
|
return status(now?.error ? <Text color="error">{`Peer: your memories did not load (${now.error})`}</Text> : <Text dimColor>Loading…</Text>)
|
|
756
928
|
}
|
|
757
929
|
if (now.error) return status(<Text color="error">{`Peer: your memories did not load (${now.error})`}</Text>)
|
|
758
|
-
if (!found.connected) return status(<Text dimColor>{`No
|
|
930
|
+
if (!found.connected) return status(<Text dimColor>{`No PeerMemory here (${found.reason}).`}</Text>)
|
|
759
931
|
if (found.total === 0) return status(<Text dimColor>{ask.text ? 'Nothing has those words.' : 'No memories yet.'}</Text>)
|
|
760
932
|
|
|
761
933
|
const words = searchWords(ask.text)
|
|
@@ -859,7 +1031,7 @@ export const register: Register = on => {
|
|
|
859
1031
|
</Box>
|
|
860
1032
|
)
|
|
861
1033
|
}
|
|
862
|
-
if (!now.connected) return <Text dimColor>{`No
|
|
1034
|
+
if (!now.connected) return <Text dimColor>{`No PeerMemory here (${now.reason}).`}</Text>
|
|
863
1035
|
|
|
864
1036
|
const groups = sections(now.saves)
|
|
865
1037
|
const total = groups.check.length + groups.added.length + groups.updated.length + groups.removed.length
|
|
@@ -928,7 +1100,7 @@ export const register: Register = on => {
|
|
|
928
1100
|
// Where memory went into the thread, oldest first: one line each, what and when; a press opens which memories,
|
|
929
1101
|
// and Show in thread where it came with a message.
|
|
930
1102
|
const uses = now.uses ?? []
|
|
931
|
-
const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked
|
|
1103
|
+
const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked PeerMemory' }
|
|
932
1104
|
const useRow = (use: MemoryUse, index: number) => {
|
|
933
1105
|
const id = `use-${index}`
|
|
934
1106
|
const isOpen = open === id
|
|
@@ -976,7 +1148,7 @@ export const register: Register = on => {
|
|
|
976
1148
|
{uses.map(useRow)}
|
|
977
1149
|
</Box>
|
|
978
1150
|
)}
|
|
979
|
-
{total === 0 && <Text dimColor>Nothing saved yet.
|
|
1151
|
+
{total === 0 && <Text dimColor>Nothing saved yet. PeerMemory saves after longer turns.</Text>}
|
|
980
1152
|
{section('Check', groups.check, true)}
|
|
981
1153
|
{section('New', groups.added)}
|
|
982
1154
|
{section('Updated', groups.updated)}
|
|
@@ -26,11 +26,27 @@ 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)
|
|
48
|
+
const toasts: string[] = []
|
|
49
|
+
on('ui.toast', (_, e) => { toasts.push(String((e as { text?: string }).text ?? '')); return { value: undefined } as never })
|
|
34
50
|
on('clock.now', () => ({ value: 1 }) as never)
|
|
35
51
|
const opened: string[] = []
|
|
36
52
|
on('ui.open', (_, e) => { opened.push((e as { id: string }).id); return { value: { isPlaced: true } } as never })
|
|
@@ -43,7 +59,9 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
|
|
|
43
59
|
|
|
44
60
|
// Before the thread's memories have loaded the square holds a dash; a press opens the pane and loads them.
|
|
45
61
|
const before = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
46
|
-
|
|
62
|
+
const first = (await before.findAll({ type: 'Text' })).map(t => t.text)
|
|
63
|
+
expect(first.slice(0, 2)).toEqual(['–', ' memories'])
|
|
64
|
+
expect(first).toContain('●')
|
|
47
65
|
await before.press({ key: 'memory' } as never)
|
|
48
66
|
await before.unmount()
|
|
49
67
|
expect(opened).toEqual(['peer-thread-memories'])
|
|
@@ -52,8 +70,33 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
|
|
|
52
70
|
const ui = await $.ui.mount({ ...BAND, surface })
|
|
53
71
|
const texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
|
|
54
72
|
// New, updated once (two saves of one memory), the removed one gone: 2.
|
|
55
|
-
expect(texts.slice(0, 2)).toEqual(['
|
|
73
|
+
expect(texts.slice(0, 2)).toEqual(['2', ' memories'])
|
|
56
74
|
expect(texts.some(t => t.includes('hotovo'))).toBe(false)
|
|
57
75
|
await ui.unmount()
|
|
58
76
|
}
|
|
77
|
+
|
|
78
|
+
// Elephant is lit while shared; pressing Jellyfish only switches, pressing the lit one again stops sharing,
|
|
79
|
+
// and pressing it while Inactive shares again.
|
|
80
|
+
const lit = async (ui: { findAll: (q: { type: 'Button' }) => Promise<{ props?: { label?: string; variant?: string } }[]> }) =>
|
|
81
|
+
(await ui.findAll({ type: 'Button' })).filter(b => b.props?.variant === 'primary').map(b => b.props?.label)
|
|
82
|
+
const modes = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
83
|
+
expect(await lit(modes as never)).toEqual(['Use Elephant'])
|
|
84
|
+
await modes.press({ key: 'jellyfish' } as never)
|
|
85
|
+
expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
|
|
86
|
+
expect(sent).toEqual([])
|
|
87
|
+
await modes.press({ key: 'jellyfish' } as never)
|
|
88
|
+
expect(sent).toEqual(['/peer stop'])
|
|
89
|
+
let texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
|
|
90
|
+
expect(texts).toContain('○')
|
|
91
|
+
expect(await modes.findAll({ type: 'Button' } as never)).toContainEqual(expect.objectContaining({ props: expect.objectContaining({ label: 'Inactive' }) }))
|
|
92
|
+
expect(await lit(modes as never)).toEqual([])
|
|
93
|
+
await modes.press({ key: 'jellyfish' } as never)
|
|
94
|
+
expect(sent).toEqual(['/peer stop', '/peer'])
|
|
95
|
+
texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
|
|
96
|
+
expect(texts).toContain('●')
|
|
97
|
+
expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
|
|
98
|
+
// The desktop does not follow the pointer over the band, so each press says what the thread now uses, as a toast.
|
|
99
|
+
expect(toasts.at(-1)).toBe('Jellyfish: no memory in this thread.')
|
|
100
|
+
expect((await modes.findAll({ type: 'Box' } as never) as { key?: string }[]).some(b => b.key === 'hint-peer-jellyfish')).toBe(false)
|
|
101
|
+
await modes.unmount()
|
|
59
102
|
})
|
|
@@ -0,0 +1,99 @@
|
|
|
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('ui.toast', () => ({ value: undefined }) as never)
|
|
44
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
45
|
+
// Beneath the mod: OpenViking's recall and a hook of the person's own, a tool that answers, core's memory.
|
|
46
|
+
on('classic.UserPromptSubmit', () => ({ additionalContext: ['<openviking-context>- [memory 80%] x</openviking-context>', 'kept'] }) as never)
|
|
47
|
+
let toolRuns = 0
|
|
48
|
+
on('tool.call', () => { toolRuns++; return { result: { content: 'ok' } } as never })
|
|
49
|
+
on('prompt.section', () => ({ text: 'how memory works' }) as never)
|
|
50
|
+
on('prompt.context', (_, e) => ({ blocks: (e as { blocks: unknown[] }).blocks }) as never)
|
|
51
|
+
|
|
52
|
+
const context = () =>
|
|
53
|
+
$.prompt.context({
|
|
54
|
+
blocks: [{ name: 'claudeMd', text: CLAUDE_MD }],
|
|
55
|
+
instructionFiles: [{ path: MEMORY_FILE, kind: 'memory', content: '- a fact' }],
|
|
56
|
+
})
|
|
57
|
+
const search = () => $.tool.call({ tool: 'mcp__plugin_openviking-memory_openviking__search', query: 'x' } as never)
|
|
58
|
+
const recalled = async () => (await $.classic.UserPromptSubmit({ prompt: 'hi', transcript_path: '/t/s1.jsonl' } as never)).additionalContext
|
|
59
|
+
const cursorMoves = () => runs.filter(argv => argv.includes('--input-type=module'))
|
|
60
|
+
|
|
61
|
+
await $.session.start({ cwd: '/', surface: 'desktop', isInteractive: true })
|
|
62
|
+
|
|
63
|
+
// Shared, with Elephant: OpenViking on, its recall reaches the model, its tools run, Claude Code's memory stays.
|
|
64
|
+
expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
|
|
65
|
+
expect(env.OPENVIKING_AUTO_CAPTURE).toBe('1')
|
|
66
|
+
expect(await recalled()).toHaveLength(2)
|
|
67
|
+
await search()
|
|
68
|
+
expect(toolRuns).toBe(1)
|
|
69
|
+
expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
|
|
70
|
+
|
|
71
|
+
// Jellyfish: OpenViking off, its recall and tools held back, and no Claude Code memory either.
|
|
72
|
+
const band = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
73
|
+
await band.press({ key: 'jellyfish' } as never)
|
|
74
|
+
expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
|
|
75
|
+
expect(env.OPENVIKING_AUTO_CAPTURE).toBeUndefined()
|
|
76
|
+
expect(await recalled()).toEqual(['kept'])
|
|
77
|
+
await search()
|
|
78
|
+
expect(toolRuns).toBe(1)
|
|
79
|
+
expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBeNull()
|
|
80
|
+
const kept = (await context()).blocks.find(b => b.name === 'claudeMd')?.text ?? ''
|
|
81
|
+
expect(kept).toContain('Be brief.')
|
|
82
|
+
expect(kept).not.toContain('a fact')
|
|
83
|
+
|
|
84
|
+
// Pressing the lit Jellyfish stops sharing: Inactive, OpenViking still off, Claude Code's own memory back.
|
|
85
|
+
await band.press({ key: 'jellyfish' } as never)
|
|
86
|
+
expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
|
|
87
|
+
expect(await recalled()).toEqual(['kept'])
|
|
88
|
+
expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
|
|
89
|
+
expect((await context()).blocks.find(b => b.name === 'claudeMd')?.text).toContain('a fact')
|
|
90
|
+
|
|
91
|
+
// Use Elephant from Inactive shares again, with OpenViking on, and its capture starts at the turns there are now,
|
|
92
|
+
// so what was said under Jellyfish and Inactive is never saved. Starting with Elephant moved nothing.
|
|
93
|
+
expect(cursorMoves()).toHaveLength(0)
|
|
94
|
+
await band.press({ key: 'elephant' } as never)
|
|
95
|
+
expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
|
|
96
|
+
expect(cursorMoves()).toHaveLength(1)
|
|
97
|
+
expect(cursorMoves()[0]?.slice(-3)).toEqual(['s1', '/t/s1.jsonl', '/repo'])
|
|
98
|
+
await band.unmount()
|
|
99
|
+
})
|
|
@@ -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 = ''
|
|
@@ -118,7 +118,7 @@ test('a message shows under it what OpenViking saved from it, and what it only u
|
|
|
118
118
|
olderTexts = (await older.findAll({ type: 'Text' })).map(t => t.text)
|
|
119
119
|
expect(olderTexts).toContain('The partner API takes at most 100 items')
|
|
120
120
|
expect(olderTexts).toContain(' Asked ')
|
|
121
|
-
expect(olderTexts).toContain('
|
|
121
|
+
expect(olderTexts).toContain('PeerMemory search')
|
|
122
122
|
await older.unmount()
|
|
123
123
|
|
|
124
124
|
// The /peer-thread pane lists where memory went into the thread; one with a message can show it.
|
|
@@ -130,7 +130,7 @@ test('a message shows under it what OpenViking saved from it, and what it only u
|
|
|
130
130
|
expect(usedTexts).toContain('Used')
|
|
131
131
|
expect(usedTexts).toContain('At the start · 2 memories listed')
|
|
132
132
|
expect(usedTexts).toContain("With your message · 1 teammate's page")
|
|
133
|
-
expect(usedTexts).toContain('Asked
|
|
133
|
+
expect(usedTexts).toContain('Asked PeerMemory: search')
|
|
134
134
|
await usedPane.press({ key: 'use-0' } as never)
|
|
135
135
|
expect((await usedPane.findAll({ type: 'Text' })).map(t => t.text)).toContain('· This Mac')
|
|
136
136
|
expect((await usedPane.findAll({ type: 'Button' })).some(b => b.key === 'use-0-show')).toBe(false)
|
|
@@ -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
|
}
|