@tritard/waterbrother 0.16.62 → 0.16.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.62",
3
+ "version": "0.16.64",
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": {
@@ -18,10 +18,24 @@ function gatewayBridgePath(serviceId) {
18
18
  }
19
19
 
20
20
  function normalizeGatewayState(parsed = {}) {
21
+ const continuations = parsed?.continuations && typeof parsed.continuations === "object" ? parsed.continuations : {};
21
22
  return {
22
23
  offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
23
24
  peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
24
- pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
25
+ pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {},
26
+ continuations: Object.fromEntries(
27
+ Object.entries(continuations).map(([key, item]) => [
28
+ key,
29
+ {
30
+ chatId: String(item?.chatId || "").trim(),
31
+ userId: String(item?.userId || "").trim(),
32
+ lastPrompt: String(item?.lastPrompt || "").trim(),
33
+ kind: String(item?.kind || "follow-up").trim() || "follow-up",
34
+ source: String(item?.source || "assistant-question").trim() || "assistant-question",
35
+ expiresAt: String(item?.expiresAt || "").trim()
36
+ }
37
+ ])
38
+ )
25
39
  };
26
40
  }
27
41
 
@@ -114,7 +128,7 @@ export async function loadGatewayState(serviceId) {
114
128
  return normalizeGatewayState(JSON.parse(raw));
115
129
  } catch (error) {
116
130
  if (error?.code === "ENOENT") {
117
- return { offset: 0, peers: {}, pendingPairings: {} };
131
+ return { offset: 0, peers: {}, pendingPairings: {}, continuations: {} };
118
132
  }
119
133
  throw error;
120
134
  }
package/src/gateway.js CHANGED
@@ -22,6 +22,7 @@ const TELEGRAM_TYPING_INTERVAL_MS = 4000;
22
22
  const TELEGRAM_PREVIEW_TEXT = "<i>Working…</i>";
23
23
  const TELEGRAM_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
24
24
  const TELEGRAM_BRIDGE_POLL_MS = 250;
25
+ const TELEGRAM_CONTINUATION_TTL_MS = 15 * 60 * 1000;
25
26
  const TELEGRAM_COMMANDS = [
26
27
  { command: "help", description: "Show Telegram control help" },
27
28
  { command: "about", description: "Show local Waterbrother identity and capabilities" },
@@ -98,6 +99,10 @@ function getTelegramBotAliases(me = {}) {
98
99
  return [...aliases].filter(Boolean);
99
100
  }
100
101
 
102
+ function continuationKey(chatId, userId) {
103
+ return `${String(chatId || "").trim()}:${String(userId || "").trim()}`;
104
+ }
105
+
101
106
  function splitLongPlainText(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
102
107
  const value = String(text || "").trim();
103
108
  if (!value) return [""];
@@ -749,6 +754,16 @@ function classifyTelegramGroupIntent(text = "") {
749
754
  return { kind: "chat", reason: "default non-execution" };
750
755
  }
751
756
 
757
+ function buildContinuationPrompt(text = "", continuation = null) {
758
+ const body = String(text || "").trim();
759
+ if (!continuation?.lastPrompt) return body;
760
+ return [
761
+ `Continue the active Telegram follow-up.`,
762
+ `Your last question/request to this user was: ${continuation.lastPrompt}`,
763
+ `User reply: ${body}`
764
+ ].join("\n");
765
+ }
766
+
752
767
  function suggestTaskText(text = "") {
753
768
  return String(text || "")
754
769
  .replace(/^@[\w_]+\s*/g, "")
@@ -944,6 +959,7 @@ class TelegramGateway {
944
959
  const replyFromId = String(message?.reply_to_message?.from?.id || "").trim();
945
960
  const myId = String(this.me?.id || "").trim();
946
961
  if (replyFromId && myId && replyFromId === myId) return true;
962
+ if (this.hasPendingContinuation(message)) return true;
947
963
  return false;
948
964
  }
949
965
 
@@ -960,7 +976,15 @@ class TelegramGateway {
960
976
 
961
977
  prunePairings() {
962
978
  const { state, pruned } = prunePendingPairings(this.state, this.pairingExpiryMinutes);
963
- this.state = state;
979
+ const continuations = {};
980
+ const now = Date.now();
981
+ for (const [key, item] of Object.entries(state.continuations || {})) {
982
+ const expiresAtMs = Date.parse(String(item?.expiresAt || ""));
983
+ if (Number.isFinite(expiresAtMs) && expiresAtMs > now) {
984
+ continuations[key] = item;
985
+ }
986
+ }
987
+ this.state = { ...state, continuations };
964
988
  return pruned;
965
989
  }
966
990
 
@@ -969,6 +993,142 @@ class TelegramGateway {
969
993
  await saveGatewayState("telegram", this.state);
970
994
  }
971
995
 
996
+ hasPendingContinuation(message) {
997
+ const key = continuationKey(message?.chat?.id, message?.from?.id);
998
+ const item = this.state?.continuations?.[key];
999
+ if (!item) return false;
1000
+ const expiresAtMs = Date.parse(String(item.expiresAt || ""));
1001
+ return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
1002
+ }
1003
+
1004
+ getPendingContinuation(message) {
1005
+ const key = continuationKey(message?.chat?.id, message?.from?.id);
1006
+ const item = this.state?.continuations?.[key];
1007
+ if (!item) return null;
1008
+ const expiresAtMs = Date.parse(String(item.expiresAt || ""));
1009
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) return null;
1010
+ return item;
1011
+ }
1012
+
1013
+ clearContinuation(message) {
1014
+ const key = continuationKey(message?.chat?.id, message?.from?.id);
1015
+ if (this.state?.continuations?.[key]) {
1016
+ delete this.state.continuations[key];
1017
+ }
1018
+ }
1019
+
1020
+ async rememberContinuation(message, text = "") {
1021
+ const body = String(text || "").trim();
1022
+ if (!body) return;
1023
+ const asksFollowUp =
1024
+ /\?\s*$/.test(body)
1025
+ || /\b(would you like|do you want|which\b|what\b.*\?|how\b.*\?|who\b.*\?|where\b.*\?)\b/i.test(body);
1026
+ if (!asksFollowUp) return;
1027
+ const key = continuationKey(message?.chat?.id, message?.from?.id);
1028
+ this.state.continuations[key] = {
1029
+ chatId: String(message?.chat?.id || "").trim(),
1030
+ userId: String(message?.from?.id || "").trim(),
1031
+ lastPrompt: body.slice(0, 400),
1032
+ kind: "follow-up",
1033
+ source: "assistant-question",
1034
+ expiresAt: new Date(Date.now() + TELEGRAM_CONTINUATION_TTL_MS).toISOString()
1035
+ };
1036
+ await this.persistState();
1037
+ }
1038
+
1039
+ async loadTrustedChatProject(message) {
1040
+ if (!this.isGroupChat(message)) return { cwd: "", project: null };
1041
+ const chatId = String(message?.chat?.id || "").trim();
1042
+ const bridge = await loadGatewayBridge("telegram");
1043
+ const candidateCwds = [
1044
+ String(bridge.activeHost?.cwd || "").trim(),
1045
+ String(this.cwd || "").trim()
1046
+ ].filter(Boolean);
1047
+ for (const candidateCwd of candidateCwds) {
1048
+ try {
1049
+ const project = await loadSharedProject(candidateCwd);
1050
+ if (project?.enabled && String(project.room?.provider || "") === "telegram" && String(project.room?.chatId || "") === chatId) {
1051
+ return { cwd: candidateCwd, project };
1052
+ }
1053
+ } catch {}
1054
+ }
1055
+ return { cwd: "", project: null };
1056
+ }
1057
+
1058
+ async autoPairTrustedParticipant(message, cwd) {
1059
+ const { userId, usernameHandle, displayName } = this.describeTelegramUser(message?.from || {});
1060
+ const { userConfig, projectConfig } = await loadConfigLayers(cwd || this.cwd);
1061
+ const targetScope = projectConfig?.channels?.telegram ? "project" : "user";
1062
+ const targetConfig = targetScope === "project" ? { ...projectConfig } : { ...userConfig };
1063
+ const channels = targetConfig.channels && typeof targetConfig.channels === "object" ? { ...targetConfig.channels } : {};
1064
+ const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : {};
1065
+ const allowed = new Set(Array.isArray(telegram.allowedUserIds) ? telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean) : []);
1066
+ allowed.add(userId);
1067
+ telegram.enabled = telegram.enabled !== false;
1068
+ if (!telegram.pairingMode) telegram.pairingMode = "manual";
1069
+ telegram.allowedUserIds = [...allowed];
1070
+ channels.telegram = telegram;
1071
+ targetConfig.channels = channels;
1072
+ await saveConfig(targetConfig, { scope: targetScope, cwd: cwd || this.cwd });
1073
+ this.channel.allowedUserIds = telegram.allowedUserIds;
1074
+ if (this.runtime.channels?.telegram) {
1075
+ this.runtime.channels.telegram.allowedUserIds = telegram.allowedUserIds;
1076
+ }
1077
+ delete this.state.pendingPairings[userId];
1078
+ this.state.peers[userId] = {
1079
+ ...(this.state.peers[userId] || {}),
1080
+ chatId: String(message.chat.id),
1081
+ username: displayName,
1082
+ usernameHandle,
1083
+ displayName,
1084
+ linkedAt: this.state.peers[userId]?.linkedAt || new Date().toISOString(),
1085
+ lastSeenAt: new Date().toISOString(),
1086
+ lastMessageId: message.message_id,
1087
+ sessions: Array.isArray(this.state.peers[userId]?.sessions) ? this.state.peers[userId].sessions : []
1088
+ };
1089
+ await this.persistState();
1090
+ }
1091
+
1092
+ async maybeAutoOnboardTrustedParticipant(message) {
1093
+ if (!this.isGroupChat(message)) return null;
1094
+ const { cwd, project } = await this.loadTrustedChatProject(message);
1095
+ if (!project?.enabled) return null;
1096
+ const actor = this.describeTelegramUser(message?.from || {});
1097
+ const existingMember = Array.isArray(project.members)
1098
+ ? project.members.find((member) => String(member?.id || "").trim() === actor.userId) || null
1099
+ : null;
1100
+ if (existingMember && this.isAuthorizedUser(message.from)) return null;
1101
+ if (!this.isAuthorizedUser(message.from)) {
1102
+ await this.autoPairTrustedParticipant(message, cwd);
1103
+ }
1104
+ const nextProject = existingMember
1105
+ ? project
1106
+ : await upsertSharedMember(
1107
+ cwd,
1108
+ { id: actor.userId, name: actor.displayName || actor.userId, role: "observer", paired: true },
1109
+ { actorId: "system:trusted-room", actorName: "trusted room" }
1110
+ );
1111
+ return {
1112
+ cwd,
1113
+ project: nextProject,
1114
+ actor
1115
+ };
1116
+ }
1117
+
1118
+ buildTrustedRoomIntroMarkup({ project, actor }) {
1119
+ return [
1120
+ "<b>Roundtable room</b>",
1121
+ `${escapeTelegramHtml(actor.displayName || actor.userId)} is now in <code>${escapeTelegramHtml(project.projectName || "this project")}</code> as <code>observer</code>.`,
1122
+ `room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
1123
+ project.activeOperator?.id
1124
+ ? `active operator: <code>${escapeTelegramHtml(project.activeOperator.name || project.activeOperator.id)}</code>`
1125
+ : "active operator: <code>none</code>",
1126
+ "As observer, you can ask questions, discuss plans, and review work here.",
1127
+ "An owner can promote you to editor or owner conversationally.",
1128
+ "Examples: <code>what project is this chat bound to?</code>, <code>who is in the room?</code>, <code>what can I do here?</code>"
1129
+ ].join("\n");
1130
+ }
1131
+
972
1132
  describeTelegramUser(from = {}) {
973
1133
  const userId = String(from?.id || "").trim();
974
1134
  const usernameHandle = String(from?.username || "").trim();
@@ -1837,12 +1997,20 @@ class TelegramGateway {
1837
1997
  if (!message?.text) return;
1838
1998
  if (!this.messageTargetsBot(message)) return;
1839
1999
  this.prunePairings();
2000
+ const trustedOnboarding = !this.isAuthorizedUser(message.from)
2001
+ ? await this.maybeAutoOnboardTrustedParticipant(message)
2002
+ : null;
1840
2003
  if (!this.isAuthorizedUser(message.from)) {
1841
2004
  await this.rejectUnpairedMessage(message);
1842
2005
  return;
1843
2006
  }
2007
+ if (trustedOnboarding) {
2008
+ await this.sendMarkup(message.chat.id, this.buildTrustedRoomIntroMarkup(trustedOnboarding), message.message_id);
2009
+ }
1844
2010
 
1845
2011
  const text = this.normalizeTelegramCommandText(String(message.text || "").trim());
2012
+ const continuation = this.getPendingContinuation(message);
2013
+ this.clearContinuation(message);
1846
2014
  const userId = String(message.from.id);
1847
2015
 
1848
2016
  if (text === "/help" || text === "/start") {
@@ -2561,9 +2729,14 @@ Ask them to run <code>/whoami</code> and then <code>/accept-invite ${escapeTeleg
2561
2729
  }
2562
2730
 
2563
2731
  const isExplicitRun = text.startsWith("/run ");
2564
- const promptText = this.stripBotMention(isExplicitRun ? text.replace("/run", "").trim() : text);
2732
+ const rawPromptText = this.stripBotMention(isExplicitRun ? text.replace("/run", "").trim() : text);
2733
+ const promptText = continuation ? buildContinuationPrompt(rawPromptText, continuation) : rawPromptText;
2565
2734
  const groupIntent = this.isGroupChat(message)
2566
- ? (isExplicitRun ? { kind: "execution", reason: "explicit /run" } : classifyTelegramGroupIntent(promptText))
2735
+ ? (isExplicitRun
2736
+ ? { kind: "execution", reason: "explicit /run" }
2737
+ : continuation
2738
+ ? { kind: "execution", reason: "follow-up continuation" }
2739
+ : classifyTelegramGroupIntent(rawPromptText))
2567
2740
  : { kind: "execution", reason: "private chat" };
2568
2741
  const shouldExecutePrompt = !this.isGroupChat(message) || groupIntent.kind === "execution";
2569
2742
  const sharedBinding = await this.bindSharedRoomForMessage(message, sessionId);
@@ -2738,6 +2911,7 @@ Ask them to run <code>/whoami</code> and then <code>/accept-invite ${escapeTeleg
2738
2911
  const content = (await this.runPromptViaBridge(message, sessionId, promptText, { explicitExecution: shouldExecutePrompt }))
2739
2912
  ?? (await this.runPromptFallback(sessionId, promptText));
2740
2913
  await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
2914
+ await this.rememberContinuation(message, content);
2741
2915
  } catch (error) {
2742
2916
  await this.deliverPromptFailure(message.chat.id, message.message_id, previewMessage, error);
2743
2917
  } finally {