@tloncorp/openclaw 0.2.0 → 0.4.1

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 (78) hide show
  1. package/README.md +232 -23
  2. package/dist/index.js +204 -69
  3. package/dist/index.js.map +1 -1
  4. package/dist/setup-api.js +3 -0
  5. package/dist/setup-api.js.map +1 -0
  6. package/dist/setup-entry.js +4 -0
  7. package/dist/setup-entry.js.map +1 -0
  8. package/dist/src/actions.js +30 -8
  9. package/dist/src/actions.js.map +1 -1
  10. package/dist/src/channel.js +155 -420
  11. package/dist/src/channel.js.map +1 -1
  12. package/dist/src/channel.runtime.js +142 -0
  13. package/dist/src/channel.runtime.js.map +1 -0
  14. package/dist/src/config-schema.js +37 -1
  15. package/dist/src/config-schema.js.map +1 -1
  16. package/dist/src/effective-owner.js +22 -0
  17. package/dist/src/effective-owner.js.map +1 -0
  18. package/dist/src/gateway-status.js +72 -0
  19. package/dist/src/gateway-status.js.map +1 -0
  20. package/dist/src/heartbeat-telemetry.js +155 -0
  21. package/dist/src/heartbeat-telemetry.js.map +1 -0
  22. package/dist/src/monitor/approval.js.map +1 -1
  23. package/dist/src/monitor/computing-presence.js +221 -0
  24. package/dist/src/monitor/computing-presence.js.map +1 -0
  25. package/dist/src/monitor/discovery.js +2 -2
  26. package/dist/src/monitor/index.js +665 -171
  27. package/dist/src/monitor/index.js.map +1 -1
  28. package/dist/src/monitor/media.js +165 -6
  29. package/dist/src/monitor/media.js.map +1 -1
  30. package/dist/src/monitor/nudge-runner.js +232 -0
  31. package/dist/src/monitor/nudge-runner.js.map +1 -0
  32. package/dist/src/monitor/nudge-state.js +58 -0
  33. package/dist/src/monitor/nudge-state.js.map +1 -0
  34. package/dist/src/monitor/owner-reply-persistence.js +92 -0
  35. package/dist/src/monitor/owner-reply-persistence.js.map +1 -0
  36. package/dist/src/monitor/pending-nudge-persistence.js +15 -0
  37. package/dist/src/monitor/pending-nudge-persistence.js.map +1 -0
  38. package/dist/src/monitor/settings-sync.js +28 -0
  39. package/dist/src/monitor/settings-sync.js.map +1 -0
  40. package/dist/src/monitor/utils.js +0 -4
  41. package/dist/src/monitor/utils.js.map +1 -1
  42. package/dist/src/nudge-candidate.js +109 -0
  43. package/dist/src/nudge-candidate.js.map +1 -0
  44. package/dist/src/nudge-decision.js +309 -0
  45. package/dist/src/nudge-decision.js.map +1 -0
  46. package/dist/src/nudge-messages.js +25 -0
  47. package/dist/src/nudge-messages.js.map +1 -0
  48. package/dist/src/nudge-scheduler.js +91 -0
  49. package/dist/src/nudge-scheduler.js.map +1 -0
  50. package/dist/src/pending-nudge.js +57 -0
  51. package/dist/src/pending-nudge.js.map +1 -0
  52. package/dist/src/settings.js +77 -5
  53. package/dist/src/settings.js.map +1 -1
  54. package/dist/src/setup-core.js +164 -0
  55. package/dist/src/setup-core.js.map +1 -0
  56. package/dist/src/setup-surface.js +85 -0
  57. package/dist/src/setup-surface.js.map +1 -0
  58. package/dist/src/telemetry.js +252 -0
  59. package/dist/src/telemetry.js.map +1 -0
  60. package/dist/src/tlon-binary.js +46 -0
  61. package/dist/src/tlon-binary.js.map +1 -0
  62. package/dist/src/tlon-tool-guard.js +44 -0
  63. package/dist/src/tlon-tool-guard.js.map +1 -0
  64. package/dist/src/tool-trace.js +100 -0
  65. package/dist/src/tool-trace.js.map +1 -0
  66. package/dist/src/types.js +31 -1
  67. package/dist/src/types.js.map +1 -1
  68. package/dist/src/urbit/api-client.js +4 -3
  69. package/dist/src/urbit/api-client.js.map +1 -1
  70. package/dist/src/urbit/base-url.js +2 -2
  71. package/dist/src/urbit/base-url.js.map +1 -1
  72. package/dist/src/urbit/fetch.js +1 -1
  73. package/dist/src/urbit/fetch.js.map +1 -1
  74. package/dist/src/urbit/upload.js +1 -1
  75. package/dist/src/urbit/upload.js.map +1 -1
  76. package/dist/src/version.generated.js +1 -1
  77. package/dist/src/version.generated.js.map +1 -1
  78. package/package.json +34 -24
@@ -1,22 +1,36 @@
1
+ import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
2
+ import { configureGatewayStatus, gatewayStart } from "@tloncorp/api";
1
3
  import { format } from "node:util";
4
+ import { getEffectiveOwnerShip, setEffectiveOwnerShip } from "../effective-owner.js";
5
+ import { getGatewayStatusManager, computeLeaseUntil, ACTIVE_WINDOW_SECS, OFFLINE_REPLY_COOLDOWN_SECS, } from "../gateway-status.js";
6
+ import { registerPersistCallback, syncPendingNudgeFromStore, getPendingNudge, clearPendingNudge, setPendingNudge, isNudgeEligible, } from "../pending-nudge.js";
2
7
  import { getTlonRuntime } from "../runtime.js";
3
8
  import { setSessionRole } from "../session-roles.js";
4
9
  import { createSettingsManager } from "../settings.js";
5
10
  import { normalizeShip, parseChannelNest } from "../targets.js";
11
+ import { createTlonTelemetry } from "../telemetry.js";
6
12
  import { resolveTlonAccount } from "../types.js";
13
+ import { configureTlonApiWithPoke } from "../urbit/api-client.js";
7
14
  import { authenticate } from "../urbit/auth.js";
8
15
  import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js";
9
- import { configureTlonApiWithPoke } from "../urbit/api-client.js";
10
16
  import { sendDm, sendChannelPost } from "../urbit/send.js";
11
- import { markdownToStory } from "../urbit/story.js";
12
17
  import { UrbitSSEClient } from "../urbit/sse-client.js";
18
+ import { markdownToStory } from "../urbit/story.js";
13
19
  import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, findPendingApproval, removePendingApproval, pruneExpired, formatBlockedList, formatPendingList, isExpired, emojiToApprovalAction, normalizeNotificationId, } from "./approval.js";
14
20
  import { setBridge, removeBridge } from "./command-bridge.js";
21
+ import { createComputingPresenceTracker } from "./computing-presence.js";
15
22
  import { fetchAllChannels, fetchInitData } from "./discovery.js";
16
- import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
17
- import { downloadMessageImages } from "./media.js";
23
+ import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchChannelHistory, fetchThreadHistory, } from "./history.js";
24
+ import { downloadMessageImages, parseBlobData, formatBlobAnnotations, downloadBlobAttachments, } from "./media.js";
25
+ import { createNudgeRunner, shouldStartNudgeRunner } from "./nudge-runner.js";
26
+ import { clearShadowsForAccount, getLastNudgeStageShadow, getLastOwnerActivity, ownerActivityFromSettings, setLastNudgeStageShadow, setLastOwnerActivity, } from "./nudge-state.js";
27
+ import { createOwnerReplyPersistenceQueue } from "./owner-reply-persistence.js";
28
+ import { createPendingNudgePersistenceQueue } from "./pending-nudge-persistence.js";
18
29
  import { createProcessedMessageTracker } from "./processed-messages.js";
30
+ import { resolveSettingsMirrorSync } from "./settings-sync.js";
19
31
  import { extractMessageText, extractCites, formatModelName, isBotMentioned, stripBotMention, isDmAllowed, isSummarizationRequest, sanitizeMessageText, } from "./utils.js";
32
+ /** Refresh stale settings subscription state periodically as a fallback for silently-dead SSE subscriptions. */
33
+ const SETTINGS_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
20
34
  /**
21
35
  * Extract ship from author field, handling both string (ship) and object (bot-meta) formats.
22
36
  */
@@ -123,15 +137,14 @@ export async function monitorTlonProvider(opts = {}) {
123
137
  });
124
138
  // Configure @tloncorp/api's global client to use the SSE client's poke for all send operations
125
139
  configureTlonApiWithPoke(api.poke.bind(api), botShipName, account.url);
140
+ const computingPresence = createComputingPresenceTracker({ runtime });
126
141
  const processedTracker = createProcessedMessageTracker(2000);
127
142
  let groupChannels = [];
128
143
  const channelToGroup = new Map();
129
144
  let botNickname = null;
130
145
  let botAvatar = null;
131
146
  // Helper to get bot profile for outbound messages
132
- const getBotProfile = () => botNickname || botAvatar
133
- ? { nickname: botNickname || "", avatar: botAvatar || "" }
134
- : undefined;
147
+ const getBotProfile = () => botNickname || botAvatar ? { nickname: botNickname || "", avatar: botAvatar || "" } : undefined;
135
148
  // Settings store manager for hot-reloading config
136
149
  const settingsManager = createSettingsManager(api, {
137
150
  log: (msg) => runtime.log?.(msg),
@@ -147,8 +160,28 @@ export async function monitorTlonProvider(opts = {}) {
147
160
  let effectiveOwnerShip = account.ownerShip
148
161
  ? normalizeShip(account.ownerShip)
149
162
  : null;
163
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
150
164
  let pendingApprovals = [];
151
165
  let currentSettings = {};
166
+ // Tracks whether pendingNudge has been successfully rehydrated from the settings
167
+ // store (or locally set/cleared). While false, refresh is allowed to recover a
168
+ // persisted pendingNudge that was missed due to a transient startup scry failure.
169
+ // Once true, the in-memory state is authoritative and refresh cannot clobber it.
170
+ let pendingNudgeRehydrated = false;
171
+ /** Set pending nudge and take ownership so refresh cannot clobber. */
172
+ const setLocalPendingNudge = (accountId, nudge) => {
173
+ setPendingNudge(accountId, nudge);
174
+ pendingNudgeRehydrated = true;
175
+ };
176
+ /** Clear pending nudge and take ownership so refresh cannot resurrect stale store data. */
177
+ const clearLocalPendingNudge = (accountId) => {
178
+ clearPendingNudge(accountId);
179
+ pendingNudgeRehydrated = true;
180
+ };
181
+ const telemetry = createTlonTelemetry({
182
+ config: account.telemetry,
183
+ runtime,
184
+ });
152
185
  // Track threads we've participated in (by parentId) - respond without mention requirement
153
186
  const participatedThreads = new Set();
154
187
  // Track consecutive bot responses per channel/DM for rate limiting
@@ -165,14 +198,15 @@ export async function monitorTlonProvider(opts = {}) {
165
198
  // Sanitize nickname to prevent format injection
166
199
  function sanitizeNickname(nickname) {
167
200
  return nickname
168
- .replace(/[\[\]()]/g, "") // Remove format-breaking chars
201
+ .replace(/[[\]()]/g, "") // Remove format-breaking chars
169
202
  .slice(0, 50); // Reasonable length limit
170
203
  }
171
204
  // Format a ship with nickname if available
172
205
  function formatShipWithNickname(ship) {
173
206
  const nickname = nicknameCache.get(ship);
174
- if (!nickname)
207
+ if (!nickname) {
175
208
  return ship;
209
+ }
176
210
  const sanitized = sanitizeNickname(nickname);
177
211
  return sanitized ? `${ship} (${sanitized})` : ship;
178
212
  }
@@ -194,7 +228,7 @@ export async function monitorTlonProvider(opts = {}) {
194
228
  }
195
229
  // Fetch all contacts to populate nickname cache
196
230
  try {
197
- const allContacts = await api.scry("/contacts/v1/all.json");
231
+ const allContacts = (await api.scry("/contacts/v1/all.json"));
198
232
  if (allContacts && typeof allContacts === "object") {
199
233
  for (const [ship, contact] of Object.entries(allContacts)) {
200
234
  const nickname = contact?.nickname?.value ?? contact?.nickname;
@@ -303,11 +337,28 @@ export async function monitorTlonProvider(opts = {}) {
303
337
  }
304
338
  }
305
339
  }
340
+ // Clear stale in-memory pending-nudge state before settings load.
341
+ // If load fails during a same-process restart, we should not keep attributing
342
+ // owner replies against a previous monitor run's record.
343
+ syncPendingNudgeFromStore(account.accountId, null);
344
+ // Drop stale per-process shadows from any prior run in the same process.
345
+ // Mirrors the same-process-restart reasoning as the pending-nudge sync above.
346
+ clearShadowsForAccount(account.accountId);
306
347
  // Load settings from settings store (hot-reloadable config)
307
348
  try {
308
- currentSettings = await settingsManager.load();
309
- // Migrate file config to settings store if not already present
310
- await migrateConfigToSettings();
349
+ const loadResult = await settingsManager.load();
350
+ currentSettings = loadResult.settings;
351
+ // Only seed file config into %settings when the startup snapshot is fresh.
352
+ // On a transient startup scry failure, `load()` preserves the last known
353
+ // snapshot (or `{}` on first load). Running migration against a stale
354
+ // snapshot would treat every persisted override as absent and clobber it
355
+ // with file-backed values once the settings agent recovers.
356
+ if (loadResult.fresh) {
357
+ await migrateConfigToSettings();
358
+ }
359
+ else {
360
+ runtime.log?.("[tlon] Skipping config->settings migration on stale startup snapshot");
361
+ }
311
362
  // Apply settings overrides
312
363
  // Note: groupChannels from settings store are merged AFTER discovery runs (below)
313
364
  if (currentSettings.defaultAuthorizedShips?.length) {
@@ -338,8 +389,21 @@ export async function monitorTlonProvider(opts = {}) {
338
389
  }
339
390
  if (currentSettings.ownerShip) {
340
391
  effectiveOwnerShip = normalizeShip(currentSettings.ownerShip);
392
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
341
393
  runtime.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
342
394
  }
395
+ // Rehydrate pending nudge from settings store only if the scry returned real data.
396
+ // On fallback (scry failure), leave pendingNudgeRehydrated false so the refresh
397
+ // recovery path can still pick up a persisted pendingNudge later.
398
+ if (loadResult.fresh) {
399
+ syncPendingNudgeFromStore(account.accountId, currentSettings.pendingNudge ?? null);
400
+ pendingNudgeRehydrated = true;
401
+ }
402
+ // Seed nudge shadows from the loaded settings snapshot. Missing fields
403
+ // seed the shadow as absent / 0 — the tick short-circuits on null
404
+ // activity, which is correct for a cold startup with an empty store.
405
+ setLastOwnerActivity(account.accountId, ownerActivityFromSettings(currentSettings));
406
+ setLastNudgeStageShadow(account.accountId, currentSettings.lastNudgeStage ?? 0);
343
407
  if (currentSettings.pendingApprovals?.length) {
344
408
  pendingApprovals = currentSettings.pendingApprovals;
345
409
  runtime.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
@@ -348,6 +412,97 @@ export async function monitorTlonProvider(opts = {}) {
348
412
  catch (err) {
349
413
  runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
350
414
  }
415
+ const pendingNudgePersistence = createPendingNudgePersistenceQueue(async (nudge) => {
416
+ try {
417
+ if (nudge) {
418
+ await api.poke({
419
+ app: "settings",
420
+ mark: "settings-event",
421
+ json: {
422
+ "put-entry": {
423
+ desk: "moltbot",
424
+ "bucket-key": "tlon",
425
+ "entry-key": "pendingNudge",
426
+ value: JSON.stringify(nudge),
427
+ },
428
+ },
429
+ });
430
+ }
431
+ else {
432
+ await api.poke({
433
+ app: "settings",
434
+ mark: "settings-event",
435
+ json: {
436
+ "del-entry": {
437
+ desk: "moltbot",
438
+ "bucket-key": "tlon",
439
+ "entry-key": "pendingNudge",
440
+ },
441
+ },
442
+ });
443
+ }
444
+ }
445
+ catch (err) {
446
+ runtime.error?.(nudge
447
+ ? `[tlon] Failed to persist pendingNudge: ${String(err)}`
448
+ : `[tlon] Failed to clear pendingNudge: ${String(err)}`);
449
+ }
450
+ });
451
+ // Register per-account persist callback for pending nudge writes.
452
+ registerPersistCallback(account.accountId, (nudge) => {
453
+ pendingNudgePersistence.enqueue(nudge);
454
+ });
455
+ const ownerReplyPersistence = createOwnerReplyPersistenceQueue(api, {
456
+ error: (msg) => runtime.error?.(msg),
457
+ });
458
+ let nudgeRunner = null;
459
+ // Clear expired pending nudge on startup (after persist callback is registered so del-entry fires).
460
+ const rehydratedNudge = getPendingNudge(account.accountId);
461
+ if (rehydratedNudge && !isNudgeEligible(rehydratedNudge)) {
462
+ const ageMs = Date.now() - rehydratedNudge.sentAt;
463
+ clearLocalPendingNudge(account.accountId);
464
+ runtime.log?.(`[tlon] Cleared expired pending nudge on startup (stage ${rehydratedNudge.stage}, age ${ageMs}ms)`);
465
+ }
466
+ // ── Gateway-status: non-blocking background activation ──────
467
+ // getGatewayStatusManager() returns null when multi-account or zero accounts configured
468
+ // (see index.ts registration gate).
469
+ const gsManager = getGatewayStatusManager();
470
+ if (gsManager && effectiveOwnerShip) {
471
+ const capturedOwnerShip = effectiveOwnerShip;
472
+ const signal = opts.abortSignal;
473
+ // Fire-and-forget: wait for gateway_start signal, then activate.
474
+ // Does NOT block monitor startup — discovery, subscriptions, etc. proceed immediately.
475
+ void (async () => {
476
+ try {
477
+ const raced = await Promise.race([
478
+ gsManager.waitForGatewayStart().then(() => "started"),
479
+ ...(signal
480
+ ? [
481
+ new Promise((r) => signal.addEventListener("abort", () => r("aborted"), { once: true })),
482
+ ]
483
+ : []),
484
+ ]);
485
+ if (raced !== "started" || gsManager.stopped) {
486
+ return;
487
+ }
488
+ await configureGatewayStatus({
489
+ owner: capturedOwnerShip,
490
+ activeWindowSecs: ACTIVE_WINDOW_SECS,
491
+ offlineReplyCooldownSecs: OFFLINE_REPLY_COOLDOWN_SECS,
492
+ });
493
+ await gatewayStart({
494
+ bootId: gsManager.bootId,
495
+ leaseUntil: computeLeaseUntil(),
496
+ });
497
+ gsManager.markActivated();
498
+ gsManager.startHeartbeat();
499
+ runtime.log?.(`[gateway-status] activated (bootId=${gsManager.bootId}, owner=${capturedOwnerShip})`);
500
+ }
501
+ catch (err) {
502
+ runtime.error?.(`[gateway-status] start failed: ${String(err)}`);
503
+ }
504
+ })();
505
+ }
351
506
  // Run channel discovery AFTER settings are loaded (so settings store value is used)
352
507
  if (effectiveAutoDiscoverChannels) {
353
508
  try {
@@ -559,10 +714,10 @@ export async function monitorTlonProvider(opts = {}) {
559
714
  */
560
715
  const SCRY_TIMEOUT_MS = 15_000;
561
716
  async function scryBlockedShips() {
562
- const blocked = await Promise.race([
717
+ const blocked = (await Promise.race([
563
718
  api.scry("/chat/blocked.json"),
564
719
  new Promise((_, reject) => setTimeout(() => reject(new Error("blocked list scry timeout")), SCRY_TIMEOUT_MS)),
565
- ]);
720
+ ]));
566
721
  return Array.isArray(blocked) ? blocked : [];
567
722
  }
568
723
  // Check if a ship is blocked using Tlon's native block list
@@ -591,6 +746,11 @@ export async function monitorTlonProvider(opts = {}) {
591
746
  async function unblockShip(ship) {
592
747
  const normalizedShip = normalizeShip(ship);
593
748
  try {
749
+ const blocked = await isShipBlocked(normalizedShip);
750
+ if (!blocked) {
751
+ runtime.log?.(`[tlon] Ship ${normalizedShip} is not blocked; skipping unblock`);
752
+ return true;
753
+ }
594
754
  await api.poke({
595
755
  app: "chat",
596
756
  mark: "chat-unblock-ship",
@@ -722,6 +882,7 @@ export async function monitorTlonProvider(opts = {}) {
722
882
  messageContent: approval.originalMessage.messageContent,
723
883
  isGroup: false,
724
884
  timestamp: approval.originalMessage.timestamp,
885
+ blobField: approval.originalMessage.blob,
725
886
  });
726
887
  }
727
888
  break;
@@ -743,6 +904,7 @@ export async function monitorTlonProvider(opts = {}) {
743
904
  timestamp: approval.originalMessage.timestamp,
744
905
  parentId: approval.originalMessage.parentId,
745
906
  isThreadReply: approval.originalMessage.isThreadReply,
907
+ blobField: approval.originalMessage.blob,
746
908
  });
747
909
  }
748
910
  }
@@ -868,42 +1030,73 @@ export async function monitorTlonProvider(opts = {}) {
868
1030
  if (isGroup) {
869
1031
  messageText = stripBotMention(messageText, botShipName);
870
1032
  }
871
- // Track owner interaction timestamp for heartbeat engagement recovery.
872
- // Store both epoch ms (for code) and ISO date (for LLM — models can't reliably convert epoch).
1033
+ // Track owner interaction timestamp for the nudge scheduler.
1034
+ // The shadows update synchronously; the durable %settings writes happen
1035
+ // in the background via an ordered queue so the owner-DM hot path never
1036
+ // waits on an Urbit RTT.
873
1037
  if (isOwner(senderShip)) {
874
- const isoDate = new Date(timestamp).toISOString().split("T")[0]; // YYYY-MM-DD
875
- Promise.all([
876
- api.poke({
877
- app: "settings",
878
- mark: "settings-event",
879
- json: {
880
- "put-entry": {
881
- desk: "moltbot",
882
- "bucket-key": "tlon",
883
- "entry-key": "lastOwnerMessageAt",
884
- value: timestamp,
885
- },
886
- },
887
- }),
888
- api.poke({
889
- app: "settings",
890
- mark: "settings-event",
891
- json: {
892
- "put-entry": {
893
- desk: "moltbot",
894
- "bucket-key": "tlon",
895
- "entry-key": "lastOwnerMessageDate",
896
- value: isoDate,
897
- },
898
- },
899
- }),
900
- ])
901
- .then(() => {
902
- runtime.log?.(`[tlon] Updated lastOwnerMessageAt: ${timestamp} (${isoDate})`);
903
- })
904
- .catch((err) => {
905
- runtime.error?.(`[tlon] Failed to update lastOwnerMessageAt: ${String(err)}`);
1038
+ const isoDate = new Date(timestamp).toISOString().split("T")[0] ?? ""; // YYYY-MM-DD
1039
+ // (1a) Synchronous shadow: owner activity. Updated FIRST so any tick
1040
+ // that observes both shadows sees "activity-first" ordering.
1041
+ setLastOwnerActivity(account.accountId, { at: timestamp, date: isoDate });
1042
+ // Check for pending nudge re-engagement. Stage is cleared on ANY owner
1043
+ // reply when the stage shadow is non-zero (or pendingNudge is present)
1044
+ // so the next inactivity cycle can send the same stage again. Gating on
1045
+ // `pendingNudge` alone would miss the in-flight-tick race: the scheduler
1046
+ // pokes `lastNudgeStage` and sets the shadow before `sendDm()`, but
1047
+ // only writes `pendingNudge` after the send resolves — so a reply that
1048
+ // lands in that window would otherwise leave the stage stuck.
1049
+ const pending = getPendingNudge(account.accountId);
1050
+ const shadowStage = getLastNudgeStageShadow(account.accountId) ?? 0;
1051
+ const willClearStage = shadowStage > 0 || Boolean(pending);
1052
+ // (1b) Synchronous shadow: stage cleared (only when we'd clear).
1053
+ if (willClearStage) {
1054
+ setLastNudgeStageShadow(account.accountId, 0);
1055
+ }
1056
+ // (2) Enqueue durable writes. The queue awaits the put-entries before
1057
+ // issuing the del-entry on the wire, closing the crash-consistency
1058
+ // gap. The handler does NOT await the queue.
1059
+ ownerReplyPersistence.enqueue({
1060
+ at: timestamp,
1061
+ date: isoDate,
1062
+ clearStage: willClearStage,
906
1063
  });
1064
+ if (pending) {
1065
+ if (isNudgeEligible(pending, timestamp)) {
1066
+ const reengagedAt = timestamp;
1067
+ telemetry?.captureHeartbeatReengagement({
1068
+ ownerShip: pending.ownerShip,
1069
+ botShip: account.ship ?? "",
1070
+ nudgeStage: pending.stage,
1071
+ nudgeSentAt: pending.sentAt,
1072
+ reengagedAt,
1073
+ reengagementDelayMs: reengagedAt - pending.sentAt,
1074
+ channel: "tlon",
1075
+ accountId: pending.accountId,
1076
+ });
1077
+ runtime.log?.(`[tlon] Heartbeat nudge re-engagement: stage ${pending.stage}, delay ${reengagedAt - pending.sentAt}ms`);
1078
+ }
1079
+ else {
1080
+ runtime.log?.(`[tlon] Pending nudge expired (stage ${pending.stage}, sent ${pending.sentAt})`);
1081
+ }
1082
+ clearLocalPendingNudge(account.accountId);
1083
+ }
1084
+ // Inject reply context for the agent when the reply appears to be a
1085
+ // response to a recent, eligible nudge.
1086
+ //
1087
+ // Restricted to DMs (`!isGroup`). The nudge itself was sent as a DM,
1088
+ // so prefacing a channel/group reply with DM-only context — including
1089
+ // the verbatim nudge `content` — would leak that context into an
1090
+ // unrelated public conversation.
1091
+ if (pending && isNudgeEligible(pending, timestamp) && !isGroup) {
1092
+ const sentIso = new Date(pending.sentAt).toISOString();
1093
+ const contentBlock = pending.content ? `Message content:\n\n${pending.content}\n\n` : "";
1094
+ messageText =
1095
+ `[Context: You recently sent ${pending.ownerShip} a stage-${pending.stage} ` +
1096
+ `re-engagement nudge at ${sentIso}. ${contentBlock}` +
1097
+ `The owner's reply below may be responding to that nudge.]\n\n` +
1098
+ messageText;
1099
+ }
907
1100
  }
908
1101
  // Download any images from the message content
909
1102
  let attachments = [];
@@ -918,14 +1111,39 @@ export async function monitorTlonProvider(opts = {}) {
918
1111
  runtime.log?.(`[tlon] Failed to download images: ${error?.message ?? String(error)}`);
919
1112
  }
920
1113
  }
1114
+ // Parse and handle blob attachments (files, voice memos, videos)
1115
+ const blobData = parseBlobData(params.blobField);
1116
+ if (blobData) {
1117
+ // Add text annotations so the agent knows what was attached
1118
+ const blobAnnotations = formatBlobAnnotations(blobData);
1119
+ if (blobAnnotations) {
1120
+ messageText = blobAnnotations + "\n" + messageText;
1121
+ runtime.log?.(`[tlon] Added blob annotations: ${blobAnnotations} attachment(s)`);
1122
+ }
1123
+ // Download blob files as attachments
1124
+ try {
1125
+ const { attachments: blobAttachments, notices: blobDownloadNotices } = await downloadBlobAttachments(blobData);
1126
+ if (blobDownloadNotices.length > 0) {
1127
+ messageText = blobDownloadNotices.join("\n") + "\n" + messageText;
1128
+ runtime.log?.(`[tlon] Skipped oversized blob attachment(s): ${blobDownloadNotices.join(" | ")}`);
1129
+ }
1130
+ if (blobAttachments.length > 0) {
1131
+ attachments = attachments.concat(blobAttachments);
1132
+ runtime.log?.(`[tlon] Downloaded blob attachment(s) ${JSON.stringify(blobAttachments)}`);
1133
+ }
1134
+ }
1135
+ catch (error) {
1136
+ runtime.log?.(`[tlon] Failed to download blob attachments: ${error?.message ?? String(error)}`);
1137
+ }
1138
+ }
921
1139
  // Fetch thread context when entering a thread for the first time
922
1140
  if (isThreadReply && parentId && groupChannel) {
923
1141
  try {
924
1142
  const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
925
1143
  if (threadHistory.length > 0) {
926
1144
  const threadContext = threadHistory
927
- .slice(-10) // Last 10 messages for context
928
- .map((msg) => `${msg.author}: ${sanitizeMessageText(msg.content)}`)
1145
+ .slice(-20) // Last 20 thread messages for context
1146
+ .map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
929
1147
  .join("\n");
930
1148
  // Prepend thread context to the message
931
1149
  // Include note about ongoing conversation for agent judgment
@@ -939,6 +1157,31 @@ export async function monitorTlonProvider(opts = {}) {
939
1157
  // Continue without thread context - not critical
940
1158
  }
941
1159
  }
1160
+ // Fetch recent channel history on mention (non-thread) so the agent has
1161
+ // context about what the channel has been discussing.
1162
+ if (isGroup && groupChannel && !isThreadReply) {
1163
+ try {
1164
+ const recentHistory = await fetchChannelHistory(api, groupChannel, 20, runtime);
1165
+ if (recentHistory.length > 0) {
1166
+ // Filter out the current message itself (avoid duplication)
1167
+ const contextMessages = recentHistory
1168
+ .filter((msg) => msg.id !== params.messageId)
1169
+ .slice(0, 20)
1170
+ .toReversed() // oldest first for natural reading order
1171
+ .map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
1172
+ .join("\n");
1173
+ if (contextMessages) {
1174
+ const contextNote = `[Recent channel activity - ${recentHistory.length} messages. Use this context to understand what's being discussed.]`;
1175
+ messageText = `${contextNote}\n\n${contextMessages}\n\n[Current message (mentioned you)]\n${messageText}`;
1176
+ runtime?.log?.(`[tlon] Added channel context (${recentHistory.length} messages) to mention in ${groupChannel}`);
1177
+ }
1178
+ }
1179
+ }
1180
+ catch (error) {
1181
+ runtime?.log?.(`[tlon] Could not fetch channel context: ${error?.message ?? String(error)}`);
1182
+ // Continue without channel context - not critical
1183
+ }
1184
+ }
942
1185
  if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
943
1186
  try {
944
1187
  const history = await getChannelHistory(api, groupChannel, 50, runtime);
@@ -984,7 +1227,12 @@ export async function monitorTlonProvider(opts = {}) {
984
1227
  });
985
1228
  }
986
1229
  else {
987
- await sendDm({ botProfile: getBotProfile(), fromShip: botShipName, toShip: senderShip, text: errorMsg });
1230
+ await sendDm({
1231
+ botProfile: getBotProfile(),
1232
+ fromShip: botShipName,
1233
+ toShip: senderShip,
1234
+ text: errorMsg,
1235
+ });
988
1236
  }
989
1237
  return;
990
1238
  }
@@ -1036,6 +1284,7 @@ export async function monitorTlonProvider(opts = {}) {
1036
1284
  const fromLabel = isGroup
1037
1285
  ? `${senderDisplay} [${senderRole}] in ${channelNest}`
1038
1286
  : `${senderDisplay} [${senderRole}]`;
1287
+ const attachmentCount = attachments.length;
1039
1288
  // Compute command authorization for slash commands (owner-only)
1040
1289
  const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(messageText, cfg);
1041
1290
  let commandAuthorized = false;
@@ -1074,11 +1323,10 @@ export async function monitorTlonProvider(opts = {}) {
1074
1323
  body: bodyWithAttachments,
1075
1324
  });
1076
1325
  // Use raw text (no thread context) for command detection so "/status" is recognized
1077
- const commandBody = isGroup
1078
- ? stripBotMention(rawMessageText, botShipName)
1079
- : rawMessageText;
1326
+ const commandBody = isGroup ? stripBotMention(rawMessageText, botShipName) : rawMessageText;
1080
1327
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1081
1328
  Body: body,
1329
+ BodyForAgent: bodyWithAttachments,
1082
1330
  RawBody: messageText,
1083
1331
  CommandBody: commandBody,
1084
1332
  From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
@@ -1095,86 +1343,196 @@ export async function monitorTlonProvider(opts = {}) {
1095
1343
  Provider: "tlon",
1096
1344
  Surface: "tlon",
1097
1345
  MessageSid: messageId,
1098
- // Include downloaded media attachments
1099
- ...(attachments.length > 0 && { Attachments: attachments }),
1346
+ // Include downloaded media attachments (MediaPaths/MediaUrls/MediaTypes for OpenClaw media pipeline)
1347
+ ...(attachments.length > 0 && {
1348
+ MediaPaths: attachments.map((a) => a.path),
1349
+ MediaUrls: attachments.map((a) => a.path),
1350
+ MediaTypes: attachments.map((a) => a.contentType),
1351
+ }),
1100
1352
  OriginatingChannel: "tlon",
1101
1353
  OriginatingTo: `tlon:${isGroup ? groupChannel : senderShip}`,
1102
1354
  // Include thread context for automatic reply routing
1103
1355
  ...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
1104
1356
  });
1105
1357
  const dispatchStartTime = Date.now();
1358
+ const replyTelemetry = telemetry?.startReply({
1359
+ sessionKey: route.sessionKey,
1360
+ ownerShip: effectiveOwnerShip,
1361
+ botShip: botShipName,
1362
+ chatType: isGroup ? "groupChannel" : "dm",
1363
+ isThreadReply: Boolean(isThreadReply),
1364
+ senderRole,
1365
+ attachmentCount,
1366
+ });
1367
+ let selectedProvider = null;
1368
+ let selectedModel = null;
1369
+ let selectedThinkLevel = null;
1370
+ let deliveredMessageCount = 0;
1371
+ let replyCharCount = 0;
1372
+ let replyWordCount = 0;
1373
+ let replyMediaCount = 0;
1106
1374
  const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
1107
1375
  const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
1108
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1109
- ctx: ctxPayload,
1110
- cfg,
1111
- dispatcherOptions: {
1112
- responsePrefix,
1113
- humanDelay,
1114
- deliver: async (payload) => {
1115
- let replyText = payload.text;
1116
- if (!replyText) {
1117
- return;
1118
- }
1119
- // Process any block directives in the response (strips them from text)
1120
- replyText = await processBlockDirectives(replyText, senderShip);
1121
- if (!replyText) {
1122
- return;
1123
- } // Response was only a directive
1124
- // Use settings store value if set, otherwise fall back to file config
1125
- const showSignature = effectiveShowModelSig;
1126
- if (showSignature) {
1127
- const modelCfg = cfg.agents?.defaults?.model;
1128
- const modelInfo = payload.metadata?.model ||
1129
- payload.model ||
1130
- route.model ||
1131
- (typeof modelCfg === "string" ? modelCfg : modelCfg?.primary);
1132
- replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
1133
- }
1134
- // Add addendum if this is the last response before bot rate limit
1135
- if (isGroup && groupChannel && knownBotShips.has(senderShip)) {
1136
- const count = consecutiveBotMessages.get(groupChannel) ?? 0;
1137
- if (maxBotResponses > 0 && count === maxBotResponses) {
1138
- const otherBot = formatShipWithNickname(senderShip);
1139
- replyText += `\n\n---\n_This is my last response to ${otherBot} for now. To continue our conversation, someone will need to mention me._`;
1140
- }
1141
- }
1142
- if (isGroup && groupChannel) {
1143
- // Send to any channel type (chat, heap, diary) using the nest directly
1144
- await sendChannelPost({
1145
- botProfile: getBotProfile(),
1146
- fromShip: botShipName,
1147
- nest: groupChannel,
1148
- story: markdownToStory(replyText),
1149
- replyToId: deliverParentId ?? undefined,
1150
- });
1151
- // Track thread participation for future replies without mention
1152
- if (deliverParentId) {
1153
- participatedThreads.add(String(deliverParentId));
1154
- runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
1155
- }
1156
- }
1157
- else {
1158
- await sendDm({
1159
- botProfile: getBotProfile(),
1160
- fromShip: botShipName,
1161
- toShip: senderShip,
1162
- text: replyText,
1163
- replyToId: deliverParentId ? String(deliverParentId) : undefined,
1164
- });
1165
- }
1376
+ const presenceConversationId = isGroup ? (groupChannel ?? null) : senderShip;
1377
+ const presenceRunId = String(messageId);
1378
+ const typingCallbacks = presenceConversationId
1379
+ ? createTypingCallbacks({
1380
+ start: async () => {
1381
+ await computingPresence.refreshRun({
1382
+ conversationId: presenceConversationId,
1383
+ runId: presenceRunId,
1384
+ });
1166
1385
  },
1167
- onError: (err, info) => {
1168
- const dispatchDuration = Date.now() - dispatchStartTime;
1169
- runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1386
+ stop: async () => {
1387
+ await computingPresence.stopRun({
1388
+ conversationId: presenceConversationId,
1389
+ runId: presenceRunId,
1390
+ });
1170
1391
  },
1392
+ onStartError: (err) => {
1393
+ runtime.error?.(`[tlon] Failed to start computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1394
+ },
1395
+ onStopError: (err) => {
1396
+ runtime.error?.(`[tlon] Failed to stop computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1397
+ },
1398
+ keepaliveIntervalMs: 20_000,
1399
+ })
1400
+ : undefined;
1401
+ const replyOptions = {
1402
+ onModelSelected: ({ provider, model, thinkLevel }) => {
1403
+ selectedProvider = provider;
1404
+ selectedModel = model;
1405
+ selectedThinkLevel = thinkLevel ?? null;
1171
1406
  },
1172
- });
1407
+ ...(presenceConversationId
1408
+ ? {
1409
+ onAssistantMessageStart: async () => {
1410
+ await computingPresence.clearToolCalls({
1411
+ conversationId: presenceConversationId,
1412
+ runId: presenceRunId,
1413
+ });
1414
+ },
1415
+ onToolStart: async (payload) => {
1416
+ await computingPresence.addToolCall({
1417
+ conversationId: presenceConversationId,
1418
+ runId: presenceRunId,
1419
+ toolName: payload.name,
1420
+ });
1421
+ },
1422
+ }
1423
+ : {}),
1424
+ };
1425
+ let dispatchResult;
1426
+ let dispatchError;
1427
+ try {
1428
+ dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1429
+ ctx: ctxPayload,
1430
+ cfg,
1431
+ replyOptions,
1432
+ dispatcherOptions: {
1433
+ responsePrefix,
1434
+ humanDelay,
1435
+ typingCallbacks,
1436
+ deliver: async (payload) => {
1437
+ let replyText = payload.text;
1438
+ if (!replyText) {
1439
+ return;
1440
+ }
1441
+ // Process any block directives in the response (strips them from text)
1442
+ replyText = await processBlockDirectives(replyText, senderShip);
1443
+ if (!replyText) {
1444
+ return;
1445
+ } // Response was only a directive
1446
+ // Use settings store value if set, otherwise fall back to file config
1447
+ const showSignature = effectiveShowModelSig;
1448
+ if (showSignature) {
1449
+ const modelCfg = cfg.agents?.defaults?.model;
1450
+ const modelInfo = selectedModel ||
1451
+ payload.metadata?.model ||
1452
+ payload.model ||
1453
+ route.model ||
1454
+ (typeof modelCfg === "string" ? modelCfg : modelCfg?.primary);
1455
+ replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
1456
+ }
1457
+ // Add addendum if this is the last response before bot rate limit
1458
+ if (isGroup && groupChannel && knownBotShips.has(senderShip)) {
1459
+ const count = consecutiveBotMessages.get(groupChannel) ?? 0;
1460
+ if (maxBotResponses > 0 && count === maxBotResponses) {
1461
+ const otherBot = formatShipWithNickname(senderShip);
1462
+ replyText += `\n\n---\n_This is my last response to ${otherBot} for now. To continue our conversation, someone will need to mention me._`;
1463
+ }
1464
+ }
1465
+ if (isGroup && groupChannel) {
1466
+ // Send to any channel type (chat, heap, diary) using the nest directly
1467
+ await sendChannelPost({
1468
+ botProfile: getBotProfile(),
1469
+ fromShip: botShipName,
1470
+ nest: groupChannel,
1471
+ story: markdownToStory(replyText),
1472
+ replyToId: deliverParentId ?? undefined,
1473
+ });
1474
+ // Track thread participation for future replies without mention
1475
+ if (deliverParentId) {
1476
+ participatedThreads.add(String(deliverParentId));
1477
+ runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
1478
+ }
1479
+ }
1480
+ else {
1481
+ await sendDm({
1482
+ botProfile: getBotProfile(),
1483
+ fromShip: botShipName,
1484
+ toShip: senderShip,
1485
+ text: replyText,
1486
+ replyToId: deliverParentId ? String(deliverParentId) : undefined,
1487
+ });
1488
+ }
1489
+ if (presenceConversationId) {
1490
+ await computingPresence.stopRun({
1491
+ conversationId: presenceConversationId,
1492
+ runId: presenceRunId,
1493
+ });
1494
+ }
1495
+ deliveredMessageCount += 1;
1496
+ replyCharCount += replyText.length;
1497
+ replyWordCount += replyText.trim() ? replyText.trim().split(/\s+/).length : 0;
1498
+ replyMediaCount += Array.isArray(payload.mediaUrls)
1499
+ ? payload.mediaUrls.length
1500
+ : payload.mediaUrl
1501
+ ? 1
1502
+ : 0;
1503
+ },
1504
+ onError: (err, info) => {
1505
+ const dispatchDuration = Date.now() - dispatchStartTime;
1506
+ runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
1507
+ },
1508
+ },
1509
+ });
1510
+ }
1511
+ catch (error) {
1512
+ dispatchError = error;
1513
+ throw error;
1514
+ }
1515
+ finally {
1516
+ await replyTelemetry?.capture({
1517
+ deliveredMessageCount,
1518
+ replyCharCount,
1519
+ replyWordCount,
1520
+ replyMediaCount,
1521
+ dispatchDurationMs: Date.now() - dispatchStartTime,
1522
+ queuedFinal: dispatchResult?.queuedFinal ?? false,
1523
+ queuedFinalCount: dispatchResult?.counts.final ?? 0,
1524
+ queuedBlockCount: dispatchResult?.counts.block ?? 0,
1525
+ provider: selectedProvider,
1526
+ model: selectedModel,
1527
+ thinkLevel: selectedThinkLevel,
1528
+ dispatchError,
1529
+ });
1530
+ }
1173
1531
  };
1174
1532
  // Track which channels we're interested in for filtering firehose events
1175
1533
  const watchedChannels = new Set(groupChannels);
1176
1534
  const _watchedDMs = new Set();
1177
- // Firehose handler for all channel messages (/v2)
1535
+ // Firehose handler for all channel messages (/v4)
1178
1536
  const handleChannelsFirehose = async (event) => {
1179
1537
  try {
1180
1538
  const nest = event?.nest;
@@ -1206,8 +1564,9 @@ export async function monitorTlonProvider(opts = {}) {
1206
1564
  : (response?.post?.id ?? "unknown");
1207
1565
  for (const [reactShip, reactEmoji] of Object.entries(effectiveReacts)) {
1208
1566
  const ship = normalizeShip(reactShip);
1209
- if (!ship || ship === botShipName)
1567
+ if (!ship || ship === botShipName) {
1210
1568
  continue;
1569
+ }
1211
1570
  try {
1212
1571
  const route = core.channel.routing.resolveAgentRoute({
1213
1572
  cfg,
@@ -1220,7 +1579,9 @@ export async function monitorTlonProvider(opts = {}) {
1220
1579
  const contentSnippet = cached?.content
1221
1580
  ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1222
1581
  : "";
1223
- const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1582
+ const authorInfo = cached?.author
1583
+ ? ` (by ${formatShipWithNickname(cached.author)})`
1584
+ : "";
1224
1585
  const reactorDisplay = formatShipWithNickname(ship);
1225
1586
  const eventText = `Tlon reaction in ${nest}: ${reactEmoji} by ${reactorDisplay} on post ${postId}${authorInfo}${contentSnippet}`;
1226
1587
  runtime.log?.(`[tlon] REACTION: ${eventText}`);
@@ -1230,9 +1591,7 @@ export async function monitorTlonProvider(opts = {}) {
1230
1591
  // Include context so agent knows what was reacted to, since we're
1231
1592
  // deliberately omitting thread context (parentId) to avoid the agent
1232
1593
  // suppressing responses when it sees its own message in thread history.
1233
- const reactionParentId = replyReacts
1234
- ? (response?.post?.id ?? postId)
1235
- : postId;
1594
+ const reactionParentId = replyReacts ? (response?.post?.id ?? postId) : postId;
1236
1595
  const reactText = cached?.content
1237
1596
  ? `${reactEmoji} (reacting to: "${cached.content}")`
1238
1597
  : reactEmoji;
@@ -1266,12 +1625,12 @@ export async function monitorTlonProvider(opts = {}) {
1266
1625
  }
1267
1626
  // Handle post responses (new posts and replies)
1268
1627
  const essay = response?.post?.["r-post"]?.set?.essay;
1269
- const memo = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
1270
- const content = memo || essay;
1628
+ const replyEssay = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.["reply-essay"];
1629
+ const content = replyEssay || essay;
1271
1630
  if (!content) {
1272
1631
  return;
1273
1632
  }
1274
- const isThreadReply = Boolean(memo);
1633
+ const isThreadReply = Boolean(replyEssay);
1275
1634
  const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
1276
1635
  if (!processedTracker.mark(messageId)) {
1277
1636
  return;
@@ -1284,7 +1643,8 @@ export async function monitorTlonProvider(opts = {}) {
1284
1643
  const citedContent = await resolveAllCites(content.content);
1285
1644
  const rawText = extractMessageText(content.content);
1286
1645
  const messageText = citedContent + rawText;
1287
- if (!messageText.trim()) {
1646
+ const hasBlob = Boolean(content?.blob);
1647
+ if (!messageText.trim() && !hasBlob) {
1288
1648
  return;
1289
1649
  }
1290
1650
  // Cache ALL messages (including bot's own) so reaction lookups have context
@@ -1296,7 +1656,7 @@ export async function monitorTlonProvider(opts = {}) {
1296
1656
  });
1297
1657
  // Check if sender is a bot (BotProfile object has ship, nickname, avatar)
1298
1658
  const authorRaw = content?.author;
1299
- const isSenderBot = typeof authorRaw === 'object' && authorRaw !== null && 'ship' in authorRaw;
1659
+ const isSenderBot = typeof authorRaw === "object" && authorRaw !== null && "ship" in authorRaw;
1300
1660
  if (isSenderBot) {
1301
1661
  knownBotShips.add(senderShip);
1302
1662
  }
@@ -1316,11 +1676,15 @@ export async function monitorTlonProvider(opts = {}) {
1316
1676
  // 2. Thread replies where we've participated - respond if relevant (let agent decide)
1317
1677
  const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
1318
1678
  const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(String(parentId));
1319
- if (!mentioned && !inParticipatedThread) {
1679
+ const isOwnerBlob = hasBlob && isOwner(senderShip);
1680
+ if (!mentioned && !inParticipatedThread && !isOwnerBlob) {
1320
1681
  return;
1321
1682
  }
1322
1683
  // Log why we're responding
1323
- if (inParticipatedThread && !mentioned) {
1684
+ if (isOwnerBlob && !mentioned && !inParticipatedThread) {
1685
+ runtime.log?.(`[tlon] Responding to owner blob-only message in ${nest}`);
1686
+ }
1687
+ else if (inParticipatedThread && !mentioned) {
1324
1688
  runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
1325
1689
  }
1326
1690
  // Rate limit consecutive bot responses (only in group channels)
@@ -1362,6 +1726,7 @@ export async function monitorTlonProvider(opts = {}) {
1362
1726
  timestamp: content.sent || Date.now(),
1363
1727
  parentId: parentId ?? undefined,
1364
1728
  isThreadReply,
1729
+ blob: content.blob ?? undefined,
1365
1730
  },
1366
1731
  }, pendingApprovals.map((a) => a.id));
1367
1732
  await queueApprovalRequest(approval);
@@ -1379,6 +1744,7 @@ export async function monitorTlonProvider(opts = {}) {
1379
1744
  senderShip,
1380
1745
  messageText,
1381
1746
  messageContent: content.content, // Pass raw content for media extraction
1747
+ blobField: content.blob,
1382
1748
  isGroup: true,
1383
1749
  channelNest: nest,
1384
1750
  hostShip: parsed?.hostShip,
@@ -1392,7 +1758,7 @@ export async function monitorTlonProvider(opts = {}) {
1392
1758
  runtime.error?.(`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`);
1393
1759
  }
1394
1760
  };
1395
- // Firehose handler for all DM messages (/v3)
1761
+ // Firehose handler for all DM messages (/v4)
1396
1762
  // Track which DM invites we've already processed to avoid duplicate accepts
1397
1763
  const processedDmInvites = new Set();
1398
1764
  const handleChatFirehose = async (event) => {
@@ -1525,7 +1891,9 @@ export async function monitorTlonProvider(opts = {}) {
1525
1891
  const contentSnippet = cached?.content
1526
1892
  ? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
1527
1893
  : "";
1528
- const authorInfo = cached?.author ? ` (by ${formatShipWithNickname(cached.author)})` : "";
1894
+ const authorInfo = cached?.author
1895
+ ? ` (by ${formatShipWithNickname(cached.author)})`
1896
+ : "";
1529
1897
  const reactorDisplay = formatShipWithNickname(reactAuthor);
1530
1898
  const eventText = `Tlon DM reaction ${action}: ${reactEmoji} by ${reactorDisplay} on message ${messageId}${authorInfo}${contentSnippet}`;
1531
1899
  core.system.enqueueSystemEvent(eventText, {
@@ -1541,19 +1909,19 @@ export async function monitorTlonProvider(opts = {}) {
1541
1909
  }
1542
1910
  return;
1543
1911
  }
1544
- // Extract memo from DM thread reply
1545
- const dmReplyMemo = dmReply?.delta?.add?.memo;
1912
+ // Extract reply-essay from DM thread reply
1913
+ const dmReplyEssay = dmReply?.delta?.add?.["reply-essay"];
1546
1914
  const dmReplyParentId = dmReply ? event.id : undefined;
1547
- const isDmThreadReply = Boolean(dmReplyMemo);
1548
- const dmContent = essay || dmReplyMemo;
1915
+ const isDmThreadReply = Boolean(dmReplyEssay);
1916
+ const dmContent = essay || dmReplyEssay;
1549
1917
  // For DM thread replies, extract the reply's own ID (distinct from the parent post ID)
1550
1918
  // The reply ID may be in dmReply.id, or we construct it from author/sent
1551
1919
  let dmReplyOwnId;
1552
1920
  if (isDmThreadReply && dmReply) {
1553
1921
  dmReplyOwnId = dmReply.id ?? dmReply.delta?.add?.id;
1554
1922
  // If no explicit reply ID, construct from author/sent (same format as our outbound)
1555
- if (!dmReplyOwnId && dmReplyMemo?.author && dmReplyMemo?.sent) {
1556
- dmReplyOwnId = `${normalizeShip(extractAuthorShip(dmReplyMemo.author))}/${dmReplyMemo.sent}`;
1923
+ if (!dmReplyOwnId && dmReplyEssay?.author && dmReplyEssay?.sent) {
1924
+ dmReplyOwnId = `${normalizeShip(extractAuthorShip(dmReplyEssay.author))}/${dmReplyEssay.sent}`;
1557
1925
  }
1558
1926
  }
1559
1927
  if (!dmContent) {
@@ -1564,7 +1932,7 @@ export async function monitorTlonProvider(opts = {}) {
1564
1932
  if (!processedTracker.mark(effectiveMessageId)) {
1565
1933
  return;
1566
1934
  }
1567
- const authorShip = normalizeShip(extractAuthorShip(dmContent?.author));
1935
+ const authorShip = normalizeShip(extractAuthorShip(dmContent.author));
1568
1936
  const partnerShip = extractDmPartnerShip(whom);
1569
1937
  const senderShip = partnerShip || authorShip;
1570
1938
  // Cache DM messages (including bot's own) so reaction lookups have context
@@ -1593,7 +1961,8 @@ export async function monitorTlonProvider(opts = {}) {
1593
1961
  const citedContent = await resolveAllCites(dmContent.content);
1594
1962
  const rawText = extractMessageText(dmContent.content);
1595
1963
  const messageText = citedContent + rawText;
1596
- if (!messageText.trim()) {
1964
+ const hasBlob = Boolean(dmContent?.blob);
1965
+ if (!messageText.trim() && !hasBlob) {
1597
1966
  return;
1598
1967
  }
1599
1968
  // Owner is always allowed to DM (bypass allowlist)
@@ -1604,6 +1973,7 @@ export async function monitorTlonProvider(opts = {}) {
1604
1973
  senderShip,
1605
1974
  messageText,
1606
1975
  messageContent: dmContent.content,
1976
+ blobField: dmContent.blob,
1607
1977
  isGroup: false,
1608
1978
  timestamp: dmContent.sent || Date.now(),
1609
1979
  parentId: dmReplyParentId,
@@ -1624,6 +1994,7 @@ export async function monitorTlonProvider(opts = {}) {
1624
1994
  messageText,
1625
1995
  messageContent: dmContent.content,
1626
1996
  timestamp: dmContent.sent || Date.now(),
1997
+ blob: dmContent.blob ?? undefined,
1627
1998
  },
1628
1999
  }, pendingApprovals.map((a) => a.id));
1629
2000
  await queueApprovalRequest(approval);
@@ -1638,6 +2009,7 @@ export async function monitorTlonProvider(opts = {}) {
1638
2009
  senderShip,
1639
2010
  messageText,
1640
2011
  messageContent: dmContent.content, // Pass raw content for media extraction
2012
+ blobField: dmContent.blob,
1641
2013
  isGroup: false,
1642
2014
  timestamp: dmContent.sent || Date.now(),
1643
2015
  parentId: dmReplyParentId,
@@ -1650,10 +2022,10 @@ export async function monitorTlonProvider(opts = {}) {
1650
2022
  };
1651
2023
  try {
1652
2024
  runtime.log?.("[tlon] Subscribing to firehose updates...");
1653
- // Subscribe to channels firehose (/v2)
2025
+ // Subscribe to channels firehose (/v4)
1654
2026
  await api.subscribe({
1655
2027
  app: "channels",
1656
- path: "/v2",
2028
+ path: "/v4",
1657
2029
  event: (data) => handleChannelsFirehose(data),
1658
2030
  err: (error) => {
1659
2031
  runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
@@ -1662,11 +2034,11 @@ export async function monitorTlonProvider(opts = {}) {
1662
2034
  runtime.log?.("[tlon] Channels firehose quit received, SSE client will resubscribe");
1663
2035
  },
1664
2036
  });
1665
- runtime.log?.("[tlon] Subscribed to channels firehose (/v2)");
1666
- // Subscribe to chat/DM firehose (/v3)
2037
+ runtime.log?.("[tlon] Subscribed to channels firehose (/v4)");
2038
+ // Subscribe to chat/DM firehose (/v4)
1667
2039
  await api.subscribe({
1668
2040
  app: "chat",
1669
- path: "/v3",
2041
+ path: "/v4",
1670
2042
  event: (data) => handleChatFirehose(data),
1671
2043
  err: (error) => {
1672
2044
  runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
@@ -1675,7 +2047,7 @@ export async function monitorTlonProvider(opts = {}) {
1675
2047
  runtime.log?.("[tlon] Chat firehose quit received, SSE client will resubscribe");
1676
2048
  },
1677
2049
  });
1678
- runtime.log?.("[tlon] Subscribed to chat firehose (/v3)");
2050
+ runtime.log?.("[tlon] Subscribed to chat firehose (/v4)");
1679
2051
  // Subscribe to contacts updates to track nickname changes
1680
2052
  await api.subscribe({
1681
2053
  app: "contacts",
@@ -1685,7 +2057,8 @@ export async function monitorTlonProvider(opts = {}) {
1685
2057
  // Look for self profile updates
1686
2058
  if (event?.self) {
1687
2059
  const selfUpdate = event.self;
1688
- if (selfUpdate?.contact?.nickname?.value !== undefined || selfUpdate?.contact?.avatar?.value !== undefined) {
2060
+ if (selfUpdate?.contact?.nickname?.value !== undefined ||
2061
+ selfUpdate?.contact?.avatar?.value !== undefined) {
1689
2062
  const newNickname = selfUpdate.contact.nickname.value || null;
1690
2063
  if (newNickname !== botNickname) {
1691
2064
  botNickname = newNickname;
@@ -1731,8 +2104,34 @@ export async function monitorTlonProvider(opts = {}) {
1731
2104
  });
1732
2105
  runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
1733
2106
  // Subscribe to settings store for hot-reloading config
1734
- settingsManager.onChange((newSettings) => {
1735
- currentSettings = newSettings;
2107
+ const applySettingsSnapshot = (newSettings, source, opts = {}) => {
2108
+ const prevSettings = currentSettings;
2109
+ // If pendingNudge has been rehydrated (startup succeeded or monitor has locally
2110
+ // set/cleared it), the in-memory state is authoritative — refreshes cannot clobber
2111
+ // it or resurrect stale store echoes. If not yet rehydrated (startup scry failed),
2112
+ // allow the store value through so refresh can recover the persisted record.
2113
+ let effectivePendingNudge;
2114
+ if (pendingNudgeRehydrated) {
2115
+ effectivePendingNudge = getPendingNudge(account.accountId) ?? undefined;
2116
+ }
2117
+ else if (newSettings.pendingNudge) {
2118
+ syncPendingNudgeFromStore(account.accountId, newSettings.pendingNudge);
2119
+ pendingNudgeRehydrated = true;
2120
+ effectivePendingNudge = newSettings.pendingNudge;
2121
+ runtime.log?.("[tlon] Settings refresh: recovered persisted pendingNudge after startup failure");
2122
+ }
2123
+ else {
2124
+ effectivePendingNudge = undefined;
2125
+ }
2126
+ const nextRuntimeSettings = {
2127
+ ...newSettings,
2128
+ pendingNudge: effectivePendingNudge,
2129
+ };
2130
+ if (source === "refresh" &&
2131
+ JSON.stringify(prevSettings) === JSON.stringify(nextRuntimeSettings)) {
2132
+ currentSettings = nextRuntimeSettings;
2133
+ return;
2134
+ }
1736
2135
  // Update watched channels if settings changed
1737
2136
  if (newSettings.groupChannels?.length) {
1738
2137
  const newChannels = newSettings.groupChannels;
@@ -1781,20 +2180,68 @@ export async function monitorTlonProvider(opts = {}) {
1781
2180
  effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
1782
2181
  runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
1783
2182
  }
1784
- // Update owner ship
1785
- if (newSettings.ownerShip !== undefined) {
1786
- effectiveOwnerShip = newSettings.ownerShip
1787
- ? normalizeShip(newSettings.ownerShip)
1788
- : account.ownerShip
1789
- ? normalizeShip(account.ownerShip)
1790
- : null;
2183
+ // ownerShip is applied on both live subscription and refresh.
2184
+ // pendingNudge is only rehydrated from the store during startup load. Once the
2185
+ // monitor is running, the in-memory pending state is authoritative so refreshes
2186
+ // cannot clobber live state or resurrect stale store echoes.
2187
+ const sync = resolveSettingsMirrorSync({
2188
+ prevSettings,
2189
+ newSettings,
2190
+ fileConfigOwnerShip: account.ownerShip ? normalizeShip(account.ownerShip) : null,
2191
+ });
2192
+ if (sync.ownerShipChanged) {
2193
+ effectiveOwnerShip = sync.effectiveOwnerShip;
1791
2194
  runtime.log?.(`[tlon] Settings: ownerShip = ${effectiveOwnerShip}`);
2195
+ setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
2196
+ }
2197
+ // Reconcile the scheduler's owner-activity shadow with live settings
2198
+ // changes. Subscription events are authoritative (real-time ship echo
2199
+ // of a poke, admin override, test harness seeding). Refresh updates
2200
+ // are trusted only when `load()` returned `{ fresh: true }` — on
2201
+ // `fresh: false` the manager preserves the last-known snapshot, which
2202
+ // may not yet reflect a locally observed owner reply the ship hasn't
2203
+ // echoed back, so clobbering the shadow from that path would regress
2204
+ // the fix that motivated the shadow in the first place.
2205
+ //
2206
+ // Gating on a prev/new diff means a subscription event for some
2207
+ // unrelated key (e.g. channelRules) cannot reset the shadow via the
2208
+ // snapshot's unchanged owner-activity fields.
2209
+ const shadowReconcileTrusted = source === "subscription" || opts.fresh === true;
2210
+ const ownerActivityChanged = prevSettings.lastOwnerMessageAt !== newSettings.lastOwnerMessageAt ||
2211
+ prevSettings.lastOwnerMessageDate !== newSettings.lastOwnerMessageDate;
2212
+ if (shadowReconcileTrusted && ownerActivityChanged) {
2213
+ setLastOwnerActivity(account.accountId, ownerActivityFromSettings(newSettings));
2214
+ runtime.log?.(`[tlon] nudge: reconciled lastOwnerActivity shadow from ${source} (at=${newSettings.lastOwnerMessageAt ?? "null"})`);
2215
+ }
2216
+ // Reconcile the scheduler's stage shadow with live `lastNudgeStage`
2217
+ // changes for the same trust-and-diff reasons as the activity branch
2218
+ // above. Without this, an external `%settings` clear (or admin
2219
+ // lower) cannot move the in-memory guard down — the runner's
2220
+ // `resolveAuthoritativeStage()` currently uses the shadow as the
2221
+ // authoritative stage, so a stuck-high shadow suppresses later
2222
+ // same-stage nudges.
2223
+ //
2224
+ // Trust gate: subscription events are real-time and only fire when
2225
+ // storage actually transitioned, so they cannot represent a stale
2226
+ // post-poke read. Refresh is trusted only when `load()` returned
2227
+ // `{ fresh: true }`, matching the activity-shadow rule. Scry is
2228
+ // still useful for drift logging, but it is not part of the
2229
+ // runner's stage guard today.
2230
+ const stageChanged = prevSettings.lastNudgeStage !== newSettings.lastNudgeStage;
2231
+ if (shadowReconcileTrusted && stageChanged) {
2232
+ const nextStage = (newSettings.lastNudgeStage ?? 0);
2233
+ setLastNudgeStageShadow(account.accountId, nextStage);
2234
+ runtime.log?.(`[tlon] nudge: reconciled lastNudgeStageShadow from ${source} (stage=${nextStage})`);
1792
2235
  }
1793
2236
  // Update pending approvals
1794
2237
  if (newSettings.pendingApprovals !== undefined) {
1795
2238
  pendingApprovals = newSettings.pendingApprovals;
1796
2239
  runtime.log?.(`[tlon] Settings: pendingApprovals updated (${pendingApprovals.length} items)`);
1797
2240
  }
2241
+ currentSettings = nextRuntimeSettings;
2242
+ };
2243
+ settingsManager.onChange((newSettings) => {
2244
+ applySettingsSnapshot(newSettings, "subscription");
1798
2245
  });
1799
2246
  try {
1800
2247
  await settingsManager.startSubscription();
@@ -2106,34 +2553,71 @@ export async function monitorTlonProvider(opts = {}) {
2106
2553
  }, 2 * 60 * 1000);
2107
2554
  // Periodically re-scry settings as a fallback for stale subscriptions.
2108
2555
  // The settings subscription can silently die (SSE quit without reconnect),
2109
- // leaving the in-memory allowlist permanently stale.
2556
+ // leaving both authorization state and heartbeat telemetry mirrors stale.
2110
2557
  const settingsRefreshInterval = setInterval(async () => {
2111
2558
  if (opts.abortSignal?.aborted) {
2112
2559
  return;
2113
2560
  }
2114
2561
  try {
2115
- const refreshed = await settingsManager.load();
2116
- if (refreshed.dmAllowlist !== undefined) {
2117
- const newList = refreshed.dmAllowlist;
2118
- if (JSON.stringify(newList) !== JSON.stringify(effectiveDmAllowlist)) {
2119
- effectiveDmAllowlist = newList;
2120
- runtime.log?.(`[tlon] Settings refresh: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
2121
- }
2122
- }
2123
- if (refreshed.defaultAuthorizedShips !== undefined) {
2124
- currentSettings = { ...currentSettings, defaultAuthorizedShips: refreshed.defaultAuthorizedShips };
2125
- }
2562
+ const refreshResult = await settingsManager.load();
2563
+ applySettingsSnapshot(refreshResult.settings, "refresh", { fresh: refreshResult.fresh });
2126
2564
  }
2127
2565
  catch (err) {
2128
2566
  runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);
2129
2567
  }
2130
- }, 5 * 60 * 1000);
2568
+ }, SETTINGS_REFRESH_INTERVAL_MS);
2569
+ // Plugin-owned re-engagement nudge scheduler. Owns tick lifecycle and
2570
+ // reentrancy; runs independently of LLM heartbeat.
2571
+ //
2572
+ // Gating is computed by the pure `shouldStartNudgeRunner` helper; see
2573
+ // that function for the two invariants (explicit opt-in flag + exactly
2574
+ // one configured Tlon account).
2575
+ //
2576
+ // `TLON_NUDGE_TICK_INTERVAL_MS` exists so the integration harness can
2577
+ // drive ticks on a short cadence without rebuilding the plugin; in
2578
+ // production the default 15-minute interval applies.
2579
+ const nudgeStartDecision = shouldStartNudgeRunner(cfg);
2580
+ if (!nudgeStartDecision.start) {
2581
+ runtime.log?.(`[tlon] nudge: scheduler disabled — ${nudgeStartDecision.detail}`);
2582
+ }
2583
+ else {
2584
+ const intervalEnv = process.env.TLON_NUDGE_TICK_INTERVAL_MS;
2585
+ const intervalMsOverride = intervalEnv ? Number(intervalEnv) : NaN;
2586
+ nudgeRunner = createNudgeRunner({
2587
+ accountId: account.accountId,
2588
+ botShip: botShipName,
2589
+ api,
2590
+ cfg,
2591
+ getSettings: () => currentSettings,
2592
+ getEffectiveOwnerShip,
2593
+ getLastOwnerActivity,
2594
+ getLastNudgeStageShadow,
2595
+ setLastNudgeStageShadow,
2596
+ setLocalPendingNudge,
2597
+ sendDm,
2598
+ getBotProfile,
2599
+ telemetry,
2600
+ runtime,
2601
+ abortSignal: opts.abortSignal,
2602
+ ownerReplyPersistence,
2603
+ ...(Number.isFinite(intervalMsOverride) && intervalMsOverride > 0
2604
+ ? { intervalMs: intervalMsOverride }
2605
+ : {}),
2606
+ });
2607
+ nudgeRunner.start();
2608
+ }
2131
2609
  if (opts.abortSignal) {
2132
2610
  const signal = opts.abortSignal;
2133
2611
  await new Promise((resolve) => {
2134
2612
  signal.addEventListener("abort", () => {
2135
2613
  clearInterval(pollInterval);
2136
2614
  clearInterval(settingsRefreshInterval);
2615
+ // Kick off scheduler shutdown; don't block the event-handler
2616
+ // callback. The `finally` block awaits the same stop promise
2617
+ // before draining the persistence queues and closing the
2618
+ // api, so any in-flight tick is guaranteed to settle first.
2619
+ void nudgeRunner?.stop();
2620
+ gsManager?.stopHeartbeat();
2137
2621
  resolve(null);
2138
2622
  }, { once: true });
2139
2623
  });
@@ -2144,6 +2628,16 @@ export async function monitorTlonProvider(opts = {}) {
2144
2628
  }
2145
2629
  finally {
2146
2630
  removeBridge(accountKey, commandBridge);
2631
+ // Await the scheduler drain before flushing persistence queues.
2632
+ // `stop()` waits for any in-flight tick to finish so its final
2633
+ // `setLocalPendingNudge` / `enqueueStageClear` / etc. writes land
2634
+ // inside the queues we flush below, rather than leaking into a
2635
+ // half-closed api after cleanup.
2636
+ await nudgeRunner?.stop();
2637
+ await ownerReplyPersistence.flush();
2638
+ await pendingNudgePersistence.flush();
2639
+ clearShadowsForAccount(account.accountId);
2640
+ await telemetry?.close();
2147
2641
  try {
2148
2642
  await api?.close();
2149
2643
  }