@tloncorp/openclaw 0.4.2 → 0.4.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.
@@ -3,11 +3,12 @@ import { configureGatewayStatus, gatewayStart } from "@tloncorp/api";
3
3
  import { format } from "node:util";
4
4
  import { getEffectiveOwnerShip, setEffectiveOwnerShip } from "../effective-owner.js";
5
5
  import { getGatewayStatusManager, computeLeaseUntil, ACTIVE_WINDOW_SECS, OFFLINE_REPLY_COOLDOWN_SECS, } from "../gateway-status.js";
6
+ import { handleOwnerListenCommand } from "../owner-listen-command.js";
6
7
  import { registerPersistCallback, syncPendingNudgeFromStore, getPendingNudge, clearPendingNudge, setPendingNudge, isNudgeEligible, } from "../pending-nudge.js";
7
8
  import { getTlonRuntime } from "../runtime.js";
8
9
  import { setSessionRole } from "../session-roles.js";
9
10
  import { createSettingsManager } from "../settings.js";
10
- import { normalizeShip, parseChannelNest } from "../targets.js";
11
+ import { canonicalizeNest, normalizeShip, parseChannelNest } from "../targets.js";
11
12
  import { createTlonTelemetry } from "../telemetry.js";
12
13
  import { resolveTlonAccount } from "../types.js";
13
14
  import { configureTlonApiWithPoke } from "../urbit/api-client.js";
@@ -20,7 +21,7 @@ import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmatio
20
21
  import { setBridge, removeBridge } from "./command-bridge.js";
21
22
  import { createComputingPresenceTracker } from "./computing-presence.js";
22
23
  import { fetchAllChannels, fetchInitData } from "./discovery.js";
23
- import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchChannelHistory, fetchThreadHistory, } from "./history.js";
24
+ import { cacheMessage, buildThreadContextMessage, lookupCachedMessage, getChannelHistory, fetchChannelHistory, fetchThreadContextHistory, } from "./history.js";
24
25
  import { downloadMessageImages, parseBlobData, formatBlobAnnotations, downloadBlobAttachments, } from "./media.js";
25
26
  import { createNudgeRunner, shouldStartNudgeRunner } from "./nudge-runner.js";
26
27
  import { clearShadowsForAccount, getLastNudgeStageShadow, getLastOwnerActivity, ownerActivityFromSettings, setLastNudgeStageShadow, setLastOwnerActivity, } from "./nudge-state.js";
@@ -28,7 +29,7 @@ import { createOwnerReplyPersistenceQueue } from "./owner-reply-persistence.js";
28
29
  import { createPendingNudgePersistenceQueue } from "./pending-nudge-persistence.js";
29
30
  import { createProcessedMessageTracker } from "./processed-messages.js";
30
31
  import { resolveSettingsMirrorSync } from "./settings-sync.js";
31
- import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, sanitizeMessageText, } from "./utils.js";
32
+ import { extractMessageText, extractCites, formatModelName, isBotMentioned, isOwnerListenSlashCommand, stripBotMention, isDmAllowed, isSummarizationRequest, sanitizeMessageText, shouldEngageInGroup, } from "./utils.js";
32
33
  /** Refresh stale settings subscription state periodically as a fallback for silently-dead SSE subscriptions. */
33
34
  const SETTINGS_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
34
35
  /**
@@ -161,6 +162,19 @@ export async function monitorTlonProvider(opts = {}) {
161
162
  ? normalizeShip(account.ownerShip)
162
163
  : null;
163
164
  setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
165
+ let effectiveOwnerListenEnabled = account.ownerListenEnabled ?? true;
166
+ // Canonicalize on every read so an entry stored from a slightly-off user
167
+ // input (e.g. missing "~" or wrong case) still matches incoming nest events.
168
+ const canonicalizeNestList = (list) => {
169
+ const out = new Set();
170
+ for (const raw of list) {
171
+ const canonical = canonicalizeNest(raw);
172
+ if (canonical)
173
+ out.add(canonical);
174
+ }
175
+ return [...out];
176
+ };
177
+ let effectiveOwnerListenDisabled = new Set(canonicalizeNestList(account.ownerListenDisabledChannels ?? []));
164
178
  let pendingApprovals = [];
165
179
  let currentSettings = {};
166
180
  // Tracks whether pendingNudge has been successfully rehydrated from the settings
@@ -392,6 +406,14 @@ export async function monitorTlonProvider(opts = {}) {
392
406
  setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
393
407
  runtime.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
394
408
  }
409
+ if (currentSettings.ownerListenEnabled !== undefined) {
410
+ effectiveOwnerListenEnabled = currentSettings.ownerListenEnabled;
411
+ runtime.log?.(`[tlon] Using ownerListenEnabled from settings store: ${effectiveOwnerListenEnabled}`);
412
+ }
413
+ if (currentSettings.ownerListenDisabledChannels !== undefined) {
414
+ effectiveOwnerListenDisabled = new Set(canonicalizeNestList(currentSettings.ownerListenDisabledChannels));
415
+ runtime.log?.(`[tlon] Loaded ${effectiveOwnerListenDisabled.size} owner-listen-disabled channel(s) from settings`);
416
+ }
395
417
  // Rehydrate pending nudge from settings store only if the scry returned real data.
396
418
  // On fallback (scry failure), leave pendingNudgeRehydrated false so the refresh
397
419
  // recovery path can still pick up a persisted pendingNudge later.
@@ -478,7 +500,9 @@ export async function monitorTlonProvider(opts = {}) {
478
500
  gsManager.waitForGatewayStart().then(() => "started"),
479
501
  ...(signal
480
502
  ? [
481
- new Promise((r) => signal.addEventListener("abort", () => r("aborted"), { once: true })),
503
+ new Promise((r) => signal.addEventListener("abort", () => r("aborted"), {
504
+ once: true,
505
+ })),
482
506
  ]
483
507
  : []),
484
508
  ]);
@@ -661,7 +685,10 @@ export async function monitorTlonProvider(opts = {}) {
661
685
  async function addToChannelAllowlist(ship, channelNest) {
662
686
  const normalizedShip = normalizeShip(ship);
663
687
  const channelRules = currentSettings.channelRules ?? {};
664
- const rule = channelRules[channelNest] ?? { mode: "restricted", allowedShips: [] };
688
+ const rule = channelRules[channelNest] ?? {
689
+ mode: "restricted",
690
+ allowedShips: [],
691
+ };
665
692
  const allowedShips = [...(rule.allowedShips ?? [])]; // Clone to avoid mutation
666
693
  if (!allowedShips.includes(normalizedShip)) {
667
694
  allowedShips.push(normalizedShip);
@@ -990,6 +1017,88 @@ export async function monitorTlonProvider(opts = {}) {
990
1017
  const success = await unblockShip(ship);
991
1018
  return success ? `Unblocked ${ship}.` : `Failed to unblock ${ship}.`;
992
1019
  },
1020
+ // ── Owner-listen controls ────────────────────────────────────────────
1021
+ isOwnedChannel(nest) {
1022
+ // Canonicalize first so case variants in user input (e.g.
1023
+ // `chat/~ZOD/general`) match the lowercase owner/bot ship strings.
1024
+ const canonical = canonicalizeNest(nest);
1025
+ if (!canonical) {
1026
+ return false;
1027
+ }
1028
+ const parsed = parseChannelNest(canonical);
1029
+ if (!parsed) {
1030
+ return false;
1031
+ }
1032
+ return parsed.hostShip === effectiveOwnerShip || parsed.hostShip === botShipName;
1033
+ },
1034
+ getOwnerListenGlobal() {
1035
+ return effectiveOwnerListenEnabled;
1036
+ },
1037
+ async setOwnerListenGlobal(enabled) {
1038
+ effectiveOwnerListenEnabled = enabled;
1039
+ try {
1040
+ await api.poke({
1041
+ app: "settings",
1042
+ mark: "settings-event",
1043
+ json: {
1044
+ "put-entry": {
1045
+ desk: "moltbot",
1046
+ "bucket-key": "tlon",
1047
+ "entry-key": "ownerListenEnabled",
1048
+ value: enabled,
1049
+ },
1050
+ },
1051
+ });
1052
+ runtime.log?.(`[tlon] ownerListenEnabled → ${enabled}`);
1053
+ }
1054
+ catch (err) {
1055
+ runtime.error?.(`[tlon] Failed to persist ownerListenEnabled: ${String(err)}`);
1056
+ }
1057
+ return enabled;
1058
+ },
1059
+ isOwnerListenDisabled(nest) {
1060
+ const canonical = canonicalizeNest(nest);
1061
+ if (!canonical) {
1062
+ return false;
1063
+ }
1064
+ return effectiveOwnerListenDisabled.has(canonical);
1065
+ },
1066
+ async setOwnerListenDisabled(nest, disabled) {
1067
+ const canonical = canonicalizeNest(nest);
1068
+ if (!canonical) {
1069
+ runtime.error?.(`[tlon] setOwnerListenDisabled: cannot parse nest ${nest}`);
1070
+ return !disabled;
1071
+ }
1072
+ if (disabled) {
1073
+ effectiveOwnerListenDisabled.add(canonical);
1074
+ }
1075
+ else {
1076
+ effectiveOwnerListenDisabled.delete(canonical);
1077
+ }
1078
+ const list = [...effectiveOwnerListenDisabled];
1079
+ try {
1080
+ await api.poke({
1081
+ app: "settings",
1082
+ mark: "settings-event",
1083
+ json: {
1084
+ "put-entry": {
1085
+ desk: "moltbot",
1086
+ "bucket-key": "tlon",
1087
+ "entry-key": "ownerListenDisabledChannels",
1088
+ value: list,
1089
+ },
1090
+ },
1091
+ });
1092
+ runtime.log?.(`[tlon] ownerListenDisabledChannels → [${list.join(", ")}]`);
1093
+ }
1094
+ catch (err) {
1095
+ runtime.error?.(`[tlon] Failed to persist ownerListenDisabledChannels: ${String(err)}`);
1096
+ }
1097
+ return !disabled;
1098
+ },
1099
+ listOwnerListenDisabled() {
1100
+ return [...effectiveOwnerListenDisabled];
1101
+ },
993
1102
  };
994
1103
  setBridge(accountKey, commandBridge);
995
1104
  // Check if a ship is the owner (always allowed to DM)
@@ -1139,17 +1248,16 @@ export async function monitorTlonProvider(opts = {}) {
1139
1248
  // Fetch thread context when entering a thread for the first time
1140
1249
  if (isThreadReply && parentId && groupChannel) {
1141
1250
  try {
1142
- const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
1143
- if (threadHistory.length > 0) {
1144
- const threadContext = threadHistory
1145
- .slice(-20) // Last 20 thread messages for context
1146
- .map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
1147
- .join("\n");
1148
- // Prepend thread context to the message
1149
- // Include note about ongoing conversation for agent judgment
1150
- const contextNote = `[Thread conversation - ${threadHistory.length} previous replies. You are participating in this thread. Only respond if relevant or helpful - you don't need to reply to every message.]`;
1151
- messageText = `${contextNote}\n\n[Previous messages]\n${threadContext}\n\n[Current message]\n${messageText}`;
1152
- runtime?.log?.(`[tlon] Added thread context (${threadHistory.length} replies) to message`);
1251
+ const threadContextHistory = await fetchThreadContextHistory(api, groupChannel, parentId, 20, runtime);
1252
+ if (threadContextHistory.length > 0) {
1253
+ const threadContextMessage = buildThreadContextMessage(threadContextHistory, messageText, {
1254
+ formatAuthor: formatShipWithNickname,
1255
+ sanitizeContent: sanitizeMessageText,
1256
+ });
1257
+ if (threadContextMessage) {
1258
+ messageText = threadContextMessage.messageText;
1259
+ runtime?.log?.(`[tlon] Added thread context (${threadContextMessage.contextMessages.length} messages, parent included) to message`);
1260
+ }
1153
1261
  }
1154
1262
  }
1155
1263
  catch (error) {
@@ -1352,7 +1460,10 @@ export async function monitorTlonProvider(opts = {}) {
1352
1460
  OriginatingChannel: "tlon",
1353
1461
  OriginatingTo: `tlon:${isGroup ? groupChannel : senderShip}`,
1354
1462
  // Include thread context for automatic reply routing
1355
- ...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
1463
+ ...(parentId && {
1464
+ MessageThreadId: String(parentId),
1465
+ ReplyToId: String(parentId),
1466
+ }),
1356
1467
  });
1357
1468
  const dispatchStartTime = Date.now();
1358
1469
  const replyTelemetry = telemetry?.startReply({
@@ -1541,7 +1652,8 @@ export async function monitorTlonProvider(opts = {}) {
1541
1652
  }
1542
1653
  // Auto-watch channels from firehose: if we receive events for a channel,
1543
1654
  // the bot is a member of the group — add it to watchedChannels automatically.
1544
- if (!watchedChannels.has(nest) && (nest.startsWith("chat/") || nest.startsWith("heap/"))) {
1655
+ if (!watchedChannels.has(nest) &&
1656
+ (nest.startsWith("chat/") || nest.startsWith("heap/") || nest.startsWith("diary/"))) {
1545
1657
  watchedChannels.add(nest);
1546
1658
  runtime.log?.(`[tlon] Auto-watching channel from firehose: ${nest}`);
1547
1659
  }
@@ -1671,17 +1783,59 @@ export async function monitorTlonProvider(opts = {}) {
1671
1783
  ? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
1672
1784
  : response?.post?.["r-post"]?.set?.seal;
1673
1785
  const parentId = seal?.["parent-id"] || seal?.parent || null;
1786
+ const parsedDispatchNest = parseChannelNest(nest);
1787
+ // Control-plane escape hatch: owner-listen may be disabled, but the owner
1788
+ // still needs a no-mention way to turn it back on from the same owned
1789
+ // channel. Handle the exact slash command before the normal engagement
1790
+ // gate, without waking the agent/model for ordinary chatter.
1791
+ if (isOwnerListenSlashCommand(rawText) &&
1792
+ isOwner(senderShip) &&
1793
+ parsedDispatchNest &&
1794
+ (parsedDispatchNest.hostShip === effectiveOwnerShip ||
1795
+ parsedDispatchNest.hostShip === botShipName)) {
1796
+ const args = rawText
1797
+ .trim()
1798
+ .replace(/^\/owner-listen(?:\s+|$)/i, "")
1799
+ .trim();
1800
+ const replyText = await handleOwnerListenCommand(commandBridge, args, `tlon:group:${nest}`);
1801
+ await sendChannelPost({
1802
+ botProfile: getBotProfile(),
1803
+ fromShip: botShipName,
1804
+ nest,
1805
+ story: markdownToStory(replyText),
1806
+ replyToId: parentId ?? undefined,
1807
+ });
1808
+ return;
1809
+ }
1674
1810
  // Check if we should respond:
1675
1811
  // 1. Direct mention always triggers response
1676
1812
  // 2. Thread replies where we've participated - respond if relevant (let agent decide)
1813
+ // 3. Owner blob-only message (image/file with no text from owner)
1814
+ // 4. Owner-listen: owner posts in an owner/bot-hosted channel and the
1815
+ // channel is not in the per-channel disabled list
1677
1816
  const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
1678
- const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(String(parentId));
1817
+ const inParticipatedThread = Boolean(isThreadReply && parentId && participatedThreads.has(String(parentId)));
1679
1818
  const isOwnerBlob = hasBlob && isOwner(senderShip);
1680
- if (!mentioned && !inParticipatedThread && !isOwnerBlob) {
1819
+ const engageDecision = shouldEngageInGroup({
1820
+ mentioned,
1821
+ inParticipatedThread,
1822
+ isOwnerBlob,
1823
+ senderShip,
1824
+ ownerShip: effectiveOwnerShip,
1825
+ botShipName,
1826
+ channelNest: nest,
1827
+ groupHost: parsedDispatchNest?.hostShip ?? null,
1828
+ ownerListenEnabled: effectiveOwnerListenEnabled,
1829
+ ownerListenDisabledChannels: effectiveOwnerListenDisabled,
1830
+ });
1831
+ if (!engageDecision.engage) {
1681
1832
  return;
1682
1833
  }
1683
1834
  // Log why we're responding
1684
- if (isOwnerBlob && !mentioned && !inParticipatedThread) {
1835
+ if (engageDecision.reason === "owner-owned") {
1836
+ runtime.log?.(`[tlon] Owner ${senderShip} heard without mention in owned channel ${nest}`);
1837
+ }
1838
+ else if (isOwnerBlob && !mentioned && !inParticipatedThread) {
1685
1839
  runtime.log?.(`[tlon] Responding to owner blob-only message in ${nest}`);
1686
1840
  }
1687
1841
  else if (inParticipatedThread && !mentioned) {
@@ -2180,6 +2334,14 @@ export async function monitorTlonProvider(opts = {}) {
2180
2334
  effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
2181
2335
  runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
2182
2336
  }
2337
+ if (newSettings.ownerListenEnabled !== undefined) {
2338
+ effectiveOwnerListenEnabled = newSettings.ownerListenEnabled;
2339
+ runtime.log?.(`[tlon] Settings: ownerListenEnabled = ${effectiveOwnerListenEnabled}`);
2340
+ }
2341
+ if (newSettings.ownerListenDisabledChannels !== undefined) {
2342
+ effectiveOwnerListenDisabled = new Set(canonicalizeNestList(newSettings.ownerListenDisabledChannels));
2343
+ runtime.log?.(`[tlon] Settings: ownerListenDisabledChannels updated (${effectiveOwnerListenDisabled.size} channel(s) disabled)`);
2344
+ }
2183
2345
  // ownerShip is applied on both live subscription and refresh.
2184
2346
  // pendingNudge is only rehydrated from the store during startup load. Once the
2185
2347
  // monitor is running, the in-memory pending state is authoritative so refreshes
@@ -2229,7 +2391,7 @@ export async function monitorTlonProvider(opts = {}) {
2229
2391
  // runner's stage guard today.
2230
2392
  const stageChanged = prevSettings.lastNudgeStage !== newSettings.lastNudgeStage;
2231
2393
  if (shadowReconcileTrusted && stageChanged) {
2232
- const nextStage = (newSettings.lastNudgeStage ?? 0);
2394
+ const nextStage = newSettings.lastNudgeStage ?? 0;
2233
2395
  setLastNudgeStageShadow(account.accountId, nextStage);
2234
2396
  runtime.log?.(`[tlon] nudge: reconciled lastNudgeStageShadow from ${source} (stage=${nextStage})`);
2235
2397
  }
@@ -2292,8 +2454,10 @@ export async function monitorTlonProvider(opts = {}) {
2292
2454
  if (event.channels && typeof event.channels === "object") {
2293
2455
  const channels = event.channels;
2294
2456
  for (const [channelNest, _channelData] of Object.entries(channels)) {
2295
- // Only monitor chat and heap channels
2296
- if (!channelNest.startsWith("chat/") && !channelNest.startsWith("heap/")) {
2457
+ // Only monitor chat, heap, and diary channels
2458
+ if (!channelNest.startsWith("chat/") &&
2459
+ !channelNest.startsWith("heap/") &&
2460
+ !channelNest.startsWith("diary/")) {
2297
2461
  continue;
2298
2462
  }
2299
2463
  // If this is a new channel we're not watching yet, add it
@@ -2334,7 +2498,9 @@ export async function monitorTlonProvider(opts = {}) {
2334
2498
  const join = event.join;
2335
2499
  if (join.channels) {
2336
2500
  for (const channelNest of join.channels) {
2337
- if (!channelNest.startsWith("chat/") && !channelNest.startsWith("heap/")) {
2501
+ if (!channelNest.startsWith("chat/") &&
2502
+ !channelNest.startsWith("heap/") &&
2503
+ !channelNest.startsWith("diary/")) {
2338
2504
  continue;
2339
2505
  }
2340
2506
  if (!watchedChannels.has(channelNest)) {
@@ -2560,7 +2726,9 @@ export async function monitorTlonProvider(opts = {}) {
2560
2726
  }
2561
2727
  try {
2562
2728
  const refreshResult = await settingsManager.load();
2563
- applySettingsSnapshot(refreshResult.settings, "refresh", { fresh: refreshResult.fresh });
2729
+ applySettingsSnapshot(refreshResult.settings, "refresh", {
2730
+ fresh: refreshResult.fresh,
2731
+ });
2564
2732
  }
2565
2733
  catch (err) {
2566
2734
  runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);