@tloncorp/openclaw 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -23
- package/dist/index.js +292 -43
- package/dist/index.js.map +1 -1
- package/dist/setup-api.js +3 -0
- package/dist/setup-api.js.map +1 -0
- package/dist/setup-entry.js +4 -0
- package/dist/setup-entry.js.map +1 -0
- package/dist/src/actions.js +30 -8
- package/dist/src/actions.js.map +1 -1
- package/dist/src/channel.js +156 -373
- package/dist/src/channel.js.map +1 -1
- package/dist/src/channel.runtime.js +142 -0
- package/dist/src/channel.runtime.js.map +1 -0
- package/dist/src/config-schema.js +41 -1
- package/dist/src/config-schema.js.map +1 -1
- package/dist/src/effective-owner.js +22 -0
- package/dist/src/effective-owner.js.map +1 -0
- package/dist/src/gateway-status.js +72 -0
- package/dist/src/gateway-status.js.map +1 -0
- package/dist/src/monitor/approval.js +194 -96
- package/dist/src/monitor/approval.js.map +1 -1
- package/dist/src/monitor/command-auth.js +62 -0
- package/dist/src/monitor/command-auth.js.map +1 -0
- package/dist/src/monitor/command-bridge.js +27 -0
- package/dist/src/monitor/command-bridge.js.map +1 -0
- package/dist/src/monitor/computing-presence.js +221 -0
- package/dist/src/monitor/computing-presence.js.map +1 -0
- package/dist/src/monitor/discovery.js +17 -9
- package/dist/src/monitor/discovery.js.map +1 -1
- package/dist/src/monitor/index.js +960 -251
- package/dist/src/monitor/index.js.map +1 -1
- package/dist/src/monitor/media.js +195 -30
- package/dist/src/monitor/media.js.map +1 -1
- package/dist/src/monitor/nudge-runner.js +232 -0
- package/dist/src/monitor/nudge-runner.js.map +1 -0
- package/dist/src/monitor/nudge-state.js +58 -0
- package/dist/src/monitor/nudge-state.js.map +1 -0
- package/dist/src/monitor/owner-reply-persistence.js +92 -0
- package/dist/src/monitor/owner-reply-persistence.js.map +1 -0
- package/dist/src/monitor/pending-nudge-persistence.js +15 -0
- package/dist/src/monitor/pending-nudge-persistence.js.map +1 -0
- package/dist/src/monitor/settings-sync.js +28 -0
- package/dist/src/monitor/settings-sync.js.map +1 -0
- package/dist/src/monitor/utils.js +21 -4
- package/dist/src/monitor/utils.js.map +1 -1
- package/dist/src/nudge-decision.js +309 -0
- package/dist/src/nudge-decision.js.map +1 -0
- package/dist/src/nudge-messages.js +25 -0
- package/dist/src/nudge-messages.js.map +1 -0
- package/dist/src/nudge-scheduler.js +91 -0
- package/dist/src/nudge-scheduler.js.map +1 -0
- package/dist/src/pending-nudge.js +57 -0
- package/dist/src/pending-nudge.js.map +1 -0
- package/dist/src/session-roles.js +39 -0
- package/dist/src/session-roles.js.map +1 -0
- package/dist/src/settings.js +82 -6
- package/dist/src/settings.js.map +1 -1
- package/dist/src/setup-core.js +164 -0
- package/dist/src/setup-core.js.map +1 -0
- package/dist/src/setup-surface.js +85 -0
- package/dist/src/setup-surface.js.map +1 -0
- package/dist/src/telemetry.js +252 -0
- package/dist/src/telemetry.js.map +1 -0
- package/dist/src/tlon-binary.js +46 -0
- package/dist/src/tlon-binary.js.map +1 -0
- package/dist/src/tlon-tool-guard.js +44 -0
- package/dist/src/tlon-tool-guard.js.map +1 -0
- package/dist/src/tool-trace.js +100 -0
- package/dist/src/tool-trace.js.map +1 -0
- package/dist/src/types.js +35 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/urbit/api-client.js +4 -3
- package/dist/src/urbit/api-client.js.map +1 -1
- package/dist/src/urbit/base-url.js +2 -2
- package/dist/src/urbit/base-url.js.map +1 -1
- package/dist/src/urbit/fetch.js +1 -1
- package/dist/src/urbit/fetch.js.map +1 -1
- package/dist/src/urbit/send.js +6 -2
- package/dist/src/urbit/send.js.map +1 -1
- package/dist/src/urbit/sse-client.js +13 -2
- package/dist/src/urbit/sse-client.js.map +1 -1
- package/dist/src/urbit/upload.js +25 -20
- package/dist/src/urbit/upload.js.map +1 -1
- package/dist/src/version.generated.js +3 -0
- package/dist/src/version.generated.js.map +1 -0
- package/package.json +32 -25
- package/dist/src/onboarding.js +0 -178
- package/dist/src/onboarding.js.map +0 -1
|
@@ -1,20 +1,45 @@
|
|
|
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";
|
|
8
|
+
import { setSessionRole } from "../session-roles.js";
|
|
3
9
|
import { createSettingsManager } from "../settings.js";
|
|
4
10
|
import { normalizeShip, parseChannelNest } from "../targets.js";
|
|
11
|
+
import { createTlonTelemetry } from "../telemetry.js";
|
|
5
12
|
import { resolveTlonAccount } from "../types.js";
|
|
13
|
+
import { configureTlonApiWithPoke } from "../urbit/api-client.js";
|
|
6
14
|
import { authenticate } from "../urbit/auth.js";
|
|
7
15
|
import { ssrfPolicyFromAllowPrivateNetwork } from "../urbit/context.js";
|
|
8
|
-
import { configureTlonApiWithPoke } from "../urbit/api-client.js";
|
|
9
16
|
import { sendDm, sendChannelPost } from "../urbit/send.js";
|
|
10
|
-
import { markdownToStory } from "../urbit/story.js";
|
|
11
17
|
import { UrbitSSEClient } from "../urbit/sse-client.js";
|
|
12
|
-
import {
|
|
18
|
+
import { markdownToStory } from "../urbit/story.js";
|
|
19
|
+
import { createPendingApproval, formatApprovalRequest, formatApprovalConfirmation, findPendingApproval, removePendingApproval, pruneExpired, formatBlockedList, formatPendingList, isExpired, emojiToApprovalAction, normalizeNotificationId, } from "./approval.js";
|
|
20
|
+
import { setBridge, removeBridge } from "./command-bridge.js";
|
|
21
|
+
import { createComputingPresenceTracker } from "./computing-presence.js";
|
|
13
22
|
import { fetchAllChannels, fetchInitData } from "./discovery.js";
|
|
14
|
-
import { cacheMessage, lookupCachedMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
|
|
15
|
-
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";
|
|
16
29
|
import { createProcessedMessageTracker } from "./processed-messages.js";
|
|
17
|
-
import {
|
|
30
|
+
import { resolveSettingsMirrorSync } from "./settings-sync.js";
|
|
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;
|
|
34
|
+
/**
|
|
35
|
+
* Extract ship from author field, handling both string (ship) and object (bot-meta) formats.
|
|
36
|
+
*/
|
|
37
|
+
function extractAuthorShip(author) {
|
|
38
|
+
if (typeof author === "object" && author !== null && "ship" in author) {
|
|
39
|
+
return author.ship;
|
|
40
|
+
}
|
|
41
|
+
return typeof author === "string" ? author : "";
|
|
42
|
+
}
|
|
18
43
|
/**
|
|
19
44
|
* Resolve channel authorization by merging file config with settings store.
|
|
20
45
|
* Settings store takes precedence for fields it defines.
|
|
@@ -112,10 +137,14 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
112
137
|
});
|
|
113
138
|
// Configure @tloncorp/api's global client to use the SSE client's poke for all send operations
|
|
114
139
|
configureTlonApiWithPoke(api.poke.bind(api), botShipName, account.url);
|
|
140
|
+
const computingPresence = createComputingPresenceTracker({ runtime });
|
|
115
141
|
const processedTracker = createProcessedMessageTracker(2000);
|
|
116
142
|
let groupChannels = [];
|
|
117
143
|
const channelToGroup = new Map();
|
|
118
144
|
let botNickname = null;
|
|
145
|
+
let botAvatar = null;
|
|
146
|
+
// Helper to get bot profile for outbound messages
|
|
147
|
+
const getBotProfile = () => botNickname || botAvatar ? { nickname: botNickname || "", avatar: botAvatar || "" } : undefined;
|
|
119
148
|
// Settings store manager for hot-reloading config
|
|
120
149
|
const settingsManager = createSettingsManager(api, {
|
|
121
150
|
log: (msg) => runtime.log?.(msg),
|
|
@@ -131,10 +160,36 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
131
160
|
let effectiveOwnerShip = account.ownerShip
|
|
132
161
|
? normalizeShip(account.ownerShip)
|
|
133
162
|
: null;
|
|
163
|
+
setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
|
|
134
164
|
let pendingApprovals = [];
|
|
135
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
|
+
});
|
|
136
185
|
// Track threads we've participated in (by parentId) - respond without mention requirement
|
|
137
186
|
const participatedThreads = new Set();
|
|
187
|
+
// Track consecutive bot responses per channel/DM for rate limiting
|
|
188
|
+
// Key: channel nest or dm partner ship, Value: count of consecutive bot messages
|
|
189
|
+
const consecutiveBotMessages = new Map();
|
|
190
|
+
// Known bot ships (ships that have sent messages with BotProfile author)
|
|
191
|
+
const knownBotShips = new Set();
|
|
192
|
+
const maxBotResponses = account.maxConsecutiveBotResponses ?? 3;
|
|
138
193
|
// Track DM senders per session to detect shared sessions (security warning)
|
|
139
194
|
const dmSendersBySession = new Map();
|
|
140
195
|
let sharedSessionWarningSent = false;
|
|
@@ -143,14 +198,15 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
143
198
|
// Sanitize nickname to prevent format injection
|
|
144
199
|
function sanitizeNickname(nickname) {
|
|
145
200
|
return nickname
|
|
146
|
-
.replace(/[
|
|
201
|
+
.replace(/[[\]()]/g, "") // Remove format-breaking chars
|
|
147
202
|
.slice(0, 50); // Reasonable length limit
|
|
148
203
|
}
|
|
149
204
|
// Format a ship with nickname if available
|
|
150
205
|
function formatShipWithNickname(ship) {
|
|
151
206
|
const nickname = nicknameCache.get(ship);
|
|
152
|
-
if (!nickname)
|
|
207
|
+
if (!nickname) {
|
|
153
208
|
return ship;
|
|
209
|
+
}
|
|
154
210
|
const sanitized = sanitizeNickname(nickname);
|
|
155
211
|
return sanitized ? `${ship} (${sanitized})` : ship;
|
|
156
212
|
}
|
|
@@ -160,9 +216,10 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
160
216
|
if (selfProfile && typeof selfProfile === "object") {
|
|
161
217
|
const profile = selfProfile;
|
|
162
218
|
botNickname = profile.nickname?.value || null;
|
|
219
|
+
botAvatar = profile.avatar?.value || null;
|
|
163
220
|
if (botNickname) {
|
|
164
221
|
runtime.log?.(`[tlon] Bot nickname: ${botNickname}`);
|
|
165
|
-
nicknameCache.set(botShipName, botNickname);
|
|
222
|
+
nicknameCache.set(botShipName, sanitizeNickname(botNickname));
|
|
166
223
|
}
|
|
167
224
|
}
|
|
168
225
|
}
|
|
@@ -171,12 +228,12 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
171
228
|
}
|
|
172
229
|
// Fetch all contacts to populate nickname cache
|
|
173
230
|
try {
|
|
174
|
-
const allContacts = await api.scry("/contacts/v1/all.json");
|
|
231
|
+
const allContacts = (await api.scry("/contacts/v1/all.json"));
|
|
175
232
|
if (allContacts && typeof allContacts === "object") {
|
|
176
233
|
for (const [ship, contact] of Object.entries(allContacts)) {
|
|
177
234
|
const nickname = contact?.nickname?.value ?? contact?.nickname;
|
|
178
235
|
if (nickname && typeof nickname === "string") {
|
|
179
|
-
nicknameCache.set(normalizeShip(ship), nickname);
|
|
236
|
+
nicknameCache.set(normalizeShip(ship), sanitizeNickname(nickname));
|
|
180
237
|
}
|
|
181
238
|
}
|
|
182
239
|
runtime.log?.(`[tlon] Loaded ${nicknameCache.size} contact nickname(s)`);
|
|
@@ -187,6 +244,22 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
187
244
|
}
|
|
188
245
|
// Store init foreigns for processing after settings are loaded
|
|
189
246
|
let initForeigns = null;
|
|
247
|
+
// Group name cache for human-readable display (flag -> title)
|
|
248
|
+
const groupNameCache = new Map();
|
|
249
|
+
// Build display context for approval formatting
|
|
250
|
+
function buildDisplayContext() {
|
|
251
|
+
const channelNames = new Map();
|
|
252
|
+
for (const nest of watchedChannels) {
|
|
253
|
+
const parsed = parseChannelNest(nest);
|
|
254
|
+
if (parsed) {
|
|
255
|
+
channelNames.set(nest, parsed.channelName);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
channelNames,
|
|
260
|
+
groupNames: groupNameCache,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
190
263
|
// Migrate file config to settings store (seed on first run)
|
|
191
264
|
async function migrateConfigToSettings() {
|
|
192
265
|
const migrations = [
|
|
@@ -230,12 +303,17 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
230
303
|
fileValue: account.showModelSignature,
|
|
231
304
|
settingsValue: currentSettings.showModelSig,
|
|
232
305
|
},
|
|
306
|
+
{
|
|
307
|
+
key: "ownerShip",
|
|
308
|
+
fileValue: account.ownerShip,
|
|
309
|
+
settingsValue: currentSettings.ownerShip,
|
|
310
|
+
},
|
|
233
311
|
];
|
|
234
312
|
for (const { key, fileValue, settingsValue } of migrations) {
|
|
235
313
|
// Only migrate if file has a value and settings store doesn't
|
|
236
314
|
const hasFileValue = Array.isArray(fileValue) ? fileValue.length > 0 : fileValue != null;
|
|
237
315
|
const hasSettingsValue = Array.isArray(settingsValue)
|
|
238
|
-
?
|
|
316
|
+
? true // empty array = intentionally set in settings store
|
|
239
317
|
: settingsValue != null;
|
|
240
318
|
if (hasFileValue && !hasSettingsValue) {
|
|
241
319
|
try {
|
|
@@ -259,11 +337,28 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
259
337
|
}
|
|
260
338
|
}
|
|
261
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);
|
|
262
347
|
// Load settings from settings store (hot-reloadable config)
|
|
263
348
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
267
362
|
// Apply settings overrides
|
|
268
363
|
// Note: groupChannels from settings store are merged AFTER discovery runs (below)
|
|
269
364
|
if (currentSettings.defaultAuthorizedShips?.length) {
|
|
@@ -273,9 +368,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
273
368
|
effectiveAutoDiscoverChannels = currentSettings.autoDiscoverChannels;
|
|
274
369
|
runtime.log?.(`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`);
|
|
275
370
|
}
|
|
276
|
-
if (currentSettings.dmAllowlist
|
|
371
|
+
if (currentSettings.dmAllowlist !== undefined) {
|
|
277
372
|
effectiveDmAllowlist = currentSettings.dmAllowlist;
|
|
278
|
-
runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`);
|
|
373
|
+
runtime.log?.(`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
|
|
279
374
|
}
|
|
280
375
|
if (currentSettings.showModelSig !== undefined) {
|
|
281
376
|
effectiveShowModelSig = currentSettings.showModelSig;
|
|
@@ -294,8 +389,21 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
294
389
|
}
|
|
295
390
|
if (currentSettings.ownerShip) {
|
|
296
391
|
effectiveOwnerShip = normalizeShip(currentSettings.ownerShip);
|
|
392
|
+
setEffectiveOwnerShip(account.accountId, effectiveOwnerShip);
|
|
297
393
|
runtime.log?.(`[tlon] Using ownerShip from settings store: ${effectiveOwnerShip}`);
|
|
298
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);
|
|
299
407
|
if (currentSettings.pendingApprovals?.length) {
|
|
300
408
|
pendingApprovals = currentSettings.pendingApprovals;
|
|
301
409
|
runtime.log?.(`[tlon] Loaded ${pendingApprovals.length} pending approval(s) from settings`);
|
|
@@ -304,6 +412,97 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
304
412
|
catch (err) {
|
|
305
413
|
runtime.log?.(`[tlon] Settings store not available, using file config: ${String(err)}`);
|
|
306
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
|
+
}
|
|
307
506
|
// Run channel discovery AFTER settings are loaded (so settings store value is used)
|
|
308
507
|
if (effectiveAutoDiscoverChannels) {
|
|
309
508
|
try {
|
|
@@ -315,6 +514,10 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
315
514
|
for (const [nest, groupFlag] of initData.channelToGroup) {
|
|
316
515
|
channelToGroup.set(nest, groupFlag);
|
|
317
516
|
}
|
|
517
|
+
// Populate group name cache for human-readable display
|
|
518
|
+
for (const [flag, title] of initData.groupNames) {
|
|
519
|
+
groupNameCache.set(flag, title);
|
|
520
|
+
}
|
|
318
521
|
initForeigns = initData.foreigns;
|
|
319
522
|
}
|
|
320
523
|
catch (error) {
|
|
@@ -427,6 +630,33 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
427
630
|
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
|
|
428
631
|
}
|
|
429
632
|
}
|
|
633
|
+
// Helper to remove ship from dmAllowlist in both memory and settings store
|
|
634
|
+
async function removeFromDmAllowlist(ship) {
|
|
635
|
+
const normalizedShip = normalizeShip(ship);
|
|
636
|
+
const before = effectiveDmAllowlist.length;
|
|
637
|
+
effectiveDmAllowlist = effectiveDmAllowlist.filter((s) => s !== normalizedShip);
|
|
638
|
+
if (effectiveDmAllowlist.length === before) {
|
|
639
|
+
return; // Ship wasn't on the list
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
await api.poke({
|
|
643
|
+
app: "settings",
|
|
644
|
+
mark: "settings-event",
|
|
645
|
+
json: {
|
|
646
|
+
"put-entry": {
|
|
647
|
+
desk: "moltbot",
|
|
648
|
+
"bucket-key": "tlon",
|
|
649
|
+
"entry-key": "dmAllowlist",
|
|
650
|
+
value: effectiveDmAllowlist,
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
runtime.log?.(`[tlon] Removed ${normalizedShip} from dmAllowlist`);
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
430
660
|
// Helper to update channelRules in settings store
|
|
431
661
|
async function addToChannelAllowlist(ship, channelNest) {
|
|
432
662
|
const normalizedShip = normalizeShip(ship);
|
|
@@ -476,12 +706,26 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
476
706
|
runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
|
|
477
707
|
}
|
|
478
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* Scry the chat agent's blocked ship list with an explicit timeout.
|
|
711
|
+
* The urbitFetch timeout (30s) may not fire if the underlying connection
|
|
712
|
+
* stalls (e.g. after a chat-block-ship poke causes the agent to restart).
|
|
713
|
+
* This wrapper guarantees resolution within SCRY_TIMEOUT_MS.
|
|
714
|
+
*/
|
|
715
|
+
const SCRY_TIMEOUT_MS = 15_000;
|
|
716
|
+
async function scryBlockedShips() {
|
|
717
|
+
const blocked = (await Promise.race([
|
|
718
|
+
api.scry("/chat/blocked.json"),
|
|
719
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("blocked list scry timeout")), SCRY_TIMEOUT_MS)),
|
|
720
|
+
]));
|
|
721
|
+
return Array.isArray(blocked) ? blocked : [];
|
|
722
|
+
}
|
|
479
723
|
// Check if a ship is blocked using Tlon's native block list
|
|
480
724
|
async function isShipBlocked(ship) {
|
|
481
725
|
const normalizedShip = normalizeShip(ship);
|
|
482
726
|
try {
|
|
483
|
-
const blocked =
|
|
484
|
-
return
|
|
727
|
+
const blocked = await scryBlockedShips();
|
|
728
|
+
return blocked.some((s) => normalizeShip(s) === normalizedShip);
|
|
485
729
|
}
|
|
486
730
|
catch (err) {
|
|
487
731
|
runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
|
|
@@ -491,8 +735,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
491
735
|
// Get all blocked ships
|
|
492
736
|
async function getBlockedShips() {
|
|
493
737
|
try {
|
|
494
|
-
|
|
495
|
-
return Array.isArray(blocked) ? blocked : [];
|
|
738
|
+
return await scryBlockedShips();
|
|
496
739
|
}
|
|
497
740
|
catch (err) {
|
|
498
741
|
runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
|
|
@@ -503,6 +746,11 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
503
746
|
async function unblockShip(ship) {
|
|
504
747
|
const normalizedShip = normalizeShip(ship);
|
|
505
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
|
+
}
|
|
506
754
|
await api.poke({
|
|
507
755
|
app: "chat",
|
|
508
756
|
mark: "chat-unblock-ship",
|
|
@@ -516,24 +764,65 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
516
764
|
return false;
|
|
517
765
|
}
|
|
518
766
|
}
|
|
519
|
-
// Helper to send DM notification to owner
|
|
767
|
+
// Helper to send DM notification to owner. Returns the message ID if sent successfully.
|
|
520
768
|
async function sendOwnerNotification(message) {
|
|
521
769
|
if (!effectiveOwnerShip) {
|
|
522
770
|
runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
|
|
523
|
-
return;
|
|
771
|
+
return undefined;
|
|
524
772
|
}
|
|
525
773
|
try {
|
|
526
|
-
await sendDm({
|
|
774
|
+
const result = await sendDm({
|
|
775
|
+
botProfile: getBotProfile(),
|
|
527
776
|
fromShip: botShipName,
|
|
528
777
|
toShip: effectiveOwnerShip,
|
|
529
778
|
text: message,
|
|
530
779
|
});
|
|
531
780
|
runtime.log?.(`[tlon] Sent notification to owner ${effectiveOwnerShip}`);
|
|
781
|
+
return result.messageId;
|
|
532
782
|
}
|
|
533
783
|
catch (err) {
|
|
534
784
|
runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
|
|
785
|
+
return undefined;
|
|
535
786
|
}
|
|
536
787
|
}
|
|
788
|
+
// Regex to match block directives in agent responses
|
|
789
|
+
// Format: [BLOCK_USER: ~ship-name | reason for blocking]
|
|
790
|
+
const blockDirectiveRegex = /\[BLOCK_USER:\s*(~[\w-]+)\s*\|\s*(.+?)\]/g;
|
|
791
|
+
// Process block directives from agent response and return text with directives stripped
|
|
792
|
+
async function processBlockDirectives(text, senderShip) {
|
|
793
|
+
const matches = [...text.matchAll(blockDirectiveRegex)];
|
|
794
|
+
if (matches.length > 0) {
|
|
795
|
+
runtime.log?.(`[tlon] Found ${matches.length} block directive(s) in response`);
|
|
796
|
+
runtime.log?.(`[tlon] Sender ship: "${senderShip}" -> normalized: "${normalizeShip(senderShip)}"`);
|
|
797
|
+
runtime.log?.(`[tlon] Owner ship: "${effectiveOwnerShip}"`);
|
|
798
|
+
}
|
|
799
|
+
for (const match of matches) {
|
|
800
|
+
const targetShip = normalizeShip(match[1]);
|
|
801
|
+
const reason = match[2].trim();
|
|
802
|
+
runtime.log?.(`[tlon] Processing block directive: target="${targetShip}", reason="${reason}"`);
|
|
803
|
+
// Safety: Never block the owner
|
|
804
|
+
if (effectiveOwnerShip && targetShip === effectiveOwnerShip) {
|
|
805
|
+
runtime.log?.(`[tlon] Agent attempted to block owner ship ${targetShip} - ignoring`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
// Only allow blocking the current message sender (not arbitrary third parties)
|
|
809
|
+
const normalizedSender = normalizeShip(senderShip);
|
|
810
|
+
if (targetShip !== normalizedSender) {
|
|
811
|
+
runtime.log?.(`[tlon] Agent tried to block "${targetShip}" but sender is "${normalizedSender}" - ignoring`);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// Block the abusive sender
|
|
815
|
+
runtime.log?.(`[tlon] Executing block for ${targetShip}...`);
|
|
816
|
+
await blockShip(targetShip);
|
|
817
|
+
// Notify owner
|
|
818
|
+
if (effectiveOwnerShip) {
|
|
819
|
+
await sendOwnerNotification(`[Agent Action] Blocked ${targetShip}\nReason: ${reason}`);
|
|
820
|
+
}
|
|
821
|
+
runtime.log?.(`[tlon] Agent blocked ${targetShip}: ${reason}`);
|
|
822
|
+
}
|
|
823
|
+
// Strip directives from visible response
|
|
824
|
+
return text.replace(blockDirectiveRegex, "").trim();
|
|
825
|
+
}
|
|
537
826
|
// Queue a new approval request and notify the owner
|
|
538
827
|
async function queueApprovalRequest(approval) {
|
|
539
828
|
// Check if ship is blocked - silently ignore
|
|
@@ -554,33 +843,36 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
554
843
|
existing.messagePreview = approval.messagePreview;
|
|
555
844
|
}
|
|
556
845
|
runtime.log?.(`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`);
|
|
846
|
+
// Send notification first, then save once with the notification ID.
|
|
847
|
+
// Saving before sendOwnerNotification causes a race: the settings subscription
|
|
848
|
+
// event replaces pendingApprovals in-memory, so the notificationMessageId
|
|
849
|
+
// set on the old object reference is lost.
|
|
850
|
+
const existMsg = formatApprovalRequest(existing, buildDisplayContext());
|
|
851
|
+
const existNotifId = await sendOwnerNotification(existMsg);
|
|
852
|
+
if (existNotifId) {
|
|
853
|
+
existing.notificationMessageId = normalizeNotificationId(existNotifId);
|
|
854
|
+
}
|
|
557
855
|
await savePendingApprovals();
|
|
558
|
-
const message = formatApprovalRequest(existing);
|
|
559
|
-
await sendOwnerNotification(message);
|
|
560
856
|
return;
|
|
561
857
|
}
|
|
858
|
+
// Send notification before saving so notificationMessageId is included
|
|
859
|
+
// in the single save. See comment above about the settings subscription race.
|
|
860
|
+
const message = formatApprovalRequest(approval, buildDisplayContext());
|
|
861
|
+
const notifId = await sendOwnerNotification(message);
|
|
862
|
+
if (notifId) {
|
|
863
|
+
approval.notificationMessageId = normalizeNotificationId(notifId);
|
|
864
|
+
}
|
|
562
865
|
pendingApprovals.push(approval);
|
|
563
866
|
await savePendingApprovals();
|
|
564
|
-
const message = formatApprovalRequest(approval);
|
|
565
|
-
await sendOwnerNotification(message);
|
|
566
867
|
runtime.log?.(`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`);
|
|
567
868
|
}
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
const approval = findPendingApproval(pendingApprovals, parsed.id);
|
|
575
|
-
if (!approval) {
|
|
576
|
-
await sendOwnerNotification("No pending approval found" + (parsed.id ? ` for ID: ${parsed.id}` : ""));
|
|
577
|
-
return true; // Still consumed the message
|
|
578
|
-
}
|
|
579
|
-
if (parsed.action === "approve") {
|
|
869
|
+
// ── Approval action execution ─────────────────────────────────────
|
|
870
|
+
// Shared by the slash command bridge and the reaction-based approval handler.
|
|
871
|
+
async function executeApprovalAction(approval, action) {
|
|
872
|
+
if (action === "approve") {
|
|
580
873
|
switch (approval.type) {
|
|
581
874
|
case "dm":
|
|
582
875
|
await addToDmAllowlist(approval.requestingShip);
|
|
583
|
-
// Process the original message if available
|
|
584
876
|
if (approval.originalMessage) {
|
|
585
877
|
runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} after approval`);
|
|
586
878
|
await processMessage({
|
|
@@ -590,15 +882,15 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
590
882
|
messageContent: approval.originalMessage.messageContent,
|
|
591
883
|
isGroup: false,
|
|
592
884
|
timestamp: approval.originalMessage.timestamp,
|
|
885
|
+
blobField: approval.originalMessage.blob,
|
|
593
886
|
});
|
|
594
887
|
}
|
|
595
888
|
break;
|
|
596
889
|
case "channel":
|
|
597
890
|
if (approval.channelNest) {
|
|
598
891
|
await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
|
|
599
|
-
// Process the original message if available
|
|
600
892
|
if (approval.originalMessage) {
|
|
601
|
-
const
|
|
893
|
+
const nest = parseChannelNest(approval.channelNest);
|
|
602
894
|
runtime.log?.(`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`);
|
|
603
895
|
await processMessage({
|
|
604
896
|
messageId: approval.originalMessage.messageId,
|
|
@@ -607,17 +899,17 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
607
899
|
messageContent: approval.originalMessage.messageContent,
|
|
608
900
|
isGroup: true,
|
|
609
901
|
channelNest: approval.channelNest,
|
|
610
|
-
hostShip:
|
|
611
|
-
channelName:
|
|
902
|
+
hostShip: nest?.hostShip,
|
|
903
|
+
channelName: nest?.channelName,
|
|
612
904
|
timestamp: approval.originalMessage.timestamp,
|
|
613
905
|
parentId: approval.originalMessage.parentId,
|
|
614
906
|
isThreadReply: approval.originalMessage.isThreadReply,
|
|
907
|
+
blobField: approval.originalMessage.blob,
|
|
615
908
|
});
|
|
616
909
|
}
|
|
617
910
|
}
|
|
618
911
|
break;
|
|
619
912
|
case "group":
|
|
620
|
-
// Accept the group invite (don't add to allowlist - each invite requires approval)
|
|
621
913
|
if (approval.groupFlag) {
|
|
622
914
|
try {
|
|
623
915
|
await api.poke({
|
|
@@ -629,8 +921,6 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
629
921
|
},
|
|
630
922
|
});
|
|
631
923
|
runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
|
|
632
|
-
// Immediately discover channels from the newly joined group
|
|
633
|
-
// Small delay to allow the join to propagate
|
|
634
924
|
setTimeout(async () => {
|
|
635
925
|
try {
|
|
636
926
|
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
@@ -656,58 +946,52 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
656
946
|
}
|
|
657
947
|
break;
|
|
658
948
|
}
|
|
659
|
-
await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
|
|
660
949
|
}
|
|
661
|
-
else if (
|
|
662
|
-
// Block the ship using Tlon's native blocking
|
|
950
|
+
else if (action === "block") {
|
|
663
951
|
await blockShip(approval.requestingShip);
|
|
664
|
-
await
|
|
665
|
-
}
|
|
666
|
-
else {
|
|
667
|
-
// Denied - just remove from pending, no notification to requester
|
|
668
|
-
await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
|
|
952
|
+
await removeFromDmAllowlist(approval.requestingShip);
|
|
669
953
|
}
|
|
670
|
-
//
|
|
954
|
+
// "deny" — no side effects beyond removing from pending
|
|
671
955
|
pendingApprovals = removePendingApproval(pendingApprovals, approval.id);
|
|
672
956
|
await savePendingApprovals();
|
|
673
|
-
return
|
|
957
|
+
return formatApprovalConfirmation(approval, action, buildDisplayContext());
|
|
674
958
|
}
|
|
675
|
-
//
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
runtime.log?.(`[tlon] Owner requested pending approvals list (${pendingApprovals.length} pending)`);
|
|
691
|
-
return true;
|
|
692
|
-
}
|
|
693
|
-
case "unblock": {
|
|
694
|
-
const shipToUnblock = command.ship;
|
|
695
|
-
const isBlocked = await isShipBlocked(shipToUnblock);
|
|
696
|
-
if (!isBlocked) {
|
|
697
|
-
await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
|
|
698
|
-
return true;
|
|
699
|
-
}
|
|
700
|
-
const success = await unblockShip(shipToUnblock);
|
|
701
|
-
if (success) {
|
|
702
|
-
await sendOwnerNotification(`Unblocked ${shipToUnblock}.`);
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
await sendOwnerNotification(`Failed to unblock ${shipToUnblock}.`);
|
|
706
|
-
}
|
|
707
|
-
return true;
|
|
959
|
+
// ── Command bridge ──────────────────────────────────────────────────
|
|
960
|
+
// Exposes approval/admin actions to slash commands registered in index.ts.
|
|
961
|
+
// Handlers return response text; the slash command framework sends it back.
|
|
962
|
+
const accountKey = opts.accountId ?? undefined;
|
|
963
|
+
const commandBridge = {
|
|
964
|
+
get ownerShip() {
|
|
965
|
+
return effectiveOwnerShip;
|
|
966
|
+
},
|
|
967
|
+
async handleAction(action, id) {
|
|
968
|
+
// Prune expired approvals
|
|
969
|
+
pendingApprovals = pruneExpired(pendingApprovals);
|
|
970
|
+
await savePendingApprovals();
|
|
971
|
+
const approval = findPendingApproval(pendingApprovals, id);
|
|
972
|
+
if (!approval) {
|
|
973
|
+
return "No pending approval found" + (id ? ` for ID: #${id}` : ".");
|
|
708
974
|
}
|
|
709
|
-
|
|
710
|
-
|
|
975
|
+
return executeApprovalAction(approval, action);
|
|
976
|
+
},
|
|
977
|
+
async getPendingList() {
|
|
978
|
+
return formatPendingList(pendingApprovals, buildDisplayContext());
|
|
979
|
+
},
|
|
980
|
+
async getBlockedList() {
|
|
981
|
+
const blockedShips = await getBlockedShips();
|
|
982
|
+
return formatBlockedList(blockedShips);
|
|
983
|
+
},
|
|
984
|
+
async handleUnblock(ship) {
|
|
985
|
+
runtime.log?.(`[tlon] handleUnblock: checking if ${ship} is blocked...`);
|
|
986
|
+
const blocked = await isShipBlocked(ship);
|
|
987
|
+
if (!blocked) {
|
|
988
|
+
return `${ship} is not blocked.`;
|
|
989
|
+
}
|
|
990
|
+
const success = await unblockShip(ship);
|
|
991
|
+
return success ? `Unblocked ${ship}.` : `Failed to unblock ${ship}.`;
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
setBridge(accountKey, commandBridge);
|
|
711
995
|
// Check if a ship is the owner (always allowed to DM)
|
|
712
996
|
function isOwner(ship) {
|
|
713
997
|
if (!effectiveOwnerShip) {
|
|
@@ -731,14 +1015,14 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
731
1015
|
return /^~?[a-z-]+$/i.test(normalized) ? normalized : "";
|
|
732
1016
|
}
|
|
733
1017
|
const processMessage = async (params) => {
|
|
734
|
-
const { messageId, senderShip, isGroup, channelNest, hostShip, channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
|
|
1018
|
+
const { messageId, senderShip, isGroup, channelNest, hostShip: _hostShip, channelName: _channelName, timestamp, parentId, isThreadReply, messageContent, } = params;
|
|
735
1019
|
// replyParentId overrides parentId for the deliver callback (thread reply routing)
|
|
736
1020
|
// but doesn't affect the ctx payload (MessageThreadId/ReplyToId).
|
|
737
1021
|
// Used for reactions: agent sees no thread context (so it responds), but
|
|
738
1022
|
// the reply is still delivered as a thread reply.
|
|
739
1023
|
const deliverParentId = params.replyParentId ?? parentId;
|
|
740
1024
|
const groupChannel = channelNest; // For compatibility
|
|
741
|
-
let messageText = params.messageText;
|
|
1025
|
+
let messageText = sanitizeMessageText(params.messageText);
|
|
742
1026
|
const rawMessageText = messageText; // Preserve original before any modifications
|
|
743
1027
|
// Strip bot mention EARLY, before thread context is prepended.
|
|
744
1028
|
// This ensures [Current message] in thread context won't contain the bot ship name,
|
|
@@ -746,42 +1030,73 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
746
1030
|
if (isGroup) {
|
|
747
1031
|
messageText = stripBotMention(messageText, botShipName);
|
|
748
1032
|
}
|
|
749
|
-
// Track owner interaction timestamp for
|
|
750
|
-
//
|
|
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.
|
|
751
1037
|
if (isOwner(senderShip)) {
|
|
752
|
-
const isoDate = new Date(timestamp).toISOString().split("T")[0]; // YYYY-MM-DD
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}),
|
|
778
|
-
])
|
|
779
|
-
.then(() => {
|
|
780
|
-
runtime.log?.(`[tlon] Updated lastOwnerMessageAt: ${timestamp} (${isoDate})`);
|
|
781
|
-
})
|
|
782
|
-
.catch((err) => {
|
|
783
|
-
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,
|
|
784
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
|
+
}
|
|
785
1100
|
}
|
|
786
1101
|
// Download any images from the message content
|
|
787
1102
|
let attachments = [];
|
|
@@ -796,14 +1111,39 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
796
1111
|
runtime.log?.(`[tlon] Failed to download images: ${error?.message ?? String(error)}`);
|
|
797
1112
|
}
|
|
798
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
|
+
}
|
|
799
1139
|
// Fetch thread context when entering a thread for the first time
|
|
800
1140
|
if (isThreadReply && parentId && groupChannel) {
|
|
801
1141
|
try {
|
|
802
1142
|
const threadHistory = await fetchThreadHistory(api, groupChannel, parentId, 20, runtime);
|
|
803
1143
|
if (threadHistory.length > 0) {
|
|
804
1144
|
const threadContext = threadHistory
|
|
805
|
-
.slice(-
|
|
806
|
-
.map((msg) => `${msg.author}: ${msg.content}`)
|
|
1145
|
+
.slice(-20) // Last 20 thread messages for context
|
|
1146
|
+
.map((msg) => `${formatShipWithNickname(msg.author)}: ${sanitizeMessageText(msg.content)}`)
|
|
807
1147
|
.join("\n");
|
|
808
1148
|
// Prepend thread context to the message
|
|
809
1149
|
// Include note about ongoing conversation for agent judgment
|
|
@@ -817,6 +1157,31 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
817
1157
|
// Continue without thread context - not critical
|
|
818
1158
|
}
|
|
819
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
|
+
}
|
|
820
1185
|
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
|
|
821
1186
|
try {
|
|
822
1187
|
const history = await getChannelHistory(api, groupChannel, 50, runtime);
|
|
@@ -824,6 +1189,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
824
1189
|
const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
|
825
1190
|
if (isGroup && groupChannel) {
|
|
826
1191
|
await sendChannelPost({
|
|
1192
|
+
botProfile: getBotProfile(),
|
|
827
1193
|
fromShip: botShipName,
|
|
828
1194
|
nest: groupChannel,
|
|
829
1195
|
story: markdownToStory(noHistoryMsg),
|
|
@@ -831,6 +1197,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
831
1197
|
}
|
|
832
1198
|
else {
|
|
833
1199
|
await sendDm({
|
|
1200
|
+
botProfile: getBotProfile(),
|
|
834
1201
|
fromShip: botShipName,
|
|
835
1202
|
toShip: senderShip,
|
|
836
1203
|
text: noHistoryMsg,
|
|
@@ -839,7 +1206,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
839
1206
|
return;
|
|
840
1207
|
}
|
|
841
1208
|
const historyText = history
|
|
842
|
-
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
|
|
1209
|
+
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${sanitizeMessageText(msg.content)}`)
|
|
843
1210
|
.join("\n");
|
|
844
1211
|
messageText =
|
|
845
1212
|
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
|
|
@@ -853,13 +1220,19 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
853
1220
|
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
|
|
854
1221
|
if (isGroup && groupChannel) {
|
|
855
1222
|
await sendChannelPost({
|
|
1223
|
+
botProfile: getBotProfile(),
|
|
856
1224
|
fromShip: botShipName,
|
|
857
1225
|
nest: groupChannel,
|
|
858
1226
|
story: markdownToStory(errorMsg),
|
|
859
1227
|
});
|
|
860
1228
|
}
|
|
861
1229
|
else {
|
|
862
|
-
await sendDm({
|
|
1230
|
+
await sendDm({
|
|
1231
|
+
botProfile: getBotProfile(),
|
|
1232
|
+
fromShip: botShipName,
|
|
1233
|
+
toShip: senderShip,
|
|
1234
|
+
text: errorMsg,
|
|
1235
|
+
});
|
|
863
1236
|
}
|
|
864
1237
|
return;
|
|
865
1238
|
}
|
|
@@ -894,6 +1267,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
894
1267
|
`Docs: https://docs.openclaw.ai/concepts/session#secure-dm-mode`;
|
|
895
1268
|
// Send async, don't block message processing
|
|
896
1269
|
sendDm({
|
|
1270
|
+
botProfile: getBotProfile(),
|
|
897
1271
|
fromShip: botShipName,
|
|
898
1272
|
toShip: effectiveOwnerShip,
|
|
899
1273
|
text: warningMsg,
|
|
@@ -903,10 +1277,14 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
903
1277
|
senders.add(senderShip);
|
|
904
1278
|
}
|
|
905
1279
|
const senderRole = isOwner(senderShip) ? "owner" : "user";
|
|
1280
|
+
// Store role for before_tool_call hook (tool access control)
|
|
1281
|
+
setSessionRole(route.sessionKey, senderRole);
|
|
1282
|
+
runtime.log?.(`[tlon] Stored session role: sessionKey=${route.sessionKey}, role=${senderRole}`);
|
|
906
1283
|
const senderDisplay = formatShipWithNickname(senderShip);
|
|
907
1284
|
const fromLabel = isGroup
|
|
908
1285
|
? `${senderDisplay} [${senderRole}] in ${channelNest}`
|
|
909
1286
|
: `${senderDisplay} [${senderRole}]`;
|
|
1287
|
+
const attachmentCount = attachments.length;
|
|
910
1288
|
// Compute command authorization for slash commands (owner-only)
|
|
911
1289
|
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(messageText, cfg);
|
|
912
1290
|
let commandAuthorized = false;
|
|
@@ -945,11 +1323,10 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
945
1323
|
body: bodyWithAttachments,
|
|
946
1324
|
});
|
|
947
1325
|
// Use raw text (no thread context) for command detection so "/status" is recognized
|
|
948
|
-
const commandBody = isGroup
|
|
949
|
-
? stripBotMention(rawMessageText, botShipName)
|
|
950
|
-
: rawMessageText;
|
|
1326
|
+
const commandBody = isGroup ? stripBotMention(rawMessageText, botShipName) : rawMessageText;
|
|
951
1327
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
952
1328
|
Body: body,
|
|
1329
|
+
BodyForAgent: bodyWithAttachments,
|
|
953
1330
|
RawBody: messageText,
|
|
954
1331
|
CommandBody: commandBody,
|
|
955
1332
|
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
|
@@ -966,65 +1343,196 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
966
1343
|
Provider: "tlon",
|
|
967
1344
|
Surface: "tlon",
|
|
968
1345
|
MessageSid: messageId,
|
|
969
|
-
// Include downloaded media attachments
|
|
970
|
-
...(attachments.length > 0 && {
|
|
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
|
+
}),
|
|
971
1352
|
OriginatingChannel: "tlon",
|
|
972
|
-
OriginatingTo: `tlon:${isGroup ? groupChannel :
|
|
1353
|
+
OriginatingTo: `tlon:${isGroup ? groupChannel : senderShip}`,
|
|
973
1354
|
// Include thread context for automatic reply routing
|
|
974
1355
|
...(parentId && { MessageThreadId: String(parentId), ReplyToId: String(parentId) }),
|
|
975
1356
|
});
|
|
976
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;
|
|
977
1374
|
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix;
|
|
978
1375
|
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
|
|
998
|
-
}
|
|
999
|
-
if (isGroup && groupChannel) {
|
|
1000
|
-
// Send to any channel type (chat, heap, diary) using the nest directly
|
|
1001
|
-
await sendChannelPost({
|
|
1002
|
-
fromShip: botShipName,
|
|
1003
|
-
nest: groupChannel,
|
|
1004
|
-
story: markdownToStory(replyText),
|
|
1005
|
-
replyToId: deliverParentId ?? undefined,
|
|
1006
|
-
});
|
|
1007
|
-
// Track thread participation for future replies without mention
|
|
1008
|
-
if (deliverParentId) {
|
|
1009
|
-
participatedThreads.add(String(deliverParentId));
|
|
1010
|
-
runtime.log?.(`[tlon] Now tracking thread for future replies: ${deliverParentId}`);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
else {
|
|
1014
|
-
await sendDm({ fromShip: botShipName, toShip: senderShip, text: replyText, replyToId: deliverParentId ? String(deliverParentId) : undefined });
|
|
1015
|
-
}
|
|
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
|
+
});
|
|
1385
|
+
},
|
|
1386
|
+
stop: async () => {
|
|
1387
|
+
await computingPresence.stopRun({
|
|
1388
|
+
conversationId: presenceConversationId,
|
|
1389
|
+
runId: presenceRunId,
|
|
1390
|
+
});
|
|
1391
|
+
},
|
|
1392
|
+
onStartError: (err) => {
|
|
1393
|
+
runtime.error?.(`[tlon] Failed to start computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
1016
1394
|
},
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
runtime.error?.(`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`);
|
|
1395
|
+
onStopError: (err) => {
|
|
1396
|
+
runtime.error?.(`[tlon] Failed to stop computing presence for ${presenceConversationId}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
1020
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;
|
|
1021
1406
|
},
|
|
1022
|
-
|
|
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
|
+
}
|
|
1023
1531
|
};
|
|
1024
1532
|
// Track which channels we're interested in for filtering firehose events
|
|
1025
1533
|
const watchedChannels = new Set(groupChannels);
|
|
1026
1534
|
const _watchedDMs = new Set();
|
|
1027
|
-
// Firehose handler for all channel messages (/
|
|
1535
|
+
// Firehose handler for all channel messages (/v4)
|
|
1028
1536
|
const handleChannelsFirehose = async (event) => {
|
|
1029
1537
|
try {
|
|
1030
1538
|
const nest = event?.nest;
|
|
@@ -1056,8 +1564,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1056
1564
|
: (response?.post?.id ?? "unknown");
|
|
1057
1565
|
for (const [reactShip, reactEmoji] of Object.entries(effectiveReacts)) {
|
|
1058
1566
|
const ship = normalizeShip(reactShip);
|
|
1059
|
-
if (!ship || ship === botShipName)
|
|
1567
|
+
if (!ship || ship === botShipName) {
|
|
1060
1568
|
continue;
|
|
1569
|
+
}
|
|
1061
1570
|
try {
|
|
1062
1571
|
const route = core.channel.routing.resolveAgentRoute({
|
|
1063
1572
|
cfg,
|
|
@@ -1070,7 +1579,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1070
1579
|
const contentSnippet = cached?.content
|
|
1071
1580
|
? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
|
|
1072
1581
|
: "";
|
|
1073
|
-
const authorInfo = cached?.author
|
|
1582
|
+
const authorInfo = cached?.author
|
|
1583
|
+
? ` (by ${formatShipWithNickname(cached.author)})`
|
|
1584
|
+
: "";
|
|
1074
1585
|
const reactorDisplay = formatShipWithNickname(ship);
|
|
1075
1586
|
const eventText = `Tlon reaction in ${nest}: ${reactEmoji} by ${reactorDisplay} on post ${postId}${authorInfo}${contentSnippet}`;
|
|
1076
1587
|
runtime.log?.(`[tlon] REACTION: ${eventText}`);
|
|
@@ -1080,9 +1591,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1080
1591
|
// Include context so agent knows what was reacted to, since we're
|
|
1081
1592
|
// deliberately omitting thread context (parentId) to avoid the agent
|
|
1082
1593
|
// suppressing responses when it sees its own message in thread history.
|
|
1083
|
-
const reactionParentId = replyReacts
|
|
1084
|
-
? (response?.post?.id ?? postId)
|
|
1085
|
-
: postId;
|
|
1594
|
+
const reactionParentId = replyReacts ? (response?.post?.id ?? postId) : postId;
|
|
1086
1595
|
const reactText = cached?.content
|
|
1087
1596
|
? `${reactEmoji} (reacting to: "${cached.content}")`
|
|
1088
1597
|
: reactEmoji;
|
|
@@ -1116,17 +1625,17 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1116
1625
|
}
|
|
1117
1626
|
// Handle post responses (new posts and replies)
|
|
1118
1627
|
const essay = response?.post?.["r-post"]?.set?.essay;
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1628
|
+
const replyEssay = response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.["reply-essay"];
|
|
1629
|
+
const content = replyEssay || essay;
|
|
1630
|
+
if (!content) {
|
|
1121
1631
|
return;
|
|
1122
1632
|
}
|
|
1123
|
-
const
|
|
1124
|
-
const isThreadReply = Boolean(memo);
|
|
1633
|
+
const isThreadReply = Boolean(replyEssay);
|
|
1125
1634
|
const messageId = isThreadReply ? response?.post?.["r-post"]?.reply?.id : response?.post?.id;
|
|
1126
1635
|
if (!processedTracker.mark(messageId)) {
|
|
1127
1636
|
return;
|
|
1128
1637
|
}
|
|
1129
|
-
const senderShip = normalizeShip(content?.author
|
|
1638
|
+
const senderShip = normalizeShip(extractAuthorShip(content?.author));
|
|
1130
1639
|
if (!senderShip) {
|
|
1131
1640
|
return;
|
|
1132
1641
|
}
|
|
@@ -1134,7 +1643,8 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1134
1643
|
const citedContent = await resolveAllCites(content.content);
|
|
1135
1644
|
const rawText = extractMessageText(content.content);
|
|
1136
1645
|
const messageText = citedContent + rawText;
|
|
1137
|
-
|
|
1646
|
+
const hasBlob = Boolean(content?.blob);
|
|
1647
|
+
if (!messageText.trim() && !hasBlob) {
|
|
1138
1648
|
return;
|
|
1139
1649
|
}
|
|
1140
1650
|
// Cache ALL messages (including bot's own) so reaction lookups have context
|
|
@@ -1144,10 +1654,18 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1144
1654
|
timestamp: content.sent || Date.now(),
|
|
1145
1655
|
id: messageId,
|
|
1146
1656
|
});
|
|
1657
|
+
// Check if sender is a bot (BotProfile object has ship, nickname, avatar)
|
|
1658
|
+
const authorRaw = content?.author;
|
|
1659
|
+
const isSenderBot = typeof authorRaw === "object" && authorRaw !== null && "ship" in authorRaw;
|
|
1660
|
+
if (isSenderBot) {
|
|
1661
|
+
knownBotShips.add(senderShip);
|
|
1662
|
+
}
|
|
1147
1663
|
// Skip processing bot's own messages (but they're already cached above)
|
|
1148
1664
|
if (senderShip === botShipName) {
|
|
1149
1665
|
return;
|
|
1150
1666
|
}
|
|
1667
|
+
// Check if sender is a known bot (for rate limiting later)
|
|
1668
|
+
const isKnownBot = isSenderBot || knownBotShips.has(senderShip);
|
|
1151
1669
|
// Get thread info early for participation check
|
|
1152
1670
|
const seal = isThreadReply
|
|
1153
1671
|
? response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
|
@@ -1158,13 +1676,33 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1158
1676
|
// 2. Thread replies where we've participated - respond if relevant (let agent decide)
|
|
1159
1677
|
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined);
|
|
1160
1678
|
const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(String(parentId));
|
|
1161
|
-
|
|
1679
|
+
const isOwnerBlob = hasBlob && isOwner(senderShip);
|
|
1680
|
+
if (!mentioned && !inParticipatedThread && !isOwnerBlob) {
|
|
1162
1681
|
return;
|
|
1163
1682
|
}
|
|
1164
1683
|
// Log why we're responding
|
|
1165
|
-
if (
|
|
1684
|
+
if (isOwnerBlob && !mentioned && !inParticipatedThread) {
|
|
1685
|
+
runtime.log?.(`[tlon] Responding to owner blob-only message in ${nest}`);
|
|
1686
|
+
}
|
|
1687
|
+
else if (inParticipatedThread && !mentioned) {
|
|
1166
1688
|
runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
|
|
1167
1689
|
}
|
|
1690
|
+
// Rate limit consecutive bot responses (only in group channels)
|
|
1691
|
+
if (isKnownBot) {
|
|
1692
|
+
const count = (consecutiveBotMessages.get(nest) ?? 0) + 1;
|
|
1693
|
+
consecutiveBotMessages.set(nest, count);
|
|
1694
|
+
runtime.log?.(`[tlon] Bot mention from ${senderShip} in ${nest}: consecutive count = ${count}`);
|
|
1695
|
+
if (maxBotResponses > 0 && count > maxBotResponses) {
|
|
1696
|
+
runtime.log?.(`[tlon] Rate limiting: skipping response to bot ${senderShip} (count ${count} > limit ${maxBotResponses})`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
else {
|
|
1701
|
+
// Human mention resets the consecutive bot counter
|
|
1702
|
+
// (requires explicit engagement, not just any human message)
|
|
1703
|
+
consecutiveBotMessages.set(nest, 0);
|
|
1704
|
+
runtime.log?.(`[tlon] Human mention from ${senderShip} in ${nest}: reset bot counter`);
|
|
1705
|
+
}
|
|
1168
1706
|
// Owner is always allowed
|
|
1169
1707
|
if (isOwner(senderShip)) {
|
|
1170
1708
|
runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
|
|
@@ -1188,8 +1726,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1188
1726
|
timestamp: content.sent || Date.now(),
|
|
1189
1727
|
parentId: parentId ?? undefined,
|
|
1190
1728
|
isThreadReply,
|
|
1729
|
+
blob: content.blob ?? undefined,
|
|
1191
1730
|
},
|
|
1192
|
-
});
|
|
1731
|
+
}, pendingApprovals.map((a) => a.id));
|
|
1193
1732
|
await queueApprovalRequest(approval);
|
|
1194
1733
|
}
|
|
1195
1734
|
else {
|
|
@@ -1205,6 +1744,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1205
1744
|
senderShip,
|
|
1206
1745
|
messageText,
|
|
1207
1746
|
messageContent: content.content, // Pass raw content for media extraction
|
|
1747
|
+
blobField: content.blob,
|
|
1208
1748
|
isGroup: true,
|
|
1209
1749
|
channelNest: nest,
|
|
1210
1750
|
hostShip: parsed?.hostShip,
|
|
@@ -1218,7 +1758,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1218
1758
|
runtime.error?.(`[tlon] Error handling channel firehose event: ${error?.message ?? String(error)}`);
|
|
1219
1759
|
}
|
|
1220
1760
|
};
|
|
1221
|
-
// Firehose handler for all DM messages (/
|
|
1761
|
+
// Firehose handler for all DM messages (/v4)
|
|
1222
1762
|
// Track which DM invites we've already processed to avoid duplicate accepts
|
|
1223
1763
|
const processedDmInvites = new Set();
|
|
1224
1764
|
const handleChatFirehose = async (event) => {
|
|
@@ -1268,7 +1808,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1268
1808
|
type: "dm",
|
|
1269
1809
|
requestingShip: ship,
|
|
1270
1810
|
messagePreview: "(DM invite - no message yet)",
|
|
1271
|
-
});
|
|
1811
|
+
}, pendingApprovals.map((a) => a.id));
|
|
1272
1812
|
await queueApprovalRequest(approval);
|
|
1273
1813
|
processedDmInvites.add(ship); // Mark as processed to avoid duplicate notifications
|
|
1274
1814
|
}
|
|
@@ -1290,9 +1830,34 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1290
1830
|
if (dmAddReact || dmDelReact) {
|
|
1291
1831
|
const isAdd = Boolean(dmAddReact);
|
|
1292
1832
|
const reactData = dmAddReact || dmDelReact;
|
|
1293
|
-
const reactAuthor = normalizeShip(reactData?.author
|
|
1294
|
-
const reactEmoji =
|
|
1833
|
+
const reactAuthor = normalizeShip(extractAuthorShip(reactData?.author) || reactData?.ship || "");
|
|
1834
|
+
const reactEmoji = dmAddReact?.react ?? "";
|
|
1295
1835
|
if (reactAuthor && reactAuthor !== botShipName) {
|
|
1836
|
+
// Check if this is an approval reaction from the owner on a notification message
|
|
1837
|
+
if (isAdd && isOwner(reactAuthor)) {
|
|
1838
|
+
const approvalAction = emojiToApprovalAction(reactEmoji);
|
|
1839
|
+
if (approvalAction) {
|
|
1840
|
+
const normalizedEventId = normalizeNotificationId(messageId);
|
|
1841
|
+
const matchedApproval = pendingApprovals.find((a) => a.notificationMessageId === normalizedEventId);
|
|
1842
|
+
if (matchedApproval) {
|
|
1843
|
+
if (isExpired(matchedApproval)) {
|
|
1844
|
+
runtime.log?.(`[tlon] Ignoring reaction on expired approval #${matchedApproval.id}`);
|
|
1845
|
+
// Fall through to normal reaction handling
|
|
1846
|
+
}
|
|
1847
|
+
else {
|
|
1848
|
+
runtime.log?.(`[tlon] Reaction-based approval: ${reactEmoji} → ${approvalAction} for #${matchedApproval.id}`);
|
|
1849
|
+
try {
|
|
1850
|
+
const confirmText = await executeApprovalAction(matchedApproval, approvalAction);
|
|
1851
|
+
await sendOwnerNotification(confirmText);
|
|
1852
|
+
}
|
|
1853
|
+
catch (err) {
|
|
1854
|
+
runtime.error?.(`[tlon] Reaction approval error: ${String(err)}`);
|
|
1855
|
+
}
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1296
1861
|
try {
|
|
1297
1862
|
const partnerShip = extractDmPartnerShip(whom);
|
|
1298
1863
|
const route = core.channel.routing.resolveAgentRoute({
|
|
@@ -1326,7 +1891,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1326
1891
|
const contentSnippet = cached?.content
|
|
1327
1892
|
? ` (message: "${cached.content.substring(0, 200)}${cached.content.length > 200 ? "..." : ""}")`
|
|
1328
1893
|
: "";
|
|
1329
|
-
const authorInfo = cached?.author
|
|
1894
|
+
const authorInfo = cached?.author
|
|
1895
|
+
? ` (by ${formatShipWithNickname(cached.author)})`
|
|
1896
|
+
: "";
|
|
1330
1897
|
const reactorDisplay = formatShipWithNickname(reactAuthor);
|
|
1331
1898
|
const eventText = `Tlon DM reaction ${action}: ${reactEmoji} by ${reactorDisplay} on message ${messageId}${authorInfo}${contentSnippet}`;
|
|
1332
1899
|
core.system.enqueueSystemEvent(eventText, {
|
|
@@ -1342,19 +1909,19 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1342
1909
|
}
|
|
1343
1910
|
return;
|
|
1344
1911
|
}
|
|
1345
|
-
// Extract
|
|
1346
|
-
const
|
|
1912
|
+
// Extract reply-essay from DM thread reply
|
|
1913
|
+
const dmReplyEssay = dmReply?.delta?.add?.["reply-essay"];
|
|
1347
1914
|
const dmReplyParentId = dmReply ? event.id : undefined;
|
|
1348
|
-
const isDmThreadReply = Boolean(
|
|
1349
|
-
const dmContent = essay ||
|
|
1915
|
+
const isDmThreadReply = Boolean(dmReplyEssay);
|
|
1916
|
+
const dmContent = essay || dmReplyEssay;
|
|
1350
1917
|
// For DM thread replies, extract the reply's own ID (distinct from the parent post ID)
|
|
1351
1918
|
// The reply ID may be in dmReply.id, or we construct it from author/sent
|
|
1352
1919
|
let dmReplyOwnId;
|
|
1353
1920
|
if (isDmThreadReply && dmReply) {
|
|
1354
1921
|
dmReplyOwnId = dmReply.id ?? dmReply.delta?.add?.id;
|
|
1355
1922
|
// If no explicit reply ID, construct from author/sent (same format as our outbound)
|
|
1356
|
-
if (!dmReplyOwnId &&
|
|
1357
|
-
dmReplyOwnId = `${normalizeShip(
|
|
1923
|
+
if (!dmReplyOwnId && dmReplyEssay?.author && dmReplyEssay?.sent) {
|
|
1924
|
+
dmReplyOwnId = `${normalizeShip(extractAuthorShip(dmReplyEssay.author))}/${dmReplyEssay.sent}`;
|
|
1358
1925
|
}
|
|
1359
1926
|
}
|
|
1360
1927
|
if (!dmContent) {
|
|
@@ -1365,7 +1932,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1365
1932
|
if (!processedTracker.mark(effectiveMessageId)) {
|
|
1366
1933
|
return;
|
|
1367
1934
|
}
|
|
1368
|
-
const authorShip = normalizeShip(dmContent
|
|
1935
|
+
const authorShip = normalizeShip(extractAuthorShip(dmContent.author));
|
|
1369
1936
|
const partnerShip = extractDmPartnerShip(whom);
|
|
1370
1937
|
const senderShip = partnerShip || authorShip;
|
|
1371
1938
|
// Cache DM messages (including bot's own) so reaction lookups have context
|
|
@@ -1394,25 +1961,10 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1394
1961
|
const citedContent = await resolveAllCites(dmContent.content);
|
|
1395
1962
|
const rawText = extractMessageText(dmContent.content);
|
|
1396
1963
|
const messageText = citedContent + rawText;
|
|
1397
|
-
|
|
1964
|
+
const hasBlob = Boolean(dmContent?.blob);
|
|
1965
|
+
if (!messageText.trim() && !hasBlob) {
|
|
1398
1966
|
return;
|
|
1399
1967
|
}
|
|
1400
|
-
// Check if this is the owner sending an approval response
|
|
1401
|
-
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
|
|
1402
|
-
const handled = await handleApprovalResponse(messageText);
|
|
1403
|
-
if (handled) {
|
|
1404
|
-
runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
// Check if this is the owner sending an admin command
|
|
1409
|
-
if (isOwner(senderShip) && isAdminCommand(messageText)) {
|
|
1410
|
-
const handled = await handleAdminCommand(messageText);
|
|
1411
|
-
if (handled) {
|
|
1412
|
-
runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
1968
|
// Owner is always allowed to DM (bypass allowlist)
|
|
1417
1969
|
if (isOwner(senderShip)) {
|
|
1418
1970
|
runtime.log?.(`[tlon] Processing DM from owner ${senderShip}${isDmThreadReply ? ` (thread reply, parent=${dmReplyParentId}, replyId=${effectiveMessageId})` : ""}`);
|
|
@@ -1421,6 +1973,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1421
1973
|
senderShip,
|
|
1422
1974
|
messageText,
|
|
1423
1975
|
messageContent: dmContent.content,
|
|
1976
|
+
blobField: dmContent.blob,
|
|
1424
1977
|
isGroup: false,
|
|
1425
1978
|
timestamp: dmContent.sent || Date.now(),
|
|
1426
1979
|
parentId: dmReplyParentId,
|
|
@@ -1441,8 +1994,9 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1441
1994
|
messageText,
|
|
1442
1995
|
messageContent: dmContent.content,
|
|
1443
1996
|
timestamp: dmContent.sent || Date.now(),
|
|
1997
|
+
blob: dmContent.blob ?? undefined,
|
|
1444
1998
|
},
|
|
1445
|
-
});
|
|
1999
|
+
}, pendingApprovals.map((a) => a.id));
|
|
1446
2000
|
await queueApprovalRequest(approval);
|
|
1447
2001
|
}
|
|
1448
2002
|
else {
|
|
@@ -1455,6 +2009,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1455
2009
|
senderShip,
|
|
1456
2010
|
messageText,
|
|
1457
2011
|
messageContent: dmContent.content, // Pass raw content for media extraction
|
|
2012
|
+
blobField: dmContent.blob,
|
|
1458
2013
|
isGroup: false,
|
|
1459
2014
|
timestamp: dmContent.sent || Date.now(),
|
|
1460
2015
|
parentId: dmReplyParentId,
|
|
@@ -1467,11 +2022,11 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1467
2022
|
};
|
|
1468
2023
|
try {
|
|
1469
2024
|
runtime.log?.("[tlon] Subscribing to firehose updates...");
|
|
1470
|
-
// Subscribe to channels firehose (/
|
|
2025
|
+
// Subscribe to channels firehose (/v4)
|
|
1471
2026
|
await api.subscribe({
|
|
1472
2027
|
app: "channels",
|
|
1473
|
-
path: "/
|
|
1474
|
-
event: handleChannelsFirehose,
|
|
2028
|
+
path: "/v4",
|
|
2029
|
+
event: (data) => handleChannelsFirehose(data),
|
|
1475
2030
|
err: (error) => {
|
|
1476
2031
|
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
|
|
1477
2032
|
},
|
|
@@ -1479,12 +2034,12 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1479
2034
|
runtime.log?.("[tlon] Channels firehose quit received, SSE client will resubscribe");
|
|
1480
2035
|
},
|
|
1481
2036
|
});
|
|
1482
|
-
runtime.log?.("[tlon] Subscribed to channels firehose (/
|
|
1483
|
-
// Subscribe to chat/DM firehose (/
|
|
2037
|
+
runtime.log?.("[tlon] Subscribed to channels firehose (/v4)");
|
|
2038
|
+
// Subscribe to chat/DM firehose (/v4)
|
|
1484
2039
|
await api.subscribe({
|
|
1485
2040
|
app: "chat",
|
|
1486
|
-
path: "/
|
|
1487
|
-
event: handleChatFirehose,
|
|
2041
|
+
path: "/v4",
|
|
2042
|
+
event: (data) => handleChatFirehose(data),
|
|
1488
2043
|
err: (error) => {
|
|
1489
2044
|
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
|
|
1490
2045
|
},
|
|
@@ -1492,7 +2047,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1492
2047
|
runtime.log?.("[tlon] Chat firehose quit received, SSE client will resubscribe");
|
|
1493
2048
|
},
|
|
1494
2049
|
});
|
|
1495
|
-
runtime.log?.("[tlon] Subscribed to chat firehose (/
|
|
2050
|
+
runtime.log?.("[tlon] Subscribed to chat firehose (/v4)");
|
|
1496
2051
|
// Subscribe to contacts updates to track nickname changes
|
|
1497
2052
|
await api.subscribe({
|
|
1498
2053
|
app: "contacts",
|
|
@@ -1502,18 +2057,24 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1502
2057
|
// Look for self profile updates
|
|
1503
2058
|
if (event?.self) {
|
|
1504
2059
|
const selfUpdate = event.self;
|
|
1505
|
-
if (selfUpdate?.contact?.nickname?.value !== undefined
|
|
2060
|
+
if (selfUpdate?.contact?.nickname?.value !== undefined ||
|
|
2061
|
+
selfUpdate?.contact?.avatar?.value !== undefined) {
|
|
1506
2062
|
const newNickname = selfUpdate.contact.nickname.value || null;
|
|
1507
2063
|
if (newNickname !== botNickname) {
|
|
1508
2064
|
botNickname = newNickname;
|
|
1509
2065
|
runtime.log?.(`[tlon] Bot nickname updated: ${botNickname}`);
|
|
1510
2066
|
if (botNickname) {
|
|
1511
|
-
nicknameCache.set(botShipName, botNickname);
|
|
2067
|
+
nicknameCache.set(botShipName, sanitizeNickname(botNickname));
|
|
1512
2068
|
}
|
|
1513
2069
|
else {
|
|
1514
2070
|
nicknameCache.delete(botShipName);
|
|
1515
2071
|
}
|
|
1516
2072
|
}
|
|
2073
|
+
const newAvatar = selfUpdate.contact?.avatar?.value || null;
|
|
2074
|
+
if (newAvatar !== botAvatar) {
|
|
2075
|
+
botAvatar = newAvatar;
|
|
2076
|
+
runtime.log?.(`[tlon] Bot avatar updated: ${botAvatar ? "set" : "cleared"}`);
|
|
2077
|
+
}
|
|
1517
2078
|
}
|
|
1518
2079
|
}
|
|
1519
2080
|
// Look for peer profile updates (other users)
|
|
@@ -1522,7 +2083,7 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1522
2083
|
const nickname = event.peer.contact?.nickname?.value ?? event.peer.contact?.nickname;
|
|
1523
2084
|
if (ship) {
|
|
1524
2085
|
if (nickname && typeof nickname === "string") {
|
|
1525
|
-
nicknameCache.set(ship, nickname);
|
|
2086
|
+
nicknameCache.set(ship, sanitizeNickname(nickname));
|
|
1526
2087
|
}
|
|
1527
2088
|
else {
|
|
1528
2089
|
nicknameCache.delete(ship);
|
|
@@ -1543,8 +2104,34 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1543
2104
|
});
|
|
1544
2105
|
runtime.log?.("[tlon] Subscribed to contacts updates (/v1/news)");
|
|
1545
2106
|
// Subscribe to settings store for hot-reloading config
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
+
}
|
|
1548
2135
|
// Update watched channels if settings changed
|
|
1549
2136
|
if (newSettings.groupChannels?.length) {
|
|
1550
2137
|
const newChannels = newSettings.groupChannels;
|
|
@@ -1557,11 +2144,10 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1557
2144
|
// Note: we don't remove channels from watchedChannels to avoid missing messages
|
|
1558
2145
|
// during transitions. The authorization check handles access control.
|
|
1559
2146
|
}
|
|
1560
|
-
// Update DM allowlist
|
|
2147
|
+
// Update DM allowlist — respect empty lists (don't fall back to file config)
|
|
1561
2148
|
if (newSettings.dmAllowlist !== undefined) {
|
|
1562
|
-
effectiveDmAllowlist =
|
|
1563
|
-
|
|
1564
|
-
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
|
|
2149
|
+
effectiveDmAllowlist = newSettings.dmAllowlist;
|
|
2150
|
+
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.length > 0 ? effectiveDmAllowlist.join(", ") : "(empty)"}`);
|
|
1565
2151
|
}
|
|
1566
2152
|
// Update model signature setting
|
|
1567
2153
|
if (newSettings.showModelSig !== undefined) {
|
|
@@ -1594,20 +2180,68 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1594
2180
|
effectiveAutoDiscoverChannels = newSettings.autoDiscoverChannels;
|
|
1595
2181
|
runtime.log?.(`[tlon] Settings: autoDiscoverChannels = ${effectiveAutoDiscoverChannels}`);
|
|
1596
2182
|
}
|
|
1597
|
-
//
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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;
|
|
1604
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})`);
|
|
1605
2235
|
}
|
|
1606
2236
|
// Update pending approvals
|
|
1607
2237
|
if (newSettings.pendingApprovals !== undefined) {
|
|
1608
2238
|
pendingApprovals = newSettings.pendingApprovals;
|
|
1609
2239
|
runtime.log?.(`[tlon] Settings: pendingApprovals updated (${pendingApprovals.length} items)`);
|
|
1610
2240
|
}
|
|
2241
|
+
currentSettings = nextRuntimeSettings;
|
|
2242
|
+
};
|
|
2243
|
+
settingsManager.onChange((newSettings) => {
|
|
2244
|
+
applySettingsSnapshot(newSettings, "subscription");
|
|
1611
2245
|
});
|
|
1612
2246
|
try {
|
|
1613
2247
|
await settingsManager.startSubscription();
|
|
@@ -1803,7 +2437,8 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1803
2437
|
type: "group",
|
|
1804
2438
|
requestingShip: inviterShip,
|
|
1805
2439
|
groupFlag,
|
|
1806
|
-
|
|
2440
|
+
groupTitle: validInvite.preview?.meta?.title,
|
|
2441
|
+
}, pendingApprovals.map((a) => a.id));
|
|
1807
2442
|
await queueApprovalRequest(approval);
|
|
1808
2443
|
processedGroupInvites.add(groupFlag);
|
|
1809
2444
|
}
|
|
@@ -1822,7 +2457,8 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1822
2457
|
type: "group",
|
|
1823
2458
|
requestingShip: inviterShip,
|
|
1824
2459
|
groupFlag,
|
|
1825
|
-
|
|
2460
|
+
groupTitle: validInvite.preview?.meta?.title,
|
|
2461
|
+
}, pendingApprovals.map((a) => a.id));
|
|
1826
2462
|
await queueApprovalRequest(approval);
|
|
1827
2463
|
processedGroupInvites.add(groupFlag);
|
|
1828
2464
|
}
|
|
@@ -1915,11 +2551,73 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1915
2551
|
}
|
|
1916
2552
|
}
|
|
1917
2553
|
}, 2 * 60 * 1000);
|
|
2554
|
+
// Periodically re-scry settings as a fallback for stale subscriptions.
|
|
2555
|
+
// The settings subscription can silently die (SSE quit without reconnect),
|
|
2556
|
+
// leaving both authorization state and heartbeat telemetry mirrors stale.
|
|
2557
|
+
const settingsRefreshInterval = setInterval(async () => {
|
|
2558
|
+
if (opts.abortSignal?.aborted) {
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
try {
|
|
2562
|
+
const refreshResult = await settingsManager.load();
|
|
2563
|
+
applySettingsSnapshot(refreshResult.settings, "refresh", { fresh: refreshResult.fresh });
|
|
2564
|
+
}
|
|
2565
|
+
catch (err) {
|
|
2566
|
+
runtime.error?.(`[tlon] Settings refresh failed: ${String(err)}`);
|
|
2567
|
+
}
|
|
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
|
+
}
|
|
1918
2609
|
if (opts.abortSignal) {
|
|
1919
2610
|
const signal = opts.abortSignal;
|
|
1920
2611
|
await new Promise((resolve) => {
|
|
1921
2612
|
signal.addEventListener("abort", () => {
|
|
1922
2613
|
clearInterval(pollInterval);
|
|
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();
|
|
1923
2621
|
resolve(null);
|
|
1924
2622
|
}, { once: true });
|
|
1925
2623
|
});
|
|
@@ -1929,6 +2627,17 @@ export async function monitorTlonProvider(opts = {}) {
|
|
|
1929
2627
|
}
|
|
1930
2628
|
}
|
|
1931
2629
|
finally {
|
|
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();
|
|
1932
2641
|
try {
|
|
1933
2642
|
await api?.close();
|
|
1934
2643
|
}
|