aboutsummaryrefslogtreecommitdiffstats
path: root/src/contextvm.js
blob: 7af55a9c7a997d3910e05d3139476500e6240e26 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// ContextVM — MCP JSON-RPC over nostr, the transport Cordn coordinators speak.
// https://github.com/ContextVM/contextvm-docs
//
// A call is one kind-25910 event: the JSON-RPC request as content, a `p` tag
// naming the server. It travels sealed in an ephemeral gift wrap (kind 21059,
// a throwaway key, NIP-44 to the server), the only form the public Cordn
// coordinator answered when probed (2026-10-06); plain 25910 requests from
// us went unanswered. The reply comes back the same way, addressed to our
// pubkey, its inner event signed by the server and `e`-tagged to the request.
// Nothing here is Cordn-specific: any ContextVM tool server works.

import { subscribeOn, publishOn, nip44, finalizeEvent, generateSecretKey } from './nostr.js';
import { verifyEvent } from 'nostr-tools/pure';

export const KIND = { MESSAGE: 25910, WRAP: 21059 };
const now = () => Math.floor(Date.now() / 1000);

// { server, relays, pubkey, sign, decrypt, subClosed }
//   sign(unsigned) -> signed event (the caller's key: the server reads the
//   caller's identity off the inner event's signature)
//   decrypt(peerPubkey, ciphertext) -> plaintext (NIP-44, our key)
export function cvmClient({ server, relays, pubkey, sign, decrypt, subClosed }) {
  const waiting = new Map(); // inner request event id -> { id, resolve }
  let unsub = null, stopped = false, seq = 0;

  function listen() {
    if (stopped) return;
    if (unsub) { try { unsub(); } catch {} }
    unsub = subscribeOn(relays, { kinds: [KIND.MESSAGE, KIND.WRAP], '#p': [pubkey], since: now() - 60 }, onEvent,
      subClosed ? { onclose: () => { if (!stopped) subClosed(); } } : {});
  }

  async function onEvent(ev) {
    let inner = ev;
    if (ev.kind === KIND.WRAP) {
      try { inner = JSON.parse(await decrypt(ev.pubkey, ev.content)); } catch { return; }
      if (!inner || typeof inner !== 'object' || !verifyEvent(inner)) return;
    }
    if (inner.pubkey !== server) return;
    let msg;
    try { msg = JSON.parse(inner.content); } catch { return; }
    if (!msg || msg.method) return; // a notification (stream frames): not used yet
    // matched by the request event it answers, never by JSON-RPC id: another
    // client of the same pubkey (a second tab) numbers its requests the same way
    const re = (inner.tags.find((t) => t[0] === 'e') || [])[1];
    const w = re && waiting.get(re);
    if (w) w.resolve(msg);
  }

  // One tools/call. Resolves to the tool's structured result; throws on a
  // JSON-RPC error, a tool error, or silence.
  async function call(name, args = {}, { timeoutMs = 20_000 } = {}) {
    if (stopped) throw new Error('transport closed');
    if (!unsub) listen();
    const id = (++seq) + '-' + Math.random().toString(36).slice(2, 8);
    const inner = await sign({
      pubkey, kind: KIND.MESSAGE, created_at: now(), tags: [['p', server]],
      content: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }),
    });
    const wsk = generateSecretKey();
    const wrap = finalizeEvent({
      kind: KIND.WRAP, created_at: now(), tags: [['p', server]],
      content: nip44.encrypt(JSON.stringify(inner), nip44.getConversationKey(wsk, server)),
    }, wsk);
    const res = await new Promise((resolve, reject) => {
      const t = setTimeout(() => { waiting.delete(inner.id); reject(new Error('coordinator did not answer: ' + name)); }, timeoutMs);
      waiting.set(inner.id, { id, resolve: (m) => { clearTimeout(t); waiting.delete(inner.id); resolve(m); } });
      publishOn(relays, wrap).then((ok) => {
        if (!ok) { clearTimeout(t); waiting.delete(inner.id); reject(new Error('no relay took the request')); }
      });
    });
    if (res.error) { const e = new Error(res.error.message || 'coordinator error'); e.code = res.error.code; throw e; }
    const r = res.result || {};
    if (r.isError) throw new Error((r.content || []).map((c) => c.text).filter(Boolean).join(' ') || 'tool error');
    return r.structuredContent || r;
  }

  function stop() {
    stopped = true;
    if (unsub) { try { unsub(); } catch {} }
    unsub = null;
    for (const w of waiting.values()) w.resolve({ error: { message: 'transport closed' } });
    waiting.clear();
  }

  return { server, relays, call, listen, stop };
}