baychat 0.1.0 → 0.3.0

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 CHANGED
@@ -30,6 +30,7 @@ per session, never one that another integration already uses.
30
30
  |---------|-------------|
31
31
  | `baychat pair <code> [--base <url>]` | Redeem a pairing code and store credentials |
32
32
  | `baychat whoami` | Show the connected agent identity |
33
+ | `baychat qr [<conv>]` | Render this agent's connection QR right in the terminal — scan it with BuzzRelay or any BayChat-aware app |
33
34
  | `baychat conversations` | List conversations this agent participates in |
34
35
  | `baychat send <conv> <text>` | Send a message |
35
36
  | `baychat check <conv>` | Print messages since the last check (cursor-based) |
@@ -69,6 +70,6 @@ watching when the user asks you to leave the chat.
69
70
 
70
71
  ## Requirements
71
72
 
72
- Node.js ≥ 20. Zero runtime dependencies.
73
+ Node.js ≥ 20. One runtime dependency (`qrcode`, pure JS).
73
74
 
74
75
  MIT © BayChat
package/dist/api.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiError = void 0;
4
4
  exports.apiRequest = apiRequest;
5
+ exports.fetchContext = fetchContext;
5
6
  exports.pairRequest = pairRequest;
6
7
  class ApiError extends Error {
7
8
  status;
@@ -36,6 +37,24 @@ async function apiRequest(creds, method, apiPath, body) {
36
37
  throw await parseError(res);
37
38
  return (await res.json());
38
39
  }
40
+ /**
41
+ * Fetch the Agent Context Contract v2 block for a conversation.
42
+ * Fail-soft: a v1 server has no `/context` endpoint and 404s — we return null
43
+ * so the caller keeps behaving like today. Other failures are logged (house
44
+ * rule: never swallow errors silently) and also downgrade to null rather than
45
+ * blocking the watch/check loop on a best-effort roster lookup.
46
+ */
47
+ async function fetchContext(creds, conversationId) {
48
+ try {
49
+ return await apiRequest(creds, "GET", `/api/agent-api/conversations/${conversationId}/context`);
50
+ }
51
+ catch (err) {
52
+ if (err instanceof ApiError && err.status === 404)
53
+ return null; // old server / not a participant view
54
+ console.error(`baychat: context lookup failed (${err instanceof Error ? err.message : String(err)}) — using id fallbacks`);
55
+ return null;
56
+ }
57
+ }
39
58
  async function pairRequest(baseUrl, code) {
40
59
  const res = await fetch(`${baseUrl}/api/agent-api/pair`, {
41
60
  method: "POST",
package/dist/commands.js CHANGED
@@ -1,14 +1,22 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.requireCredentials = requireCredentials;
4
7
  exports.cmdPair = cmdPair;
5
8
  exports.cmdWhoami = cmdWhoami;
6
9
  exports.cmdConversations = cmdConversations;
7
10
  exports.cmdSend = cmdSend;
11
+ exports.resetSessionState = resetSessionState;
8
12
  exports.cmdCheck = cmdCheck;
9
13
  exports.cmdWatch = cmdWatch;
14
+ exports.cmdQr = cmdQr;
15
+ const qrcode_1 = __importDefault(require("qrcode"));
10
16
  const api_1 = require("./api");
17
+ const connection_qr_1 = require("./connection-qr");
11
18
  const config_1 = require("./config");
19
+ const context_1 = require("./context");
12
20
  const DEFAULT_BASE_URL = "https://api.baychat.io";
13
21
  function requireCredentials() {
14
22
  const creds = (0, config_1.loadCredentials)();
@@ -54,10 +62,58 @@ async function agentNameMap(creds) {
54
62
  return new Map(); // name resolution is best-effort; fall back to ids
55
63
  }
56
64
  }
57
- function senderLabel(m, names) {
58
- if (m.senderType === "AGENT")
59
- return names.get(m.senderId) ?? `agent:${m.senderId.slice(0, 8)}`;
60
- return `user:${m.senderId.slice(0, 8)}`;
65
+ /**
66
+ * Render one message line, v2-aware:
67
+ * [HH:MM] Name (member|admin|agent|orchestrator): text → you should respond
68
+ * Name resolution order: payload `sender.name` → cached context roster →
69
+ * best-effort `/agents` map (v1) → `user:`/`agent:<id8>` fallback.
70
+ */
71
+ function renderMessageLine(m, roster, agentNames, ownId) {
72
+ const rp = roster.get(m.senderId);
73
+ const kind = m.sender?.kind ?? rp?.kind ?? (m.senderType === "AGENT" ? "agent" : "user");
74
+ const fallback = (0, context_1.idFallback)(kind, m.senderId);
75
+ const name = m.sender?.name ?? rp?.name ?? agentNames.get(m.senderId) ?? fallback;
76
+ const word = (0, context_1.roleWord)({
77
+ kind,
78
+ role: m.sender?.role ?? rp?.role ?? null,
79
+ isOrchestrator: rp?.isOrchestrator ?? false,
80
+ });
81
+ let line = `[${(0, context_1.formatClock)(m.createdAt)}] ${name} (${word}): ${m.content}`;
82
+ // Routing markers computed by the server for THIS agent (spec §2/§3).
83
+ if (m.shouldRespond === true)
84
+ line += " → you should respond";
85
+ else if (Array.isArray(m.mentions) && m.mentions.includes(ownId))
86
+ line += " → you were mentioned";
87
+ return line;
88
+ }
89
+ // Whether every fresh message can already be named without a context refresh.
90
+ function allSendersResolvable(messages, roster, agentNames) {
91
+ return messages.every((m) => Boolean(m.sender?.name) || roster.has(m.senderId) || agentNames.has(m.senderId));
92
+ }
93
+ // ─── Per-process session caches ───────────────────────────────────────────
94
+ // The watch loop calls cmdCheck repeatedly in one process, so cache the v2
95
+ // context and only print the roster header once per conversation.
96
+ const contextCache = new Map();
97
+ const headerPrinted = new Set();
98
+ async function ensureContext(creds, conversationId, force = false) {
99
+ if (!force && contextCache.has(conversationId))
100
+ return contextCache.get(conversationId) ?? null;
101
+ const ctx = await (0, api_1.fetchContext)(creds, conversationId);
102
+ if (ctx)
103
+ contextCache.set(conversationId, ctx);
104
+ return ctx;
105
+ }
106
+ function maybePrintHeader(conversationId, ctx, creds) {
107
+ if (!ctx || headerPrinted.has(conversationId))
108
+ return;
109
+ headerPrinted.add(conversationId);
110
+ console.log((0, context_1.rosterHeader)(ctx, creds.agent.name));
111
+ }
112
+ /** Reset per-process session state — for tests, and harmless in normal use. */
113
+ function resetSessionState() {
114
+ contextCache.clear();
115
+ headerPrinted.clear();
116
+ resolvedEnvAgentId = null;
61
117
  }
62
118
  // The BAYCHAT_TOKEN env path can't know the agent id up front, so config.ts
63
119
  // returns the sentinel "env". Resolve it to the real id via /me once per process
@@ -75,6 +131,10 @@ async function ownAgentId(creds) {
75
131
  async function cmdCheck(conversationId) {
76
132
  const creds = requireCredentials();
77
133
  const cursor = (0, config_1.loadCursor)(conversationId);
134
+ // Fetch the v2 context (roster/policy/you) and print the roster header once.
135
+ // Fail-soft: on a v1 server fetchContext returns null and we behave like today.
136
+ const startCtx = await ensureContext(creds, conversationId);
137
+ maybePrintHeader(conversationId, startCtx, creds);
78
138
  if (!cursor) {
79
139
  // First check: don't dump history. Anchor the cursor at "now"; only
80
140
  // messages sent after this moment will be reported.
@@ -83,7 +143,13 @@ async function cmdCheck(conversationId) {
83
143
  console.log(`Watching ${conversationId} from ${now}. Run check/watch again for new messages.`);
84
144
  return 0;
85
145
  }
86
- const { messages } = await (0, api_1.apiRequest)(creds, "GET", `/api/agent-api/conversations/${conversationId}/messages?since=${encodeURIComponent(cursor)}`);
146
+ const res = await (0, api_1.apiRequest)(creds, "GET", `/api/agent-api/conversations/${conversationId}/messages?since=${encodeURIComponent(cursor)}`);
147
+ const messages = res.messages;
148
+ // The v2 poll envelope carries a fresh context block — cache it for free. The header was
149
+ // already printed from the start-of-check context (or stays unprinted on a v1 server).
150
+ if (res.context) {
151
+ contextCache.set(conversationId, res.context);
152
+ }
87
153
  const ownId = await ownAgentId(creds);
88
154
  const fresh = messages.filter((m) => !m.deletedAt && m.senderId !== ownId);
89
155
  if (messages.length > 0) {
@@ -91,9 +157,22 @@ async function cmdCheck(conversationId) {
91
157
  }
92
158
  if (fresh.length === 0)
93
159
  return 0;
94
- const names = await agentNameMap(creds);
160
+ const cachedContext = contextCache.get(conversationId) ?? null;
161
+ let roster = (0, context_1.rosterFromContext)(cachedContext);
162
+ // v1 fallback: without a v2 roster, resolve agent names via /agents (humans
163
+ // stay opaque as they did before). On v2 the roster already has everyone.
164
+ const agentNames = cachedContext
165
+ ? new Map()
166
+ : await agentNameMap(creds);
167
+ // An unknown sender means the roster is stale (someone just joined) — refresh
168
+ // the context once and re-index before rendering.
169
+ if (!allSendersResolvable(fresh, roster, agentNames)) {
170
+ const refreshed = await ensureContext(creds, conversationId, true);
171
+ if (refreshed)
172
+ roster = (0, context_1.rosterFromContext)(refreshed);
173
+ }
95
174
  for (const m of fresh) {
96
- console.log(`[${m.createdAt}] ${senderLabel(m, names)}: ${m.content}`);
175
+ console.log(renderMessageLine(m, roster, agentNames, ownId));
97
176
  }
98
177
  return fresh.length;
99
178
  }
@@ -117,3 +196,17 @@ async function cmdWatch(conversationId, opts = {}) {
117
196
  console.log("No new messages before timeout.");
118
197
  return false;
119
198
  }
199
+ async function cmdQr(conversationId) {
200
+ const creds = requireCredentials();
201
+ // The QR carries this agent's API URL + token (baychat.connection v1) — the
202
+ // same contract the app's "Show QR" renders. Never print the raw token.
203
+ const payload = (0, connection_qr_1.buildBayChatConnectionQrPayload)({
204
+ apiBaseUrl: creds.baseUrl,
205
+ apiToken: creds.token,
206
+ conversationId: conversationId ?? null,
207
+ });
208
+ const ascii = await qrcode_1.default.toString(payload, { type: "terminal", small: true, errorCorrectionLevel: "M" });
209
+ console.log(ascii);
210
+ console.log("Scan with a BayChat-aware app (e.g. BuzzRelay settings).");
211
+ console.log("⚠ This code contains this agent's API token — only show it to scanners you trust.");
212
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildBayChatConnectionQrPayload = buildBayChatConnectionQrPayload;
4
+ function normalizePublicHttpsUrl(value) {
5
+ const parsed = new URL(value.trim());
6
+ if (parsed.protocol !== "https:") {
7
+ throw new Error("BayChat connection QR requires a public HTTPS API URL");
8
+ }
9
+ parsed.pathname = parsed.pathname.replace(/\/$/, "");
10
+ parsed.search = "";
11
+ parsed.hash = "";
12
+ return parsed.toString().replace(/\/$/, "");
13
+ }
14
+ function buildBayChatConnectionQrPayload({ apiBaseUrl, apiToken, conversationId, }) {
15
+ const token = apiToken.trim();
16
+ if (!/^bay_[A-Za-z0-9._-]+$/.test(token)) {
17
+ throw new Error("A valid BayChat Agent API token is required");
18
+ }
19
+ const connection = {
20
+ api_base_url: normalizePublicHttpsUrl(apiBaseUrl),
21
+ api_token: token,
22
+ };
23
+ const normalizedConversationId = conversationId?.trim();
24
+ if (normalizedConversationId) {
25
+ connection.conversation_id = normalizedConversationId;
26
+ }
27
+ const payload = {
28
+ type: "baychat.connection",
29
+ version: 1,
30
+ connection,
31
+ };
32
+ return JSON.stringify(payload);
33
+ }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ // Agent Context Contract v2 — client-side types and pure formatting helpers.
3
+ //
4
+ // Every field the server added in v2 is OPTIONAL here: a v1 server (today's
5
+ // deployment) never sends `sender`, `mentions`, `shouldRespond`, or the
6
+ // `context` envelope, and the `/context` endpoint 404s. All rendering must
7
+ // degrade gracefully to the v1 behavior (id-prefix fallbacks) when they are
8
+ // absent. Keep this module free of I/O so it stays trivially testable.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.rosterFromContext = rosterFromContext;
11
+ exports.roleWord = roleWord;
12
+ exports.formatClock = formatClock;
13
+ exports.replyModeLabel = replyModeLabel;
14
+ exports.idFallback = idFallback;
15
+ exports.rosterHeader = rosterHeader;
16
+ /** Index a context's participants by id for O(1) sender resolution. */
17
+ function rosterFromContext(ctx) {
18
+ const map = new Map();
19
+ for (const p of ctx?.participants ?? [])
20
+ map.set(p.id, p);
21
+ return map;
22
+ }
23
+ /**
24
+ * The single conversation-role word shown after a name — one of
25
+ * `orchestrator` | `agent` | `admin` | `member`. Orchestrator wins over kind,
26
+ * kind (agent) wins over role, then admin, else member.
27
+ */
28
+ function roleWord(entry) {
29
+ if (entry.isOrchestrator)
30
+ return "orchestrator";
31
+ if (entry.kind === "agent")
32
+ return "agent";
33
+ if (entry.role === "ADMIN")
34
+ return "admin";
35
+ return "member";
36
+ }
37
+ /** `[HH:MM]` local-time clock; falls back to the raw string if unparseable. */
38
+ function formatClock(iso) {
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime()))
41
+ return iso;
42
+ const hh = String(d.getHours()).padStart(2, "0");
43
+ const mm = String(d.getMinutes()).padStart(2, "0");
44
+ return `${hh}:${mm}`;
45
+ }
46
+ /** Human-friendly reply-mode label from the raw AgentReplyPolicy enum. */
47
+ function replyModeLabel(policy) {
48
+ switch (policy) {
49
+ case "ORCHESTRATOR":
50
+ return "Orchestrator";
51
+ case "DEDICATED":
52
+ return "Dedicated";
53
+ case "MENTIONS":
54
+ return "Mentions";
55
+ case "ROUTER":
56
+ return "Router";
57
+ default:
58
+ return policy ?? "Default";
59
+ }
60
+ }
61
+ /** The `user:`/`agent:<id8>` display fallback when no name is known. Shared so the roster
62
+ * header and per-message rendering produce the identical string for an unnamed sender. */
63
+ function idFallback(kind, id) {
64
+ return `${kind === "agent" ? "agent" : "user"}:${id.slice(0, 8)}`;
65
+ }
66
+ function displayName(p) {
67
+ return p.name ?? idFallback(p.kind, p.id);
68
+ }
69
+ /**
70
+ * The roster header printed once when a session starts watching/checking a
71
+ * conversation, e.g.
72
+ *
73
+ * You are "Claude-Terminal" (orchestrator) in "Project X" — members:
74
+ * Karmen (admin), Manuel, BuzzRelay (agent). Reply mode: Orchestrator,
75
+ * max agent rounds: 2.
76
+ *
77
+ * `selfName` is the fallback for the "you are" name when the roster does not
78
+ * carry the current agent (should not happen, but never block on it).
79
+ */
80
+ function rosterHeader(ctx, selfName) {
81
+ const participants = ctx.participants ?? [];
82
+ const youId = ctx.you?.agentId;
83
+ const self = youId ? participants.find((p) => p.id === youId) : undefined;
84
+ const youName = self?.name ?? selfName;
85
+ const youRole = ctx.you?.isOrchestrator ? "orchestrator" : roleWord(self ?? { kind: "agent" });
86
+ const title = ctx.conversation?.title ?? "this conversation";
87
+ const others = participants.filter((p) => p.id !== youId);
88
+ const memberList = others
89
+ .map((p) => {
90
+ const word = roleWord(p);
91
+ return word === "member" ? displayName(p) : `${displayName(p)} (${word})`;
92
+ })
93
+ .join(", ");
94
+ let header = `You are "${youName}" (${youRole}) in "${title}"`;
95
+ header += memberList ? ` — members: ${memberList}.` : ".";
96
+ const policy = ctx.policy;
97
+ if (policy?.agentReplyPolicy) {
98
+ header += ` Reply mode: ${replyModeLabel(policy.agentReplyPolicy)}`;
99
+ if (typeof policy.maxAgentRounds === "number") {
100
+ header += `, max agent rounds: ${policy.maxAgentRounds}`;
101
+ }
102
+ header += ".";
103
+ }
104
+ return header;
105
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ Usage:
10
10
  baychat conversations List conversations this agent is in
11
11
  baychat send <conversationId> <text> Send a message
12
12
  baychat check <conversationId> Print messages since the last check
13
+ baychat qr [<conversationId>] Render this agent's connection QR in the terminal
13
14
  baychat watch <conversationId> [--interval <sec>] [--timeout <sec>]
14
15
  Block until new messages arrive (exit 0)
15
16
  or timeout (exit 2)
@@ -30,6 +31,9 @@ async function main() {
30
31
  await (0, commands_1.cmdPair)(args[0], flag(args, "--base"));
31
32
  return 0;
32
33
  }
34
+ case "qr":
35
+ await (0, commands_1.cmdQr)(args[0]);
36
+ return 0;
33
37
  case "whoami":
34
38
  await (0, commands_1.cmdWhoami)();
35
39
  return 0;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.1.0",
4
- "description": "BayChat connector CLI \u2014 pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
3
+ "version": "0.3.0",
4
+ "description": "BayChat connector CLI pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
- "baychat": "./dist/index.js"
6
+ "baychat": "dist/index.js"
7
7
  },
8
8
  "main": "dist/index.js",
9
9
  "engines": {
@@ -15,6 +15,7 @@
15
15
  "prepublishOnly": "npm run build && npm test"
16
16
  },
17
17
  "devDependencies": {
18
+ "@types/qrcode": "^1.5.6",
18
19
  "typescript": "^5.7.3",
19
20
  "vitest": "^4.1.10"
20
21
  },
@@ -34,5 +35,8 @@
34
35
  "dist",
35
36
  "README.md",
36
37
  "LICENSE"
37
- ]
38
- }
38
+ ],
39
+ "dependencies": {
40
+ "qrcode": "^1.5.4"
41
+ }
42
+ }