@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
package/README.md
CHANGED
|
@@ -18,9 +18,19 @@ In a Claude Code thread:
|
|
|
18
18
|
- `/peer <message>` shares it and passes the message on to the agent as your prompt.
|
|
19
19
|
- `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
|
|
20
20
|
is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
the
|
|
21
|
+
- A band above the prompt shows the thread's memories (`17 memories`), `Use Elephant` and `Use Jellyfish`, and
|
|
22
|
+
`● Active` (shared) or `○ Inactive` at the right; hovering a control explains it. The modes decide what memory
|
|
23
|
+
the thread uses:
|
|
24
|
+
- **Use Elephant** (shared): your OpenViking memory, recalled with each prompt and saved as the thread goes, on
|
|
25
|
+
top of Claude Code's own memory. It saves only the turns from the moment it was chosen.
|
|
26
|
+
- **Inactive** (not shared): Claude Code's own memory alone; OpenViking stays off.
|
|
27
|
+
- **Use Jellyfish** (shared): no memory at all, neither OpenViking nor Claude Code's own (`MEMORY.md`).
|
|
28
|
+
`CLAUDE.md` instructions stay.
|
|
29
|
+
|
|
30
|
+
Pressing the lit mode again stops sharing (Inactive); pressing either while Inactive shares again. Every thread
|
|
31
|
+
that is not shared runs without OpenViking. What a thread was already given stays in it after a switch. The
|
|
32
|
+
band is a Claude Code plugin in this package (`mod/`) that `peer install` adds; the desktop app draws it only
|
|
33
|
+
while you type or a turn runs.
|
|
24
34
|
- A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
|
|
25
35
|
back if Claude Code renames the thread, and `/peer stop` takes it off.
|
|
26
36
|
- `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
|
package/bin/peer.mjs
CHANGED
|
@@ -633,9 +633,196 @@ function addedLines(before, after) {
|
|
|
633
633
|
return String(after ?? "").split("\n").map((line) => line.trim()).filter((line) => line && !had.has(line) && !line.startsWith("#")).map((line) => line.replace(/^[-*]\s+/, ""));
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
// A memory's own text as Markdown, for a card that shows it: without the conversation OpenViking quotes under it
|
|
637
|
+
// (`# … ChatLog:`), without its `# Summary` heading and without a first heading that only repeats its name.
|
|
638
|
+
function memoryBody(text, name) {
|
|
639
|
+
const lines = String(text ?? "").split("\n");
|
|
640
|
+
const cut = lines.findIndex((line) => /^#+ .*ChatLog/i.test(line.trim()));
|
|
641
|
+
const kept = (cut >= 0 ? lines.slice(0, cut) : lines).filter((line) => line.trim() !== "# Summary");
|
|
642
|
+
if (kept[0] && /^# /.test(kept[0]) && kept[0].slice(2).trim().toLowerCase().replace(/[_-]+/g, " ") === String(name).toLowerCase()) kept.shift();
|
|
643
|
+
return kept.join("\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").trim().slice(0, 3000);
|
|
644
|
+
}
|
|
645
|
+
|
|
636
646
|
// Text Claude Code wrote into the turn, not the person: kept by the extraction, flagged so the person sees it.
|
|
637
647
|
const hasSystemText = (text) => /This is how Claude Code surfaces messages|<system-reminder>/.test(String(text ?? ""));
|
|
638
648
|
|
|
649
|
+
// The thread's own messages as Claude Code keeps them (~/.claude/projects/<project>/<session>.jsonl): the person's
|
|
650
|
+
// prompts with the id their row in the thread has, and when they were sent.
|
|
651
|
+
// What a find in the thread meets, in order: the person's own messages and the agent's written replies, with the
|
|
652
|
+
// Markdown marks a view does not draw taken out, cased alike.
|
|
653
|
+
// Curly quotes count as straight ones and accents as their letters (pamat finds pamäť), as the desktop's find treats them.
|
|
654
|
+
const visible = (text) =>
|
|
655
|
+
String(text ?? "")
|
|
656
|
+
.normalize("NFD")
|
|
657
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
658
|
+
.replace(/[\u2018\u2019\u201b\u2032]/g, "'")
|
|
659
|
+
.replace(/[\u201c\u201d\u201f\u2033]/g, '"')
|
|
660
|
+
.replace(/[*`#>_~]/g, "")
|
|
661
|
+
.replace(/\s+/g, " ")
|
|
662
|
+
.toLowerCase();
|
|
663
|
+
|
|
664
|
+
function transcriptPrompts(session) {
|
|
665
|
+
const projects = path.join(CLAUDE, "projects");
|
|
666
|
+
let file;
|
|
667
|
+
try {
|
|
668
|
+
file = fs.readdirSync(projects).map((dir) => path.join(projects, dir, `${session}.jsonl`)).find((f) => fs.existsSync(f));
|
|
669
|
+
} catch {}
|
|
670
|
+
if (!file) return [];
|
|
671
|
+
const prompts = [];
|
|
672
|
+
const shown = [];
|
|
673
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
674
|
+
let row;
|
|
675
|
+
try {
|
|
676
|
+
row = JSON.parse(line);
|
|
677
|
+
} catch {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
// The person's own messages only: not what a skill or a command put in the thread (isMeta), not a command's row.
|
|
681
|
+
// The agent's replies and the one-line descriptions of its steps are drawn too, and a find meets them.
|
|
682
|
+
if (row.type === "assistant" && Array.isArray(row.message?.content)) {
|
|
683
|
+
for (const block of row.message.content) {
|
|
684
|
+
if (block?.type === "text" && block.text) shown.push(visible(block.text));
|
|
685
|
+
else if (block?.type === "tool_use" && typeof block.input?.description === "string") shown.push(visible(block.input.description));
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
// A command's output (`system`, local_command) is drawn in the thread as well.
|
|
690
|
+
if (row.type === "system" && typeof row.content === "string") {
|
|
691
|
+
shown.push(visible(row.content.replace(/<[^>]+>/g, " ")));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (row.type !== "user" || !row.uuid) continue;
|
|
695
|
+
const content = row.message?.content;
|
|
696
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b) => b?.type === "text").map((b) => b.text).join("\n") : "";
|
|
697
|
+
// A command's row and its output show in the thread, so they count for a find, but they are not messages.
|
|
698
|
+
if (row.isMeta || /^\s*<(command-name|local-command)/.test(text)) {
|
|
699
|
+
if (!row.isMeta || /^\s*<local-command-stdout/.test(text)) shown.push(visible(text.replace(/<[^>]+>/g, " ")));
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (!text) continue;
|
|
703
|
+
// Pasted text shows as an attachment, so the words a find can meet are the person's own beside it.
|
|
704
|
+
const own = text.replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>/g, "").trim();
|
|
705
|
+
const keys = [...new Set([promptKey(text), promptKey(own)])].filter(Boolean);
|
|
706
|
+
if (own) {
|
|
707
|
+
prompts.push({ id: row.uuid, at: Date.parse(row.timestamp ?? "") || 0, keys, own, shownAt: shown.length });
|
|
708
|
+
shown.push(visible(own));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Each message's find: one of its own lines whole, the one the thread holds the fewest times (the longest of
|
|
712
|
+
// those), and which match, in the thread's order, is this message. A line, as a find does not match across lines.
|
|
713
|
+
const count = (needle, upTo = shown.length) => shown.slice(0, upTo).reduce((n, text) => n + text.split(needle).length - 1, 0);
|
|
714
|
+
for (const prompt of prompts) {
|
|
715
|
+
const lines = prompt.own.split("\n").map((line) => line.trim().replace(/\s+/g, " ")).filter((line) => visible(line).length >= 3);
|
|
716
|
+
let best;
|
|
717
|
+
for (const line of lines) {
|
|
718
|
+
const times = count(visible(line));
|
|
719
|
+
if (!best || times < best.times || (times === best.times && line.length > best.line.length)) best = { line, times };
|
|
720
|
+
}
|
|
721
|
+
const text = (best?.line ?? findPhrase(prompt.own)).slice(0, 500);
|
|
722
|
+
prompt.find = { text, match: count(visible(text), prompt.shownAt) + 1 };
|
|
723
|
+
}
|
|
724
|
+
return prompts;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// The words a find looks for: the message's first line, at most 60 characters, cut where a word ends.
|
|
728
|
+
function findPhrase(text) {
|
|
729
|
+
const line = String(text).trim().split("\n")[0].replace(/\s+/g, " ");
|
|
730
|
+
if (line.length <= 60) return line;
|
|
731
|
+
const cut = line.slice(0, 60);
|
|
732
|
+
return cut.slice(0, cut.lastIndexOf(" ") > 30 ? cut.lastIndexOf(" ") : 60);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// One message's text as both sides can match it: its first line, spaces collapsed, cased alike, cut short.
|
|
736
|
+
const promptKey = (text) => String(text ?? "").trim().split("\n")[0].replace(/\s+/g, " ").toLowerCase().slice(0, 80);
|
|
737
|
+
|
|
738
|
+
// The person's messages a save was made from, in order: OpenViking's copy carries its own ids and the time it was
|
|
739
|
+
// captured, a little after the message was sent.
|
|
740
|
+
async function savedPrompts(ov, archive) {
|
|
741
|
+
try {
|
|
742
|
+
const text = await ovRead(ov, `/content/read?uri=${encodeURIComponent(`${archive}/messages.jsonl`)}`);
|
|
743
|
+
return String(typeof text === "string" ? text : text?.content ?? "")
|
|
744
|
+
.split("\n")
|
|
745
|
+
.map((line) => {
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(line);
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
.filter((row) => row?.role === "user")
|
|
753
|
+
.map((row) => ({ key: promptKey((row.parts ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n")), at: Date.parse(row.created_at ?? "") || 0 }))
|
|
754
|
+
.filter((row) => row.key && !row.key.startsWith("<"));
|
|
755
|
+
} catch {
|
|
756
|
+
return [];
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// The thread's row for a message OpenViking captured: the latest prompt with the same text sent before the capture.
|
|
761
|
+
function rowFor(prompts, key, capturedAt) {
|
|
762
|
+
let found;
|
|
763
|
+
for (const prompt of prompts) if (prompt.keys.includes(key) && (!capturedAt || prompt.at <= capturedAt + 5000)) found = prompt;
|
|
764
|
+
return found;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// A new memory quotes the message it came from in its ChatLog: `**user [peer=…]**: <the message>`.
|
|
768
|
+
const quotedPrompt = (after) => promptKey(/\*\*user[^*]*\*\*:\s*(.+)/.exec(String(after ?? ""))?.[1] ?? "");
|
|
769
|
+
|
|
770
|
+
// Where memory went into the thread, as the transcript keeps it: what a hook added at the session's start or with
|
|
771
|
+
// a prompt (OpenViking's <openviking-context>, Peer's <teammates-project-knowledge>), and the agent's own calls of
|
|
772
|
+
// OpenViking's tools. Each under the person's message it came with, where there is one.
|
|
773
|
+
function threadUses(session, prompts) {
|
|
774
|
+
const projects = path.join(CLAUDE, "projects");
|
|
775
|
+
let file;
|
|
776
|
+
try {
|
|
777
|
+
file = fs.readdirSync(projects).map((dir) => path.join(projects, dir, `${session}.jsonl`)).find((f) => fs.existsSync(f));
|
|
778
|
+
} catch {}
|
|
779
|
+
if (!file) return [];
|
|
780
|
+
const byId = new Map(prompts.map((prompt) => [prompt.id, prompt]));
|
|
781
|
+
const name = (text) => path.basename(String(text).trim(), ".md").replace(/[_-]+/g, " ");
|
|
782
|
+
const clean = (text) => plain(String(text).replace(/\*\*/g, "").replace(/`/g, "")).slice(0, 120);
|
|
783
|
+
const uses = [];
|
|
784
|
+
let last;
|
|
785
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
786
|
+
let row;
|
|
787
|
+
try {
|
|
788
|
+
row = JSON.parse(line);
|
|
789
|
+
} catch {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (byId.has(row.uuid)) last = byId.get(row.uuid);
|
|
793
|
+
const at = row.timestamp ?? "";
|
|
794
|
+
const near = (where) => (where === "start" || where === "resume" || !last ? {} : { messageId: last.id, messageText: last.find?.text, messageMatch: last.find?.match });
|
|
795
|
+
const attachment = row.attachment;
|
|
796
|
+
if (row.type === "attachment" && attachment?.type === "hook_additional_context") {
|
|
797
|
+
const text = (attachment.content ?? []).join("\n");
|
|
798
|
+
const event = String(attachment.hookEvent ?? attachment.hookName ?? "");
|
|
799
|
+
for (const block of text.matchAll(/<openviking-context([^>]*)>([\s\S]*?)<\/openviking-context>/g)) {
|
|
800
|
+
const source = /source="(\w+)"/.exec(block[1])?.[1];
|
|
801
|
+
const where = event.startsWith("SessionStart") ? (source === "resume" ? "resume" : "start") : "prompt";
|
|
802
|
+
const listed = [...block[2].matchAll(/^\s+- (.+\.md)\s*$/gm)].map((m) => name(m[1]));
|
|
803
|
+
const recalled = [...block[2].matchAll(/^- \[\S+ \d+%\] (.+)$/gm)].map((m) => (m[1].startsWith("viking://") ? name(m[1]) : clean(m[1])));
|
|
804
|
+
const items = recalled.length ? recalled : listed;
|
|
805
|
+
if (items.length) uses.push({ at, where, from: "yours", kind: recalled.length ? "recalled" : "listed", items, ...near(where) });
|
|
806
|
+
}
|
|
807
|
+
for (const block of text.matchAll(/<teammates-project-knowledge[^>]*>([\s\S]*?)<\/teammates-project-knowledge>/g)) {
|
|
808
|
+
const items = [...block[1].matchAll(/^- \[[^\]]+\] (.+)$/gm)].map((m) => clean(m[1]));
|
|
809
|
+
const where = event.startsWith("SessionStart") ? "start" : "prompt";
|
|
810
|
+
if (items.length) uses.push({ at, where, from: "team", kind: "recalled", items, ...near(where) });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// The agent asking OpenViking itself, through its MCP tools (search, find, read, ...).
|
|
814
|
+
if (row.type === "assistant" && Array.isArray(row.message?.content)) {
|
|
815
|
+
for (const block of row.message.content) {
|
|
816
|
+
if (block?.type !== "tool_use" || !/openviking/i.test(String(block.name))) continue;
|
|
817
|
+
const input = block.input ?? {};
|
|
818
|
+
const detail = input.query ?? input.pattern ?? input.uri ?? (Array.isArray(input.uris) ? input.uris.join(", ") : "") ?? "";
|
|
819
|
+
uses.push({ at, where: "tool", from: "yours", kind: String(block.name).split("__").pop(), items: detail ? [clean(String(detail).startsWith("viking://") ? name(detail) : detail)] : [], ...near("tool") });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return uses.slice(-200);
|
|
824
|
+
}
|
|
825
|
+
|
|
639
826
|
async function threadMemories(session) {
|
|
640
827
|
const ov = readJson(OV_CONF);
|
|
641
828
|
if (!ov?.url) return { connected: false, reason: `no OpenViking client config at ${OV_CONF}` };
|
|
@@ -644,10 +831,11 @@ async function threadMemories(session) {
|
|
|
644
831
|
try {
|
|
645
832
|
archives = await ovRead(ov, `/fs/ls?uri=${encodeURIComponent(`${base}/history`)}`);
|
|
646
833
|
} catch (error) {
|
|
647
|
-
if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [] };
|
|
834
|
+
if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [], uses: threadUses(session, transcriptPrompts(session)) };
|
|
648
835
|
throw error;
|
|
649
836
|
}
|
|
650
837
|
const saves = [];
|
|
838
|
+
const prompts = transcriptPrompts(session);
|
|
651
839
|
for (const archive of archives.filter((entry) => entry.isDir)) {
|
|
652
840
|
let diff;
|
|
653
841
|
try {
|
|
@@ -656,14 +844,35 @@ async function threadMemories(session) {
|
|
|
656
844
|
continue;
|
|
657
845
|
}
|
|
658
846
|
const ops = diff.operations ?? {};
|
|
847
|
+
// Which row in the thread each memory belongs to: a new one under the message it quotes (exact); an update or
|
|
848
|
+
// a removal, which quotes nothing, under the save's last message (only near it).
|
|
849
|
+
const captured = prompts.length ? await savedPrompts(ov, archive.uri) : [];
|
|
850
|
+
// An update goes under the save's last message that is the person's own (a skill's or command's text is not).
|
|
851
|
+
let nearRow;
|
|
852
|
+
for (const row of [...captured].reverse()) if ((nearRow = rowFor(prompts, row.key, row.at))) break;
|
|
853
|
+
const quotedRow = (after) => {
|
|
854
|
+
const key = quotedPrompt(after);
|
|
855
|
+
const copy = [...captured].reverse().find((row) => row.key === key);
|
|
856
|
+
return key ? rowFor(prompts, key, copy?.at) : undefined;
|
|
857
|
+
};
|
|
858
|
+
// The row's id, and its words as typed: where a surface cannot scroll to the row, its find can look for them.
|
|
859
|
+
const placed = (row, exact) =>
|
|
860
|
+
row ? { messageId: row.id, messageText: row.find.text, messageMatch: row.find.match, exact } : nearRow ? { messageId: nearRow.id, messageText: nearRow.find.text, messageMatch: nearRow.find.match, exact: false } : {};
|
|
659
861
|
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 })),
|
|
862
|
+
...(ops.adds ?? []).map((op) => ({ change: "added", type: op.memory_type, name: memoryName(op.uri), lines: [summaryOf(op.after)], systemText: hasSystemText(op.after), ...placed(quotedRow(op.after), true) })),
|
|
863
|
+
...(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), ...placed(undefined, false) })),
|
|
864
|
+
...(ops.deletes ?? []).map((op) => ({ change: "removed", type: op.memory_type, name: memoryName(op.uri), lines: [summaryOf(op.before)], systemText: false, ...placed(undefined, false) })),
|
|
663
865
|
].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)) }));
|
|
866
|
+
// Each memory's text for a card: a new or removed one whole, an update as the lines it added.
|
|
867
|
+
const bodies = [
|
|
868
|
+
...(ops.adds ?? []).map((op) => memoryBody(op.after, memoryName(op.uri))),
|
|
869
|
+
...(ops.updates ?? []).map((op) => addedLines(op.before, op.after).slice(0, 12).map((line) => `- ${line}`).join("\n")),
|
|
870
|
+
...(ops.deletes ?? []).map((op) => memoryBody(op.before, memoryName(op.uri))),
|
|
871
|
+
];
|
|
872
|
+
items.forEach((item, index) => (item.body = String(bodies[index] ?? "").slice(0, 3000)));
|
|
664
873
|
saves.push({ at: diff.extracted_at ?? archive.modTime, items });
|
|
665
874
|
}
|
|
666
|
-
return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))) };
|
|
875
|
+
return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))), uses: threadUses(session, prompts) };
|
|
667
876
|
}
|
|
668
877
|
|
|
669
878
|
// ---- team memory: teammates' published project knowledge at the start of a thread ----
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peer-thread-mark",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt, with /peer stop and /peer start; panes for /peer-list, /peer-thread and /peer-all (search your memories)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"types": "./types/index.d.ts"
|