@webinson/peer 0.4.1 → 0.4.5
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 +26 -7
- package/bin/peer.mjs +351 -16
- package/mod/.claude-plugin/marketplace.json +11 -0
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +7 -0
- package/mod/peer-thread-mark/hooks/hooks.json +1 -0
- package/mod/peer-thread-mark/hooks/register.tsx +685 -0
- package/mod/peer-thread-mark/tests/all.test.tsx +107 -0
- package/mod/peer-thread-mark/tests/memories.test.tsx +54 -0
- package/mod/peer-thread-mark/types/index.d.ts +52 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,13 +18,19 @@ In a Claude Code thread:
|
|
|
18
18
|
- `/peer <message>` shares it and passes the message on to the agent as your prompt.
|
|
19
19
|
- `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
|
|
20
20
|
is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
|
|
21
|
+
- In a shared thread a band above the prompt says `Peer <workspace>` with `/peer stop` to click; after a stop it
|
|
22
|
+
turns gray with `/peer start`. It is a Claude Code plugin in this package (`mod/`) that `peer install` adds;
|
|
23
|
+
the desktop app draws it only while you type or a turn runs.
|
|
21
24
|
- A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
|
|
22
25
|
back if Claude Code renames the thread, and `/peer stop` takes it off.
|
|
23
26
|
- `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
|
|
24
|
-
state, and the threads that stopped in the last hours.
|
|
27
|
+
state, and the threads that stopped in the last hours. With the plugin installed it opens a `Peer` pane beside
|
|
28
|
+
the chat that reloads every 15 s; without it, or where no pane fits, the hook answers with the list.
|
|
25
29
|
|
|
26
|
-
`/peer
|
|
27
|
-
|
|
30
|
+
`/peer stop` and `/peer-list` are answered by the hook before the prompt reaches the model, so they
|
|
31
|
+
cost no tokens. A bare `/peer` is shared by the hook too, then reaches the agent, which confirms it in a
|
|
32
|
+
line or two: one short turn, instead of Claude Code's "hook blocked your prompt" warning. `/peer-thread` opens a pane with what this thread saved to your own OpenViking memory, in sentences (read-only;
|
|
33
|
+
`peer thread <session id>` prints it). `peer list` prints the same list in a terminal; `peer list --json` gives the pane the hub's list as it came.
|
|
28
34
|
|
|
29
35
|
## What is shared
|
|
30
36
|
|
|
@@ -56,7 +62,7 @@ only the hook knows the session id. After that the hooks report that thread arou
|
|
|
56
62
|
mark. A hook never fails or holds up a session: it gives the hub 3 seconds and drops what does not
|
|
57
63
|
go through. What the hooks did, without any prompt text, is in `~/.peer/hook.log`.
|
|
58
64
|
|
|
59
|
-
If
|
|
65
|
+
If the agent says the hook did not run, or `/peer stop` reaches the model, `peer doctor` checks the sign-in, the hooks in
|
|
60
66
|
Claude Code's settings and the paths they run, and runs the hook the way Claude Code does. An app
|
|
61
67
|
or IDE that starts Claude through the Agent SDK may not load `~/.claude/settings.json`; `claude` in
|
|
62
68
|
a terminal does.
|
|
@@ -71,21 +77,34 @@ If your team runs a shared project memory (an OpenViking server, see the Peer Hu
|
|
|
71
77
|
`deploy/team-memory/`), connect this computer once, inside the project's repository:
|
|
72
78
|
|
|
73
79
|
```bash
|
|
74
|
-
printf '%s' "$YOUR_TEAM_KEY" | peer team connect https://memory.example.com --as <you>
|
|
80
|
+
printf '%s' "$YOUR_TEAM_KEY" | peer team connect https://memory.example.com --as <you>
|
|
75
81
|
```
|
|
76
82
|
|
|
77
|
-
The
|
|
83
|
+
The project is the one your Peer workspace keeps this repository in, with all of that project's repositories;
|
|
84
|
+
a repository in no Hub project (or without `peer login`) is a project of its own, named by its normalized
|
|
85
|
+
origin (`github.com-acme-shop`). `--project <id>` names another. The key comes on stdin and is saved in `~/.peer/team-memory/member.json` (mode 0600). After that, the first
|
|
78
86
|
prompt of every thread in that repository (and the first after a resume or compaction) gets at most five
|
|
79
87
|
teammates' published findings that match it: verified or observed, not your own, about 1,500 tokens,
|
|
80
88
|
fenced as data from colleagues; a page whose paths changed in three or more commits since it was last
|
|
81
89
|
confirmed comes marked "may be stale". The prompt's first 400 characters go to the team server as the search
|
|
82
90
|
query. Nothing else of the thread is sent. `peer team status` shows the connection and the last recalls.
|
|
83
91
|
|
|
92
|
+
## Your own memory
|
|
93
|
+
|
|
94
|
+
`/peer-all` lists what your personal OpenViking (as `~/.openviking/ovcli.conf` points to it) remembers, 20 to a
|
|
95
|
+
page: `/peer-all page 2`; `--newest` or `--oldest` orders it by the time a memory last changed (for an event, when it
|
|
96
|
+
was added). With the thread mark installed it opens a pane with a search field that searches as you type, a this-repository switch and a time
|
|
97
|
+
button that goes unsorted, newest first, oldest first and back. `/peer-all <words>` searches the memories' full text: every word, in
|
|
98
|
+
any order, anywhere in a memory, case and diacritics left aside (`pamat` finds "pamäť"); `"a phrase"` in quotes keeps
|
|
99
|
+
its words together; conversation archives are left out, and `here` keeps it to the current repository:
|
|
100
|
+
`/peer-all here batch limit`. The hook answers, so the list reaches you and never the thread or its capture.
|
|
101
|
+
`peer all …` prints the same in a terminal. Editing and deleting stay in OpenViking Studio.
|
|
102
|
+
|
|
84
103
|
## Other commands
|
|
85
104
|
|
|
86
105
|
```
|
|
87
106
|
peer status who you are, the workspace and the threads this computer shares
|
|
88
|
-
peer doctor why
|
|
107
|
+
peer doctor why Peer's hook did not answer /peer
|
|
89
108
|
peer team connect connect to the team's memory server (see above)
|
|
90
109
|
peer team status the team server, project and the last recalls
|
|
91
110
|
peer install set Claude Code up again
|
package/bin/peer.mjs
CHANGED
|
@@ -21,6 +21,12 @@ const HOOK_TIMEOUT_S = 10;
|
|
|
21
21
|
const HUB_TIMEOUT_MS = 3000;
|
|
22
22
|
// Why a hook did what it did, without any prompt text: `peer doctor` shows the end of it.
|
|
23
23
|
const HOOK_LOG = path.join(HOME, "hook.log");
|
|
24
|
+
// The thread mark: a Claude Code plugin shipped in this package (mod/), a marketplace of one plugin read
|
|
25
|
+
// from that folder, so updating the package updates it.
|
|
26
|
+
const CLAUDE_BIN = process.env.PEER_CLAUDE || "claude";
|
|
27
|
+
const MOD_DIR = path.join(path.dirname(SELF), "..", "mod");
|
|
28
|
+
const MOD = "peer-thread-mark";
|
|
29
|
+
const MARKETPLACE = "webinson-peer";
|
|
24
30
|
|
|
25
31
|
const HELP = `peer: share chosen agent threads with your team
|
|
26
32
|
|
|
@@ -28,12 +34,16 @@ const HELP = `peer: share chosen agent threads with your team
|
|
|
28
34
|
sign in once and set up Claude Code (/peer, /peer stop, /peer-list)
|
|
29
35
|
peer list people in the workspace, their shared threads and what they work on
|
|
30
36
|
peer status who you are, the workspace and the threads this computer shares
|
|
31
|
-
peer doctor why
|
|
37
|
+
peer doctor why Peer's hook did not answer /peer
|
|
32
38
|
peer team connect <url> --as <member> [--project <id>]
|
|
33
39
|
connect to the team's memory server (your key on stdin); new threads in the
|
|
34
|
-
project's repositories then start with teammates' published findings
|
|
40
|
+
project's repositories then start with teammates' published findings. The
|
|
41
|
+
project is the Hub project holding this repository, else the repository itself
|
|
35
42
|
peer team status the team server, project and the last recalls
|
|
36
|
-
peer
|
|
43
|
+
peer all [words] [here] [--newest | --oldest] [page N]
|
|
44
|
+
your own memories in your personal OpenViking, or the ones with these words
|
|
45
|
+
(full text); here: only this repository's. /peer-all in Claude Code
|
|
46
|
+
peer install set up Claude Code again (hooks, the two commands and the thread mark)
|
|
37
47
|
peer uninstall remove them from Claude Code
|
|
38
48
|
peer logout forget the sign-in on this computer
|
|
39
49
|
|
|
@@ -236,6 +246,11 @@ async function hook(event) {
|
|
|
236
246
|
const prompt = String(input.prompt ?? "").trim();
|
|
237
247
|
const command = /^\/peer(?:\s+([\s\S]*))?$/.exec(prompt);
|
|
238
248
|
const argument = command?.[1]?.trim() ?? "";
|
|
249
|
+
const all = /^\/peer-all(?:\s+([\s\S]*))?$/.exec(prompt);
|
|
250
|
+
if (all) {
|
|
251
|
+
const query = memoryQuery((all[1] ?? "").split(/\s+/).filter(Boolean), input.cwd || process.cwd());
|
|
252
|
+
return block(await memoryText(query).catch((error) => `Peer: your memories did not load (${error.message})`));
|
|
253
|
+
}
|
|
239
254
|
if (prompt === "/peer-list") {
|
|
240
255
|
let saved;
|
|
241
256
|
try {
|
|
@@ -267,9 +282,11 @@ async function hook(event) {
|
|
|
267
282
|
await report(saved, session, argument ? "working" : "idle", input.cwd).catch((error) => (hubError = error));
|
|
268
283
|
const said = hubError ? `Peer: shared, but the hub did not answer yet (${hubError.message}). The next turn tries again.` : sharedText;
|
|
269
284
|
const sessionTitle = retitle(input, true, what);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
285
|
+
// A bare /peer reaches the agent too, which confirms in one line: a block would show the person Claude
|
|
286
|
+
// Code's "hook blocked your prompt" warning for what went right.
|
|
287
|
+
const note = argument ? said : `${said}\n${ALONE_NOTE}`;
|
|
288
|
+
log(argument ? "answered /peer with a message: shared, message passed on" : "answered /peer: shared, the agent confirms");
|
|
289
|
+
return answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `${SHARED_NOTE}\n${note}`, ...(sessionTitle ? { sessionTitle } : {}) } });
|
|
273
290
|
}
|
|
274
291
|
if (shared) await reportQuietly(session, "working", input.cwd);
|
|
275
292
|
// Claude Code may name a thread after its first prompts; the mark goes back on, or comes off a thread
|
|
@@ -316,6 +333,10 @@ const COMMANDS = path.join(CLAUDE, "commands");
|
|
|
316
333
|
|
|
317
334
|
// The hook's note to the model when `/peer <message>` shares a thread; peer.md looks for it.
|
|
318
335
|
const SHARED_NOTE = "[Peer's hook ran]";
|
|
336
|
+
// Added when /peer came alone: there is no message to work on, only the confirmation, and its privacy line
|
|
337
|
+
// is a promise the person relies on, so it stays whole.
|
|
338
|
+
const ALONE_NOTE =
|
|
339
|
+
"The person typed /peer alone. Reply with only that confirmation, in one or two short lines in the person's language: where the thread is shared, that teammates see its repository, branch and state but never the conversation, and that /peer stop ends it. Do nothing else and use no tools.";
|
|
319
340
|
|
|
320
341
|
const COMMAND_FILES = {
|
|
321
342
|
"peer.md": `---
|
|
@@ -334,6 +355,14 @@ description: List the people in your Peer workspace and the agent threads they s
|
|
|
334
355
|
Peer's hook answers this command before it reaches you, so you should not be reading it. If you are,
|
|
335
356
|
the hook did not run or did not answer in time: tell the person to run \`peer doctor\` in a terminal, which says why.
|
|
336
357
|
Do nothing else.
|
|
358
|
+
`,
|
|
359
|
+
"peer-all.md": `---
|
|
360
|
+
description: List and search what your own OpenViking memory holds (/peer-all [words] [here] [page N])
|
|
361
|
+
argument-hint: "[words] [here] [page N]"
|
|
362
|
+
---
|
|
363
|
+
Peer's hook answers this command before it reaches you, so you should not be reading it. If you are,
|
|
364
|
+
the hook did not run or did not answer in time: tell the person to run \`peer doctor\` in a terminal, which says why.
|
|
365
|
+
Do nothing else.
|
|
337
366
|
`,
|
|
338
367
|
};
|
|
339
368
|
|
|
@@ -371,14 +400,50 @@ function install() {
|
|
|
371
400
|
writeJson(SETTINGS, settings, 0o644);
|
|
372
401
|
fs.mkdirSync(COMMANDS, { recursive: true });
|
|
373
402
|
for (const [name, text] of Object.entries(COMMAND_FILES)) fs.writeFileSync(path.join(COMMANDS, name), text);
|
|
374
|
-
console.log(`Claude Code: hooks in ${SETTINGS}, /peer and /peer-
|
|
403
|
+
console.log(`Claude Code: hooks in ${SETTINGS}, /peer, /peer-list and /peer-all in ${COMMANDS}. Sessions started from now on have them.`);
|
|
404
|
+
installMark();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function claudeCli(args) {
|
|
408
|
+
return execFileSync(CLAUDE_BIN, args, { encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The band above the prompt in shared threads. Optional: /peer works without it, so a failure only says so.
|
|
412
|
+
function installMark() {
|
|
413
|
+
if (!fs.existsSync(path.join(MOD_DIR, ".claude-plugin", "marketplace.json"))) return;
|
|
414
|
+
const install = () => claudeCli(["plugin", "install", MOD, "--marketplace", MOD_DIR, "--scope", "user"]);
|
|
415
|
+
try {
|
|
416
|
+
try {
|
|
417
|
+
install();
|
|
418
|
+
} catch {
|
|
419
|
+
// A marketplace of that name added from an older place (another install of this package) is replaced.
|
|
420
|
+
try {
|
|
421
|
+
claudeCli(["plugin", "marketplace", "remove", MARKETPLACE]);
|
|
422
|
+
} catch {}
|
|
423
|
+
install();
|
|
424
|
+
}
|
|
425
|
+
console.log(`Claude Code: the thread mark (${MOD}), a band above the prompt in shared threads, from ${MOD_DIR}.`);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
const reason = String(error.stderr || error.message).trim().split("\n").pop();
|
|
428
|
+
console.log(`Claude Code: the thread mark was not installed (${reason}); /peer works without it.`);
|
|
429
|
+
console.log(` To add it later: claude plugin install ${MOD} --marketplace "${MOD_DIR}"`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function uninstallMark() {
|
|
434
|
+
for (const args of [["plugin", "uninstall", `${MOD}@${MARKETPLACE}`], ["plugin", "marketplace", "remove", MARKETPLACE]]) {
|
|
435
|
+
try {
|
|
436
|
+
claudeCli(args);
|
|
437
|
+
} catch {}
|
|
438
|
+
}
|
|
375
439
|
}
|
|
376
440
|
|
|
377
441
|
function uninstall() {
|
|
378
442
|
const settings = readJson(SETTINGS);
|
|
379
443
|
if (settings) writeJson(SETTINGS, withoutOurHooks(settings), 0o644);
|
|
380
444
|
for (const name of Object.keys(COMMAND_FILES)) fs.rmSync(path.join(COMMANDS, name), { force: true });
|
|
381
|
-
|
|
445
|
+
uninstallMark();
|
|
446
|
+
console.log("Claude Code: Peer's hooks, commands and thread mark removed.");
|
|
382
447
|
}
|
|
383
448
|
|
|
384
449
|
// ---- signing in ----
|
|
@@ -446,7 +511,7 @@ function status() {
|
|
|
446
511
|
console.log(shared.length ? `Threads shared from this computer: ${shared.join(", ")}` : "No thread shared from this computer.");
|
|
447
512
|
}
|
|
448
513
|
|
|
449
|
-
// ---- peer doctor: why
|
|
514
|
+
// ---- peer doctor: why Peer's hook did not answer /peer ----
|
|
450
515
|
|
|
451
516
|
async function doctor() {
|
|
452
517
|
let problems = 0;
|
|
@@ -470,8 +535,11 @@ async function doctor() {
|
|
|
470
535
|
}
|
|
471
536
|
|
|
472
537
|
try {
|
|
473
|
-
const version = execFileSync(
|
|
538
|
+
const version = execFileSync(CLAUDE_BIN, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
474
539
|
ok(`Claude Code ${version} (Peer is tested with 2.1.316)`);
|
|
540
|
+
const mark = JSON.parse(claudeCli(["plugin", "list", "--json"])).find((p) => p.id === `${MOD}@${MARKETPLACE}`);
|
|
541
|
+
if (mark?.enabled) ok(`the thread mark (${MOD}) is installed`);
|
|
542
|
+
else console.log(` -- the thread mark (${MOD}, a band above the prompt) is ${mark ? "turned off" : "not installed"}: \`peer install\` adds it`);
|
|
475
543
|
} catch {
|
|
476
544
|
console.log(" ? `claude` is not on this terminal's PATH, so its version is unknown");
|
|
477
545
|
}
|
|
@@ -500,7 +568,7 @@ async function doctor() {
|
|
|
500
568
|
}
|
|
501
569
|
if (process.env.CLAUDE_CONFIG_DIR) console.log(` note CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR}: Claude Code must run with the same value`);
|
|
502
570
|
const commands = Object.keys(COMMAND_FILES).filter((name) => fs.existsSync(path.join(COMMANDS, name)));
|
|
503
|
-
if (commands.length === Object.keys(COMMAND_FILES).length) ok(`/peer and /peer-
|
|
571
|
+
if (commands.length === Object.keys(COMMAND_FILES).length) ok(`/peer, /peer-list and /peer-all in ${COMMANDS}`);
|
|
504
572
|
else bad(`commands missing in ${COMMANDS}: run \`peer install\``);
|
|
505
573
|
|
|
506
574
|
// The hook Claude Code would run, run here: a /peer-list must come back answered within Claude's limit.
|
|
@@ -526,11 +594,78 @@ async function doctor() {
|
|
|
526
594
|
console.log(
|
|
527
595
|
problems
|
|
528
596
|
? `\n${problems} thing(s) to fix. Then start a new Claude Code session.`
|
|
529
|
-
: "\nPeer is set up here. If
|
|
597
|
+
: "\nPeer is set up here. If the agent still says the hook did not run, Claude Code is not loading your user settings: an app or IDE that runs Claude through the Agent SDK may ignore ~/.claude/settings.json. Start `claude` in a terminal and try /peer there.",
|
|
530
598
|
);
|
|
531
599
|
if (problems) process.exitCode = 1;
|
|
532
600
|
}
|
|
533
601
|
|
|
602
|
+
// ---- this thread's memories: what the person's own OpenViking kept from a Claude Code thread ----
|
|
603
|
+
// The OpenViking plugin stores each Claude Code thread as session `cc-<session id>`; every commit leaves a
|
|
604
|
+
// memory_diff.json with what it added, updated or removed. `peer thread` reads those and says them in sentences,
|
|
605
|
+
// with no model call. Read-only: it never changes a memory.
|
|
606
|
+
|
|
607
|
+
const OV_CONF = process.env.PEER_OV_CONF || path.join(os.homedir(), ".openviking", "ovcli.conf");
|
|
608
|
+
|
|
609
|
+
async function ovRead(ov, route) {
|
|
610
|
+
const response = await fetch(`${ov.url}/api/v1${route}`, {
|
|
611
|
+
headers: { "x-api-key": ov.api_key ?? "", "x-openviking-account": ov.account ?? "default", "x-openviking-user": ov.user ?? "" },
|
|
612
|
+
signal: AbortSignal.timeout(5000),
|
|
613
|
+
});
|
|
614
|
+
const value = await response.json().catch(() => ({}));
|
|
615
|
+
if (!response.ok || value.status !== "ok") throw new Error(value.error?.message ?? `OpenViking answered ${response.status}`);
|
|
616
|
+
return value.result;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// The memory's name in words: `entities/software_system/peer.md` is "peer", `.../autonomous_auditing.md` "autonomous auditing".
|
|
620
|
+
const memoryName = (uri) => path.basename(String(uri), ".md").replace(/[_-]+/g, " ");
|
|
621
|
+
|
|
622
|
+
// The sentence a memory starts with: OpenViking writes it under `# Summary`; otherwise its first line of text.
|
|
623
|
+
function summaryOf(text) {
|
|
624
|
+
const lines = String(text ?? "").split("\n").map((line) => line.trim());
|
|
625
|
+
const at = lines.indexOf("# Summary");
|
|
626
|
+
const first = (at >= 0 ? lines.slice(at + 1) : lines).find((line) => line && !line.startsWith("#"));
|
|
627
|
+
return (first ?? "").replace(/^[-*]\s+/, "");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// What an update added: the lines in `after` that `before` did not have.
|
|
631
|
+
function addedLines(before, after) {
|
|
632
|
+
const had = new Set(String(before ?? "").split("\n").map((line) => line.trim()));
|
|
633
|
+
return String(after ?? "").split("\n").map((line) => line.trim()).filter((line) => line && !had.has(line) && !line.startsWith("#")).map((line) => line.replace(/^[-*]\s+/, ""));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Text Claude Code wrote into the turn, not the person: kept by the extraction, flagged so the person sees it.
|
|
637
|
+
const hasSystemText = (text) => /This is how Claude Code surfaces messages|<system-reminder>/.test(String(text ?? ""));
|
|
638
|
+
|
|
639
|
+
async function threadMemories(session) {
|
|
640
|
+
const ov = readJson(OV_CONF);
|
|
641
|
+
if (!ov?.url) return { connected: false, reason: `no OpenViking client config at ${OV_CONF}` };
|
|
642
|
+
const base = `viking://user/${ov.user}/sessions/cc-${session}`;
|
|
643
|
+
let archives;
|
|
644
|
+
try {
|
|
645
|
+
archives = await ovRead(ov, `/fs/ls?uri=${encodeURIComponent(`${base}/history`)}`);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [] };
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
const saves = [];
|
|
651
|
+
for (const archive of archives.filter((entry) => entry.isDir)) {
|
|
652
|
+
let diff;
|
|
653
|
+
try {
|
|
654
|
+
diff = JSON.parse(await ovRead(ov, `/content/read?uri=${encodeURIComponent(`${archive.uri}/memory_diff.json`)}`));
|
|
655
|
+
} catch {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const ops = diff.operations ?? {};
|
|
659
|
+
const items = [
|
|
660
|
+
...(ops.adds ?? []).map((op) => ({ change: "added", type: op.memory_type, name: memoryName(op.uri), lines: [summaryOf(op.after)], systemText: hasSystemText(op.after) })),
|
|
661
|
+
...(ops.updates ?? []).map((op) => ({ change: "updated", type: op.memory_type, name: memoryName(op.uri), lines: addedLines(op.before, op.after), systemText: hasSystemText(op.after) && !hasSystemText(op.before) })),
|
|
662
|
+
...(ops.deletes ?? []).map((op) => ({ change: "removed", type: op.memory_type, name: memoryName(op.uri), lines: [summaryOf(op.before)], systemText: false })),
|
|
663
|
+
].map((item) => ({ ...item, name: plain(item.name), lines: item.lines.filter(Boolean).slice(0, 3).map((line) => String(line).replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 300)) }));
|
|
664
|
+
saves.push({ at: diff.extracted_at ?? archive.modTime, items });
|
|
665
|
+
}
|
|
666
|
+
return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))) };
|
|
667
|
+
}
|
|
668
|
+
|
|
534
669
|
// ---- team memory: teammates' published project knowledge at the start of a thread ----
|
|
535
670
|
// The team's OpenViking server holds pages members published for a project. `peer team connect` saves this
|
|
536
671
|
// member's address and key; the hooks then add the best few teammates' pages to the first prompt of a thread
|
|
@@ -542,7 +677,10 @@ const TEAM_LOG = path.join(HOME, "team-memory", "recall.log");
|
|
|
542
677
|
|
|
543
678
|
// OpenViking names a repository by its normalized origin URL; the team scripts do the same.
|
|
544
679
|
function repositoryPeer(cwd) {
|
|
545
|
-
|
|
680
|
+
return repositoryKey(git(cwd, "remote", "get-url", "origin"));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function repositoryKey(remote) {
|
|
546
684
|
if (!remote) return "";
|
|
547
685
|
return remote
|
|
548
686
|
.replace(/^[a-z+]+:\/\//i, "")
|
|
@@ -565,9 +703,19 @@ async function teamCall(team, method, route, body, timeoutMs = 3000) {
|
|
|
565
703
|
return { status: response.status, value: await response.json().catch(() => ({})) };
|
|
566
704
|
}
|
|
567
705
|
|
|
706
|
+
// The project is the Hub's: the workspace project whose repositories include this one, with all of its
|
|
707
|
+
// repositories. A repository in no Hub project (or without a sign-in) is a project of its own, named by its key.
|
|
708
|
+
async function teamProject(repo) {
|
|
709
|
+
const saved = config();
|
|
710
|
+
if (!saved?.session || !saved?.workspace) return { project: repo, repos: [repo] };
|
|
711
|
+
const manifest = await call(saved, "GET", `/workspaces/${saved.workspace}/manifest`, undefined, 10000);
|
|
712
|
+
const keysOf = (project) => (project.repositories ?? []).map((r) => repositoryKey(r.url ?? "")).filter(Boolean);
|
|
713
|
+
const found = (manifest?.projects ?? []).find((project) => keysOf(project).includes(repo));
|
|
714
|
+
return found ? { project: found.id, repos: keysOf(found) } : { project: repo, repos: [repo] };
|
|
715
|
+
}
|
|
716
|
+
|
|
568
717
|
async function teamConnect(args) {
|
|
569
718
|
const member = option(args, "--as");
|
|
570
|
-
const project = option(args, "--project") ?? "alpha";
|
|
571
719
|
const repo = option(args, "--repo") ?? repositoryPeer(process.cwd());
|
|
572
720
|
const url = (args[0] || "").replace(/\/+$/, "");
|
|
573
721
|
if (!url || !member) throw new Error("usage: peer team connect <server-url> --as <member> [--project <id>] [--repo <id>], key on stdin");
|
|
@@ -577,11 +725,14 @@ async function teamConnect(args) {
|
|
|
577
725
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
578
726
|
const key = Buffer.concat(chunks).toString("utf8").trim();
|
|
579
727
|
if (!key) throw new Error("no key on stdin: printf '%s' \"$KEY\" | peer team connect …");
|
|
728
|
+
const given = option(args, "--project");
|
|
729
|
+
const found = given ? { project: given, repos: [repo] } : await teamProject(repo);
|
|
730
|
+
const { project } = found;
|
|
580
731
|
const team = { url, member, key };
|
|
581
732
|
const { status } = await teamCall(team, "POST", "/search/find", { query: "probe", target_uri: `viking://resources/projects/${project}`, limit: 1 }, 10000);
|
|
582
733
|
if (status >= 300) throw new Error(`the team server refused ${member}'s key for project ${project} (${status})`);
|
|
583
734
|
const saved = readJson(TEAM_FILE) ?? {};
|
|
584
|
-
const repos = saved.project === project ?
|
|
735
|
+
const repos = [...new Set([...(saved.project === project ? saved.repos ?? [] : []), ...found.repos, repo])];
|
|
585
736
|
writeJson(TEAM_FILE, { url, member, project, repos, key });
|
|
586
737
|
console.log(`Team memory: ${member} on ${url}, project ${project}, repositories ${repos.join(", ")}.`);
|
|
587
738
|
if (readJson(SETTINGS)) install();
|
|
@@ -676,6 +827,159 @@ async function teamHook(event) {
|
|
|
676
827
|
answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block } });
|
|
677
828
|
}
|
|
678
829
|
|
|
830
|
+
// ---- your own memory: what your personal OpenViking remembered, listed and searched ----
|
|
831
|
+
// Reads the person's own OpenViking server as its CLI is configured (~/.openviking/ovcli.conf). `/peer-all` is
|
|
832
|
+
// answered by the hook, so the list is shown to the person and never enters the thread (nor its capture).
|
|
833
|
+
|
|
834
|
+
const MEMORY_CONF = process.env.PEER_OPENVIKING_CONF || path.join(os.homedir(), ".openviking", "ovcli.conf");
|
|
835
|
+
const MEMORY_PAGE = 20;
|
|
836
|
+
|
|
837
|
+
function memoryServer() {
|
|
838
|
+
const conf = readJson(MEMORY_CONF);
|
|
839
|
+
if (!conf?.url || !conf?.user) throw new Error(`no personal OpenViking configured (${MEMORY_CONF})`);
|
|
840
|
+
return conf;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function memoryCall(conf, route, body) {
|
|
844
|
+
const response = await fetch(`${conf.url.replace(/\/+$/, "")}/api/v1${route}`, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers: {
|
|
847
|
+
"content-type": "application/json",
|
|
848
|
+
...(conf.api_key ? { "x-api-key": conf.api_key } : {}),
|
|
849
|
+
"x-openviking-account": conf.account || "default",
|
|
850
|
+
"x-openviking-user": conf.user,
|
|
851
|
+
},
|
|
852
|
+
body: JSON.stringify(body),
|
|
853
|
+
signal: AbortSignal.timeout(8000),
|
|
854
|
+
});
|
|
855
|
+
const value = await response.json().catch(() => ({}));
|
|
856
|
+
if (!response.ok) throw new Error(`${route}: ${response.status} ${plain(value?.error?.message ?? value?.detail ?? "")}`);
|
|
857
|
+
return value.result ?? {};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// viking://user/<u>/peers/<repo>/memories/<type>/… or viking://user/<u>/memories/<type>/… (global).
|
|
861
|
+
function memoryWhere(uri) {
|
|
862
|
+
const match = /^viking:\/\/user\/[^/]+\/(?:peers\/([^/]+)\/)?memories\/([^/]+)\/(.+)$/.exec(uri);
|
|
863
|
+
if (!match) return null;
|
|
864
|
+
const [, repo, type, rest] = match;
|
|
865
|
+
const title = rest.split("/").pop().replace(/\.md$/, "").replace(/[_-]+/g, " ");
|
|
866
|
+
return { repo: repo || "global", type, title };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const isMemoryFile = (uri) => uri.endsWith(".md") && !uri.split("/").pop().startsWith(".") && memoryWhere(uri);
|
|
870
|
+
|
|
871
|
+
// `peer all [text] [page N] [--here] [--newest | --oldest]`; the same words after /peer-all. Without a sort the
|
|
872
|
+
// memories come in OpenViking's own order.
|
|
873
|
+
function memoryQuery(words, cwd) {
|
|
874
|
+
const query = { text: [], page: 1, here: false, sort: "" };
|
|
875
|
+
for (let i = 0; i < words.length; i++) {
|
|
876
|
+
if (words[i] === "--here" || words[i] === "here") query.here = true;
|
|
877
|
+
else if (words[i] === "--newest" || words[i] === "--oldest") query.sort = words[i] === "--newest" ? "new" : "old";
|
|
878
|
+
else if ((words[i] === "page" || words[i] === "--page") && /^\d+$/.test(words[i + 1] ?? "")) query.page = Math.max(1, Number(words[++i]));
|
|
879
|
+
else query.text.push(words[i]);
|
|
880
|
+
}
|
|
881
|
+
query.text = query.text.join(" ").trim();
|
|
882
|
+
query.repo = query.here ? repositoryPeer(cwd) : "";
|
|
883
|
+
return query;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Diacritics do not count: a letter matches its accented forms, so `pamat` finds "pamäť" and `pamäť` finds "pamat".
|
|
887
|
+
const LETTERS = { a: "aáäàâ", c: "cčç", d: "dď", e: "eéěëèê", i: "iíìî", l: "lĺľ", n: "nňñ", o: "oóôöò", r: "rŕř", s: "sš", t: "tť", u: "uúůüù", y: "yý", z: "zž" };
|
|
888
|
+
|
|
889
|
+
// The words of a search as OpenViking grep patterns: each word, or each "quoted phrase" as one, at most six.
|
|
890
|
+
function memoryTerms(text) {
|
|
891
|
+
const words = [...text.matchAll(/"([^"]+)"|(\S+)/g)].map((m) => (m[1] ?? m[2]).trim()).filter(Boolean).slice(0, 6);
|
|
892
|
+
return words.map((word) =>
|
|
893
|
+
[...word.toLowerCase()]
|
|
894
|
+
.map((char) => {
|
|
895
|
+
const base = char.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
896
|
+
if (LETTERS[base]) return `[${LETTERS[base]}]`;
|
|
897
|
+
return /\s/.test(char) ? "\\s+" : char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
898
|
+
})
|
|
899
|
+
.join(""),
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// One page of the person's memories: all of them, or the files whose text has the words; each with the time it last
|
|
904
|
+
// changed (OpenViking keeps no other time per file), which is when an event was added.
|
|
905
|
+
async function memoryPage(query) {
|
|
906
|
+
const conf = memoryServer();
|
|
907
|
+
const root = `viking://user/${conf.user}`;
|
|
908
|
+
const scope = query.repo ? `${root}/peers/${query.repo}/memories` : root;
|
|
909
|
+
// The times come from the listing, so a search asks for both at once.
|
|
910
|
+
const listing = memoryCall(conf, "/search/glob", { pattern: "**/*.md", uri: scope, node_limit: 20000, extra_fields: ["modTime"] });
|
|
911
|
+
let rows;
|
|
912
|
+
if (query.text) {
|
|
913
|
+
// Every word (or "quoted phrase") must be somewhere in the memory, in any order: one grep each, then the files
|
|
914
|
+
// all of them hit. Lines are matched, so the words of one memory may sit on different lines.
|
|
915
|
+
const terms = memoryTerms(query.text);
|
|
916
|
+
const [files, ...found] = await Promise.all([
|
|
917
|
+
listing,
|
|
918
|
+
...terms.map((term) =>
|
|
919
|
+
memoryCall(conf, "/search/grep", { uri: scope, exclude_uri: `${root}/sessions`, pattern: term, case_insensitive: true, node_limit: 2000 }),
|
|
920
|
+
),
|
|
921
|
+
]);
|
|
922
|
+
const times = new Map((files.matches ?? []).map((m) => [m.uri, m.modTime]));
|
|
923
|
+
const byFile = new Map();
|
|
924
|
+
found.forEach((result, index) => {
|
|
925
|
+
for (const hit of result.matches ?? []) {
|
|
926
|
+
if (!isMemoryFile(hit.uri)) continue;
|
|
927
|
+
if (!byFile.has(hit.uri)) byFile.set(hit.uri, { uri: hit.uri, at: times.get(hit.uri), lines: [], hits: new Set() });
|
|
928
|
+
const file = byFile.get(hit.uri);
|
|
929
|
+
file.hits.add(index);
|
|
930
|
+
const line = String(hit.content ?? "").trim();
|
|
931
|
+
if (!file.lines.includes(line)) file.lines.push(line);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
rows = [...byFile.values()].filter((file) => file.hits.size === terms.length);
|
|
935
|
+
} else {
|
|
936
|
+
const found = await listing;
|
|
937
|
+
rows = (found.matches ?? []).filter((m) => !m.isDir && isMemoryFile(m.uri)).map((m) => ({ uri: m.uri, at: m.modTime }));
|
|
938
|
+
}
|
|
939
|
+
if (query.sort) rows.sort((a, b) => String(query.sort === "new" ? b.at : a.at).localeCompare(String(query.sort === "new" ? a.at : b.at)));
|
|
940
|
+
const pages = Math.max(1, Math.ceil(rows.length / MEMORY_PAGE));
|
|
941
|
+
const page = Math.min(query.page, pages);
|
|
942
|
+
const clean = (text, max) => String(text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, max);
|
|
943
|
+
return {
|
|
944
|
+
connected: true,
|
|
945
|
+
url: conf.url.replace(/\/+$/, ""),
|
|
946
|
+
text: query.text,
|
|
947
|
+
repo: query.repo,
|
|
948
|
+
sort: query.sort,
|
|
949
|
+
total: rows.length,
|
|
950
|
+
page,
|
|
951
|
+
pages,
|
|
952
|
+
rows: rows.slice((page - 1) * MEMORY_PAGE, page * MEMORY_PAGE).map((row) => {
|
|
953
|
+
const where = memoryWhere(row.uri);
|
|
954
|
+
return {
|
|
955
|
+
uri: clean(row.uri, 400),
|
|
956
|
+
repo: plain(where.repo),
|
|
957
|
+
type: plain(where.type),
|
|
958
|
+
title: plain(where.title),
|
|
959
|
+
at: row.at ? String(row.at).slice(0, 16).replace("T", " ") : "",
|
|
960
|
+
lines: (row.lines ?? []).slice(0, 2).map((line) => clean(line, 160)),
|
|
961
|
+
};
|
|
962
|
+
}),
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function memoryText(query) {
|
|
967
|
+
const found = await memoryPage(query);
|
|
968
|
+
const { page, pages } = found;
|
|
969
|
+
const order = { new: "newest first", old: "oldest first" }[query.sort];
|
|
970
|
+
const what = [query.text ? `"${plain(query.text)}"` : "all", query.repo ? `in ${query.repo}` : "", order].filter(Boolean).join(" ");
|
|
971
|
+
const lines = [`Your memories (${found.url}), ${what}: ${found.total} file(s), page ${page} of ${pages}`];
|
|
972
|
+
for (const row of found.rows) {
|
|
973
|
+
lines.push("", `${row.at ? `${row.at} ` : ""}${row.repo} · ${row.type} · ${row.title}`);
|
|
974
|
+
for (const line of row.lines) lines.push(` ${line}`);
|
|
975
|
+
lines.push(` ${row.uri}`);
|
|
976
|
+
}
|
|
977
|
+
if (!found.total) lines.push("", "Nothing found.");
|
|
978
|
+
const next = [query.text, query.here ? "here" : "", query.sort ? `--${query.sort === "new" ? "newest" : "oldest"}` : "", page < pages ? `page ${page + 1}` : ""].filter(Boolean).join(" ");
|
|
979
|
+
lines.push("", page < pages ? `Next: /peer-all ${next}` : "", `Search: /peer-all <words> · this repository only: /peer-all here <words> · by time: --newest or --oldest · edit or delete: OpenViking Studio, ${found.url}/studio/`);
|
|
980
|
+
return lines.filter((line, i, all) => line || all[i - 1]).join("\n").trim();
|
|
981
|
+
}
|
|
982
|
+
|
|
679
983
|
// ---- main ----
|
|
680
984
|
|
|
681
985
|
const [command, ...args] = process.argv.slice(2);
|
|
@@ -688,8 +992,28 @@ try {
|
|
|
688
992
|
await logout();
|
|
689
993
|
break;
|
|
690
994
|
case "list":
|
|
691
|
-
|
|
995
|
+
// `--json` is for the thread-mark mod's pane: the hub's list as it came, plus who is asking.
|
|
996
|
+
if (args.includes("--json")) {
|
|
997
|
+
const saved = signedIn();
|
|
998
|
+
const list = await call(saved, "GET", `/workspaces/${saved.workspace}/agents`);
|
|
999
|
+
console.log(JSON.stringify({ ...list, you: saved.email }));
|
|
1000
|
+
} else console.log(await listText(signedIn()));
|
|
1001
|
+
break;
|
|
1002
|
+
case "thread": {
|
|
1003
|
+
// `peer thread --json <session id>` is for the thread-mark mod's pane.
|
|
1004
|
+
const session = args.find((arg) => !arg.startsWith("--"));
|
|
1005
|
+
if (!validSession(session)) throw new Error("usage: peer thread [--json] <claude code session id>");
|
|
1006
|
+
const memories = await threadMemories(session);
|
|
1007
|
+
if (args.includes("--json")) console.log(JSON.stringify(memories));
|
|
1008
|
+
else if (!memories.connected) console.log(`Peer: ${memories.reason}`);
|
|
1009
|
+
else if (!memories.saves.length) console.log("This thread has not saved anything to memory yet.");
|
|
1010
|
+
else
|
|
1011
|
+
for (const save of memories.saves) {
|
|
1012
|
+
console.log(save.at);
|
|
1013
|
+
for (const item of save.items) console.log(` ${item.change} ${item.type} "${item.name}": ${item.lines.join(" / ")}${item.systemText ? " [holds Claude Code's own text]" : ""}`);
|
|
1014
|
+
}
|
|
692
1015
|
break;
|
|
1016
|
+
}
|
|
693
1017
|
case "status":
|
|
694
1018
|
status();
|
|
695
1019
|
break;
|
|
@@ -707,6 +1031,17 @@ try {
|
|
|
707
1031
|
// A hook never breaks the person's session: whatever goes wrong, it exits 0.
|
|
708
1032
|
await (args[0]?.startsWith("team-") ? teamHook(args[0]) : hook(args[0])).catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
|
|
709
1033
|
break;
|
|
1034
|
+
case "all": {
|
|
1035
|
+
// `peer all --json …` is for the thread-mark mod's pane; without a personal OpenViking it says so.
|
|
1036
|
+
const json = args.includes("--json");
|
|
1037
|
+
const query = memoryQuery(args.filter((arg) => arg !== "--json"), process.cwd());
|
|
1038
|
+
if (!json) console.log(await memoryText(query));
|
|
1039
|
+
else {
|
|
1040
|
+
const found = await memoryPage(query).catch((error) => ({ connected: false, reason: error.message }));
|
|
1041
|
+
console.log(JSON.stringify(found));
|
|
1042
|
+
}
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
710
1045
|
case "team":
|
|
711
1046
|
if (args[0] === "connect") await teamConnect(args.slice(1));
|
|
712
1047
|
else if (args[0] === "status") teamStatus();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webinson-peer",
|
|
3
|
+
"owner": { "name": "Webinson" },
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "peer-thread-mark",
|
|
7
|
+
"source": "./peer-thread-mark",
|
|
8
|
+
"description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "peer-thread-mark",
|
|
3
|
+
"version": "0.4.11",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"types": "./types/index.d.ts"
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|