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