@tritard/waterbrother 0.16.127 → 0.16.129

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/discord.js +382 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.127",
3
+ "version": "0.16.129",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/discord.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { loadGatewayBridge, saveGatewayBridge } from "./gateway-state.js";
4
+ import { buildSelfAwarenessManifest, formatAboutWaterbrother, formatSelfState } from "./self-awareness.js";
5
+ import { createSession, listSessions, loadSession, saveSession } from "./session-store.js";
6
+ import { loadGatewayBridge, loadGatewayState, saveGatewayBridge, saveGatewayState } from "./gateway-state.js";
5
7
 
6
8
  const DISCORD_API_BASE = "https://discord.com/api/v10";
7
9
  const STATUS_PATH = path.join(os.homedir(), ".waterbrother", "discord-status.json");
8
10
  const DISCORD_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
9
11
  const DISCORD_BRIDGE_POLL_MS = 250;
12
+ const DISCORD_CONTINUATION_TTL_MS = 15 * 60 * 1000;
10
13
 
11
14
  const INTENT_BITS = {
12
15
  GUILDS: 1 << 0,
@@ -120,15 +123,95 @@ function buildReply(message, botUserId) {
120
123
  if (!content || content === "ping") {
121
124
  return "pong";
122
125
  }
123
- if (normalized === "status" || normalized === "online" || normalized === "are you online") {
124
- return "Discord gateway is online. I can see messages and basic mentions. Full conversational Discord runtime is the next slice.";
125
- }
126
126
  if (["hello", "hi", "hey"].includes(normalized)) {
127
127
  return "Waterbrother is here. Discord setup is live at the gateway level; deeper room workflows come next.";
128
128
  }
129
129
  return null;
130
130
  }
131
131
 
132
+ function buildDiscordHelp() {
133
+ return [
134
+ "Waterbrother Discord control",
135
+ "",
136
+ "Basic commands",
137
+ "/help show this help",
138
+ "/about show Waterbrother identity and capabilities",
139
+ "/state show current Waterbrother self-awareness state",
140
+ "/status show Discord gateway and linked remote session status",
141
+ "",
142
+ "Remote session commands",
143
+ "/new start a fresh remote session",
144
+ "/sessions list recent linked remote sessions",
145
+ "/resume <session-id> switch the linked remote session",
146
+ "/clear clear the current remote conversation history",
147
+ "",
148
+ "Use DMs or mention @Waterbrother in a server channel to run work through the live TUI."
149
+ ].join("\n");
150
+ }
151
+
152
+ function continuationKey(channelId, userId) {
153
+ return `${String(channelId || "").trim()}:${String(userId || "").trim()}`;
154
+ }
155
+
156
+ function upsertSessionHistory(existing, sessionId) {
157
+ const normalized = String(sessionId || "").trim();
158
+ if (!normalized) {
159
+ return Array.isArray(existing) ? existing.map((value) => String(value || "").trim()).filter(Boolean).slice(0, 12) : [];
160
+ }
161
+ const list = Array.isArray(existing) ? existing.map((value) => String(value || "").trim()).filter(Boolean) : [];
162
+ return [normalized, ...list.filter((value) => value !== normalized)].slice(0, 12);
163
+ }
164
+
165
+ function buildContinuationPrompt(text = "", continuation = null) {
166
+ const body = String(text || "").trim();
167
+ if (!continuation?.lastPrompt) return body;
168
+ return [
169
+ `Your last question/request to this user was: ${continuation.lastPrompt}`,
170
+ "",
171
+ `Their follow-up reply is: ${body}`
172
+ ].join("\n");
173
+ }
174
+
175
+ function formatSessionList(currentSessionId, sessions = []) {
176
+ if (!sessions.length) {
177
+ return "Remote sessions\nNo linked remote sessions yet.";
178
+ }
179
+ const lines = ["Remote sessions"];
180
+ for (const session of sessions) {
181
+ const id = String(session?.id || "").trim();
182
+ if (!id) continue;
183
+ const current = id === String(currentSessionId || "").trim() ? " (current)" : "";
184
+ const preview = String(session?.lastUserPreview || "").trim();
185
+ const updatedAt = String(session?.updatedAt || "").trim();
186
+ lines.push(`- ${id}${current}`);
187
+ if (updatedAt) lines.push(` updated: ${updatedAt}`);
188
+ if (preview) lines.push(` last: ${preview.slice(0, 120)}`);
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+
193
+ function parseDiscordSessionCommand(text = "") {
194
+ const value = String(text || "").trim();
195
+ if (!value) return null;
196
+ const normalized = value.replace(/\s+/g, " ").trim().toLowerCase();
197
+ if (normalized === "/new" || normalized === "new session" || normalized === "start a new session" || normalized === "fresh session") {
198
+ return { type: "new" };
199
+ }
200
+ if (normalized === "/clear" || normalized === "clear session" || normalized === "clear conversation" || normalized === "clear history") {
201
+ return { type: "clear" };
202
+ }
203
+ if (normalized === "/sessions" || normalized === "list sessions" || normalized === "show sessions") {
204
+ return { type: "sessions" };
205
+ }
206
+ if (normalized.startsWith("/resume ")) {
207
+ return { type: "resume", sessionId: value.slice("/resume ".length).trim() };
208
+ }
209
+ if (normalized.startsWith("resume ")) {
210
+ return { type: "resume", sessionId: value.slice(value.toLowerCase().indexOf("resume ") + "resume ".length).trim() };
211
+ }
212
+ return null;
213
+ }
214
+
132
215
  function createBridgeRequestId() {
133
216
  return `dc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
134
217
  }
@@ -141,10 +224,269 @@ function describeDiscordUser(message = {}) {
141
224
  return {
142
225
  userId: String(author.id || "").trim(),
143
226
  username,
227
+ usernameHandle: username ? `@${username}` : "",
144
228
  displayName
145
229
  };
146
230
  }
147
231
 
232
+ async function loadDiscordGatewayState() {
233
+ return loadGatewayState("discord");
234
+ }
235
+
236
+ async function persistDiscordGatewayState(state) {
237
+ return saveGatewayState("discord", state);
238
+ }
239
+
240
+ function getPeerState(state, userId) {
241
+ return state?.peers?.[String(userId || "").trim()] || null;
242
+ }
243
+
244
+ function getPendingContinuation(state, message) {
245
+ const key = continuationKey(message?.channel_id, message?.author?.id);
246
+ const item = state?.continuations?.[key];
247
+ if (!item) return null;
248
+ const expiresAtMs = Date.parse(String(item.expiresAt || ""));
249
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) return null;
250
+ return item;
251
+ }
252
+
253
+ function clearContinuation(state, message) {
254
+ const key = continuationKey(message?.channel_id, message?.author?.id);
255
+ if (state?.continuations?.[key]) {
256
+ delete state.continuations[key];
257
+ return true;
258
+ }
259
+ return false;
260
+ }
261
+
262
+ async function rememberContinuation(state, message, text = "") {
263
+ const body = String(text || "").trim();
264
+ if (!body) return false;
265
+ const asksFollowUp =
266
+ /\?\s*$/.test(body)
267
+ || /\b(would you like|do you want|which\b|what\b.*\?|how\b.*\?|who\b.*\?|where\b.*\?)\b/i.test(body);
268
+ if (!asksFollowUp) return false;
269
+ const key = continuationKey(message?.channel_id, message?.author?.id);
270
+ state.continuations[key] = {
271
+ chatId: String(message?.channel_id || "").trim(),
272
+ userId: String(message?.author?.id || "").trim(),
273
+ lastPrompt: body.slice(0, 400),
274
+ kind: "follow-up",
275
+ source: "assistant-question",
276
+ context: {},
277
+ expiresAt: new Date(Date.now() + DISCORD_CONTINUATION_TTL_MS).toISOString()
278
+ };
279
+ await persistDiscordGatewayState(state);
280
+ return true;
281
+ }
282
+
283
+ async function ensurePeerSession(runtime, state, message) {
284
+ const actor = describeDiscordUser(message);
285
+ const existing = getPeerState(state, actor.userId);
286
+ if (existing?.sessionId) {
287
+ state.peers[actor.userId] = {
288
+ ...existing,
289
+ chatId: String(message.channel_id || "").trim(),
290
+ username: actor.displayName,
291
+ usernameHandle: actor.usernameHandle,
292
+ displayName: actor.displayName,
293
+ sessions: upsertSessionHistory(existing.sessions, existing.sessionId),
294
+ lastSeenAt: new Date().toISOString(),
295
+ lastMessageId: String(message.id || "").trim()
296
+ };
297
+ await persistDiscordGatewayState(state);
298
+ return existing.sessionId;
299
+ }
300
+
301
+ const session = await createSession({
302
+ cwd: process.cwd(),
303
+ model: runtime.model,
304
+ agentProfile: runtime.agentProfile || "coder"
305
+ });
306
+ session.provider = runtime.provider;
307
+ session.baseUrl = runtime.baseUrl;
308
+ session.designModel = runtime.designModel;
309
+ session.runtimeProfile = runtime?.channels?.discord?.defaultRuntimeProfile || runtime?.gateway?.defaultRuntimeProfile || null;
310
+ await saveSession(session);
311
+
312
+ state.peers[actor.userId] = {
313
+ sessionId: session.id,
314
+ chatId: String(message.channel_id || "").trim(),
315
+ username: actor.displayName,
316
+ usernameHandle: actor.usernameHandle,
317
+ displayName: actor.displayName,
318
+ sessions: upsertSessionHistory([], session.id),
319
+ linkedAt: new Date().toISOString(),
320
+ lastSeenAt: new Date().toISOString(),
321
+ lastMessageId: String(message.id || "").trim()
322
+ };
323
+ await persistDiscordGatewayState(state);
324
+ return session.id;
325
+ }
326
+
327
+ async function startFreshSession(runtime, state, message) {
328
+ const actor = describeDiscordUser(message);
329
+ const session = await createSession({
330
+ cwd: process.cwd(),
331
+ model: runtime.model,
332
+ agentProfile: runtime.agentProfile || "coder"
333
+ });
334
+ session.provider = runtime.provider;
335
+ session.baseUrl = runtime.baseUrl;
336
+ session.designModel = runtime.designModel;
337
+ session.runtimeProfile = runtime?.channels?.discord?.defaultRuntimeProfile || runtime?.gateway?.defaultRuntimeProfile || null;
338
+ await saveSession(session);
339
+
340
+ const previous = getPeerState(state, actor.userId) || {};
341
+ state.peers[actor.userId] = {
342
+ ...previous,
343
+ sessionId: session.id,
344
+ chatId: String(message.channel_id || "").trim(),
345
+ username: actor.displayName,
346
+ usernameHandle: actor.usernameHandle,
347
+ displayName: actor.displayName,
348
+ sessions: upsertSessionHistory(previous.sessions, session.id),
349
+ linkedAt: new Date().toISOString(),
350
+ lastSeenAt: new Date().toISOString(),
351
+ lastMessageId: String(message.id || "").trim()
352
+ };
353
+ clearContinuation(state, message);
354
+ await persistDiscordGatewayState(state);
355
+ return session.id;
356
+ }
357
+
358
+ async function listPeerSessions(state, userId) {
359
+ const peer = getPeerState(state, userId);
360
+ const ids = Array.isArray(peer?.sessions) ? peer.sessions.map((value) => String(value || "").trim()).filter(Boolean) : [];
361
+ if (!ids.length && peer?.sessionId) ids.push(String(peer.sessionId));
362
+ if (!ids.length) return [];
363
+ const all = await listSessions(Math.max(20, ids.length + 4));
364
+ const byId = new Map(all.map((session) => [session.id, session]));
365
+ return ids.map((id) => byId.get(id) || { id }).filter(Boolean);
366
+ }
367
+
368
+ async function resumePeerSession(state, message, sessionId) {
369
+ const actor = describeDiscordUser(message);
370
+ const normalizedId = String(sessionId || "").trim();
371
+ if (!normalizedId) throw new Error("Usage: /resume <session-id>");
372
+ const session = await loadSession(normalizedId);
373
+ const previous = getPeerState(state, actor.userId) || {};
374
+ state.peers[actor.userId] = {
375
+ ...previous,
376
+ sessionId: session.id,
377
+ chatId: String(message.channel_id || "").trim(),
378
+ username: actor.displayName,
379
+ usernameHandle: actor.usernameHandle,
380
+ displayName: actor.displayName,
381
+ sessions: upsertSessionHistory(previous.sessions, session.id),
382
+ linkedAt: previous.linkedAt || new Date().toISOString(),
383
+ lastSeenAt: new Date().toISOString(),
384
+ lastMessageId: String(message.id || "").trim()
385
+ };
386
+ clearContinuation(state, message);
387
+ await persistDiscordGatewayState(state);
388
+ return session.id;
389
+ }
390
+
391
+ async function clearRemoteConversation(sessionId) {
392
+ const session = await loadSession(sessionId);
393
+ session.messages = [];
394
+ session.runState = {
395
+ state: "done",
396
+ detail: "",
397
+ updatedAt: new Date().toISOString()
398
+ };
399
+ await saveSession(session);
400
+ }
401
+
402
+ async function handleDiscordSessionCommand(runtime, state, message, command) {
403
+ const actor = describeDiscordUser(message);
404
+ if (command.type === "new") {
405
+ const sessionId = await startFreshSession(runtime, state, message);
406
+ return `Started new remote session: ${sessionId}`;
407
+ }
408
+ if (command.type === "sessions") {
409
+ const sessions = await listPeerSessions(state, actor.userId);
410
+ const currentSessionId = getPeerState(state, actor.userId)?.sessionId || "";
411
+ return formatSessionList(currentSessionId, sessions);
412
+ }
413
+ if (command.type === "resume") {
414
+ if (!command.sessionId) {
415
+ return "Usage: /resume <session-id>\nUse /sessions to list recent remote session ids.";
416
+ }
417
+ const sessionId = await resumePeerSession(state, message, command.sessionId);
418
+ return `Linked remote session: ${sessionId}`;
419
+ }
420
+ if (command.type === "clear") {
421
+ const sessionId = getPeerState(state, actor.userId)?.sessionId || await ensurePeerSession(runtime, state, message);
422
+ await clearRemoteConversation(sessionId);
423
+ clearContinuation(state, message);
424
+ await persistDiscordGatewayState(state);
425
+ return `Cleared conversation history for ${sessionId}.`;
426
+ }
427
+ return null;
428
+ }
429
+
430
+ async function buildDiscordAbout(runtime, state, message) {
431
+ const actor = describeDiscordUser(message);
432
+ const sessionId = getPeerState(state, actor.userId)?.sessionId || "";
433
+ const currentSession = sessionId ? await loadSession(sessionId).catch(() => null) : null;
434
+ const manifest = await buildSelfAwarenessManifest({
435
+ cwd: currentSession?.cwd || process.cwd(),
436
+ runtime,
437
+ currentSession
438
+ });
439
+ return formatAboutWaterbrother(manifest);
440
+ }
441
+
442
+ async function buildDiscordState(runtime, state, message) {
443
+ const actor = describeDiscordUser(message);
444
+ const sessionId = getPeerState(state, actor.userId)?.sessionId || "";
445
+ const currentSession = sessionId ? await loadSession(sessionId).catch(() => null) : null;
446
+ const manifest = await buildSelfAwarenessManifest({
447
+ cwd: currentSession?.cwd || process.cwd(),
448
+ runtime,
449
+ currentSession
450
+ });
451
+ return formatSelfState(manifest);
452
+ }
453
+
454
+ async function buildDiscordStatusMessage(runtime, state, message) {
455
+ const discord = normalizeDiscordRuntime(runtime);
456
+ const actor = describeDiscordUser(message);
457
+ const peer = getPeerState(state, actor.userId);
458
+ const sessionId = String(peer?.sessionId || "").trim();
459
+ const session = sessionId ? await loadSession(sessionId).catch(() => null) : null;
460
+ const liveHost = await getLiveBridgeHost({ cwd: session?.cwd || "" });
461
+ return [
462
+ "Discord status",
463
+ `gateway: ${discord.enabled && discord.token && discord.applicationId ? "configured" : "incomplete"}`,
464
+ `application id: ${discord.applicationId || "missing"}`,
465
+ `linked session: ${sessionId || "none"}`,
466
+ session?.cwd ? `cwd: ${session.cwd}` : "",
467
+ session?.runtimeProfile ? `runtime profile: ${session.runtimeProfile}` : "",
468
+ liveHost ? `live terminal: ${String(liveHost.label || liveHost.ownerName || liveHost.sessionId || "connected").trim()}` : "live terminal: not connected"
469
+ ].filter(Boolean).join("\n");
470
+ }
471
+
472
+ async function handleDiscordControlCommand(runtime, state, message, rawText) {
473
+ const normalized = String(rawText || "").replace(/\s+/g, " ").trim().toLowerCase();
474
+ if (!normalized) return null;
475
+ if (normalized === "/help" || normalized === "help") {
476
+ return buildDiscordHelp();
477
+ }
478
+ if (normalized === "/about" || normalized === "about") {
479
+ return buildDiscordAbout(runtime, state, message);
480
+ }
481
+ if (normalized === "/state" || normalized === "state") {
482
+ return buildDiscordState(runtime, state, message);
483
+ }
484
+ if (normalized === "/status" || normalized === "status" || normalized === "online" || normalized === "are you online") {
485
+ return buildDiscordStatusMessage(runtime, state, message);
486
+ }
487
+ return null;
488
+ }
489
+
148
490
  async function getLiveBridgeHost({ cwd = "" } = {}) {
149
491
  const bridge = await loadGatewayBridge("discord");
150
492
  const hosts = Array.isArray(bridge.hosts) ? bridge.hosts : [];
@@ -178,7 +520,7 @@ async function getLiveBridgeHost({ cwd = "" } = {}) {
178
520
  return bridge.activeHost?.pid ? bridge.activeHost : (nextHosts[0] || null);
179
521
  }
180
522
 
181
- async function runPromptViaBridge(runtime, message, promptText) {
523
+ async function runPromptViaBridge(runtime, message, promptText, options = {}) {
182
524
  const host = await getLiveBridgeHost();
183
525
  if (!host) {
184
526
  return { error: "No live Waterbrother TUI is connected. Start Waterbrother in the terminal first, then retry." };
@@ -195,7 +537,7 @@ async function runPromptViaBridge(runtime, message, promptText) {
195
537
  username: actor.username,
196
538
  usernameHandle: actor.username ? `@${actor.username}` : "",
197
539
  displayName: actor.displayName,
198
- sessionId: "",
540
+ sessionId: String(options.sessionId || "").trim(),
199
541
  text: String(promptText || "").trim(),
200
542
  requestKind: "prompt",
201
543
  explicitExecution: true,
@@ -226,7 +568,11 @@ async function runPromptViaBridge(runtime, message, promptText) {
226
568
  if (reply.error) {
227
569
  return { error: String(reply.error || "").trim() || "Discord bridge execution failed." };
228
570
  }
229
- return { content: String(reply.content || "").trim() || "(no content)" };
571
+ return {
572
+ content: String(reply.content || "").trim() || "(no content)",
573
+ sessionId: String(reply.sessionId || "").trim(),
574
+ meta: reply.meta && typeof reply.meta === "object" ? { ...reply.meta } : {}
575
+ };
230
576
  }
231
577
  }
232
578
 
@@ -372,19 +718,43 @@ export async function runDiscordGateway(runtime = {}, { log = console.log, signa
372
718
  if (!msg || msg.author?.bot) return;
373
719
  const scope = msg.guild_id ? `guild:${msg.guild_id}` : "dm";
374
720
  log(`discord: ${scope} #${msg.channel_id} ${msg.author?.username || "unknown"} -> ${String(msg.content || "").trim()}`);
375
- const reply = shouldReplyToMessage(msg, botUser?.id)
376
- ? buildReply(msg, botUser?.id)
377
- : null;
721
+ const shouldReply = shouldReplyToMessage(msg, botUser?.id);
722
+ const rawText = extractMentionContent(String(msg.content || "").trim(), botUser?.id);
723
+ const reply = shouldReply ? buildReply(msg, botUser?.id) : null;
378
724
  try {
379
725
  if (reply) {
380
726
  await sendChannelMessage(discord, msg.channel_id, reply);
381
727
  log(`discord: replied in ${msg.channel_id}`);
382
728
  return;
383
729
  }
384
- if (shouldReplyToMessage(msg, botUser?.id)) {
385
- const bridged = await runPromptViaBridge(runtime, msg, extractMentionContent(String(msg.content || "").trim(), botUser?.id));
730
+ if (shouldReply) {
731
+ const state = await loadDiscordGatewayState();
732
+ const controlReply = await handleDiscordControlCommand(runtime, state, msg, rawText);
733
+ if (controlReply) {
734
+ await sendChannelMessage(discord, msg.channel_id, controlReply);
735
+ log(`discord: control reply in ${msg.channel_id}`);
736
+ return;
737
+ }
738
+ const command = parseDiscordSessionCommand(rawText);
739
+ if (command) {
740
+ const commandReply = await handleDiscordSessionCommand(runtime, state, msg, command);
741
+ if (commandReply) {
742
+ await sendChannelMessage(discord, msg.channel_id, commandReply);
743
+ log(`discord: session reply in ${msg.channel_id}`);
744
+ }
745
+ return;
746
+ }
747
+ const continuation = getPendingContinuation(state, msg);
748
+ const stateChanged = clearContinuation(state, msg);
749
+ if (stateChanged) {
750
+ await persistDiscordGatewayState(state);
751
+ }
752
+ const sessionId = await ensurePeerSession(runtime, state, msg);
753
+ const promptText = continuation ? buildContinuationPrompt(rawText, continuation) : rawText;
754
+ const bridged = await runPromptViaBridge(runtime, msg, promptText, { sessionId });
386
755
  if (bridged?.content) {
387
756
  await sendChannelMessage(discord, msg.channel_id, bridged.content);
757
+ await rememberContinuation(state, msg, bridged.content);
388
758
  log(`discord: bridged reply in ${msg.channel_id}`);
389
759
  } else if (bridged?.error) {
390
760
  await sendChannelMessage(discord, msg.channel_id, bridged.error);