@kybernesis/buzz 0.5.1 → 0.6.3

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.
@@ -0,0 +1,110 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { defineTool } from "eve/tools";
9
+ import { z } from "zod";
10
+ import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ const KIND_HTTP_AUTH = 27235;
13
+ async function queryRelay(relayHttpUrl, secretKey, filters) {
14
+ const url = `${relayHttpUrl.replace(/\/$/, "")}/query`;
15
+ const body = JSON.stringify(filters);
16
+ const auth = finalizeEvent({
17
+ kind: KIND_HTTP_AUTH,
18
+ created_at: Math.floor(Date.now() / 1e3),
19
+ tags: [
20
+ ["u", url],
21
+ ["method", "POST"],
22
+ ["nonce", randomUUID()],
23
+ ["payload", createHash("sha256").update(body).digest("hex")]
24
+ ],
25
+ content: ""
26
+ }, secretKey);
27
+ const authTag = process.env.BUZZ_AUTH_TAG?.trim();
28
+ const response = await fetch(url, {
29
+ method: "POST",
30
+ headers: {
31
+ authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`,
32
+ "content-type": "application/json",
33
+ ...authTag ? { "x-auth-tag": authTag } : {}
34
+ },
35
+ body
36
+ });
37
+ const text = await response.text();
38
+ if (!response.ok) throw new Error(`relay ${response.status}: ${text.slice(0, 300)}`);
39
+ return {
40
+ events: JSON.parse(text),
41
+ status: response.status,
42
+ bytes: text.length
43
+ };
44
+ }
45
+ const KINDS = {
46
+ project: 30621,
47
+ repo: 30617,
48
+ issue: 1621,
49
+ patch: 1617,
50
+ message: 9,
51
+ note: 30023,
52
+ profile: 0
53
+ };
54
+ var find_default = defineTool({
55
+ description: "Find what exists in the Buzz workspace, across everyone — projects, repos, issues, patches, notes, profiles. Use this for questions like \"what projects exist here?\", which the buzz CLI's list commands CANNOT answer: those are scoped to this agent's own items and will report none when the workspace is full of other people's. Give a `type` (project, repo, issue, patch, note, message, profile) or raw `kinds`.",
56
+ inputSchema: z.object({
57
+ type: z.enum([
58
+ "project",
59
+ "repo",
60
+ "issue",
61
+ "patch",
62
+ "note",
63
+ "message",
64
+ "profile"
65
+ ]).optional().describe("What to look for. Omit only if passing raw kinds."),
66
+ kinds: z.array(z.number()).optional().describe("Raw Nostr kind numbers, for anything not named above."),
67
+ authors: z.array(z.string()).optional().describe("Restrict to these author pubkeys (64-char hex). Omit for everyone in the workspace."),
68
+ limit: z.number().optional().describe("How many at most. Defaults to 50."),
69
+ community: z.string().optional().describe("Which workspace, when this agent belongs to more than one. Any distinctive part of its address.")
70
+ }),
71
+ async execute({ type, kinds, authors, limit, community }) {
72
+ const keyFile = process.env.BUZZ_KEYFILE ?? ".buzz-key.json";
73
+ if (!existsSync(keyFile)) return `No Buzz identity at ${keyFile}: this agent has no standing in the workspace yet.`;
74
+ const configured = (process.env.BUZZ_RELAY_HTTP ?? process.env.BUZZ_RELAY ?? "").split(",").map((entry) => entry.trim().replace(/^ws/, "http")).filter(Boolean);
75
+ if (configured.length === 0) return "No Buzz relay configured (BUZZ_RELAY).";
76
+ let relay;
77
+ if (community) {
78
+ const matches = configured.filter((entry) => entry.includes(community));
79
+ if (matches.length !== 1) return `"${community}" does not pick out exactly one of: ${configured.join(", ")}`;
80
+ relay = matches[0];
81
+ } else if (configured.length > 1) return `This agent belongs to more than one workspace — say which: ${configured.join(", ")}`;
82
+ else relay = configured[0];
83
+ const wanted = (kinds?.length ? kinds : void 0) ?? (type ? [KINDS[type]] : void 0);
84
+ const by = authors?.length ? authors : void 0;
85
+ if (!wanted) return "Say what to look for: a `type`, or raw `kinds`.";
86
+ const stored = JSON.parse(readFileSync(keyFile, "utf8"));
87
+ const asWhom = getPublicKey(Uint8Array.from(stored.secretKey));
88
+ try {
89
+ const filter = {
90
+ kinds: wanted,
91
+ ...by ? { authors: by } : {},
92
+ limit: limit ?? 50
93
+ };
94
+ const { events, status, bytes } = await queryRelay(relay, Uint8Array.from(stored.secretKey), [filter]);
95
+ if (events.length === 0) return `Nothing in ${relay} matching ${JSON.stringify(filter)} (searched as ${asWhom.slice(0, 8)}…, relay answered ${status}).`;
96
+ return JSON.stringify(events.map((event) => ({
97
+ id: event.id,
98
+ author: event.pubkey,
99
+ created: new Date(event.created_at * 1e3).toISOString(),
100
+ name: event.tags.find((tag) => tag[0] === "name")?.[1],
101
+ slug: event.tags.find((tag) => tag[0] === "d")?.[1],
102
+ subject: event.tags.find((tag) => tag[0] === "subject")?.[1],
103
+ content: event.content?.slice(0, 400)
104
+ })), null, 1);
105
+ } catch (error) {
106
+ return `Could not read the workspace: ${error.message}`;
107
+ }
108
+ }
109
+ });
110
+ export { find_default as t };
package/dist/bridge.js CHANGED
@@ -93,7 +93,17 @@ export function buzzBridge(options) {
93
93
  inFlight.set(channel, queued.catch(() => { }));
94
94
  return queued;
95
95
  }
96
- async function ask(channel, text, pubkey) {
96
+ /**
97
+ * Where this conversation is happening, handed to the agent with the turn.
98
+ *
99
+ * An agent in two communities was asked "what projects exist in this relay?"
100
+ * and could not know which relay "this" was — so it reached for a
101
+ * human-in-the-loop question, which parks a turn until somebody answers it.
102
+ * In a channel nobody does, so the turn never finished and the room got
103
+ * nothing. The bridge knew the answer the whole time: it is the connection
104
+ * the message arrived on.
105
+ */
106
+ async function ask(channel, text, pubkey, community) {
97
107
  // Built per turn, for the person whose turn it is — and kept current for as
98
108
  // long as that turn runs.
99
109
  const client = clientFor(pubkey);
@@ -128,7 +138,7 @@ export function buzzBridge(options) {
128
138
  if (unread > 0) {
129
139
  log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
130
140
  }
131
- const response = await session.send(text);
141
+ const response = await session.send(text, { clientContext: { buzzCommunity: community, buzzChannel: channel } });
132
142
  const result = await response.result();
133
143
  // Where this turn left the conversation, so the next one starts after it.
134
144
  sessions.set(channel, { id: existing.id, streamIndex: session.state.streamIndex });
@@ -141,7 +151,10 @@ export function buzzBridge(options) {
141
151
  sessions.delete(channel);
142
152
  }
143
153
  }
144
- const created = await client.sessions.create({ message: text });
154
+ const created = await client.sessions.create({
155
+ message: text,
156
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
157
+ });
145
158
  const message = (await created.response.result()).message ?? "";
146
159
  sessions.set(channel, {
147
160
  id: created.session.state.sessionId,
@@ -233,7 +246,7 @@ export function buzzBridge(options) {
233
246
  void relay.react(event.id, SEEN);
234
247
  const stopTyping = relay.typingIn(channel);
235
248
  try {
236
- const reply = await serialize(channel, () => ask(channel, text, event.pubkey));
249
+ const reply = await serialize(channel, () => ask(channel, text, event.pubkey, from));
237
250
  if (!reply) {
238
251
  /**
239
252
  * Silence is the worst answer available.
package/dist/cli.js CHANGED
@@ -183,6 +183,73 @@ WantedBy=multi-user.target
183
183
  }
184
184
  process.stdout.write(unit);
185
185
  }
186
+ /**
187
+ * Put the Buzz CLI on this host, so the agent can act in the workspace.
188
+ *
189
+ * @remarks
190
+ * The bridge gives an agent a voice; the CLI is how Buzz expects an agent to
191
+ * DO anything — projects, issues, pull requests, notes, canvases. Its own
192
+ * README is explicit: the harness prompts the agent, "and the agent replies
193
+ * using the Buzz CLI".
194
+ *
195
+ * There is no published binary — the project releases desktop apps only — so
196
+ * this builds one, from a pinned revision, in a container, and puts it where
197
+ * the tool looks. That is a script's job and not a person's: the alternative
198
+ * is a client being walked through a git checkout and a Rust toolchain, which
199
+ * is not an answer anyone should give a customer.
200
+ *
201
+ * `BUZZ_CLI_URL` short-circuits the build for hosts that have no Docker: point
202
+ * it at a binary built once for the same platform.
203
+ */
204
+ async function installCli() {
205
+ const { execFileSync } = await import("node:child_process");
206
+ const { chmodSync, existsSync, mkdirSync } = await import("node:fs");
207
+ const { dirname, resolve } = await import("node:path");
208
+ const target = resolve(process.env.BUZZ_CLI_PATH ?? ".buzz/bin/buzz");
209
+ mkdirSync(dirname(target), { recursive: true });
210
+ const url = process.env.BUZZ_CLI_URL;
211
+ if (url) {
212
+ console.log(` downloading ${url}`);
213
+ const response = await fetch(url);
214
+ if (!response.ok)
215
+ throw new Error(`could not download the CLI (HTTP ${response.status})`);
216
+ const { writeFileSync } = await import("node:fs");
217
+ writeFileSync(target, Buffer.from(await response.arrayBuffer()));
218
+ chmodSync(target, 0o755);
219
+ console.log(` ✓ ${target}`);
220
+ return;
221
+ }
222
+ try {
223
+ execFileSync("docker", ["--version"], { stdio: "ignore" });
224
+ }
225
+ catch {
226
+ throw new Error("no docker on this host, and no BUZZ_CLI_URL set. Build the CLI once elsewhere for this " +
227
+ "platform and point BUZZ_CLI_URL at it, or install docker.");
228
+ }
229
+ // Pinned. A CLI that changes under a fleet on an unrelated restart is a
230
+ // fleet whose behaviour changed for reasons nobody can reconstruct.
231
+ const source = resolve(process.env.BUZZ_CLI_SRC ?? ".buzz/src");
232
+ if (!existsSync(source)) {
233
+ console.log(" fetching the CLI source (once)…");
234
+ execFileSync("git", ["clone", "--depth", "1", "https://github.com/block/buzz.git", source], {
235
+ stdio: "inherit",
236
+ });
237
+ }
238
+ console.log(" building — a few minutes the first time, then cached…");
239
+ execFileSync("docker", [
240
+ "run", "--rm",
241
+ "-v", `${source}:/src`,
242
+ "-w", "/src",
243
+ "rust:1-slim",
244
+ "sh", "-c",
245
+ "apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev >/dev/null 2>&1; cargo build --release -p buzz-cli",
246
+ ], { stdio: "inherit" });
247
+ const { copyFileSync } = await import("node:fs");
248
+ copyFileSync(`${source}/target/release/buzz`, target);
249
+ chmodSync(target, 0o755);
250
+ console.log(`\n ✓ ${target}`);
251
+ console.log(" The agent finds it there automatically; set BUZZ_CLI_PATH to move it.");
252
+ }
186
253
  const command = process.argv[2];
187
254
  if (command === "profile") {
188
255
  setProfile().catch((error) => {
@@ -196,6 +263,12 @@ else if (command === "run")
196
263
  run();
197
264
  else if (command === "service")
198
265
  service();
266
+ else if (command === "install-cli") {
267
+ installCli().catch((error) => {
268
+ console.error(`\n ${error.message}\n`);
269
+ process.exit(1);
270
+ });
271
+ }
199
272
  else if (command === "id") {
200
273
  const hex = asHexPubkey(process.argv[3]);
201
274
  if (!hex) {
@@ -209,7 +282,7 @@ else {
209
282
  console.log(`
210
283
  kybernesis-buzz — put an agent in a workspace
211
284
 
212
- init create or show this agent's identity, and what to invite
285
+ init create or show this agent's identity, and what to invite\n install-cli install the Buzz CLI, so the agent can act as well as talk
213
286
  profile say who this agent is, in every community it belongs to
214
287
  run run the bridge
215
288
  service [path] print (or write) a systemd unit for it
@@ -0,0 +1,10 @@
1
+ {
2
+ "kind": "eve-extension",
3
+ "formatVersion": 1,
4
+ "builtWithEve": "0.38.3",
5
+ "requires": {
6
+ "extension": 1,
7
+ "tool": 13,
8
+ "instructions": 2
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The agent's hands in Buzz.
3
+ *
4
+ * The bridge in this package gives an agent a voice — it hears mentions and
5
+ * answers as the person who asked. This gives it everything else the platform
6
+ * has: projects, issues, pull requests, patches, repositories, long-form notes,
7
+ * channel canvases, workflows, the activity feed, media.
8
+ *
9
+ * Buzz's own design says an agent acts through its CLI — the harness prompts
10
+ * the agent, "and the agent replies using the Buzz CLI". We replaced the
11
+ * harness half to get governed per-person identity, and for a while shipped
12
+ * only that half: agents could talk in a workspace and do nothing in it, and
13
+ * would say Projects did not exist because nothing had told them otherwise.
14
+ */
15
+ declare const _default: import("eve/extension").NoConfigExtensionHandle;
16
+ export default _default;
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { defineExtension } from "eve/extension";
8
+ var extension_default = defineExtension();
9
+ export { extension_default as default };
@@ -0,0 +1,116 @@
1
+ ## Acting in Buzz
2
+
3
+ You are a **member** of this workspace, with your own standing — not a visitor
4
+ driving someone else's account. Alongside talking in channels you can use the
5
+ `buzz` tool to work in it: projects, issues, pull requests, patches, repos,
6
+ long-form notes, channel canvases, workflows, the feed, media, custom emoji.
7
+
8
+ Run `buzz` with `help` (or a group's `--help`) to see exactly what a group takes
9
+ rather than guessing at flags. The surface is large and changes; the help output
10
+ is authoritative and this page is not.
11
+
12
+ The groups, and what each is for:
13
+
14
+ | group | use it for |
15
+ | --- | --- |
16
+ | `projects` | multi-repo projects — create, list, add or remove repos, update, delete |
17
+ | `issues` | file, read, list and re-status issues |
18
+ | `pr` | open, update, list and set status on pull requests |
19
+ | `patches` | send, read, list and re-status git patches |
20
+ | `repos` | announce and discover git repositories |
21
+ | `notes` | long-form notes — the team's written knowledge |
22
+ | `canvas` | a channel's canvas document: read it, replace it |
23
+ | `channels` | create and configure channels |
24
+ | `messages` | send, read, search, manage messages |
25
+ | `dms` | list, open and manage direct messages |
26
+ | `reactions` `emoji` | reactions, and the custom emoji palette |
27
+ | `users` | look people up; manage your own profile and presence |
28
+ | `workflows` | create, trigger and manage workflows |
29
+ | `feed` | read the activity feed |
30
+ | `media` `upload` | put files in the workspace and fetch them back |
31
+ | `moderation` | reports queue, bans, timeouts, audit trail |
32
+
33
+ Three things to hold onto:
34
+
35
+ **What you do is yours.** These actions are signed with your own key and appear
36
+ under your name, not the name of whoever asked. Someone asking you to file an
37
+ issue produces an issue filed by you. Act accordingly: say what you are about to
38
+ do before doing something a person would want to have been asked about first,
39
+ and never take a destructive action — deleting, banning, removing — on an
40
+ instruction you were not clearly given.
41
+
42
+ **The workspace decides what you may do, not this list.** Permissions are your
43
+ membership's. If an action comes back refused, that is an answer, not a fault to
44
+ work around: report it plainly rather than retrying it another way.
45
+
46
+ **Read before you write.** `list` and `get` cost nothing and stop you inventing
47
+ a project that already exists, or filing an issue twice under different words.
48
+
49
+ ### Never park a turn waiting for an answer
50
+
51
+ If you need something from the person, **ask in your reply**. Do not call a
52
+ human-in-the-loop tool — the kind that suspends a turn until someone responds.
53
+
54
+ Those tools assume a surface where a turn can wait. A channel is not one: the
55
+ turn parks, nothing is posted, and the person sees you go quiet — which is the
56
+ same thing they saw when the bridge was broken, and it is not distinguishable
57
+ from being ignored. It has happened: asked which of two workspaces to look in,
58
+ an agent raised an input request instead of asking out loud, and the question
59
+ sat unanswered forever because nobody could see it.
60
+
61
+ Your reply IS the way to ask. Say what you need, and let their next message
62
+ answer it — that is what a conversation is.
63
+
64
+ ### You are told where you are
65
+
66
+ Every turn carries `buzzCommunity` and `buzzChannel`: the workspace this
67
+ conversation is in, and the channel it is in. When someone says "this relay" or
68
+ "here", that is what they mean — use it as the `community` for tool calls rather
69
+ than asking which one. Ask only when they clearly mean a workspace other than
70
+ the one you are talking in.
71
+
72
+ ### Answering is not a tool call
73
+
74
+ When someone talks to you in Buzz, **your reply is the text you return for the
75
+ turn**. The bridge posts it: threaded to the message you are answering, with the
76
+ seen mark and the typing indicator already handled.
77
+
78
+ Do not use `messages send` to answer the message you are currently answering.
79
+ Both paths post as you, so the room gets the same answer twice — once threaded
80
+ and once loose — and the two arrive in an order nobody chose. Answering twice is
81
+ a bug people have already lived through here; do not recreate it from the other
82
+ side.
83
+
84
+ `messages send` is for messages that are **not** this turn's answer:
85
+
86
+ - posting into a *different* channel ("tell #eng the deploy is done")
87
+ - starting a conversation nobody prompted — a scheduled summary, a heads-up
88
+ - following up later, after the turn that was asked of you has ended
89
+
90
+ The same distinction applies to reactions: the bridge marks the message you are
91
+ answering, so use `reactions` only on *other* messages.
92
+
93
+ ### Projects and repos overlap
94
+
95
+ The Projects view people look at lists **both**: entries with a project record of
96
+ their own, and entries that exist only as a repository. So "what projects do we
97
+ have?" is answered by looking at both kinds, not just projects — answering with
98
+ one of them is how you tell someone an item they are looking at right now does
99
+ not exist.
100
+
101
+ Say which is which when it matters ("three, though one is a repo without a
102
+ project record"), and never correct someone's own vocabulary at the cost of
103
+ answering their question.
104
+
105
+ ### Which workspace
106
+
107
+ If this agent belongs to more than one community, every command must say which
108
+ one — pass `community` with any distinctive part of its address. There is no
109
+ default: a project created in the wrong company's workspace is not a mistake
110
+ that can be taken back.
111
+
112
+ ### When it is not installed
113
+
114
+ If the tool says the CLI is missing, say so plainly and stop. It is one command
115
+ on the host (`kybernesis-buzz install-cli`) and it is not something to work
116
+ around by posting a message asking someone else to do the task.
@@ -0,0 +1,10 @@
1
+ declare const _default: import("eve/tools").ToolDefinition<{
2
+ args: string[];
3
+ community?: string | undefined;
4
+ }, string> & {
5
+ execute(input: {
6
+ args: string[];
7
+ community?: string | undefined;
8
+ }, ctx: import("eve/tools").ToolContext): Promise<string>;
9
+ };
10
+ export default _default;
@@ -0,0 +1,56 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { execFile } from "node:child_process";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { promisify } from "node:util";
11
+ import { defineTool } from "eve/tools";
12
+ import { z } from "zod";
13
+ const run = promisify(execFile);
14
+ function secretKeyHex(keyFile) {
15
+ const stored = JSON.parse(readFileSync(keyFile, "utf8"));
16
+ return Buffer.from(Uint8Array.from(stored.secretKey)).toString("hex");
17
+ }
18
+ var buzz_default = defineTool({
19
+ description: "Act in the Buzz workspace: projects, issues, pull requests, patches, repos, long-form notes, channel canvases, workflows, the activity feed, media, emoji, moderation. Pass the command as a list of arguments, e.g. [\"projects\",\"list\"] or [\"issues\",\"create\",\"--repo\",\"acme/api\",\"--title\",\"Login fails on Safari\"]. Use [\"help\"] or [\"<group>\",\"--help\"] to see what a group takes instead of guessing at flags. Actions are signed as THIS AGENT and appear under its name. Do NOT use it to answer the message you are currently answering — returning your reply for the turn does that, threaded and marked; sending as well posts the same answer twice. Use messages/dms only for a DIFFERENT channel, an unprompted message, or a later follow-up.",
20
+ inputSchema: z.object({
21
+ args: z.array(z.string()).min(1).describe("The command as separate arguments, without the leading \"buzz\". Example: [\"projects\",\"get\",\"--slug\",\"atlas\"]."),
22
+ community: z.string().optional().describe("Which workspace to act in, when this agent belongs to more than one. Any distinctive part of its address is enough. Omit it when there is only one.")
23
+ }),
24
+ async execute({ args, community }) {
25
+ const local = ".buzz/bin/buzz";
26
+ const binary = process.env.BUZZ_CLI_PATH ?? (existsSync(local) ? resolve(local) : "buzz");
27
+ const keyFile = process.env.BUZZ_KEYFILE ?? ".buzz-key.json";
28
+ const configured = (process.env.BUZZ_RELAY_HTTP ?? process.env.BUZZ_RELAY ?? "").split(",").map((entry) => entry.trim().replace(/^ws/, "http")).filter(Boolean);
29
+ if (configured.length === 0) return "No Buzz relay configured: set BUZZ_RELAY (the bridge's websocket URL) or BUZZ_RELAY_HTTP.";
30
+ let relay;
31
+ if (community) {
32
+ const matches = configured.filter((entry) => entry.includes(community));
33
+ if (matches.length !== 1) return `"${community}" ${matches.length === 0 ? "matches none of" : "matches more than one of"} this agent's workspaces: ${configured.join(", ")}`;
34
+ relay = matches[0];
35
+ } else if (configured.length > 1) return `This agent belongs to more than one workspace, so say which one to act in: ${configured.join(", ")}. Pass it as "community".`;
36
+ else relay = configured[0];
37
+ if (!existsSync(keyFile)) return `No Buzz identity at ${keyFile}. This agent has no standing in the workspace until \`kybernesis-buzz init\` has run on this host.`;
38
+ try {
39
+ const { stdout } = await run(binary, args, {
40
+ env: {
41
+ ...process.env,
42
+ BUZZ_RELAY_URL: relay,
43
+ BUZZ_PRIVATE_KEY: secretKeyHex(keyFile)
44
+ },
45
+ timeout: 12e4,
46
+ maxBuffer: 4194304
47
+ });
48
+ return stdout.trim() || "(no output)";
49
+ } catch (error) {
50
+ const failure = error;
51
+ if (failure.code === "ENOENT") return `The Buzz CLI is not installed on this host (looked for "${binary}"). Install it with \`kybernesis-buzz install-cli\`, or set BUZZ_CLI_PATH to where it lives.`;
52
+ return (failure.stderr ?? failure.message ?? "").trim() || "The Buzz CLI failed without saying why.";
53
+ }
54
+ }
55
+ });
56
+ export { buzz_default as default };
@@ -0,0 +1,16 @@
1
+ declare const _default: import("eve/tools").ToolDefinition<{
2
+ type?: "issue" | "message" | "note" | "patch" | "profile" | "project" | "repo" | undefined;
3
+ kinds?: number[] | undefined;
4
+ authors?: string[] | undefined;
5
+ limit?: number | undefined;
6
+ community?: string | undefined;
7
+ }, string> & {
8
+ execute(input: {
9
+ type?: "issue" | "message" | "note" | "patch" | "profile" | "project" | "repo" | undefined;
10
+ kinds?: number[] | undefined;
11
+ authors?: string[] | undefined;
12
+ limit?: number | undefined;
13
+ community?: string | undefined;
14
+ }, ctx: import("eve/tools").ToolContext): Promise<string>;
15
+ };
16
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { t as find_default } from "../../_chunks/find-Dww643iv.mjs";
8
+ export { find_default as default };
package/dist/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import extension_default from "./extension/extension.mjs";
8
+ export { extension_default as buzz, extension_default as default };
@@ -0,0 +1,22 @@
1
+ import { type NostrEvent } from "./relay.js";
2
+ /**
3
+ * Read the whole workspace, not just your own corner of it.
4
+ *
5
+ * @remarks
6
+ * The Buzz CLI's list commands are owner-scoped — `--owner` defaults to the
7
+ * current identity — which is right for "my repos" and wrong for the question
8
+ * people actually ask an agent: *what projects exist here?* Asked that in a
9
+ * workspace holding three, an agent answered "none", because it had listed its
10
+ * own and had none. It was not broken and it was not lying; it had been given a
11
+ * tool that could only see one author and no way to know that mattered.
12
+ *
13
+ * There is no CLI command for the general case, but the relay has the endpoint
14
+ * the CLI itself uses: `POST /query`, NIP-98 signed, taking an array of Nostr
15
+ * filters. That is one request away from every kind the platform stores —
16
+ * projects, repos, issues, patches, notes — across everyone in the workspace.
17
+ */
18
+ export declare function queryRelay(relayHttpUrl: string, secretKey: Uint8Array, filters: readonly Record<string, unknown>[]): Promise<{
19
+ events: NostrEvent[];
20
+ status: number;
21
+ bytes: number;
22
+ }>;
package/dist/query.js ADDED
@@ -0,0 +1,75 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { finalizeEvent } from "nostr-tools/pure";
3
+ import { KIND_HTTP_AUTH } from "./relay.js";
4
+ /**
5
+ * Read the whole workspace, not just your own corner of it.
6
+ *
7
+ * @remarks
8
+ * The Buzz CLI's list commands are owner-scoped — `--owner` defaults to the
9
+ * current identity — which is right for "my repos" and wrong for the question
10
+ * people actually ask an agent: *what projects exist here?* Asked that in a
11
+ * workspace holding three, an agent answered "none", because it had listed its
12
+ * own and had none. It was not broken and it was not lying; it had been given a
13
+ * tool that could only see one author and no way to know that mattered.
14
+ *
15
+ * There is no CLI command for the general case, but the relay has the endpoint
16
+ * the CLI itself uses: `POST /query`, NIP-98 signed, taking an array of Nostr
17
+ * filters. That is one request away from every kind the platform stores —
18
+ * projects, repos, issues, patches, notes — across everyone in the workspace.
19
+ */
20
+ export async function queryRelay(relayHttpUrl, secretKey, filters) {
21
+ const url = `${relayHttpUrl.replace(/\/$/, "")}/query`;
22
+ const body = JSON.stringify(filters);
23
+ // Signed exactly as the socket path signs: the signature binds the body, so a
24
+ // filter cannot be swapped in transit.
25
+ const auth = finalizeEvent({
26
+ kind: KIND_HTTP_AUTH,
27
+ created_at: Math.floor(Date.now() / 1000),
28
+ tags: [
29
+ ["u", url],
30
+ ["method", "POST"],
31
+ // A nonce, because the relay rejects a repeated auth event as a replay.
32
+ // Without it two identical requests — the same query twice, the same
33
+ // reaction twice — sign byte-identically and the second is refused. The
34
+ // refusal does not always look like one either: a duplicated read came
35
+ // back as an empty list, so an agent reported an empty workspace instead
36
+ // of a rejected request.
37
+ ["nonce", randomUUID()],
38
+ ["payload", createHash("sha256").update(body).digest("hex")],
39
+ ],
40
+ content: "",
41
+ }, secretKey);
42
+ // Bound to this module deliberately: eve's runtime replaces the global fetch
43
+ // in the deployed server, and a request that goes through a different client
44
+ // than the one that signed it can arrive unauthenticated — which this relay
45
+ // answers with an empty list rather than a refusal, so it reads as "nothing
46
+ // here" instead of "you are not who you said you were".
47
+ /**
48
+ * The owner-attestation tag, when the workspace issues one.
49
+ *
50
+ * @remarks
51
+ * NIP-98 proves which key made the request; this proves the workspace agreed
52
+ * that key may act on someone's behalf. Some operations — owner-reviewed
53
+ * agent drafts among them — are refused without it, and the refusal names
54
+ * neither the tag nor the header, so it reads as a permissions problem with
55
+ * no obvious cause. Optional, because most of the surface never asks.
56
+ */
57
+ const authTag = process.env.BUZZ_AUTH_TAG?.trim();
58
+ const response = await fetch(url, {
59
+ method: "POST",
60
+ headers: {
61
+ authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`,
62
+ "content-type": "application/json",
63
+ ...(authTag ? { "x-auth-tag": authTag } : {}),
64
+ },
65
+ body,
66
+ });
67
+ const text = await response.text();
68
+ if (!response.ok) {
69
+ throw new Error(`relay ${response.status}: ${text.slice(0, 300)}`);
70
+ }
71
+ // The status and size travel with the answer because an empty list and a
72
+ // rejected request look identical to a caller that only sees the events, and
73
+ // this relay answers some refusals with 200 and an empty array.
74
+ return { events: JSON.parse(text), status: response.status, bytes: text.length };
75
+ }
package/dist/relay.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { finalizeEvent } from "nostr-tools/pure";
3
3
  /**
4
4
  * The workspace protocol, as far as a member needs it.
@@ -235,6 +235,13 @@ export class BuzzRelay {
235
235
  tags: [
236
236
  ["u", url],
237
237
  ["method", "POST"],
238
+ // A nonce, because the relay rejects a repeated auth event as a replay.
239
+ // Without it two identical requests — the same query twice, the same
240
+ // reaction twice — sign byte-identically and the second is refused. The
241
+ // refusal does not always look like one either: a duplicated read came
242
+ // back as an empty list, so an agent reported an empty workspace instead
243
+ // of a rejected request.
244
+ ["nonce", randomUUID()],
238
245
  ["payload", createHash("sha256").update(body).digest("hex")],
239
246
  ],
240
247
  content: "",
@@ -0,0 +1,4 @@
1
+ // Generated by eve. Do not edit by hand.
2
+
3
+ export { default as buzz } from "../extension/tools/buzz.js";
4
+ export { default as find } from "../extension/tools/find.js";
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import buzz_default from "../extension/tools/buzz.mjs";
8
+ import { t as find_default } from "../_chunks/find-Dww643iv.mjs";
9
+ export { buzz_default as buzz, find_default as find };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.5.1",
3
+ "version": "0.6.3",
4
4
  "description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,20 +24,29 @@
24
24
  "exports": {
25
25
  ".": {
26
26
  "types": "./dist/index.d.ts",
27
- "default": "./dist/index.js"
27
+ "default": "./dist/index.mjs"
28
28
  },
29
- "./package.json": "./package.json"
29
+ "./package.json": "./package.json",
30
+ "./tools": {
31
+ "types": "./dist/tools/index.d.ts",
32
+ "default": "./dist/tools/index.mjs"
33
+ },
34
+ "./extension": {
35
+ "types": "./dist/extension/extension.d.ts",
36
+ "default": "./dist/extension/extension.mjs"
37
+ }
30
38
  },
31
39
  "scripts": {
32
- "build": "tsc -p tsconfig.build.json",
33
- "prepack": "tsc -p tsconfig.build.json",
40
+ "build": "eve extension build && tsc -p tsconfig.build.json",
41
+ "prepack": "eve extension build && tsc -p tsconfig.build.json",
34
42
  "typecheck": "tsc --noEmit",
35
43
  "prepublishOnly": "node ../../scripts/prepublish.mjs",
36
44
  "test": "node --test \"test/**/*.test.mjs\""
37
45
  },
38
46
  "dependencies": {
39
47
  "@kybernesis/enterprise": "^0.7.0",
40
- "nostr-tools": "^2.10.0"
48
+ "nostr-tools": "^2.10.0",
49
+ "zod": "^4"
41
50
  },
42
51
  "devDependencies": {
43
52
  "@types/node": "24.x",
@@ -54,5 +63,11 @@
54
63
  "type": "git",
55
64
  "url": "https://github.com/KybernesisAI/platform.git",
56
65
  "directory": "packages/buzz"
66
+ },
67
+ "eve": {
68
+ "extension": {
69
+ "source": "./extension",
70
+ "dist": "./dist/extension"
71
+ }
57
72
  }
58
73
  }