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