Files
clawdbot/extensions/tlon/src/monitor.js
william arzt d46642319b Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-24 00:25:38 +00:00

1573 lines
52 KiB
JavaScript

// Polyfill window.location for Node.js environment
// Required because some clawdbot dependencies (axios, Slack SDK) expect browser globals
if (typeof global.window === "undefined") {
global.window = {};
}
if (!global.window.location) {
global.window.location = {
href: "http://localhost",
origin: "http://localhost",
protocol: "http:",
host: "localhost",
hostname: "localhost",
port: "",
pathname: "/",
search: "",
hash: "",
};
}
import { unixToDa, formatUd } from "@urbit/aura";
import { UrbitSSEClient } from "./urbit-sse-client.js";
import { loadCoreChannelDeps } from "./core-bridge.js";
console.log("[tlon] ====== monitor.js v2 loaded with action.post.reply structure ======");
/**
* Formats model name for display in signature
* Converts "anthropic/claude-sonnet-4-5" to "Claude Sonnet 4.5"
*/
function formatModelName(modelString) {
if (!modelString) return "AI";
// Remove provider prefix (e.g., "anthropic/", "openai/")
const modelName = modelString.includes("/")
? modelString.split("/")[1]
: modelString;
// Convert common model names to friendly format
const modelMappings = {
"claude-opus-4-5": "Claude Opus 4.5",
"claude-sonnet-4-5": "Claude Sonnet 4.5",
"claude-sonnet-3-5": "Claude Sonnet 3.5",
"gpt-4o": "GPT-4o",
"gpt-4-turbo": "GPT-4 Turbo",
"gpt-4": "GPT-4",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-pro": "Gemini Pro",
};
return modelMappings[modelName] || modelName
.replace(/-/g, " ")
.split(" ")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/**
* Authenticate and get cookie
*/
async function authenticate(url, code) {
const resp = await fetch(`${url}/~/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `password=${code}`,
});
if (!resp.ok) {
throw new Error(`Login failed with status ${resp.status}`);
}
// Read and discard the token body
await resp.text();
// Extract cookie
const cookie = resp.headers.get("set-cookie");
if (!cookie) {
throw new Error("No authentication cookie received");
}
return cookie;
}
/**
* Sends a direct message via Urbit
*/
async function sendDm(api, fromShip, toShip, text) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt).toString());
const id = `${fromShip}/${idUd}`;
const delta = {
add: {
memo: {
content: story,
author: fromShip,
sent: sentAt,
},
kind: null,
time: null,
},
};
const action = {
ship: toShip,
diff: { id, delta },
};
await api.poke({
app: "chat",
mark: "chat-dm-action",
json: action,
});
return { channel: "tlon", success: true, messageId: id };
}
/**
* Format a numeric ID with dots every 3 digits (Urbit @ud format)
* Example: "170141184507780357587090523864791252992" -> "170.141.184.507.780.357.587.090.523.864.791.252.992"
*/
function formatUdId(id) {
if (!id) return id;
const idStr = String(id);
// Insert dots every 3 characters from the left
return idStr.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}
/**
* Sends a message to a group channel
* @param {string} replyTo - Optional parent post ID for threading
*/
async function sendGroupMessage(api, fromShip, hostShip, channelName, text, replyTo = null, runtime = null) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
// Format reply ID with dots for Urbit @ud format
const formattedReplyTo = replyTo ? formatUdId(replyTo) : null;
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
action: formattedReplyTo ? {
// Reply action for threading (wraps reply in post like official client)
post: {
reply: {
id: formattedReplyTo,
action: {
add: {
content: story,
author: fromShip,
sent: sentAt,
}
}
}
}
} : {
// Regular post action
post: {
add: {
content: story,
author: fromShip,
sent: sentAt,
kind: "/chat",
blob: null,
meta: null,
},
},
},
},
};
runtime?.log?.(`[tlon] 📤 Sending message: replyTo=${replyTo} (formatted: ${formattedReplyTo}), text="${text.substring(0, 100)}...", nest=chat/${hostShip}/${channelName}`);
runtime?.log?.(`[tlon] 📤 Action type: ${formattedReplyTo ? 'REPLY (thread)' : 'POST (main channel)'}`);
runtime?.log?.(`[tlon] 📤 Full action structure: ${JSON.stringify(action, null, 2)}`);
try {
const pokeResult = await api.poke({
app: "channels",
mark: "channel-action-1",
json: action,
});
runtime?.log?.(`[tlon] 📤 Poke succeeded: ${JSON.stringify(pokeResult)}`);
return { channel: "tlon", success: true, messageId: `${fromShip}/${sentAt}` };
} catch (error) {
runtime?.error?.(`[tlon] 📤 Poke FAILED: ${error.message}`);
runtime?.error?.(`[tlon] 📤 Error details: ${JSON.stringify(error)}`);
throw error;
}
}
/**
* Checks if the bot's ship is mentioned in a message
*/
function isBotMentioned(messageText, botShipName) {
if (!messageText || !botShipName) return false;
// Normalize bot ship name (ensure it has ~)
const normalizedBotShip = botShipName.startsWith("~")
? botShipName
: `~${botShipName}`;
// Escape special regex characters
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Check for mention - ship name should be at start, after whitespace, or standalone
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
return mentionPattern.test(messageText);
}
/**
* Parses commands related to notebook operations
* @param {string} messageText - The message to parse
* @returns {Object|null} Command info or null if no command detected
*/
function parseNotebookCommand(messageText) {
const text = messageText.toLowerCase().trim();
// Save to notebook patterns
const savePatterns = [
/save (?:this|that) to (?:my )?notes?/i,
/save to (?:my )?notes?/i,
/save to notebook/i,
/add to (?:my )?diary/i,
/save (?:this|that) to (?:my )?diary/i,
/save to (?:my )?diary/i,
/save (?:this|that)/i,
];
for (const pattern of savePatterns) {
if (pattern.test(text)) {
return {
type: "save_to_notebook",
title: extractTitle(messageText),
};
}
}
// List notebook patterns
const listPatterns = [
/(?:list|show) (?:my )?(?:notes?|notebook|diary)/i,
/what(?:'s| is) in (?:my )?(?:notes?|notebook|diary)/i,
/check (?:my )?(?:notes?|notebook|diary)/i,
];
for (const pattern of listPatterns) {
if (pattern.test(text)) {
return {
type: "list_notebook",
};
}
}
return null;
}
/**
* Extracts a title from a save command
* @param {string} text - The message text
* @returns {string|null} Extracted title or null
*/
function extractTitle(text) {
// Try to extract title from "as [title]" or "with title [title]"
const asMatch = /(?:as|with title)\s+["']([^"']+)["']/i.exec(text);
if (asMatch) return asMatch[1];
const asMatch2 = /(?:as|with title)\s+(.+?)(?:\.|$)/i.exec(text);
if (asMatch2) return asMatch2[1].trim();
return null;
}
/**
* Sends a post to an Urbit diary channel
* @param {Object} api - Authenticated Urbit API instance
* @param {Object} account - Account configuration
* @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id"
* @param {string} title - Post title
* @param {string} content - Post content
* @returns {Promise<{essayId: string, sentAt: number}>}
*/
async function sendDiaryPost(api, account, diaryChannel, title, content) {
// Parse channel format: "diary/~host/channel-id"
const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel);
if (!match) {
throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`);
}
const host = match[1];
const channelId = match[2];
const nest = `diary/~${host}/${channelId}`;
// Construct essay (diary entry) format
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt).toString());
const fromShip = account.ship.startsWith("~") ? account.ship : `~${account.ship}`;
const essayId = `${fromShip}/${idUd}`;
const action = {
channel: {
nest,
action: {
post: {
add: {
content: [{ inline: [content] }],
sent: sentAt,
kind: "/diary",
author: fromShip,
blob: null,
meta: {
title: title || "Saved Note",
image: "",
description: "",
cover: "",
},
},
},
},
},
};
await api.poke({
app: "channels",
mark: "channel-action-1",
json: action,
});
return { essayId, sentAt };
}
/**
* Fetches diary entries from an Urbit diary channel
* @param {Object} api - Authenticated Urbit API instance
* @param {string} diaryChannel - Diary channel in format "diary/~host/channel-id"
* @param {number} limit - Maximum number of entries to fetch (default: 10)
* @returns {Promise<Array>} Array of diary entries with { id, title, content, author, sent }
*/
async function fetchDiaryEntries(api, diaryChannel, limit = 10) {
// Parse channel format: "diary/~host/channel-id"
const match = /^diary\/~?([a-z-]+)\/([a-z0-9]+)$/i.exec(diaryChannel);
if (!match) {
throw new Error(`Invalid diary channel format: ${diaryChannel}. Expected: diary/~host/channel-id`);
}
const host = match[1];
const channelId = match[2];
const nest = `diary/~${host}/${channelId}`;
try {
// Scry the diary channel for posts
const response = await api.scry({
app: "channels",
path: `/channel/${nest}/posts/newest/${limit}`,
});
if (!response || !response.posts) {
return [];
}
// Extract and format diary entries
const entries = Object.entries(response.posts).map(([id, post]) => {
const essay = post.essay || {};
// Extract text content from prose blocks
let content = "";
if (essay.content && Array.isArray(essay.content)) {
content = essay.content
.map((block) => {
if (block.block?.prose?.inline) {
return block.block.prose.inline.join("");
}
return "";
})
.join("\n");
}
return {
id,
title: essay.title || "Untitled",
content,
author: essay.author || "unknown",
sent: essay.sent || 0,
};
});
// Sort by sent time (newest first)
return entries.sort((a, b) => b.sent - a.sent);
} catch (error) {
console.error(`[tlon] Error fetching diary entries from ${nest}:`, error);
throw error;
}
}
/**
* Checks if a ship is allowed to send DMs to the bot
*/
function isDmAllowed(senderShip, account) {
// If dmAllowlist is not configured or empty, allow all
if (!account.dmAllowlist || !Array.isArray(account.dmAllowlist) || account.dmAllowlist.length === 0) {
return true;
}
// Normalize ship names for comparison (ensure ~ prefix)
const normalizedSender = senderShip.startsWith("~")
? senderShip
: `~${senderShip}`;
const normalizedAllowlist = account.dmAllowlist
.map((ship) => ship.startsWith("~") ? ship : `~${ship}`);
// Check if sender is in allowlist
return normalizedAllowlist.includes(normalizedSender);
}
/**
* Extracts text content from Tlon message structure
*/
function extractMessageText(content) {
if (!content || !Array.isArray(content)) return "";
return content
.map((block) => {
if (block.inline && Array.isArray(block.inline)) {
return block.inline
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item === "object") {
if (item.ship) return item.ship; // Ship mention
if (item.break !== undefined) return "\n"; // Line break
if (item.link && item.link.href) return item.link.href; // URL link
// Skip other objects (images, etc.)
}
return "";
})
.join("");
}
return "";
})
.join("\n")
.trim();
}
/**
* Parses a channel nest identifier
* Format: chat/~host-ship/channel-name
*/
function parseChannelNest(nest) {
if (!nest) return null;
const parts = nest.split("/");
if (parts.length !== 3 || parts[0] !== "chat") return null;
return {
hostShip: parts[1],
channelName: parts[2],
};
}
/**
* Message cache for channel history (for faster access)
* Structure: Map<channelNest, Array<{author, content, timestamp, id}>>
*/
const messageCache = new Map();
const MAX_CACHED_MESSAGES = 100;
/**
* Adds a message to the cache
*/
function cacheMessage(channelNest, message) {
if (!messageCache.has(channelNest)) {
messageCache.set(channelNest, []);
}
const cache = messageCache.get(channelNest);
cache.unshift(message); // Add to front (most recent)
// Keep only last MAX_CACHED_MESSAGES
if (cache.length > MAX_CACHED_MESSAGES) {
cache.pop();
}
}
/**
* Fetches channel history from Urbit via scry
* Format: /channels/v4/<channel-nest>/posts/newest/<count>/outline.json
* Returns pagination object: { newest, posts: {...}, total, newer, older }
*/
async function fetchChannelHistory(api, channelNest, count = 50, runtime) {
try {
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
const data = await api.scry(scryPath);
runtime?.log?.(`[tlon] Scry returned data type: ${Array.isArray(data) ? 'array' : typeof data}, keys: ${typeof data === 'object' ? Object.keys(data).slice(0, 5).join(', ') : 'N/A'}`);
if (!data) {
runtime?.log?.(`[tlon] Data is null`);
return [];
}
// Extract posts from pagination object
let posts = [];
if (Array.isArray(data)) {
// Direct array of posts
posts = data;
} else if (data.posts && typeof data.posts === 'object') {
// Pagination object with posts property (keyed by ID)
posts = Object.values(data.posts);
runtime?.log?.(`[tlon] Extracted ${posts.length} posts from pagination object`);
} else if (typeof data === 'object') {
// Fallback: treat as keyed object
posts = Object.values(data);
}
runtime?.log?.(`[tlon] Processing ${posts.length} posts`);
// Extract posts from outline format
const messages = posts.map(item => {
// Handle both post and r-post structures
const essay = item.essay || item['r-post']?.set?.essay;
const seal = item.seal || item['r-post']?.set?.seal;
return {
author: essay?.author || 'unknown',
content: extractMessageText(essay?.content || []),
timestamp: essay?.sent || Date.now(),
id: seal?.id,
};
}).filter(msg => msg.content); // Filter out empty messages
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
return messages;
} catch (error) {
runtime?.log?.(`[tlon] Error fetching channel history: ${error.message}`);
console.error(`[tlon] Error fetching channel history: ${error.message}`, error.stack);
return [];
}
}
/**
* Gets recent channel history (tries cache first, then scry)
*/
async function getChannelHistory(api, channelNest, count = 50, runtime) {
// Try cache first for speed
const cache = messageCache.get(channelNest) || [];
if (cache.length >= count) {
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
return cache.slice(0, count);
}
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
// Fall back to scry for full history
return await fetchChannelHistory(api, channelNest, count, runtime);
}
/**
* Detects if a message is a summarization request
*/
function isSummarizationRequest(messageText) {
const patterns = [
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
/what\s+did\s+i\s+miss/i,
/catch\s+me\s+up/i,
/channel\s+summary/i,
/tldr/i,
];
return patterns.some(pattern => pattern.test(messageText));
}
/**
* Formats a date for the groups-ui changes endpoint
* Format: ~YYYY.M.D..HH.MM.SS..XXXX (only date changes, time/hex stay constant)
*/
function formatChangesDate(daysAgo = 5) {
const now = new Date();
const targetDate = new Date(now - (daysAgo * 24 * 60 * 60 * 1000));
const year = targetDate.getFullYear();
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
// Keep time and hex constant as per Urbit convention
return `~${year}.${month}.${day}..20.19.51..9b9d`;
}
/**
* Fetches changes from groups-ui since a specific date
* Returns delta data that can be used to efficiently discover new channels
*/
async function fetchGroupChanges(api, runtime, daysAgo = 5) {
try {
const changeDate = formatChangesDate(daysAgo);
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
if (changes) {
runtime.log?.(`[tlon] Successfully fetched changes data`);
return changes;
}
return null;
} catch (error) {
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error.message}`);
return null;
}
}
/**
* Fetches all channels the ship has access to
* Returns an array of channel nest identifiers (e.g., "chat/~host-ship/channel-name")
* Tries changes endpoint first for efficiency, falls back to full init
*/
async function fetchAllChannels(api, runtime) {
try {
runtime.log?.(`[tlon] Attempting auto-discovery of group channels...`);
// Try delta-based changes first (more efficient)
const changes = await fetchGroupChanges(api, runtime, 5);
let initData;
if (changes) {
// We got changes, but still need to extract channel info
// For now, fall back to full init since changes format varies
runtime.log?.(`[tlon] Changes data received, using full init for channel extraction`);
initData = await api.scry("/groups-ui/v6/init.json");
} else {
// No changes data, use full init
initData = await api.scry("/groups-ui/v6/init.json");
}
const channels = [];
// Extract chat channels from the groups data structure
if (initData && initData.groups) {
for (const [groupKey, groupData] of Object.entries(initData.groups)) {
if (groupData.channels) {
for (const channelNest of Object.keys(groupData.channels)) {
// Only include chat channels (not diary, heap, etc.)
if (channelNest.startsWith("chat/")) {
channels.push(channelNest);
}
}
}
}
}
if (channels.length > 0) {
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
runtime.log?.(`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`);
} else {
runtime.log?.(`[tlon] No chat channels found via auto-discovery`);
runtime.log?.(`[tlon] Add channels manually to config: channels.tlon.groupChannels`);
}
return channels;
} catch (error) {
runtime.log?.(`[tlon] Auto-discovery failed: ${error.message}`);
runtime.log?.(`[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels`);
runtime.log?.(`[tlon] Example: ["chat/~host-ship/channel-name"]`);
return [];
}
}
/**
* Monitors Tlon/Urbit for incoming DMs and group messages
*/
export async function monitorTlonProvider(opts = {}) {
const runtime = opts.runtime ?? {
log: console.log,
error: console.error,
};
const account = opts.account;
if (!account) {
throw new Error("Tlon account configuration required");
}
runtime.log?.(`[tlon] Account config: ${JSON.stringify({
showModelSignature: account.showModelSignature,
ship: account.ship,
hasCode: !!account.code,
hasUrl: !!account.url
})}`);
const botShipName = account.ship.startsWith("~")
? account.ship
: `~${account.ship}`;
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
// Authenticate with Urbit
let api;
let cookie;
try {
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
runtime.log?.(`[tlon] Ship: ${account.ship.replace(/^~/, "")}`);
cookie = await authenticate(account.url, account.code);
runtime.log?.(`[tlon] Successfully authenticated to ${account.url}`);
// Create custom SSE client
api = new UrbitSSEClient(account.url, cookie);
} catch (error) {
runtime.error?.(`[tlon] Failed to authenticate: ${error.message}`);
throw error;
}
// Get list of group channels to monitor
let groupChannels = [];
// Try auto-discovery first (unless explicitly disabled)
if (account.autoDiscoverChannels !== false) {
try {
const discoveredChannels = await fetchAllChannels(api, runtime);
if (discoveredChannels.length > 0) {
groupChannels = discoveredChannels;
runtime.log?.(`[tlon] Auto-discovered ${groupChannels.length} channel(s)`);
}
} catch (error) {
runtime.error?.(`[tlon] Auto-discovery failed: ${error.message}`);
}
}
// Fall back to manual config if auto-discovery didn't find anything
if (groupChannels.length === 0 && account.groupChannels && account.groupChannels.length > 0) {
groupChannels = account.groupChannels;
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
}
if (groupChannels.length > 0) {
runtime.log?.(
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`
);
} else {
runtime.log?.(`[tlon] No group channels to monitor (DMs only)`);
}
// Keep track of processed message IDs to avoid duplicates
const processedMessages = new Set();
/**
* Handler for incoming DM messages
*/
const handleIncomingDM = async (update) => {
try {
runtime.log?.(`[tlon] DM handler called with update: ${JSON.stringify(update).substring(0, 200)}`);
// Handle new DM event format: response.add.memo or response.reply.delta.add.memo (for threads)
let memo = update?.response?.add?.memo;
let parentId = null;
let replyId = null;
// Check if this is a thread reply
if (!memo && update?.response?.reply) {
memo = update?.response?.reply?.delta?.add?.memo;
parentId = update.id; // The parent post ID
replyId = update?.response?.reply?.id; // The reply message ID
runtime.log?.(`[tlon] Thread reply detected, parent: ${parentId}, reply: ${replyId}`);
}
if (!memo) {
runtime.log?.(`[tlon] DM update has no memo in response.add or response.reply`);
return;
}
const messageId = replyId || update.id;
if (processedMessages.has(messageId)) return;
processedMessages.add(messageId);
const senderShip = memo.author?.startsWith("~")
? memo.author
: `~${memo.author}`;
const messageText = extractMessageText(memo.content);
if (!messageText) return;
// Determine which user's DM cache to use (the other party, not the bot)
const otherParty = senderShip === botShipName ? update.whom : senderShip;
const dmCacheKey = `dm/${otherParty}`;
// Cache all DM messages (including bot's own) for history retrieval
if (!messageCache.has(dmCacheKey)) {
messageCache.set(dmCacheKey, []);
}
const cache = messageCache.get(dmCacheKey);
cache.unshift({
id: messageId,
author: senderShip,
content: messageText,
timestamp: memo.sent || Date.now(),
});
// Keep only last 50 messages
if (cache.length > 50) {
cache.length = 50;
}
// Don't respond to our own messages
if (senderShip === botShipName) return;
// Check DM access control
if (!isDmAllowed(senderShip, account)) {
runtime.log?.(
`[tlon] Blocked DM from ${senderShip}: not in allowed list`
);
return;
}
runtime.log?.(
`[tlon] Received DM from ${senderShip}: "${messageText.slice(0, 50)}..."${parentId ? ' (thread reply)' : ''}`
);
// All DMs are processed (no mention check needed)
await processMessage({
messageId,
senderShip,
messageText,
isGroup: false,
timestamp: memo.sent || Date.now(),
parentId, // Pass parentId for thread replies
});
} catch (error) {
runtime.error?.(`[tlon] Error handling DM: ${error.message}`);
}
};
/**
* Handler for incoming group channel messages
*/
const handleIncomingGroupMessage = (channelNest) => async (update) => {
try {
runtime.log?.(`[tlon] Group handler called for ${channelNest} with update: ${JSON.stringify(update).substring(0, 200)}`);
const parsed = parseChannelNest(channelNest);
if (!parsed) return;
const { hostShip, channelName } = parsed;
// Handle both top-level posts and thread replies
// Top-level: response.post.r-post.set.essay
// Thread reply: response.post.r-post.reply.r-reply.set.memo
const essay = update?.response?.post?.["r-post"]?.set?.essay;
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
if (!essay && !memo) {
runtime.log?.(`[tlon] Group update has neither essay nor memo`);
return;
}
// Use memo for thread replies, essay for top-level posts
const content = memo || essay;
const isThreadReply = !!memo;
// For thread replies, use the reply ID, not the parent post ID
const messageId = isThreadReply
? update.response.post["r-post"]?.reply?.id
: update.response.post.id;
if (processedMessages.has(messageId)) {
runtime.log?.(`[tlon] Skipping duplicate message ${messageId}`);
return;
}
processedMessages.add(messageId);
const senderShip = content.author?.startsWith("~")
? content.author
: `~${content.author}`;
// Don't respond to our own messages
if (senderShip === botShipName) return;
const messageText = extractMessageText(content.content);
if (!messageText) return;
// Cache this message for history/summarization
cacheMessage(channelNest, {
author: senderShip,
content: messageText,
timestamp: content.sent || Date.now(),
id: messageId,
});
// Check if bot is mentioned
const mentioned = isBotMentioned(messageText, botShipName);
runtime.log?.(
`[tlon] Received group message in ${channelNest} from ${senderShip}: "${messageText.slice(0, 50)}..." (mentioned: ${mentioned})`
);
// Only process if bot is mentioned
if (!mentioned) return;
// Check channel authorization
const tlonConfig = opts.cfg?.channels?.tlon;
const authorization = tlonConfig?.authorization || {};
const channelRules = authorization.channelRules || {};
const defaultAuthorizedShips = tlonConfig?.defaultAuthorizedShips || ["~malmur-halmex"];
// Get channel rule or use default (restricted)
const channelRule = channelRules[channelNest];
const mode = channelRule?.mode || "restricted"; // Default to restricted
const allowedShips = channelRule?.allowedShips || defaultAuthorizedShips;
// Normalize sender ship (ensure it has ~)
const normalizedSender = senderShip.startsWith("~") ? senderShip : `~${senderShip}`;
// Check authorization for restricted channels
if (mode === "restricted") {
const isAuthorized = allowedShips.some(ship => {
const normalizedAllowed = ship.startsWith("~") ? ship : `~${ship}`;
return normalizedAllowed === normalizedSender;
});
if (!isAuthorized) {
runtime.log?.(
`[tlon] ⛔ Access denied: ${normalizedSender} in ${channelNest} (restricted, allowed: ${allowedShips.join(", ")})`
);
return;
}
runtime.log?.(
`[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (authorized user)`
);
} else {
runtime.log?.(
`[tlon] ✅ Access granted: ${normalizedSender} in ${channelNest} (open channel)`
);
}
// Extract seal data for thread support
// For thread replies, seal is in a different location
const seal = isThreadReply
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
: update?.response?.post?.["r-post"]?.set?.seal;
// For thread replies, all messages in the thread share the same parent-id
// We reply to the parent-id to keep our message in the same thread
const parentId = seal?.["parent-id"] || seal?.parent || null;
const postType = update?.response?.post?.["r-post"]?.set?.type;
runtime.log?.(
`[tlon] Message type: ${isThreadReply ? "thread reply" : "top-level post"}, parentId: ${parentId}, messageId: ${seal?.id}`
);
await processMessage({
messageId,
senderShip,
messageText,
isGroup: true,
groupChannel: channelNest,
groupName: `${hostShip}/${channelName}`,
timestamp: content.sent || Date.now(),
parentId, // Reply to parent-id to stay in the thread
postType,
seal,
});
} catch (error) {
runtime.error?.(
`[tlon] Error handling group message in ${channelNest}: ${error.message}`
);
}
};
// Load core channel deps
const deps = await loadCoreChannelDeps();
/**
* Process a message and generate AI response
*/
const processMessage = async (params) => {
let {
messageId,
senderShip,
messageText,
isGroup,
groupChannel,
groupName,
timestamp,
parentId, // Parent post ID to reply to (for threading)
postType,
seal,
} = params;
runtime.log?.(`[tlon] processMessage called for ${senderShip}, isGroup: ${isGroup}, message: "${messageText.substring(0, 50)}"`);
// Check if this is a summarization request
if (isGroup && isSummarizationRequest(messageText)) {
runtime.log?.(`[tlon] Detected summarization request in ${groupChannel}`);
try {
const history = await getChannelHistory(api, groupChannel, 50, runtime);
if (history.length === 0) {
const noHistoryMsg = "I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(
api,
botShipName,
parsed.hostShip,
parsed.channelName,
noHistoryMsg,
null,
runtime
);
}
} else {
await sendDm(api, botShipName, senderShip, noHistoryMsg);
}
return;
}
// Format history for AI
const historyText = history
.map(msg => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
.join("\n");
const summaryPrompt = `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\nProvide a concise summary highlighting:\n1. Main topics discussed\n2. Key decisions or conclusions\n3. Action items if any\n4. Notable participants`;
// Override message text with summary prompt
messageText = summaryPrompt;
runtime.log?.(`[tlon] Generating summary for ${history.length} messages`);
} catch (error) {
runtime.error?.(`[tlon] Error generating summary: ${error.message}`);
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error.message}`;
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(
api,
botShipName,
parsed.hostShip,
parsed.channelName,
errorMsg,
null,
runtime
);
}
} else {
await sendDm(api, botShipName, senderShip, errorMsg);
}
return;
}
}
// Check if this is a notebook command
const notebookCommand = parseNotebookCommand(messageText);
if (notebookCommand) {
runtime.log?.(`[tlon] Detected notebook command: ${notebookCommand.type}`);
// Check if notebookChannel is configured
const notebookChannel = account.notebookChannel;
if (!notebookChannel) {
const errorMsg = "Notebook feature is not configured. Please add a 'notebookChannel' to your Tlon account config (e.g., diary/~malmur-halmex/v2u22f1d).";
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime);
}
} else {
await sendDm(api, botShipName, senderShip, errorMsg);
}
return;
}
// Handle save command
if (notebookCommand.type === "save_to_notebook") {
try {
let noteContent = null;
let noteTitle = notebookCommand.title;
// If replying to a message (thread), save the parent message
if (parentId) {
runtime.log?.(`[tlon] Fetching parent message ${parentId} to save`);
// For DMs, use messageCache directly since DM history scry isn't available
if (!isGroup) {
const dmCacheKey = `dm/${senderShip}`;
const cache = messageCache.get(dmCacheKey) || [];
const parentMsg = cache.find(msg => msg.id === parentId || msg.id.includes(parentId));
if (parentMsg) {
noteContent = parentMsg.content;
if (!noteTitle) {
// Generate title from first line or first 60 chars of content
const firstLine = noteContent.split('\n')[0];
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
}
} else {
noteContent = "Could not find parent message in cache";
noteTitle = noteTitle || "Note";
}
} else {
const history = await getChannelHistory(api, groupChannel, 50, runtime);
const parentMsg = history.find(msg => msg.id === parentId || msg.id.includes(parentId));
if (parentMsg) {
noteContent = parentMsg.content;
if (!noteTitle) {
// Generate title from first line or first 60 chars of content
const firstLine = noteContent.split('\n')[0];
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
}
} else {
noteContent = "Could not find parent message";
noteTitle = noteTitle || "Note";
}
}
} else {
// No parent - fetch last bot message
if (!isGroup) {
const dmCacheKey = `dm/${senderShip}`;
const cache = messageCache.get(dmCacheKey) || [];
const lastBotMsg = cache.find(msg => msg.author === botShipName);
if (lastBotMsg) {
noteContent = lastBotMsg.content;
if (!noteTitle) {
// Generate title from first line or first 60 chars of content
const firstLine = noteContent.split('\n')[0];
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
}
} else {
noteContent = "No recent bot message found in cache";
noteTitle = noteTitle || "Note";
}
} else {
const history = await getChannelHistory(api, groupChannel, 10, runtime);
const lastBotMsg = history.find(msg => msg.author === botShipName);
if (lastBotMsg) {
noteContent = lastBotMsg.content;
if (!noteTitle) {
// Generate title from first line or first 60 chars of content
const firstLine = noteContent.split('\n')[0];
noteTitle = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
}
} else {
noteContent = "No recent bot message found";
noteTitle = noteTitle || "Note";
}
}
}
const { essayId, sentAt } = await sendDiaryPost(
api,
account,
notebookChannel,
noteTitle,
noteContent
);
const successMsg = `✓ Saved to notebook as "${noteTitle}"`;
runtime.log?.(`[tlon] Saved note ${essayId} to ${notebookChannel}`);
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, successMsg, parentId, runtime);
}
} else {
await sendDm(api, botShipName, senderShip, successMsg);
}
} catch (error) {
runtime.error?.(`[tlon] Error saving to notebook: ${error.message}`);
const errorMsg = `Failed to save to notebook: ${error.message}`;
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, errorMsg, parentId, runtime);
}
} else {
await sendDm(api, botShipName, senderShip, errorMsg);
}
}
return;
}
// Handle list command (placeholder for now)
if (notebookCommand.type === "list_notebook") {
const placeholderMsg = "List notebook handler not yet implemented.";
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
if (parsed) {
await sendGroupMessage(api, botShipName, parsed.hostShip, parsed.channelName, placeholderMsg, parentId, runtime);
}
} else {
await sendDm(api, botShipName, senderShip, placeholderMsg);
}
return;
}
return; // Don't send to AI for notebook commands
}
try {
// Resolve agent route
const route = deps.resolveAgentRoute({
cfg: opts.cfg,
channel: "tlon",
accountId: opts.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? groupChannel : senderShip,
},
});
// Format message for AI
const fromLabel = isGroup
? `${senderShip} in ${groupName}`
: senderShip;
// Add Tlon identity context to help AI recognize when it's being addressed
// The AI knows itself as "bearclawd" but in Tlon it's addressed as the ship name
const identityNote = `[Note: In Tlon/Urbit, you are known as ${botShipName}. When users mention ${botShipName}, they are addressing you directly.]\n\n`;
const messageWithIdentity = identityNote + messageText;
const body = deps.formatAgentEnvelope({
channel: "Tlon",
from: fromLabel,
timestamp,
body: messageWithIdentity,
});
// Create inbound context
// For thread replies, append parent ID to session key to create separate conversation context
const sessionKeySuffix = parentId ? `:thread:${parentId}` : '';
const finalSessionKey = `${route.sessionKey}${sessionKeySuffix}`;
runtime.log?.(
`[tlon] 🔑 Session key construction: base="${route.sessionKey}", suffix="${sessionKeySuffix}", final="${finalSessionKey}"`
);
const ctxPayload = deps.finalizeInboundContext({
Body: body,
RawBody: messageText,
CommandBody: messageText,
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
To: `tlon:${botShipName}`,
SessionKey: finalSessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderShip,
SenderId: senderShip,
Provider: "tlon",
Surface: "tlon",
MessageSid: messageId,
OriginatingChannel: "tlon",
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
});
runtime.log?.(
`[tlon] 📋 Context payload keys: ${Object.keys(ctxPayload).join(', ')}`
);
runtime.log?.(
`[tlon] 📋 Message body: "${body.substring(0, 100)}${body.length > 100 ? '...' : ''}"`
);
// Log transcript details
if (ctxPayload.Transcript && ctxPayload.Transcript.length > 0) {
runtime.log?.(
`[tlon] 📜 Transcript has ${ctxPayload.Transcript.length} message(s)`
);
// Log last few messages for debugging
const recentMessages = ctxPayload.Transcript.slice(-3);
recentMessages.forEach((msg, idx) => {
runtime.log?.(
`[tlon] 📜 Transcript[-${3-idx}]: role=${msg.role}, content length=${JSON.stringify(msg.content).length}`
);
});
} else {
runtime.log?.(
`[tlon] 📜 Transcript is empty or missing`
);
}
// Log key fields that affect AI behavior
runtime.log?.(
`[tlon] 📝 BodyForAgent: "${ctxPayload.BodyForAgent?.substring(0, 100)}${(ctxPayload.BodyForAgent?.length || 0) > 100 ? '...' : ''}"`
);
runtime.log?.(
`[tlon] 📝 ThreadStarterBody: "${ctxPayload.ThreadStarterBody?.substring(0, 100) || 'null'}${(ctxPayload.ThreadStarterBody?.length || 0) > 100 ? '...' : ''}"`
);
runtime.log?.(
`[tlon] 📝 CommandAuthorized: ${ctxPayload.CommandAuthorized}`
);
// Dispatch to AI and get response
const dispatchStartTime = Date.now();
runtime.log?.(
`[tlon] Dispatching to AI for ${senderShip} (${isGroup ? `group: ${groupName}` : 'DM'})`
);
runtime.log?.(
`[tlon] 🚀 Dispatch details: sessionKey="${finalSessionKey}", isThreadReply=${!!parentId}, messageText="${messageText.substring(0, 50)}..."`
);
const dispatchResult = await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: opts.cfg,
dispatcherOptions: {
deliver: async (payload) => {
runtime.log?.(`[tlon] 🎯 Deliver callback invoked! isThreadReply=${!!parentId}, parentId=${parentId}`);
const dispatchDuration = Date.now() - dispatchStartTime;
runtime.log?.(`[tlon] 📦 Payload keys: ${Object.keys(payload).join(', ')}, text length: ${payload.text?.length || 0}`);
let replyText = payload.text;
if (!replyText) {
runtime.log?.(`[tlon] No reply text in AI response (took ${dispatchDuration}ms)`);
return;
}
// Add model signature if enabled
const tlonConfig = opts.cfg?.channels?.tlon;
const showSignature = tlonConfig?.showModelSignature ?? false;
runtime.log?.(`[tlon] showModelSignature config: ${showSignature} (from cfg.channels.tlon)`);
runtime.log?.(`[tlon] Full payload keys: ${Object.keys(payload).join(', ')}`);
runtime.log?.(`[tlon] Full route keys: ${Object.keys(route).join(', ')}`);
runtime.log?.(`[tlon] opts.cfg.agents: ${JSON.stringify(opts.cfg?.agents?.defaults?.model)}`);
if (showSignature) {
const modelInfo = payload.metadata?.model || payload.model || route.model || opts.cfg?.agents?.defaults?.model?.primary;
runtime.log?.(`[tlon] Model info: ${JSON.stringify({
payloadMetadataModel: payload.metadata?.model,
payloadModel: payload.model,
routeModel: route.model,
cfgModel: opts.cfg?.agents?.defaults?.model?.primary,
resolved: modelInfo
})}`);
if (modelInfo) {
const modelName = formatModelName(modelInfo);
runtime.log?.(`[tlon] Adding signature: ${modelName}`);
replyText = `${replyText}\n\n_[Generated by ${modelName}]_`;
} else {
runtime.log?.(`[tlon] No model info found, using fallback`);
replyText = `${replyText}\n\n_[Generated by AI]_`;
}
}
runtime.log?.(
`[tlon] AI response received (took ${dispatchDuration}ms), sending to Tlon...`
);
// Debug delivery path
runtime.log?.(`[tlon] 🔍 Delivery debug: isGroup=${isGroup}, groupChannel=${groupChannel}, senderShip=${senderShip}, parentId=${parentId}`);
// Send reply back to Tlon
if (isGroup) {
const parsed = parseChannelNest(groupChannel);
runtime.log?.(`[tlon] 🔍 Parsed channel nest: ${JSON.stringify(parsed)}`);
if (parsed) {
// Reply in thread if this message is part of a thread
if (parentId) {
runtime.log?.(`[tlon] Replying in thread (parent: ${parentId})`);
}
await sendGroupMessage(
api,
botShipName,
parsed.hostShip,
parsed.channelName,
replyText,
parentId, // Pass parentId to reply in the thread
runtime
);
const threadInfo = parentId ? ` (in thread)` : '';
runtime.log?.(`[tlon] Delivered AI reply to group ${groupName}${threadInfo}`);
} else {
runtime.log?.(`[tlon] ⚠️ Failed to parse channel nest: ${groupChannel}`);
}
} else {
await sendDm(api, botShipName, senderShip, replyText);
runtime.log?.(`[tlon] Delivered AI reply to ${senderShip}`);
}
},
onError: (err, info) => {
const dispatchDuration = Date.now() - dispatchStartTime;
runtime.error?.(
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`
);
runtime.error?.(`[tlon] Error type: ${err?.constructor?.name || 'Unknown'}`);
runtime.error?.(`[tlon] Error details: ${JSON.stringify(info, null, 2)}`);
if (err?.stack) {
runtime.error?.(`[tlon] Stack trace: ${err.stack}`);
}
},
},
});
const totalDuration = Date.now() - dispatchStartTime;
runtime.log?.(
`[tlon] AI dispatch completed for ${senderShip} (total: ${totalDuration}ms), result keys: ${dispatchResult ? Object.keys(dispatchResult).join(', ') : 'null'}`
);
runtime.log?.(`[tlon] Dispatch result: ${JSON.stringify(dispatchResult)}`);
} catch (error) {
runtime.error?.(`[tlon] Error processing message: ${error.message}`);
runtime.error?.(`[tlon] Stack trace: ${error.stack}`);
}
};
// Track currently subscribed channels for dynamic updates
const subscribedChannels = new Set(); // Start empty, add after successful subscription
const subscribedDMs = new Set();
/**
* Subscribe to a group channel
*/
async function subscribeToChannel(channelNest) {
if (subscribedChannels.has(channelNest)) {
return; // Already subscribed
}
const parsed = parseChannelNest(channelNest);
if (!parsed) {
runtime.error?.(
`[tlon] Invalid channel format: ${channelNest} (expected: chat/~host-ship/channel-name)`
);
return;
}
try {
await api.subscribe({
app: "channels",
path: `/${channelNest}`,
event: handleIncomingGroupMessage(channelNest),
err: (error) => {
runtime.error?.(
`[tlon] Group subscription error for ${channelNest}: ${error}`
);
},
quit: () => {
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
subscribedChannels.delete(channelNest);
},
});
subscribedChannels.add(channelNest);
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
} catch (error) {
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error.message}`);
}
}
/**
* Subscribe to a DM conversation
*/
async function subscribeToDM(dmShip) {
if (subscribedDMs.has(dmShip)) {
return; // Already subscribed
}
try {
await api.subscribe({
app: "chat",
path: `/dm/${dmShip}`,
event: handleIncomingDM,
err: (error) => {
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${error}`);
},
quit: () => {
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
subscribedDMs.delete(dmShip);
},
});
subscribedDMs.add(dmShip);
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
} catch (error) {
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error.message}`);
}
}
/**
* Discover and subscribe to new channels
*/
async function refreshChannelSubscriptions() {
try {
// Check for new DMs
const dmShips = await api.scry("/chat/dm.json");
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
}
// Check for new group channels (if auto-discovery is enabled)
if (account.autoDiscoverChannels !== false) {
const discoveredChannels = await fetchAllChannels(api, runtime);
// Find truly new channels (not already subscribed)
const newChannels = discoveredChannels.filter(c => !subscribedChannels.has(c));
if (newChannels.length > 0) {
runtime.log?.(`[tlon] 🆕 Discovered ${newChannels.length} new channel(s):`);
newChannels.forEach(c => runtime.log?.(`[tlon] - ${c}`));
}
// Subscribe to all discovered channels (including new ones)
for (const channelNest of discoveredChannels) {
await subscribeToChannel(channelNest);
}
}
} catch (error) {
runtime.error?.(`[tlon] Channel refresh failed: ${error.message}`);
}
}
// Subscribe to incoming messages
try {
runtime.log?.(`[tlon] Subscribing to updates...`);
// Get list of DM ships and subscribe to each one
let dmShips = [];
try {
dmShips = await api.scry("/chat/dm.json");
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
} catch (error) {
runtime.error?.(`[tlon] Failed to fetch DM list: ${error.message}`);
}
// Subscribe to each DM individually
for (const dmShip of dmShips) {
await subscribeToDM(dmShip);
}
// Subscribe to each group channel
for (const channelNest of groupChannels) {
await subscribeToChannel(channelNest);
}
runtime.log?.(`[tlon] All subscriptions registered, connecting to SSE stream...`);
// Connect to Urbit and start the SSE stream
await api.connect();
runtime.log?.(`[tlon] Connected! All subscriptions active`);
// Start dynamic channel discovery (poll every 2 minutes)
const POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
const pollInterval = setInterval(() => {
if (!opts.abortSignal?.aborted) {
runtime.log?.(`[tlon] Checking for new channels...`);
refreshChannelSubscriptions().catch((error) => {
runtime.error?.(`[tlon] Channel refresh error: ${error.message}`);
});
}
}, POLL_INTERVAL_MS);
runtime.log?.(`[tlon] Dynamic channel discovery enabled (checking every 2 minutes)`);
// Keep the monitor running until aborted
if (opts.abortSignal) {
await new Promise((resolve) => {
opts.abortSignal.addEventListener("abort", () => {
clearInterval(pollInterval);
resolve();
}, {
once: true,
});
});
} else {
// If no abort signal, wait indefinitely
await new Promise(() => {});
}
} catch (error) {
if (opts.abortSignal?.aborted) {
runtime.log?.(`[tlon] Monitor stopped`);
return;
}
throw error;
} finally {
// Cleanup
try {
await api.close();
} catch (e) {
runtime.error?.(`[tlon] Cleanup error: ${e.message}`);
}
}
}