From 40181afdedb04ce05f9d28d0a34440e810e8c07e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:25:58 +0000 Subject: [PATCH 01/45] feat: add models status auth probes --- CHANGELOG.md | 1 + docs/cli/index.md | 7 + docs/cli/models.md | 14 + src/cli/models-cli.ts | 29 +- src/commands/models.list.test.ts | 1 + src/commands/models/list.probe.ts | 414 ++++++++++++++++++ src/commands/models/list.status-command.ts | 256 ++++++++--- ...patterns-match-without-botusername.test.ts | 13 +- ...topic-skill-filters-system-prompts.test.ts | 13 +- ...-all-group-messages-grouppolicy-is.test.ts | 13 +- ...e-callback-query-updates-by-update.test.ts | 13 +- ...gram-bot.installs-grammy-throttler.test.ts | 14 +- ...lowfrom-entries-case-insensitively.test.ts | 13 +- ...-case-insensitively-grouppolicy-is.test.ts | 13 +- ...-dms-by-telegram-accountid-binding.test.ts | 13 +- ...ies-without-native-reply-threading.test.ts | 13 +- src/telegram/bot.test.ts | 20 +- 17 files changed, 754 insertions(+), 106 deletions(-) create mode 100644 src/commands/models/list.probe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4106f7827..3612e9686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. +- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes diff --git a/docs/cli/index.md b/docs/cli/index.md index 46f6d173e..fcc013fdc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -700,8 +700,15 @@ Options: - `--json` - `--plain` - `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` +- `--probe-profile ` (repeat or comma-separated) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` Always includes the auth overview and OAuth expiry status for profiles in the auth store. +`--probe` runs live requests (may consume tokens and trigger rate limits). ### `models set ` Set `agents.defaults.model.primary`. diff --git a/docs/cli/models.md b/docs/cli/models.md index f394a44f9..ba4600ce4 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -25,12 +25,26 @@ clawdbot models scan `clawdbot models status` shows the resolved default/fallbacks plus an auth overview. When provider usage snapshots are available, the OAuth/token status section includes provider usage headers. +Add `--probe` to run live auth probes against each configured provider profile. +Probes are real requests (may consume tokens and trigger rate limits). Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +### `models status` +Options: +- `--json` +- `--plain` +- `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` (probe one provider) +- `--probe-profile ` (repeat or comma-separated profile ids) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` + ## Aliases + fallbacks ```bash diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a2674d94a..20a476f81 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) { "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", false, ) + .option("--probe", "Probe configured provider auth (live)", false) + .option("--probe-provider ", "Only probe a single provider") + .option( + "--probe-profile ", + "Only probe specific auth profile ids (repeat or comma-separated)", + (value, previous) => { + const next = Array.isArray(previous) ? previous : previous ? [previous] : []; + next.push(value); + return next; + }, + ) + .option("--probe-timeout ", "Per-probe timeout in ms") + .option("--probe-concurrency ", "Concurrent probes") + .option("--probe-max-tokens ", "Probe max tokens (best-effort)") .action(async (opts) => { await runModelsCommand(async () => { - await modelsStatusCommand(opts, defaultRuntime); + await modelsStatusCommand( + { + json: Boolean(opts.json), + plain: Boolean(opts.plain), + check: Boolean(opts.check), + probe: Boolean(opts.probe), + probeProvider: opts.probeProvider as string | undefined, + probeProfile: opts.probeProfile as string | string[] | undefined, + probeTimeout: opts.probeTimeout as string | undefined, + probeConcurrency: opts.probeConcurrency as string | undefined, + probeMaxTokens: opts.probeMaxTokens as string | undefined, + }, + defaultRuntime, + ); }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 47ebfe2f5..850f27246 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -17,6 +17,7 @@ const discoverModels = vi.fn(); vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state", loadConfig, })); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts new file mode 100644 index 000000000..fbd172b57 --- /dev/null +++ b/src/commands/models/list.probe.ts @@ -0,0 +1,414 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; + +import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + resolveAuthProfileDisplayLabel, +} from "../../agents/auth-profiles.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { describeFailoverError } from "../../agents/failover-error.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionTranscriptPath, + resolveSessionTranscriptsDirForAgent, +} from "../../config/sessions/paths.js"; +import { redactSecrets } from "../status-all/format.js"; +import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; + +const PROBE_PROMPT = "Reply with OK. Do not use tools."; + +export type AuthProbeStatus = + | "ok" + | "auth" + | "rate_limit" + | "billing" + | "timeout" + | "format" + | "unknown" + | "no_model"; + +export type AuthProbeResult = { + provider: string; + model?: string; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; + status: AuthProbeStatus; + error?: string; + latencyMs?: number; +}; + +type AuthProbeTarget = { + provider: string; + model?: { provider: string; model: string } | null; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; +}; + +export type AuthProbeSummary = { + startedAt: number; + finishedAt: number; + durationMs: number; + totalTargets: number; + options: { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; + }; + results: AuthProbeResult[]; +}; + +export type AuthProbeOptions = { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; +}; + +const toStatus = (reason?: string | null): AuthProbeStatus => { + if (!reason) return "unknown"; + if (reason === "auth") return "auth"; + if (reason === "rate_limit") return "rate_limit"; + if (reason === "billing") return "billing"; + if (reason === "timeout") return "timeout"; + if (reason === "format") return "format"; + return "unknown"; +}; + +function buildCandidateMap(modelCandidates: string[]): Map { + const map = new Map(); + for (const raw of modelCandidates) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + const list = map.get(parsed.provider) ?? []; + if (!list.includes(parsed.model)) list.push(parsed.model); + map.set(parsed.provider, list); + } + return map; +} + +function selectProbeModel(params: { + provider: string; + candidates: Map; + catalog: Array<{ provider: string; id: string }>; +}): { provider: string; model: string } | null { + const { provider, candidates, catalog } = params; + const direct = candidates.get(provider); + if (direct && direct.length > 0) { + return { provider, model: direct[0] }; + } + const fromCatalog = catalog.find((entry) => entry.provider === provider); + if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id }; + return null; +} + +function buildProbeTargets(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; +}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { + const { cfg, providers, modelCandidates, options } = params; + const store = ensureAuthProfileStore(); + const providerFilter = options.provider?.trim(); + const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; + const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); + + return loadModelCatalog({ config: cfg }).then((catalog) => { + const candidates = buildCandidateMap(modelCandidates); + const targets: AuthProbeTarget[] = []; + const results: AuthProbeResult[] = []; + + for (const provider of providers) { + const providerKey = normalizeProviderId(provider); + if (providerFilterKey && providerKey !== providerFilterKey) continue; + + const model = selectProbeModel({ + provider: providerKey, + candidates, + catalog, + }); + + const profileIds = listProfilesForProvider(store, providerKey); + const filteredProfiles = profileFilter.size + ? profileIds.filter((id) => profileFilter.has(id)) + : profileIds; + + if (filteredProfiles.length > 0) { + for (const profileId of filteredProfiles) { + const profile = store.profiles[profileId]; + const mode = profile?.type; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + profileId, + label, + source: "profile", + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + targets.push({ + provider: providerKey, + model, + profileId, + label, + source: "profile", + mode, + }); + } + continue; + } + + if (profileFilter.size > 0) continue; + + const envKey = resolveEnvApiKey(providerKey); + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (!envKey && !customKey) continue; + + const label = envKey ? "env" : "models.json"; + const source = envKey ? "env" : "models.json"; + const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key"; + + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + label, + source, + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + + targets.push({ + provider: providerKey, + model, + label, + source, + mode, + }); + } + + return { targets, results }; + }); +} + +async function probeTarget(params: { + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + workspaceDir: string; + sessionDir: string; + target: AuthProbeTarget; + timeoutMs: number; + maxTokens: number; +}): Promise { + const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params; + if (!target.model) { + return { + provider: target.provider, + model: undefined, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "no_model", + error: "No model available for probe", + }; + } + + const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`; + const sessionFile = resolveSessionTranscriptPath(sessionId, agentId); + await fs.mkdir(sessionDir, { recursive: true }); + + const start = Date.now(); + try { + await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + agentDir, + config: cfg, + prompt: PROBE_PROMPT, + provider: target.model.provider, + model: target.model.model, + authProfileId: target.profileId, + authProfileIdSource: target.profileId ? "user" : undefined, + timeoutMs, + runId: `probe-${crypto.randomUUID()}`, + lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`, + thinkLevel: "off", + reasoningLevel: "off", + verboseLevel: "off", + streamParams: { maxTokens }, + }); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "ok", + latencyMs: Date.now() - start, + }; + } catch (err) { + const described = describeFailoverError(err); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: toStatus(described.reason), + error: redactSecrets(described.message), + latencyMs: Date.now() - start, + }; + } +} + +async function runTargetsWithConcurrency(params: { + cfg: ClawdbotConfig; + targets: AuthProbeTarget[]; + timeoutMs: number; + maxTokens: number; + concurrency: number; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const { cfg, targets, timeoutMs, maxTokens, onProgress } = params; + const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency)); + + const agentId = resolveDefaultAgentId(cfg); + const agentDir = resolveClawdbotAgentDir(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const sessionDir = resolveSessionTranscriptsDirForAgent(agentId); + + await fs.mkdir(workspaceDir, { recursive: true }); + + let completed = 0; + const results: Array = Array.from({ length: targets.length }); + let cursor = 0; + + const worker = async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]; + onProgress?.({ + completed, + total: targets.length, + label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`, + }); + const result = await probeTarget({ + cfg, + agentId, + agentDir, + workspaceDir, + sessionDir, + target, + timeoutMs, + maxTokens, + }); + results[index] = result; + completed += 1; + onProgress?.({ completed, total: targets.length }); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results.filter((entry): entry is AuthProbeResult => Boolean(entry)); +} + +export async function runAuthProbes(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const startedAt = Date.now(); + const plan = await buildProbeTargets({ + cfg: params.cfg, + providers: params.providers, + modelCandidates: params.modelCandidates, + options: params.options, + }); + + const totalTargets = plan.targets.length; + params.onProgress?.({ completed: 0, total: totalTargets }); + + const results = totalTargets + ? await runTargetsWithConcurrency({ + cfg: params.cfg, + targets: plan.targets, + timeoutMs: params.options.timeoutMs, + maxTokens: params.options.maxTokens, + concurrency: params.options.concurrency, + onProgress: params.onProgress, + }) + : []; + + const finishedAt = Date.now(); + + return { + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + totalTargets, + options: params.options, + results: [...plan.results, ...results], + }; +} + +export function formatProbeLatency(latencyMs?: number | null) { + if (!latencyMs && latencyMs !== 0) return "-"; + return formatMs(latencyMs); +} + +export function groupProbeResults(results: AuthProbeResult[]): Map { + const map = new Map(); + for (const result of results) { + const list = map.get(result.provider) ?? []; + list.push(result); + map.set(result.provider, list); + } + return map; +} + +export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] { + return results.slice().sort((a, b) => { + const provider = a.provider.localeCompare(b.provider); + if (provider !== 0) return provider; + const aLabel = a.label || a.profileId || ""; + const bLabel = b.label || b.profileId || ""; + return aLabel.localeCompare(bLabel); + }); +} + +export function describeProbeSummary(summary: AuthProbeSummary): string { + if (summary.totalTargets === 0) return "No probe targets."; + return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`; +} diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 0bd8f16e9..41c126460 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -11,9 +11,15 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; -import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; +import { + buildModelAliasIndex, + parseModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { withProgressTotals } from "../../cli/progress.js"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -26,13 +32,34 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { isRich } from "./list.format.js"; +import { + describeProbeSummary, + formatProbeLatency, + groupProbeResults, + runAuthProbes, + sortProbeResults, + type AuthProbeSummary, +} from "./list.probe.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean; check?: boolean }, + opts: { + json?: boolean; + plain?: boolean; + check?: boolean; + probe?: boolean; + probeProvider?: string; + probeProfile?: string | string[]; + probeTimeout?: string; + probeConcurrency?: string; + probeMaxTokens?: string; + }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); + if (opts.plain && opts.probe) { + throw new Error("--probe cannot be used with --plain output."); + } const cfg = loadConfig(); const resolved = resolveConfiguredModelRef({ cfg, @@ -139,6 +166,69 @@ export async function modelsStatusCommand( .filter((provider) => !providerAuthMap.has(provider)) .sort((a, b) => a.localeCompare(b)); + const probeProfileIds = (() => { + if (!opts.probeProfile) return []; + const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile]; + return raw + .flatMap((value) => String(value ?? "").split(",")) + .map((value) => value.trim()) + .filter(Boolean); + })(); + const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000; + if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) { + throw new Error("--probe-timeout must be a positive number (ms)."); + } + const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2; + if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) { + throw new Error("--probe-concurrency must be > 0."); + } + const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8; + if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) { + throw new Error("--probe-max-tokens must be > 0."); + } + + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const rawCandidates = [ + rawModel || resolvedLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ...allowed, + ].filter(Boolean); + const resolvedCandidates = rawCandidates + .map( + (raw) => + resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + })?.ref, + ) + .filter((ref): ref is { provider: string; model: string } => Boolean(ref)); + const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`); + + let probeSummary: AuthProbeSummary | undefined; + if (opts.probe) { + probeSummary = await withProgressTotals( + { label: "Probing auth profiles…", total: 1 }, + async (update) => { + return await runAuthProbes({ + cfg, + providers, + modelCandidates, + options: { + provider: opts.probeProvider, + profileIds: probeProfileIds, + timeoutMs: probeTimeoutMs, + concurrency: probeConcurrency, + maxTokens: probeMaxTokens, + }, + onProgress: update, + }); + }, + ); + } + const providersWithOauth = providerAuth .filter( (entry) => @@ -228,6 +318,7 @@ export async function modelsStatusCommand( profiles: authHealth.profiles, providers: authHealth.providers, }, + probes: probeSummary, }, }, null, @@ -406,72 +497,113 @@ export async function modelsStatusCommand( runtime.log(colorize(rich, theme.heading, "OAuth/token status")); if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); - return; - } - - const usageByProvider = new Map(); - const usageProviders = Array.from( - new Set( - oauthProfiles - .map((profile) => resolveUsageProviderId(profile.provider)) - .filter((provider): provider is UsageProviderId => Boolean(provider)), - ), - ); - if (usageProviders.length > 0) { - try { - const usageSummary = await loadProviderUsageSummary({ - providers: usageProviders, - agentDir, - timeoutMs: 3500, - }); - for (const snapshot of usageSummary.providers) { - const formatted = formatUsageWindowSummary(snapshot, { - now: Date.now(), - maxWindows: 2, - includeResets: true, + } else { + const usageByProvider = new Map(); + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + if (usageProviders.length > 0) { + try { + const usageSummary = await loadProviderUsageSummary({ + providers: usageProviders, + agentDir, + timeoutMs: 3500, }); - if (formatted) { - usageByProvider.set(snapshot.provider, formatted); + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { + now: Date.now(), + maxWindows: 2, + includeResets: true, + }); + if (formatted) { + usageByProvider.set(snapshot.provider, formatted); + } } + } catch { + // ignore usage failures + } + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "static") return colorize(rich, theme.muted, "static"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + const profilesByProvider = new Map(); + for (const profile of oauthProfiles) { + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; + runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; + runtime.log(` - ${label} ${status}${expiry}${source}`); } - } catch { - // ignore usage failures } } - const formatStatus = (status: string) => { - if (status === "ok") return colorize(rich, theme.success, "ok"); - if (status === "static") return colorize(rich, theme.muted, "static"); - if (status === "expiring") return colorize(rich, theme.warn, "expiring"); - if (status === "missing") return colorize(rich, theme.warn, "unknown"); - return colorize(rich, theme.error, "expired"); - }; - - const profilesByProvider = new Map(); - for (const profile of oauthProfiles) { - const current = profilesByProvider.get(profile.provider); - if (current) current.push(profile); - else profilesByProvider.set(profile.provider, [profile]); - } - - for (const [provider, profiles] of profilesByProvider) { - const usageKey = resolveUsageProviderId(provider); - const usage = usageKey ? usageByProvider.get(usageKey) : undefined; - const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; - runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); - for (const profile of profiles) { - const labelText = profile.label || profile.profileId; - const label = colorize(rich, theme.accent, labelText); - const status = formatStatus(profile.status); - const expiry = - profile.status === "static" - ? "" - : profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(` - ${label} ${status}${expiry}${source}`); + if (probeSummary) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Auth probes")); + if (probeSummary.results.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + } else { + const grouped = groupProbeResults(sortProbeResults(probeSummary.results)); + const statusColor = (status: string) => { + if (status === "ok") return theme.success; + if (status === "rate_limit") return theme.warn; + if (status === "timeout" || status === "billing") return theme.warn; + if (status === "auth" || status === "format") return theme.error; + if (status === "no_model") return theme.muted; + return theme.muted; + }; + for (const [provider, results] of grouped) { + const modelLabel = results.find((r) => r.model)?.model ?? "-"; + runtime.log( + `- ${theme.heading(provider)}${colorize( + rich, + theme.muted, + modelLabel ? ` (model: ${modelLabel})` : "", + )}`, + ); + for (const result of results) { + const status = colorize(rich, statusColor(result.status), result.status); + const latency = formatProbeLatency(result.latencyMs); + const mode = result.mode ? ` (${result.mode})` : ""; + const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : ""; + runtime.log( + ` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize( + rich, + theme.muted, + latency, + )}${detail}`, + ); + } + } + runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary))); } } diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 4fea3521a..7024a2e52 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -111,7 +112,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -121,7 +122,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 2afe8cd1c..1a10ca94c 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 6c712ca1d..7937c1064 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 9ed0ed677..5e8a2dcfa 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index ab43c4269..05aac6388 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -114,7 +116,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -125,7 +127,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index dfdcf43e3..2c4dfa472 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index 1e1174fbf..2281fb407 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 6e83c61c3..829391727 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index 74f87d63b..164095a9c 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -113,7 +114,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -122,7 +123,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 51beb4f4b..da67c2e38 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,18 +6,20 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +let replyModule: typeof import("../auto-reply/reply.js"); const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import * as replyModule from "../auto-reply/reply.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; -import { resolveTelegramFetch } from "./fetch.js"; function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); @@ -155,7 +157,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ From f07c39b26545c67ca06f76b1cac9e7086bc4b866 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:28:50 +0000 Subject: [PATCH 02/45] docs: handle lint/format churn --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b381ceb2f..d7c76e235 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. +- Lint/format churn: + - If staged+unstaged diffs are formatting-only, auto-resolve without asking. + - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. + - Only ask when changes are semantic (logic/data/behavior). - Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. From c63144ab144dfdb190d9dbd566155d5bf3e2285a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:42:55 +0000 Subject: [PATCH 03/45] fix: hide usage errors in status --- src/infra/provider-usage.format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index f5a1b6995..d10879008 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -39,7 +39,7 @@ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, ): string | null { - if (snapshot.error) return `error: ${snapshot.error}`; + if (snapshot.error) return null; if (snapshot.windows.length === 0) return null; const now = opts?.now ?? Date.now(); const maxWindows = From 75a54f02597f577f22c19bbf7ec851b187ea6612 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:43:18 +0000 Subject: [PATCH 04/45] docs: note models usage suppression --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3612e9686..2f7865602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. +- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) From 6fba598eaf16051ebc1ed5df7e019247252f7a2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:47:45 +0000 Subject: [PATCH 05/45] fix: handle gateway slash command replies in TUI --- CHANGELOG.md | 1 + docs/tui.md | 2 + src/gateway/server-methods/chat.ts | 154 +++++++++++++++++- ...erver.chat.gateway-server-chat.e2e.test.ts | 39 +++++ src/tui/tui-event-handlers.ts | 13 +- src/tui/tui-formatters.test.ts | 9 + src/tui/tui-formatters.ts | 5 + src/tui/tui-session-actions.ts | 7 +- 8 files changed, 227 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7865602..e49b37002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. +- TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. diff --git a/docs/tui.md b/docs/tui.md index e67b22032..4d094dc6b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,6 +88,8 @@ Session lifecycle: - `/settings` - `/exit` +Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands). + ## Local shell commands - Prefix a line with `!` to run a local shell command on the TUI host. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8c71dca75..0e55b45f5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { ensureAgentWorkspace } from "../../agents/workspace.js"; +import { isControlCommandMessage } from "../../auto-reply/command-detection.js"; +import { normalizeCommandBody } from "../../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; +import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js"; +import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js"; +import { defaultGroupActivation } from "../../auto-reply/reply/groups.js"; +import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js"; +import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js"; +import { + normalizeElevatedLevel, + normalizeReasoningLevel, + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../../auto-reply/thinking.js"; +import type { MsgContext } from "../../auto-reply/templating.js"; import { agentCommand } from "../../commands/agent.js"; import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionId, updatedAt: now, }); + store[canonicalKey] = sessionEntry; const clientRunId = p.idempotencyKey; registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); @@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); + if (isControlCommandMessage(parsedMessage, cfg)) { + try { + const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; + const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg }); + const agentCfg = cfg.agents?.defaults; + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const workspace = await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + }); + const ctx: MsgContext = { + Body: parsedMessage, + CommandBody: parsedMessage, + BodyForCommands: parsedMessage, + CommandSource: "text", + CommandAuthorized: true, + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: "tui", + From: p.sessionKey, + To: INTERNAL_MESSAGE_CHANNEL, + SessionKey: p.sessionKey, + ChatType: "direct", + }; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey: p.sessionKey, + isGroup: false, + triggerBodyNormalized: normalizeCommandBody(parsedMessage), + commandAuthorized: true, + }); + const directives = parseInlineDirectives(parsedMessage); + const { provider, model } = resolveSessionModelRef(cfg, sessionEntry); + const contextTokens = resolveContextTokens({ agentCfg, model }); + const resolveDefaultThinkingLevel = async () => { + const configured = agentCfg?.thinkingDefault; + if (configured) return configured; + const catalog = await context.loadGatewayModelCatalog(); + return resolveThinkingDefault({ cfg, provider, model, catalog }); + }; + const resolvedThinkLevel = + normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ?? + (await resolveDefaultThinkingLevel()); + const resolvedVerboseLevel = + normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off"; + const resolvedReasoningLevel = + normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off"; + const resolvedElevatedLevel = normalizeElevatedLevel( + sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault, + ); + const elevated = resolveElevatedPermissions({ + cfg, + agentId, + ctx, + provider: INTERNAL_MESSAGE_CHANNEL, + }); + const commandResult = await handleCommands({ + ctx, + cfg, + command, + agentId, + directives, + elevated, + sessionEntry, + previousSessionEntry: entry, + sessionStore: store, + sessionKey: p.sessionKey, + storePath, + sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global", + workspaceDir: workspace.dir, + defaultGroupActivation: () => defaultGroupActivation(true), + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup: false, + }); + if (!commandResult.shouldContinue) { + const text = commandResult.reply?.text ?? ""; + const message = { + role: "assistant", + content: text.trim() ? [{ type: "text", text }] : [], + timestamp: Date.now(), + command: true, + }; + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "final" as const, + message, + }; + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: true, + payload: { runId: clientRunId, status: "ok" as const }, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } catch (err) { + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "error" as const, + errorMessage: formatForLog(err), + }; + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload: { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }, + error, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const envelopedMessage = formatInboundEnvelope({ channel: "WebChat", diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 75f541f39..d4035037b 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -259,6 +259,45 @@ describe("gateway server chat", () => { } }); + test("routes chat.send slash commands without agent runs", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + try { + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + const eventPromise = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "final" && + o.payload?.runId === "idem-command-1", + 8000, + ); + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "/context list", + idempotencyKey: "idem-command-1", + }); + expect(res.ok).toBe(true); + const evt = await eventPromise; + expect(evt.payload?.message?.command).toBe(true); + expect(spy.mock.calls.length).toBe(callsBefore); + } finally { + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3f8e2befd..148dca67a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,6 +1,6 @@ import type { TUI } from "@mariozechner/pi-tui"; import type { ChatLog } from "./components/chat-log.js"; -import { asString } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; @@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + if (isCommandMessage(evt.message)) { + const text = extractTextFromMessage(evt.message); + if (text) chatLog.addSystem(text); + streamAssembler.drop(evt.runId); + noteFinalizedRun(evt.runId); + state.activeChatRunId = null; + setActivityStatus("idle"); + void refreshSessionInfo?.(); + tui.requestRender(); + return; + } const stopReason = evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) ? typeof (evt.message as Record).stopReason === "string" diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 541c58727..3200b237a 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,6 +4,7 @@ import { extractContentFromMessage, extractTextFromMessage, extractThinkingFromMessage, + isCommandMessage, } from "./tui-formatters.js"; describe("extractTextFromMessage", () => { @@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => { expect(text).toContain("HTTP 429"); }); }); + +describe("isCommandMessage", () => { + it("detects command-marked messages", () => { + expect(isCommandMessage({ command: true })).toBe(true); + expect(isCommandMessage({ command: false })).toBe(false); + expect(isCommandMessage({})).toBe(false); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 11e8e68c9..f77eb9ff1 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -140,6 +140,11 @@ export function extractTextFromMessage( return formatRawAssistantErrorForUi(errorMessage); } +export function isCommandMessage(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + return (message as Record).command === true; +} + export function formatTokens(total?: number | null, context?: number | null) { if (total == null && context == null) return "tokens ?"; const totalLabel = total == null ? "?" : formatTokenCount(total); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 327363653..5dc6696ad 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -6,7 +6,7 @@ import { } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; -import { asString, extractTextFromMessage } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionContext = { @@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) { for (const entry of record.messages ?? []) { if (!entry || typeof entry !== "object") continue; const message = entry as Record; + if (isCommandMessage(message)) { + const text = extractTextFromMessage(message); + if (text) chatLog.addSystem(text); + continue; + } if (message.role === "user") { const text = extractTextFromMessage(message); if (text) chatLog.addUser(text); From 242add587f39f4ad3f8ea4f48e815d7f10917aaa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:51:49 +0000 Subject: [PATCH 06/45] fix: quiet auth probe diagnostics --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/runs.ts | 8 ++++++-- src/logging/diagnostic.ts | 17 ++++++++++------- src/process/command-queue.ts | 9 ++++++--- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49b37002..288458bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. +- CLI: suppress diagnostic session/run noise during auth probes. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 4fcefca12..dcbe56244 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); - diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } } export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { ACTIVE_EMBEDDED_RUNS.delete(sessionId); logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); - diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } notifyEmbeddedRunEnded(sessionId); } else { diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index ba6239184..adcb93eca 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -197,17 +197,20 @@ export function logSessionStateChange( }, ) { const state = getSessionState(params); + const isProbeSession = state.sessionId?.startsWith("probe-") ?? false; const prevState = state.state; state.state = params.state; state.lastActivity = Date.now(); if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1); - diag.info( - `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ - state.sessionKey ?? "unknown" - } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ - state.queueDepth - }`, - ); + if (!isProbeSession) { + diag.info( + `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ + state.sessionKey ?? "unknown" + } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ + state.queueDepth + }`, + ); + } emitDiagnosticEvent({ type: "session.state", sessionId: state.sessionId, diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 9b203c938..2f2857130 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -68,9 +68,12 @@ function drainLane(lane: string) { entry.resolve(result); } catch (err) { state.active -= 1; - diag.error( - `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, - ); + const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); + if (!isProbeLane) { + diag.error( + `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, + ); + } pump(); entry.reject(err); } From 7d0a0ae3ba449dd113476620de512be9be0ebdfc Mon Sep 17 00:00:00 2001 From: Paul van Oorschot <20116814+pvoo@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:01:15 +0100 Subject: [PATCH 07/45] fix(discord): autoThread ack reactions + exec approval null handling (#1511) * fix(discord): gate autoThread by thread owner * fix(discord): ack bot-owned autoThreads * fix(discord): ack mentions in open channels - Ack reactions in bot-owned autoThreads - Ack reactions in open channels (no mention required) - DRY: Pass pre-computed isAutoThreadOwnedByBot to avoid redundant checks - Consolidate ack logic with explanatory comment * fix: allow null values in exec.approval.request schema The ExecApprovalRequestParamsSchema was rejecting null values for optional fields like resolvedPath, but the calling code in bash-tools.exec.ts passes null. This caused intermittent 'invalid exec.approval.request params' validation errors. Fix: Accept Type.Union([Type.String(), Type.Null()]) for all optional string fields in the schema. Update test to reflect new behavior. * fix: align discord ack reactions with mention gating (#1511) (thanks @pvoo) --------- Co-authored-by: Wimmie Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 + src/discord/monitor.test.ts | 51 ++++++++ src/discord/monitor/allow-list.ts | 21 ++- .../monitor/message-handler.preflight.ts | 3 + .../monitor/message-handler.process.test.ts | 123 ++++++++++++++++++ src/discord/monitor/message-utils.ts | 3 + src/discord/monitor/threading.ts | 2 + src/gateway/protocol/schema/exec-approvals.ts | 14 +- .../server-methods/exec-approval.test.ts | 8 +- 9 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 src/discord/monitor/message-handler.process.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 288458bd1..b33b621e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.clawd.bot - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes +- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. +- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index be0c8aa65..bc85e5764 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -377,12 +377,63 @@ describe("discord mention gating", () => { resolveDiscordShouldRequireMention({ isGuildMessage: true, isThread: true, + botId: "bot123", + threadOwnerId: "bot123", channelConfig, guildInfo, }), ).toBe(false); }); + it("requires mention inside user-created threads with autoThread enabled", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + threadOwnerId: "user456", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + + it("requires mention when thread owner is unknown", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + it("inherits parent channel mention rules for threads", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 7d495af66..12c2d1d39 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: { export function resolveDiscordShouldRequireMention(params: { isGuildMessage: boolean; isThread: boolean; + botId?: string | null; + threadOwnerId?: string | null; channelConfig?: DiscordChannelConfigResolved | null; guildInfo?: DiscordGuildEntryResolved | null; + /** Pass pre-computed value to avoid redundant checks. */ + isAutoThreadOwnedByBot?: boolean; }): boolean { if (!params.isGuildMessage) return false; - if (params.isThread && params.channelConfig?.autoThread) return false; + // Only skip mention requirement in threads created by the bot (when autoThread is enabled). + const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params); + if (isBotThread) return false; return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; } +export function isDiscordAutoThreadOwnedByBot(params: { + isThread: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + botId?: string | null; + threadOwnerId?: string | null; +}): boolean { + if (!params.isThread) return false; + if (!params.channelConfig?.autoThread) return false; + const botId = params.botId?.trim(); + const threadOwnerId = params.threadOwnerId?.trim(); + return Boolean(botId && threadOwnerId && botId === threadOwnerId); +} + export function isDiscordGroupAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; guildAllowlisted: boolean; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 6df141e35..607b02cdd 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -328,9 +328,12 @@ export async function preflightDiscordMessage( } satisfies HistoryEntry) : undefined; + const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; const shouldRequireMention = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), + botId, + threadOwnerId, channelConfig, guildInfo, }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts new file mode 100644 index 000000000..351f46f74 --- /dev/null +++ b/src/discord/monitor/message-handler.process.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reactMessageDiscord = vi.fn(async () => {}); +const removeReactionDiscord = vi.fn(async () => {}); + +vi.mock("../send.js", () => ({ + reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), + removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), +})); + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +})); + +vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ + createReplyDispatcherWithTyping: vi.fn(() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), +})); + +import { processDiscordMessage } from "./message-handler.process.js"; + +async function createBaseContext(overrides: Record = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); + const storePath = path.join(dir, "sessions.json"); + return { + cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } }, + discordConfig: {}, + accountId: "default", + token: "token", + runtime: { log: () => {}, error: () => {} }, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1024, + textLimit: 4000, + replyToMode: "off", + ackReactionScope: "group-mentions", + groupPolicy: "open", + data: { guild: { id: "g1", name: "Guild" } }, + client: { rest: {} }, + message: { + id: "m1", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + }, + author: { + id: "U1", + username: "alice", + discriminator: "0", + globalName: "Alice", + }, + channelInfo: { name: "general" }, + channelName: "general", + isGuildMessage: true, + isDirectMessage: false, + isGroupDm: false, + commandAuthorized: true, + baseText: "hi", + messageText: "hi", + wasMentioned: false, + shouldRequireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + shouldBypassMention: false, + threadChannel: null, + threadParentId: undefined, + threadParentName: undefined, + threadParentType: undefined, + threadName: undefined, + displayChannelSlug: "general", + guildInfo: null, + guildSlug: "guild", + channelConfig: null, + baseSessionKey: "agent:main:discord:guild:g1", + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:guild:g1", + mainSessionKey: "agent:main:main", + }, + ...overrides, + }; +} + +beforeEach(() => { + reactMessageDiscord.mockClear(); + removeReactionDiscord.mockClear(); +}); + +describe("processDiscordMessage ack reactions", () => { + it("skips ack reactions for group-mentions when mentions are not required", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: false, + effectiveWasMentioned: false, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).not.toHaveBeenCalled(); + }); + + it("sends ack reactions for mention-gated guild messages when mentioned", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: true, + effectiveWasMentioned: true, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); + }); +}); diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index a681afa16..2647e5113 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -16,6 +16,7 @@ export type DiscordChannelInfo = { name?: string; topic?: string; parentId?: string; + ownerId?: string; }; type DiscordSnapshotAuthor = { @@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo( const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; const payload: DiscordChannelInfo = { type: channel.type, name, topic, parentId, + ownerId, }; DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value: payload, diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index bae4ef1c5..71af6408f 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -14,6 +14,7 @@ export type DiscordThreadChannel = { name?: string | null; parentId?: string | null; parent?: { id?: string; name?: string }; + ownerId?: string | null; }; export type DiscordThreadStarter = { @@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: { name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, + ownerId: channelInfo?.ownerId ?? undefined, }; } diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index d58e74ab2..e6f7ce906 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, - cwd: Type.Optional(Type.String()), - host: Type.Optional(Type.String()), - security: Type.Optional(Type.String()), - ask: Type.Optional(Type.String()), - agentId: Type.Optional(Type.String()), - resolvedPath: Type.Optional(Type.String()), - sessionKey: Type.Optional(Type.String()), + cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), + host: Type.Optional(Type.Union([Type.String(), Type.Null()])), + security: Type.Optional(Type.Union([Type.String(), Type.Null()])), + ask: Type.Optional(Type.Union([Type.String(), Type.Null()])), + agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts index 0b1da93f3..71a63e5a3 100644 --- a/src/gateway/server-methods/exec-approval.test.ts +++ b/src/gateway/server-methods/exec-approval.test.ts @@ -36,16 +36,16 @@ describe("exec approval handlers", () => { expect(validateExecApprovalRequestParams(params)).toBe(true); }); - // This documents the TypeBox/AJV behavior that caused the Discord exec bug: - // Type.Optional(Type.String()) does NOT accept null, only string or undefined. - it("rejects request with resolvedPath as null", () => { + // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()])) + // This matches the calling code in bash-tools.exec.ts which passes null. + it("accepts request with resolvedPath as null", () => { const params = { command: "echo hi", cwd: "/tmp", host: "node", resolvedPath: null, }; - expect(validateExecApprovalRequestParams(params)).toBe(false); + expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); From fdbaae6a33c4aa6571d2b816f643b5f128c2f474 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sat, 24 Jan 2026 09:08:12 +1300 Subject: [PATCH 08/45] macOS: fix trigger word input disappearing when typing and on add (#1506) Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time. Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing. The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`. --- .../Sources/Clawdbot/VoiceWakeSettings.swift | 94 +++++++++++-------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift index 176980cc5..a41e8bb1f 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift @@ -21,6 +21,7 @@ struct VoiceWakeSettings: View { @State private var micObserver = AudioInputDeviceObserver() @State private var micRefreshTask: Task? @State private var availableLocales: [Locale] = [] + @State private var triggerEntries: [TriggerEntry] = [] private let fieldLabelWidth: CGFloat = 140 private let controlWidth: CGFloat = 240 private let isPreview = ProcessInfo.processInfo.isPreview @@ -31,9 +32,9 @@ struct VoiceWakeSettings: View { var id: String { self.uid } } - private struct IndexedWord: Identifiable { - let id: Int - let value: String + private struct TriggerEntry: Identifiable { + let id: UUID + var value: String } private var voiceWakeBinding: Binding { @@ -105,6 +106,7 @@ struct VoiceWakeSettings: View { .onAppear { guard !self.isPreview else { return } self.startMicObserver() + self.loadTriggerEntries() } .onChange(of: self.state.voiceWakeMicID) { _, _ in guard !self.isPreview else { return } @@ -122,8 +124,10 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil Task { await self.meter.stop() } self.micObserver.stop() + self.syncTriggerEntriesToState() } else { self.startMicObserver() + self.loadTriggerEntries() } } .onDisappear { @@ -136,11 +140,16 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil self.micObserver.stop() Task { await self.meter.stop() } + self.syncTriggerEntriesToState() } } - private var indexedWords: [IndexedWord] { - self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } + private func loadTriggerEntries() { + self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } + } + + private func syncTriggerEntriesToState() { + self.state.swabbleTriggerWords = self.triggerEntries.map(\.value) } private var triggerTable: some View { @@ -154,29 +163,42 @@ struct VoiceWakeSettings: View { } label: { Label("Add word", systemImage: "plus") } - .disabled(self.state.swabbleTriggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + .disabled(self.triggerEntries + .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } + Button("Reset defaults") { + self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } + self.syncTriggerEntriesToState() + } } - Table(self.indexedWords) { - TableColumn("Word") { row in - TextField("Wake word", text: self.binding(for: row.id)) - .textFieldStyle(.roundedBorder) - } - TableColumn("") { row in - Button { - self.removeWord(at: row.id) - } label: { - Image(systemName: "trash") + VStack(spacing: 0) { + ForEach(self.$triggerEntries) { $entry in + HStack(spacing: 8) { + TextField("Wake word", text: $entry.value) + .textFieldStyle(.roundedBorder) + .onSubmit { + self.syncTriggerEntriesToState() + } + + Button { + self.removeWord(id: entry.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove trigger word") + .frame(width: 24) + } + .padding(8) + + if entry.id != self.triggerEntries.last?.id { + Divider() } - .buttonStyle(.borderless) - .help("Remove trigger word") } - .width(36) } - .frame(minHeight: 180) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) @@ -211,24 +233,12 @@ struct VoiceWakeSettings: View { } private func addWord() { - self.state.swabbleTriggerWords.append("") + self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) } - private func removeWord(at index: Int) { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords.remove(at: index) - } - - private func binding(for index: Int) -> Binding { - Binding( - get: { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" } - return self.state.swabbleTriggerWords[index] - }, - set: { newValue in - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords[index] = newValue - }) + private func removeWord(id: UUID) { + self.triggerEntries.removeAll { $0.id == id } + self.syncTriggerEntriesToState() } private func toggleTest() { @@ -638,13 +648,14 @@ extension VoiceWakeSettings { state.voicePushToTalkEnabled = true state.swabbleTriggerWords = ["Claude", "Hey"] - let view = VoiceWakeSettings(state: state, isActive: true) + var view = VoiceWakeSettings(state: state, isActive: true) view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableLocales = [Locale(identifier: "en_US")] view.meterLevel = 0.42 view.meterError = "No input" view.testState = .detected("ok") view.isTesting = true + view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")] _ = view.body _ = view.localePicker @@ -654,8 +665,9 @@ extension VoiceWakeSettings { _ = view.chimeSection view.addWord() - _ = view.binding(for: 0).wrappedValue - view.removeWord(at: 0) + if let entryId = view.triggerEntries.first?.id { + view.removeWord(id: entryId) + } } } #endif From 4ee70be69059b261b8333898b33e8b73a8d42278 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:14:56 +0000 Subject: [PATCH 09/45] chore: bump version to 2026.1.23 --- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/Sources/Info.plist | 4 ++-- apps/ios/Tests/Info.plist | 4 ++-- apps/ios/project.yml | 8 ++++---- apps/macos/Sources/Clawdbot/Resources/Info.plist | 4 ++-- docs/platforms/mac/release.md | 14 +++++++------- extensions/bluebubbles/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/discord/package.json | 2 +- extensions/google-antigravity-auth/package.json | 2 +- extensions/google-gemini-cli-auth/package.json | 2 +- extensions/imessage/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/matrix/CHANGELOG.md | 5 +++++ extensions/matrix/package.json | 2 +- extensions/mattermost/package.json | 2 +- extensions/memory-core/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- extensions/msteams/CHANGELOG.md | 5 +++++ extensions/msteams/package.json | 2 +- extensions/nextcloud-talk/package.json | 2 +- extensions/nostr/CHANGELOG.md | 5 +++++ extensions/nostr/package.json | 2 +- extensions/open-prose/package.json | 6 ++++-- extensions/signal/package.json | 2 +- extensions/slack/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/voice-call/CHANGELOG.md | 5 +++++ extensions/voice-call/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/zalo/CHANGELOG.md | 5 +++++ extensions/zalo/package.json | 2 +- extensions/zalouser/CHANGELOG.md | 5 +++++ extensions/zalouser/package.json | 2 +- package.json | 2 +- 36 files changed, 76 insertions(+), 44 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 7a99b672a..a98a29aa0 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.clawdbot.android" minSdk = 31 targetSdk = 36 - versionCode = 202601210 - versionName = "2026.1.21" + versionCode = 202601230 + versionName = "2026.1.23" } buildTypes { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 1b7b5b3d5..02785e4f0 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 20260121 + 20260123 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index e0351f399..cbd68e6a3 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 20260121 + 20260123 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index ea6519001..a9b58617e 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: Clawdbot CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.1.21" - CFBundleVersion: "20260121" + CFBundleShortVersionString: "2026.1.23" + CFBundleVersion: "20260123" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: ClawdbotTests - CFBundleShortVersionString: "2026.1.21" - CFBundleVersion: "20260121" + CFBundleShortVersionString: "2026.1.23" + CFBundleVersion: "20260123" diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index 283901c0f..d5b40867a 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.21 + 2026.1.23 CFBundleVersion - 202601210 + 202601230 CFBundleIconFile Clawdbot CFBundleURLTypes diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 91b129b16..b4c7d5a84 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.21 \ +APP_VERSION=2026.1.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.21 \ +APP_VERSION=2026.1.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`. +- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index d511722a9..4385272be 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/bluebubbles", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot BlueBubbles channel plugin", "clawdbot": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 36b9e1403..02d1cdbdd 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/copilot-proxy", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Copilot Proxy provider plugin", "clawdbot": { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index fd1b655a0..407ce60d1 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/diagnostics-otel", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot diagnostics OpenTelemetry exporter", "clawdbot": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 8f43497a9..0a645718b 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/discord", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Discord channel plugin", "clawdbot": { diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index c7626c272..ff3c485f2 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-antigravity-auth", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Google Antigravity OAuth provider plugin", "clawdbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 9e9515f3e..f4b666ab0 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-gemini-cli-auth", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Gemini CLI OAuth provider plugin", "clawdbot": { diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 94b120b26..a3ac1c642 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/imessage", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot iMessage channel plugin", "clawdbot": { diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 2b4a5b2dd..ea774ecba 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/lobster", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "clawdbot": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 2d25c41c4..9f959843d 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index e5dc66a8c..1ba43d57e 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/matrix", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Matrix channel plugin", "clawdbot": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index e704cedc5..251fe7b0b 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/mattermost", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Mattermost channel plugin", "clawdbot": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 57dfc36ef..48a089aaa 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-core", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot core memory search plugin", "clawdbot": { diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index bdaa21f35..4f0e97377 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/memory-lancedb", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "dependencies": { diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index aa132c382..63f54e309 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 5f7843f8a..80d566e7c 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/msteams", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Microsoft Teams channel plugin", "clawdbot": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c5ee0b8c6..5c6f5e243 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nextcloud-talk", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Nextcloud Talk channel plugin", "clawdbot": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 2005c22b3..610f34e81 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index dc5a9e002..0efec2efa 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/nostr", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "clawdbot": { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 73282f117..3fa6e8b17 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,9 +1,11 @@ { "name": "@clawdbot/open-prose", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "clawdbot": { - "extensions": ["./index.ts"] + "extensions": [ + "./index.ts" + ] } } diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 6c26e7774..89de33544 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/signal", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Signal channel plugin", "clawdbot": { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 93470c49d..f129515f5 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/slack", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Slack channel plugin", "clawdbot": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 2dc95db2e..e4005c739 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/telegram", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Telegram channel plugin", "clawdbot": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 08d0f5006..0edc0dcb8 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index ef0323ee4..88a0326b3 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/voice-call", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot voice-call plugin", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 49f0fb541..3dcc4cf6b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/whatsapp", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot WhatsApp channel plugin", "clawdbot": { diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index a5e56e1da..ab6d394fa 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 0f59602e7..7ced3106a 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalo", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Zalo channel plugin", "clawdbot": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index f5b7a3071..ec3c9e340 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.23 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.22 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 71e6da3cb..9f406c56c 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalouser", - "version": "2026.1.22", + "version": "2026.1.23", "type": "module", "description": "Clawdbot Zalo Personal Account plugin via zca-cli", "dependencies": { diff --git a/package.json b/package.json index ae2aab470..f12d83a74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.22", + "version": "2026.1.23", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", From ae0741a3466ce4dcf15bd88aa51aa4af4446c53c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:23:00 +0000 Subject: [PATCH 10/45] feat(compaction): add staged helpers --- src/agents/compaction.test.ts | 70 +++++++ src/agents/compaction.ts | 341 ++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 src/agents/compaction.test.ts create mode 100644 src/agents/compaction.ts diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts new file mode 100644 index 000000000..d1a790f89 --- /dev/null +++ b/src/agents/compaction.test.ts @@ -0,0 +1,70 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { describe, expect, it } from "vitest"; + +import { + estimateMessagesTokens, + pruneHistoryForContextShare, + splitMessagesByTokenShare, +} from "./compaction.js"; + +function makeMessage(id: number, size: number): AgentMessage { + return { + role: "user", + content: "x".repeat(size), + timestamp: id, + }; +} + +describe("splitMessagesByTokenShare", () => { + it("splits messages into two non-empty parts", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + ]; + + const parts = splitMessagesByTokenShare(messages, 2); + expect(parts.length).toBeGreaterThanOrEqual(2); + expect(parts[0]?.length).toBeGreaterThan(0); + expect(parts[1]?.length).toBeGreaterThan(0); + expect(parts.flat().length).toBe(messages.length); + }); +}); + +describe("pruneHistoryForContextShare", () => { + it("drops older chunks until the history budget is met", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + ]; + const maxContextTokens = 2000; // budget is 1000 tokens (50%) + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + expect(pruned.droppedChunks).toBeGreaterThan(0); + expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5)); + expect(pruned.messages.length).toBeGreaterThan(0); + }); + + it("keeps history when already within budget", () => { + const messages: AgentMessage[] = [makeMessage(1, 1000)]; + const maxContextTokens = 2000; + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + expect(pruned.droppedChunks).toBe(0); + expect(pruned.messages.length).toBe(messages.length); + expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages)); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts new file mode 100644 index 000000000..ae134e827 --- /dev/null +++ b/src/agents/compaction.ts @@ -0,0 +1,341 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent"; + +import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; + +export const BASE_CHUNK_RATIO = 0.4; +export const MIN_CHUNK_RATIO = 0.15; +export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy +const DEFAULT_SUMMARY_FALLBACK = "No prior history."; +const DEFAULT_PARTS = 2; +const MERGE_SUMMARIES_INSTRUCTIONS = + "Merge these partial summaries into a single cohesive summary. Preserve decisions," + + " TODOs, open questions, and any constraints."; + +export function estimateMessagesTokens(messages: AgentMessage[]): number { + return messages.reduce((sum, message) => sum + estimateTokens(message), 0); +} + +function normalizeParts(parts: number, messageCount: number): number { + if (!Number.isFinite(parts) || parts <= 1) return 1; + return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount)); +} + +export function splitMessagesByTokenShare( + messages: AgentMessage[], + parts = DEFAULT_PARTS, +): AgentMessage[][] { + if (messages.length === 0) return []; + const normalizedParts = normalizeParts(parts, messages.length); + if (normalizedParts <= 1) return [messages]; + + const totalTokens = estimateMessagesTokens(messages); + const targetTokens = totalTokens / normalizedParts; + const chunks: AgentMessage[][] = []; + let current: AgentMessage[] = []; + let currentTokens = 0; + + for (const message of messages) { + const messageTokens = estimateTokens(message); + if ( + chunks.length < normalizedParts - 1 && + current.length > 0 && + currentTokens + messageTokens > targetTokens + ) { + chunks.push(current); + current = []; + currentTokens = 0; + } + + current.push(message); + currentTokens += messageTokens; + } + + if (current.length > 0) { + chunks.push(current); + } + + return chunks; +} + +export function chunkMessagesByMaxTokens( + messages: AgentMessage[], + maxTokens: number, +): AgentMessage[][] { + if (messages.length === 0) return []; + + const chunks: AgentMessage[][] = []; + let currentChunk: AgentMessage[] = []; + let currentTokens = 0; + + for (const message of messages) { + const messageTokens = estimateTokens(message); + if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) { + chunks.push(currentChunk); + currentChunk = []; + currentTokens = 0; + } + + currentChunk.push(message); + currentTokens += messageTokens; + + if (messageTokens > maxTokens) { + // Split oversized messages to avoid unbounded chunk growth. + chunks.push(currentChunk); + currentChunk = []; + currentTokens = 0; + } + } + + if (currentChunk.length > 0) { + chunks.push(currentChunk); + } + + return chunks; +} + +/** + * Compute adaptive chunk ratio based on average message size. + * When messages are large, we use smaller chunks to avoid exceeding model limits. + */ +export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number { + if (messages.length === 0) return BASE_CHUNK_RATIO; + + const totalTokens = estimateMessagesTokens(messages); + const avgTokens = totalTokens / messages.length; + + // Apply safety margin to account for estimation inaccuracy + const safeAvgTokens = avgTokens * SAFETY_MARGIN; + const avgRatio = safeAvgTokens / contextWindow; + + // If average message is > 10% of context, reduce chunk ratio + if (avgRatio > 0.1) { + const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO); + return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction); + } + + return BASE_CHUNK_RATIO; +} + +/** + * Check if a single message is too large to summarize. + * If single message > 50% of context, it can't be summarized safely. + */ +export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean { + const tokens = estimateTokens(msg) * SAFETY_MARGIN; + return tokens > contextWindow * 0.5; +} + +async function summarizeChunks(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + customInstructions?: string; + previousSummary?: string; +}): Promise { + if (params.messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens); + let summary = params.previousSummary; + + for (const chunk of chunks) { + summary = await generateSummary( + chunk, + params.model, + params.reserveTokens, + params.apiKey, + params.signal, + params.customInstructions, + summary, + ); + } + + return summary ?? DEFAULT_SUMMARY_FALLBACK; +} + +/** + * Summarize with progressive fallback for handling oversized messages. + * If full summarization fails, tries partial summarization excluding oversized messages. + */ +export async function summarizeWithFallback(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + previousSummary?: string; +}): Promise { + const { messages, contextWindow } = params; + + if (messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + // Try full summarization first + try { + return await summarizeChunks(params); + } catch (fullError) { + console.warn( + `Full summarization failed, trying partial: ${ + fullError instanceof Error ? fullError.message : String(fullError) + }`, + ); + } + + // Fallback 1: Summarize only small messages, note oversized ones + const smallMessages: AgentMessage[] = []; + const oversizedNotes: string[] = []; + + for (const msg of messages) { + if (isOversizedForSummary(msg, contextWindow)) { + const role = (msg as { role?: string }).role ?? "message"; + const tokens = estimateTokens(msg); + oversizedNotes.push( + `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`, + ); + } else { + smallMessages.push(msg); + } + } + + if (smallMessages.length > 0) { + try { + const partialSummary = await summarizeChunks({ + ...params, + messages: smallMessages, + }); + const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; + return partialSummary + notes; + } catch (partialError) { + console.warn( + `Partial summarization also failed: ${ + partialError instanceof Error ? partialError.message : String(partialError) + }`, + ); + } + } + + // Final fallback: Just note what was there + return ( + `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` + + `Summary unavailable due to size limits.` + ); +} + +export async function summarizeInStages(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + previousSummary?: string; + parts?: number; + minMessagesForSplit?: number; +}): Promise { + const { messages } = params; + if (messages.length === 0) { + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; + } + + const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4); + const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length); + const totalTokens = estimateMessagesTokens(messages); + + if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) { + return summarizeWithFallback(params); + } + + const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0); + if (splits.length <= 1) { + return summarizeWithFallback(params); + } + + const partialSummaries: string[] = []; + for (const chunk of splits) { + partialSummaries.push( + await summarizeWithFallback({ + ...params, + messages: chunk, + previousSummary: undefined, + }), + ); + } + + if (partialSummaries.length === 1) { + return partialSummaries[0]; + } + + const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({ + role: "assistant", + content: [{ type: "text", text: summary }], + timestamp: Date.now(), + })); + + const mergeInstructions = params.customInstructions + ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}` + : MERGE_SUMMARIES_INSTRUCTIONS; + + return summarizeWithFallback({ + ...params, + messages: summaryMessages, + customInstructions: mergeInstructions, + }); +} + +export function pruneHistoryForContextShare(params: { + messages: AgentMessage[]; + maxContextTokens: number; + maxHistoryShare?: number; + parts?: number; +}): { + messages: AgentMessage[]; + droppedChunks: number; + droppedMessages: number; + droppedTokens: number; + keptTokens: number; + budgetTokens: number; +} { + const maxHistoryShare = params.maxHistoryShare ?? 0.5; + const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare)); + let keptMessages = params.messages; + let droppedChunks = 0; + let droppedMessages = 0; + let droppedTokens = 0; + + const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length); + + while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) { + const chunks = splitMessagesByTokenShare(keptMessages, parts); + if (chunks.length <= 1) break; + const [dropped, ...rest] = chunks; + droppedChunks += 1; + droppedMessages += dropped.length; + droppedTokens += estimateMessagesTokens(dropped); + keptMessages = rest.flat(); + } + + return { + messages: keptMessages, + droppedChunks, + droppedMessages, + droppedTokens, + keptTokens: estimateMessagesTokens(keptMessages), + budgetTokens, + }; +} + +export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number { + return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS)); +} From 022aa100638ebc37752dd2ce1fc173482325524c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:23:05 +0000 Subject: [PATCH 11/45] feat(compaction): apply staged pruning --- .../pi-extensions/compaction-safeguard.ts | 237 ++++-------------- 1 file changed, 51 insertions(+), 186 deletions(-) diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a6a66637a..7f82a2757 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -1,12 +1,16 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent"; -import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent"; - -import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; - -const BASE_CHUNK_RATIO = 0.4; -const MIN_CHUNK_RATIO = 0.15; -const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy +import { + BASE_CHUNK_RATIO, + MIN_CHUNK_RATIO, + SAFETY_MARGIN, + computeAdaptiveChunkRatio, + estimateMessagesTokens, + isOversizedForSummary, + pruneHistoryForContextShare, + resolveContextWindowTokens, + summarizeInStages, +} from "../compaction.js"; const FALLBACK_SUMMARY = "Summary unavailable due to context limits. Older messages were truncated."; const TURN_PREFIX_INSTRUCTIONS = @@ -129,175 +133,6 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str return `\n\n${sections.join("\n\n")}`; } -function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] { - if (messages.length === 0) return []; - - const chunks: AgentMessage[][] = []; - let currentChunk: AgentMessage[] = []; - let currentTokens = 0; - - for (const message of messages) { - const messageTokens = estimateTokens(message); - if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) { - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - - currentChunk.push(message); - currentTokens += messageTokens; - - if (messageTokens > maxTokens) { - // Split oversized messages to avoid unbounded chunk growth. - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - } - - if (currentChunk.length > 0) { - chunks.push(currentChunk); - } - - return chunks; -} - -/** - * Compute adaptive chunk ratio based on average message size. - * When messages are large, we use smaller chunks to avoid exceeding model limits. - */ -function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number { - if (messages.length === 0) return BASE_CHUNK_RATIO; - - const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0); - const avgTokens = totalTokens / messages.length; - - // Apply safety margin to account for estimation inaccuracy - const safeAvgTokens = avgTokens * SAFETY_MARGIN; - const avgRatio = safeAvgTokens / contextWindow; - - // If average message is > 10% of context, reduce chunk ratio - if (avgRatio > 0.1) { - const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO); - return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction); - } - - return BASE_CHUNK_RATIO; -} - -/** - * Check if a single message is too large to summarize. - * If single message > 50% of context, it can't be summarized safely. - */ -function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean { - const tokens = estimateTokens(msg) * SAFETY_MARGIN; - return tokens > contextWindow * 0.5; -} - -async function summarizeChunks(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - customInstructions?: string; - previousSummary?: string; -}): Promise { - if (params.messages.length === 0) { - return params.previousSummary ?? "No prior history."; - } - - const chunks = chunkMessages(params.messages, params.maxChunkTokens); - let summary = params.previousSummary; - - for (const chunk of chunks) { - summary = await generateSummary( - chunk, - params.model, - params.reserveTokens, - params.apiKey, - params.signal, - params.customInstructions, - summary, - ); - } - - return summary ?? "No prior history."; -} - -/** - * Summarize with progressive fallback for handling oversized messages. - * If full summarization fails, tries partial summarization excluding oversized messages. - */ -async function summarizeWithFallback(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - previousSummary?: string; -}): Promise { - const { messages, contextWindow } = params; - - if (messages.length === 0) { - return params.previousSummary ?? "No prior history."; - } - - // Try full summarization first - try { - return await summarizeChunks(params); - } catch (fullError) { - console.warn( - `Full summarization failed, trying partial: ${ - fullError instanceof Error ? fullError.message : String(fullError) - }`, - ); - } - - // Fallback 1: Summarize only small messages, note oversized ones - const smallMessages: AgentMessage[] = []; - const oversizedNotes: string[] = []; - - for (const msg of messages) { - if (isOversizedForSummary(msg, contextWindow)) { - const role = (msg as { role?: string }).role ?? "message"; - const tokens = estimateTokens(msg); - oversizedNotes.push( - `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`, - ); - } else { - smallMessages.push(msg); - } - } - - if (smallMessages.length > 0) { - try { - const partialSummary = await summarizeChunks({ - ...params, - messages: smallMessages, - }); - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - return partialSummary + notes; - } catch (partialError) { - console.warn( - `Partial summarization also failed: ${ - partialError instanceof Error ? partialError.message : String(partialError) - }`, - ); - } - } - - // Final fallback: Just note what was there - return ( - `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` + - `Summary unavailable due to size limits.` - ); -} - export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions, signal } = event; @@ -335,19 +170,48 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } try { - const contextWindowTokens = Math.max( - 1, - Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS), - ); + const contextWindowTokens = resolveContextWindowTokens(model); + const turnPrefixMessages = preparation.turnPrefixMessages ?? []; + let messagesToSummarize = preparation.messagesToSummarize; + + const tokensBefore = + typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore) + ? preparation.tokensBefore + : undefined; + if (tokensBefore !== undefined) { + const summarizableTokens = + estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages); + const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens)); + const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5); + + if (newContentTokens > maxHistoryTokens) { + const pruned = pruneHistoryForContextShare({ + messages: messagesToSummarize, + maxContextTokens: contextWindowTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + if (pruned.droppedChunks > 0) { + const newContentRatio = (newContentTokens / contextWindowTokens) * 100; + console.warn( + `Compaction safeguard: new content uses ${newContentRatio.toFixed( + 1, + )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` + + `(${pruned.droppedMessages} messages) to fit history budget.`, + ); + messagesToSummarize = pruned.messages; + } + } + } // Use adaptive chunk ratio based on message sizes - const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]; + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio)); const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); - const historySummary = await summarizeWithFallback({ - messages: preparation.messagesToSummarize, + const historySummary = await summarizeInStages({ + messages: messagesToSummarize, model, apiKey, signal, @@ -359,9 +223,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); let summary = historySummary; - if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) { - const prefixSummary = await summarizeWithFallback({ - messages: preparation.turnPrefixMessages, + if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { + const prefixSummary = await summarizeInStages({ + messages: turnPrefixMessages, model, apiKey, signal, @@ -369,6 +233,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { maxChunkTokens, contextWindow: contextWindowTokens, customInstructions: TURN_PREFIX_INSTRUCTIONS, + previousSummary: undefined, }); summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`; } From 99d4820b3984cfce3e0fb61a267fa0fc787f50ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:23:09 +0000 Subject: [PATCH 12/45] docs: clarify exe.dev ops --- AGENTS.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7c76e235..f0b3ab183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,13 +23,14 @@ - Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”. ## exe.dev VM ops (general) -- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal). -- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`). -- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset. -- Restart: exe.dev often lacks systemd user bus; stop old gateway and run: +- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set). +- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops. +- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`). +- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set. +- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix). +- Restart: stop old gateway and run: `pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &` -- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`. -- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH. +- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`. ## Build, Test, and Development Commands - Runtime baseline: Node **22+** (keep Node + Bun paths working). From 02bd6e4a249d1abfe169db3d1bc8e2486c613b14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:17:14 +0000 Subject: [PATCH 13/45] refactor: centralize ack reaction gating --- extensions/bluebubbles/src/monitor.test.ts | 4 + extensions/bluebubbles/src/monitor.ts | 27 ++-- .../matrix/src/matrix/monitor/handler.ts | 27 ++-- src/channels/ack-reactions.test.ts | 134 ++++++++++++++++++ src/channels/ack-reactions.ts | 27 ++++ .../monitor/message-handler.process.ts | 30 ++-- src/plugin-sdk/index.ts | 2 + src/plugins/runtime/index.ts | 4 + src/plugins/runtime/types.ts | 4 + src/slack/monitor/message-handler/prepare.ts | 31 ++-- src/telegram/bot-message-context.ts | 28 ++-- 11 files changed, 253 insertions(+), 65 deletions(-) create mode 100644 src/channels/ack-reactions.test.ts create mode 100644 src/channels/ack-reactions.ts diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index fa40e82a7..1901af351 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IncomingMessage, ServerResponse } from "node:http"; import { EventEmitter } from "node:events"; +import { shouldAckReaction } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import { handleBlueBubblesWebhookRequest, @@ -135,6 +136,9 @@ function createMockRuntime(): PluginRuntime { buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], }, + reactions: { + shouldAckReaction, + }, groups: { resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 81a921ca9..325599c97 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1521,19 +1521,20 @@ async function processMessage( core, runtime, }); - const shouldAckReaction = () => { - if (!ackReactionValue) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return !isGroup; - if (ackReactionScope === "group-all") return isGroup; - if (ackReactionScope === "group-mentions") { - if (!isGroup) return false; - if (!requireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReactionValue && + core.channel.reactions.shouldAckReaction({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention, + }), + ); const ackMessageId = message.messageId?.trim() || ""; const ackReactionPromise = shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 49deabbf8..10db2be20 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -410,6 +410,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam !hasExplicitMention && commandAuthorized && core.channel.text.hasControlCommand(bodyText); + const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info({ roomId, reason: "no-mention" }, "skipping room message"); return; @@ -515,18 +516,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackScope === "all") return true; - if (ackScope === "direct") return isDirectMessage; - if (ackScope === "group-all") return isRoom; - if (ackScope === "group-mentions") { - if (!isRoom) return false; - if (!shouldRequireMention) return false; - return wasMentioned || shouldBypassMention; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + core.channel.reactions.shouldAckReaction({ + scope: ackScope, + isDirect: isDirectMessage, + isGroup: isRoom, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned: wasMentioned || shouldBypassMention, + shouldBypassMention, + }), + ); if (shouldAckReaction() && messageId) { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts new file mode 100644 index 000000000..14333e965 --- /dev/null +++ b/src/channels/ack-reactions.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { shouldAckReaction } from "./ack-reactions.js"; + +describe("shouldAckReaction", () => { + it("honors direct and group-all scopes", () => { + expect( + shouldAckReaction({ + scope: "direct", + isDirect: true, + isGroup: false, + isMentionableGroup: false, + requireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + }), + ).toBe(true); + + expect( + shouldAckReaction({ + scope: "group-all", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + }), + ).toBe(true); + }); + + it("skips when scope is off or none", () => { + expect( + shouldAckReaction({ + scope: "off", + isDirect: true, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "none", + isDirect: true, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + }); + + it("defaults to group-mentions gating", () => { + expect( + shouldAckReaction({ + scope: undefined, + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(true); + }); + + it("requires mention gating for group-mentions", () => { + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: false, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: false, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: false, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(false); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + }), + ).toBe(true); + + expect( + shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: false, + shouldBypassMention: true, + }), + ).toBe(true); + }); +}); diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts new file mode 100644 index 000000000..dfe2f1879 --- /dev/null +++ b/src/channels/ack-reactions.ts @@ -0,0 +1,27 @@ +export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; + +export type AckReactionGateParams = { + scope: AckReactionScope | undefined; + isDirect: boolean; + isGroup: boolean; + isMentionableGroup: boolean; + requireMention: boolean; + canDetectMention: boolean; + effectiveWasMentioned: boolean; + shouldBypassMention?: boolean; +}; + +export function shouldAckReaction(params: AckReactionGateParams): boolean { + const scope = params.scope ?? "group-mentions"; + if (scope === "off" || scope === "none") return false; + if (scope === "all") return true; + if (scope === "direct") return params.isDirect; + if (scope === "group-all") return params.isGroup; + if (scope === "group-mentions") { + if (!params.isMentionableGroup) return false; + if (!params.requireMention) return false; + if (!params.canDetectMention) return false; + return params.effectiveWasMentioned || params.shouldBypassMention === true; + } + return false; +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index ad1e4baea..3a6b650cd 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -8,6 +8,7 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, @@ -73,6 +74,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) shouldRequireMention, canDetectMention, effectiveWasMentioned, + shouldBypassMention, threadChannel, threadParentId, threadParentName, @@ -95,20 +97,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } const ackReaction = resolveAckReaction(cfg, route.agentId); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return isDirectMessage; - const isGroupChat = isGuildMessage || isGroupDm; - if (ackReactionScope === "group-all") return isGroupChat; - if (ackReactionScope === "group-mentions") { - if (!isGuildMessage) return false; - if (!shouldRequireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ackReactionScope, + isDirect: isDirectMessage, + isGroup: isGuildMessage || isGroupDm, + isMentionableGroup: isGuildMessage, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention, + }), + ); const ackReactionPromise = shouldAckReaction() ? reactMessageDiscord(message.channelId, message.id, ackReaction, { rest: client.rest, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 72bb72422..7de6df564 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -117,6 +117,8 @@ export { resolveMentionGating, resolveMentionGatingWithBypass, } from "../channels/mention-gating.js"; +export type { AckReactionGateParams, AckReactionScope } from "../channels/ack-reactions.js"; +export { shouldAckReaction } from "../channels/ack-reactions.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 6bb10984b..e0ade62ca 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -25,6 +25,7 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; +import { shouldAckReaction } from "../../channels/ack-reactions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; @@ -198,6 +199,9 @@ export function createPluginRuntime(): PluginRuntime { buildMentionRegexes, matchesMentionPatterns, }, + reactions: { + shouldAckReaction, + }, groups: { resolveGroupPolicy: resolveChannelGroupPolicy, resolveRequireMention: resolveChannelGroupRequireMention, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 1f321d04b..86c7caa55 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -19,6 +19,7 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer; type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes; type MatchesMentionPatterns = typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; +type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction; type ResolveChannelGroupPolicy = typeof import("../../config/group-policy.js").resolveChannelGroupPolicy; type ResolveChannelGroupRequireMention = @@ -211,6 +212,9 @@ export type PluginRuntime = { buildMentionRegexes: BuildMentionRegexes; matchesMentionPatterns: MatchesMentionPatterns; }; + reactions: { + shouldAckReaction: ShouldAckReaction; + }; groups: { resolveGroupPolicy: ResolveChannelGroupPolicy; resolveRequireMention: ResolveChannelGroupRequireMention; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 767ecea89..7bea8a170 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -19,6 +19,10 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import { + shouldAckReaction as shouldAckReactionGate, + type AckReactionScope, +} from "../../../channels/ack-reactions.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; @@ -324,19 +328,20 @@ export async function prepareSlackMessage(params: { const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReactionValue = ackReaction ?? ""; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ctx.ackReactionScope === "all") return true; - if (ctx.ackReactionScope === "direct") return isDirectMessage; - if (ctx.ackReactionScope === "group-all") return isRoomish; - if (ctx.ackReactionScope === "group-mentions") { - if (!isRoom) return false; - if (!shouldRequireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ctx.ackReactionScope as AckReactionScope | undefined, + isDirect: isDirectMessage, + isGroup: isRoomish, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention: mentionGate.shouldBypassMention, + }), + ); const ackReactionMessageTs = message.ts; const ackReactionPromise = diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index dc8039a18..5b67e7eb1 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -24,6 +24,7 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; import { @@ -369,19 +370,20 @@ export const buildTelegramMessageContext = async ({ // ACK reactions const ackReaction = resolveAckReaction(cfg, route.agentId); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return !isGroup; - if (ackReactionScope === "group-all") return isGroup; - if (ackReactionScope === "group-mentions") { - if (!isGroup) return false; - if (!requireMention) return false; - if (!canDetectMention) return false; - return effectiveWasMentioned; - } - return false; - }; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention: mentionGate.shouldBypassMention, + }), + ); const api = bot.api as unknown as { setMessageReaction?: ( chatId: number | string, From 892197c43e0e918c09dc26d5a29f031df6458c3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:20:28 +0000 Subject: [PATCH 14/45] refactor: reuse ack reaction helper for whatsapp --- src/channels/ack-reactions.test.ts | 92 +++++++++++++++++++++- src/channels/ack-reactions.ts | 28 +++++++ src/plugin-sdk/index.ts | 8 +- src/web/auto-reply/monitor/ack-reaction.ts | 36 ++++----- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts index 14333e965..8be3fc323 100644 --- a/src/channels/ack-reactions.test.ts +++ b/src/channels/ack-reactions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { shouldAckReaction } from "./ack-reactions.js"; +import { shouldAckReaction, shouldAckReactionForWhatsApp } from "./ack-reactions.js"; describe("shouldAckReaction", () => { it("honors direct and group-all scopes", () => { @@ -132,3 +132,93 @@ describe("shouldAckReaction", () => { ).toBe(true); }); }); + +describe("shouldAckReactionForWhatsApp", () => { + it("respects direct and group modes", () => { + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: true, + isGroup: false, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: true, + isGroup: false, + directEnabled: false, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(false); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "always", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "never", + wasMentioned: true, + groupActivated: true, + }), + ).toBe(false); + }); + + it("honors mentions or activation for group-mentions", () => { + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: true, + groupActivated: false, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: true, + }), + ).toBe(true); + + expect( + shouldAckReactionForWhatsApp({ + emoji: "👀", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions", + wasMentioned: false, + groupActivated: false, + }), + ).toBe(false); + }); +}); diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts index dfe2f1879..beeb34a47 100644 --- a/src/channels/ack-reactions.ts +++ b/src/channels/ack-reactions.ts @@ -1,5 +1,7 @@ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; +export type WhatsAppAckReactionMode = "always" | "mentions" | "never"; + export type AckReactionGateParams = { scope: AckReactionScope | undefined; isDirect: boolean; @@ -25,3 +27,29 @@ export function shouldAckReaction(params: AckReactionGateParams): boolean { } return false; } + +export function shouldAckReactionForWhatsApp(params: { + emoji: string; + isDirect: boolean; + isGroup: boolean; + directEnabled: boolean; + groupMode: WhatsAppAckReactionMode; + wasMentioned: boolean; + groupActivated: boolean; +}): boolean { + if (!params.emoji) return false; + if (params.isDirect) return params.directEnabled; + if (!params.isGroup) return false; + if (params.groupMode === "never") return false; + if (params.groupMode === "always") return true; + return shouldAckReaction({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + requireMention: true, + canDetectMention: true, + effectiveWasMentioned: params.wasMentioned, + shouldBypassMention: params.groupActivated, + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7de6df564..652657b92 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -117,8 +117,12 @@ export { resolveMentionGating, resolveMentionGatingWithBypass, } from "../channels/mention-gating.js"; -export type { AckReactionGateParams, AckReactionScope } from "../channels/ack-reactions.js"; -export { shouldAckReaction } from "../channels/ack-reactions.js"; +export type { + AckReactionGateParams, + AckReactionScope, + WhatsAppAckReactionMode, +} from "../channels/ack-reactions.js"; +export { shouldAckReaction, shouldAckReactionForWhatsApp } from "../channels/ack-reactions.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/web/auto-reply/monitor/ack-reaction.ts b/src/web/auto-reply/monitor/ack-reaction.ts index 58a2504cd..6a99da312 100644 --- a/src/web/auto-reply/monitor/ack-reaction.ts +++ b/src/web/auto-reply/monitor/ack-reaction.ts @@ -1,5 +1,6 @@ import type { loadConfig } from "../../../config/config.js"; import { logVerbose } from "../../../globals.js"; +import { shouldAckReactionForWhatsApp } from "../../../channels/ack-reactions.js"; import { sendReactionWhatsApp } from "../../outbound.js"; import { formatError } from "../../session.js"; import type { WebInboundMsg } from "../types.js"; @@ -24,30 +25,25 @@ export function maybeSendAckReaction(params: { const groupMode = ackConfig?.group ?? "mentions"; const conversationIdForCheck = params.msg.conversationId ?? params.msg.from; - const shouldSendReaction = () => { - if (!emoji) return false; - - if (params.msg.chatType === "direct") { - return directEnabled; - } - - if (params.msg.chatType === "group") { - if (groupMode === "never") return false; - if (groupMode === "always") return true; - if (groupMode === "mentions") { - const activation = resolveGroupActivationFor({ + const activation = + params.msg.chatType === "group" + ? resolveGroupActivationFor({ cfg: params.cfg, agentId: params.agentId, sessionKey: params.sessionKey, conversationId: conversationIdForCheck, - }); - if (activation === "always") return true; - return params.msg.wasMentioned === true; - } - } - - return false; - }; + }) + : null; + const shouldSendReaction = () => + shouldAckReactionForWhatsApp({ + emoji, + isDirect: params.msg.chatType === "direct", + isGroup: params.msg.chatType === "group", + directEnabled, + groupMode, + wasMentioned: params.msg.wasMentioned === true, + groupActivated: activation === "always", + }); if (!shouldSendReaction()) return; From da26954dd0366d3d3e33fe7aa0ed976d9ef2b275 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:25:03 +0000 Subject: [PATCH 15/45] test(compaction): cover staged pruning --- src/agents/compaction.test.ts | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index d1a790f89..1cfacda9a 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -30,6 +30,20 @@ describe("splitMessagesByTokenShare", () => { expect(parts[1]?.length).toBeGreaterThan(0); expect(parts.flat().length).toBe(messages.length); }); + + it("preserves message order across parts", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + makeMessage(5, 4000), + makeMessage(6, 4000), + ]; + + const parts = splitMessagesByTokenShare(messages, 3); + expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp)); + }); }); describe("pruneHistoryForContextShare", () => { @@ -53,6 +67,29 @@ describe("pruneHistoryForContextShare", () => { expect(pruned.messages.length).toBeGreaterThan(0); }); + it("keeps the newest messages when pruning", () => { + const messages: AgentMessage[] = [ + makeMessage(1, 4000), + makeMessage(2, 4000), + makeMessage(3, 4000), + makeMessage(4, 4000), + makeMessage(5, 4000), + makeMessage(6, 4000), + ]; + const totalTokens = estimateMessagesTokens(messages); + const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25% + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + + const keptIds = pruned.messages.map((msg) => msg.timestamp); + const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp); + expect(keptIds).toEqual(expectedSuffix); + }); + it("keeps history when already within budget", () => { const messages: AgentMessage[] = [makeMessage(1, 1000)]; const maxContextTokens = 2000; From 2e0a835e07508837bd2d1a6639399fbea4b6d9bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:51:37 +0000 Subject: [PATCH 16/45] fix: unify inbound dispatch pipeline --- CHANGELOG.md | 1 + .../reply/agent-runner-execution.ts | 6 +- src/auto-reply/reply/provider-dispatcher.ts | 48 +- src/auto-reply/types.ts | 9 + src/discord/monitor.slash.test.ts | 34 +- ...ild-messages-mentionpatterns-match.test.ts | 14 +- ...ends-status-replies-responseprefix.test.ts | 14 +- .../message-handler.inbound-contract.test.ts | 19 +- .../monitor/message-handler.process.ts | 4 +- src/gateway/server-methods/chat.ts | 460 ++++++++++-------- ...ver.chat.gateway-server-chat-b.e2e.test.ts | 33 +- ...erver.chat.gateway-server-chat.e2e.test.ts | 16 +- src/gateway/test-helpers.mocks.ts | 5 + src/imessage/monitor/monitor-provider.ts | 4 +- ...onitor.event-handler.sender-prefix.test.ts | 8 +- ...event-handler.typing-read-receipts.test.ts | 17 +- .../event-handler.inbound-contract.test.ts | 19 +- src/signal/monitor/event-handler.ts | 4 +- src/slack/monitor/message-handler/dispatch.ts | 4 +- ...patterns-match-without-botusername.test.ts | 12 + ...topic-skill-filters-system-prompts.test.ts | 12 + ...-all-group-messages-grouppolicy-is.test.ts | 12 + ...e-callback-query-updates-by-update.test.ts | 12 + ...gram-bot.installs-grammy-throttler.test.ts | 11 + ...lowfrom-entries-case-insensitively.test.ts | 12 + ...-case-insensitively-grouppolicy-is.test.ts | 12 + ...-dms-by-telegram-accountid-binding.test.ts | 12 + ...ies-without-native-reply-threading.test.ts | 14 + src/telegram/bot.test.ts | 12 + 29 files changed, 543 insertions(+), 297 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33b621e8..a09fcd603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes +- Gateway/WebChat: route inbound messages through the unified dispatch pipeline so /new works consistently across WebChat/TUI and channels. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 0e7dfa233..532bac00a 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -82,7 +82,8 @@ export async function runAgentTurnWithFallback(params: { // Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates. const directlySentBlockKeys = new Set(); - const runId = crypto.randomUUID(); + const runId = params.opts?.runId ?? crypto.randomUUID(); + params.opts?.onAgentRunStart?.(runId); if (params.sessionKey) { registerAgentRunContext(runId, { sessionKey: params.sessionKey, @@ -174,6 +175,7 @@ export async function runAgentTurnWithFallback(params: { extraSystemPrompt: params.followupRun.run.extraSystemPrompt, ownerNumbers: params.followupRun.run.ownerNumbers, cliSessionId, + images: params.opts?.images, }) .then((result) => { emitAgentEvent({ @@ -248,6 +250,8 @@ export async function runAgentTurnWithFallback(params: { bashElevated: params.followupRun.run.bashElevated, timeoutMs: params.followupRun.run.timeoutMs, runId, + images: params.opts?.images, + abortSignal: params.opts?.abortSignal, blockReplyBreak: params.resolvedBlockStreamingBreak, blockReplyChunking: params.blockReplyChunking, onPartialReply: allowPartialStream diff --git a/src/auto-reply/reply/provider-dispatcher.ts b/src/auto-reply/reply/provider-dispatcher.ts index 68e2431d1..e4766156e 100644 --- a/src/auto-reply/reply/provider-dispatcher.ts +++ b/src/auto-reply/reply/provider-dispatcher.ts @@ -1,58 +1,44 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import type { FinalizedMsgContext } from "../templating.js"; +import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; -import type { DispatchFromConfigResult } from "./dispatch-from-config.js"; -import { dispatchReplyFromConfig } from "./dispatch-from-config.js"; +import type { DispatchInboundResult } from "../dispatch.js"; import { - createReplyDispatcher, - createReplyDispatcherWithTyping, - type ReplyDispatcherOptions, - type ReplyDispatcherWithTypingOptions, + dispatchInboundMessageWithBufferedDispatcher, + dispatchInboundMessageWithDispatcher, +} from "../dispatch.js"; +import type { + ReplyDispatcherOptions, + ReplyDispatcherWithTypingOptions, } from "./reply-dispatcher.js"; export async function dispatchReplyWithBufferedBlockDispatcher(params: { - ctx: FinalizedMsgContext; + ctx: MsgContext | FinalizedMsgContext; cfg: ClawdbotConfig; dispatcherOptions: ReplyDispatcherWithTypingOptions; replyOptions?: Omit; replyResolver?: typeof import("../reply.js").getReplyFromConfig; -}): Promise { - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping( - params.dispatcherOptions, - ); - - const result = await dispatchReplyFromConfig({ +}): Promise { + return await dispatchInboundMessageWithBufferedDispatcher({ ctx: params.ctx, cfg: params.cfg, - dispatcher, + dispatcherOptions: params.dispatcherOptions, replyResolver: params.replyResolver, - replyOptions: { - ...params.replyOptions, - ...replyOptions, - }, + replyOptions: params.replyOptions, }); - - markDispatchIdle(); - return result; } export async function dispatchReplyWithDispatcher(params: { - ctx: FinalizedMsgContext; + ctx: MsgContext | FinalizedMsgContext; cfg: ClawdbotConfig; dispatcherOptions: ReplyDispatcherOptions; replyOptions?: Omit; replyResolver?: typeof import("../reply.js").getReplyFromConfig; -}): Promise { - const dispatcher = createReplyDispatcher(params.dispatcherOptions); - - const result = await dispatchReplyFromConfig({ +}): Promise { + return await dispatchInboundMessageWithDispatcher({ ctx: params.ctx, cfg: params.cfg, - dispatcher, + dispatcherOptions: params.dispatcherOptions, replyResolver: params.replyResolver, replyOptions: params.replyOptions, }); - - await dispatcher.waitForIdle(); - return result; } diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index e1bf611db..250c14091 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,3 +1,4 @@ +import type { ImageContent } from "@mariozechner/pi-ai"; import type { TypingController } from "./reply/typing.js"; export type BlockReplyContext = { @@ -13,6 +14,14 @@ export type ModelSelectedContext = { }; export type GetReplyOptions = { + /** Override run id for agent events (defaults to random UUID). */ + runId?: string; + /** Abort signal for the underlying agent run. */ + abortSignal?: AbortSignal; + /** Optional inbound images (used for webchat attachments). */ + images?: ImageContent[]; + /** Notifies when an agent run actually starts (useful for webchat command handling). */ + onAgentRunStart?: (runId: string) => void; onReplyStart?: () => Promise | void; onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index 018f93ed0..af098eb96 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; const dispatchMock = vi.fn(); @@ -20,15 +21,34 @@ vi.mock("@buape/carbon", () => ({ }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); beforeEach(() => { - dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { - dispatcher.sendToolResult({ text: "tool update" }); - dispatcher.sendFinalReply({ text: "final reply" }); - return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } }; + dispatchMock.mockReset().mockImplementation(async (params) => { + if ("dispatcher" in params && params.dispatcher) { + params.dispatcher.sendToolResult({ text: "tool update" }); + params.dispatcher.sendFinalReply({ text: "final reply" }); + return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } }; + } + if ("dispatcherOptions" in params && params.dispatcherOptions) { + const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping( + params.dispatcherOptions, + ); + dispatcher.sendToolResult({ text: "tool update" }); + dispatcher.sendFinalReply({ text: "final reply" }); + await dispatcher.waitForIdle(); + markDispatchIdle(); + return { queuedFinal: true, counts: dispatcher.getQueuedCounts() }; + } + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }); }); diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index b31387b45..d91c7b3d3 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({ reactMock(...args); }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), @@ -41,7 +47,7 @@ beforeEach(() => { updateLastRouteMock.mockReset(); dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 9da41c577..88fd6e212 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({ reactMock(...args); }, })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), -})); +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args), + dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args), + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), @@ -40,7 +46,7 @@ beforeEach(() => { updateLastRouteMock.mockReset(); dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts index 1ffecb293..708c69993 100644 --- a/src/discord/monitor/message-handler.inbound-contract.test.ts +++ b/src/discord/monitor/message-handler.inbound-contract.test.ts @@ -9,17 +9,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont let capturedCtx: MsgContext | undefined; -vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { +vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => { capturedCtx = params.ctx; - return { queuedFinal: false, counts: { tool: 0, block: 0 } }; - }), -})); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); import { processDiscordMessage } from "./message-handler.process.js"; describe("discord processDiscordMessage inbound contract", () => { - it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + it("passes a finalized MsgContext to dispatchInboundMessage", async () => { capturedCtx = undefined; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 3a6b650cd..fe26c79d5 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -14,7 +14,7 @@ import { formatThreadStarterEnvelope, resolveEnvelopeFormatOptions, } from "../../auto-reply/envelope.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntries, @@ -358,7 +358,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) onReplyStart: () => sendTyping({ client, channelId: typingChannelId }), }); - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await dispatchInboundMessage({ ctx: ctxPayload, cfg, dispatcher, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 0e55b45f5..50f441779 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2,30 +2,18 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { ensureAgentWorkspace } from "../../agents/workspace.js"; -import { isControlCommandMessage } from "../../auto-reply/command-detection.js"; -import { normalizeCommandBody } from "../../auto-reply/commands-registry.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; -import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js"; -import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js"; -import { defaultGroupActivation } from "../../auto-reply/reply/groups.js"; -import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js"; -import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { - normalizeElevatedLevel, - normalizeReasoningLevel, - normalizeThinkLevel, - normalizeVerboseLevel, -} from "../../auto-reply/thinking.js"; + extractShortModelName, + type ResponsePrefixContext, +} from "../../auto-reply/reply/response-prefix-template.js"; import type { MsgContext } from "../../auto-reply/templating.js"; -import { agentCommand } from "../../commands/agent.js"; -import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; -import { registerAgentRunContext } from "../../infra/agent-events.js"; -import { isAcpSessionKey } from "../../routing/session-key.js"; -import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { @@ -53,7 +41,144 @@ import { } from "../session-utils.js"; import { stripEnvelopeFromMessages } from "../chat-sanitize.js"; import { formatForLog } from "../ws-log.js"; -import type { GatewayRequestHandlers } from "./types.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; + +type TranscriptAppendResult = { + ok: boolean; + messageId?: string; + message?: Record; + error?: string; +}; + +function resolveTranscriptPath(params: { + sessionId: string; + storePath: string | undefined; + sessionFile?: string; +}): string | null { + const { sessionId, storePath, sessionFile } = params; + if (sessionFile) return sessionFile; + if (!storePath) return null; + return path.join(path.dirname(storePath), `${sessionId}.jsonl`); +} + +function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): { + ok: boolean; + error?: string; +} { + if (fs.existsSync(params.transcriptPath)) return { ok: true }; + try { + fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true }); + const header = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +function appendAssistantTranscriptMessage(params: { + message: string; + label?: string; + sessionId: string; + storePath: string | undefined; + sessionFile?: string; + createIfMissing?: boolean; +}): TranscriptAppendResult { + const transcriptPath = resolveTranscriptPath({ + sessionId: params.sessionId, + storePath: params.storePath, + sessionFile: params.sessionFile, + }); + if (!transcriptPath) { + return { ok: false, error: "transcript path not resolved" }; + } + + if (!fs.existsSync(transcriptPath)) { + if (!params.createIfMissing) { + return { ok: false, error: "transcript file not found" }; + } + const ensured = ensureTranscriptFile({ + transcriptPath, + sessionId: params.sessionId, + }); + if (!ensured.ok) { + return { ok: false, error: ensured.error ?? "failed to create transcript file" }; + } + } + + const now = Date.now(); + const messageId = randomUUID().slice(0, 8); + const labelPrefix = params.label ? `[${params.label}]\n\n` : ""; + const messageBody: Record = { + role: "assistant", + content: [{ type: "text", text: `${labelPrefix}${params.message}` }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const transcriptEntry = { + type: "message", + id: messageId, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + + return { ok: true, messageId, message: transcriptEntry.message }; +} + +function nextChatSeq(context: { agentRunSeq: Map }, runId: string) { + const next = (context.agentRunSeq.get(runId) ?? 0) + 1; + context.agentRunSeq.set(runId, next); + return next; +} + +function broadcastChatFinal(params: { + context: Pick; + runId: string; + sessionKey: string; + message?: Record; +}) { + const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payload = { + runId: params.runId, + sessionKey: params.sessionKey, + seq, + state: "final" as const, + message: params.message, + }; + params.context.broadcast("chat", payload); + params.context.nodeSendToSession(params.sessionKey, "chat", payload); +} + +function broadcastChatError(params: { + context: Pick; + runId: string; + sessionKey: string; + errorMessage?: string; +}) { + const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payload = { + runId: params.runId, + sessionKey: params.sessionKey, + seq, + state: "error" as const, + errorMessage: params.errorMessage, + }; + params.context.broadcast("chat", payload); + params.context.nodeSendToSession(params.sessionKey, "chat", payload); +} export const chatHandlers: GatewayRequestHandlers = { "chat.history": async ({ params, respond, context }) => { @@ -168,7 +293,7 @@ export const chatHandlers: GatewayRequestHandlers = { runIds: res.aborted ? [runId] : [], }); }, - "chat.send": async ({ params, respond, context }) => { + "chat.send": async ({ params, respond, context, client }) => { if (!validateChatSendParams(params)) { respond( false, @@ -228,20 +353,13 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey); + const { cfg, entry } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, }); const now = Date.now(); - const sessionId = entry?.sessionId ?? randomUUID(); - const sessionEntry = mergeSessionEntry(entry, { - sessionId, - updatedAt: now, - }); - store[canonicalKey] = sessionEntry; const clientRunId = p.idempotencyKey; - registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); const sendPolicy = resolveSendPolicy({ cfg, @@ -298,21 +416,11 @@ export const chatHandlers: GatewayRequestHandlers = { const abortController = new AbortController(); context.chatAbortControllers.set(clientRunId, { controller: abortController, - sessionId, + sessionId: entry?.sessionId ?? clientRunId, sessionKey: p.sessionKey, startedAtMs: now, expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), }); - context.addChatRun(clientRunId, { - sessionKey: p.sessionKey, - clientRunId, - }); - - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[canonicalKey] = sessionEntry; - }); - } const ackPayload = { runId: clientRunId, @@ -320,170 +428,116 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); - if (isControlCommandMessage(parsedMessage, cfg)) { - try { - const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; - const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg }); - const agentCfg = cfg.agents?.defaults; - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const workspace = await ensureAgentWorkspace({ - dir: workspaceDir, - ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, - }); - const ctx: MsgContext = { - Body: parsedMessage, - CommandBody: parsedMessage, - BodyForCommands: parsedMessage, - CommandSource: "text", - CommandAuthorized: true, - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: "tui", - From: p.sessionKey, - To: INTERNAL_MESSAGE_CHANNEL, - SessionKey: p.sessionKey, - ChatType: "direct", - }; - const command = buildCommandContext({ - ctx, - cfg, - agentId, - sessionKey: p.sessionKey, - isGroup: false, - triggerBodyNormalized: normalizeCommandBody(parsedMessage), - commandAuthorized: true, - }); - const directives = parseInlineDirectives(parsedMessage); - const { provider, model } = resolveSessionModelRef(cfg, sessionEntry); - const contextTokens = resolveContextTokens({ agentCfg, model }); - const resolveDefaultThinkingLevel = async () => { - const configured = agentCfg?.thinkingDefault; - if (configured) return configured; - const catalog = await context.loadGatewayModelCatalog(); - return resolveThinkingDefault({ cfg, provider, model, catalog }); - }; - const resolvedThinkLevel = - normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ?? - (await resolveDefaultThinkingLevel()); - const resolvedVerboseLevel = - normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off"; - const resolvedReasoningLevel = - normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off"; - const resolvedElevatedLevel = normalizeElevatedLevel( - sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault, - ); - const elevated = resolveElevatedPermissions({ - cfg, - agentId, - ctx, - provider: INTERNAL_MESSAGE_CHANNEL, - }); - const commandResult = await handleCommands({ - ctx, - cfg, - command, - agentId, - directives, - elevated, - sessionEntry, - previousSessionEntry: entry, - sessionStore: store, - sessionKey: p.sessionKey, - storePath, - sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global", - workspaceDir: workspace.dir, - defaultGroupActivation: () => defaultGroupActivation(true), - resolvedThinkLevel, - resolvedVerboseLevel, - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel, - provider, - model, - contextTokens, - isGroup: false, - }); - if (!commandResult.shouldContinue) { - const text = commandResult.reply?.text ?? ""; - const message = { - role: "assistant", - content: text.trim() ? [{ type: "text", text }] : [], - timestamp: Date.now(), - command: true, - }; - const payload = { + const trimmedMessage = parsedMessage.trim(); + const injectThinking = Boolean( + p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), + ); + const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; + const clientInfo = client?.connect?.client; + const ctx: MsgContext = { + Body: parsedMessage, + BodyForAgent: parsedMessage, + BodyForCommands: commandBody, + RawBody: parsedMessage, + CommandBody: commandBody, + SessionKey: p.sessionKey, + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, + ChatType: "direct", + CommandAuthorized: true, + MessageSid: clientRunId, + SenderId: clientInfo?.id, + SenderName: clientInfo?.displayName, + SenderUsername: clientInfo?.displayName, + }; + + const agentId = resolveSessionAgentId({ + sessionKey: p.sessionKey, + config: cfg, + }); + let prefixContext: ResponsePrefixContext = { + identityName: resolveIdentityName(cfg, agentId), + }; + const finalReplyParts: string[] = []; + const dispatcher = createReplyDispatcher({ + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefixContextProvider: () => prefixContext, + onError: (err) => { + context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`); + }, + deliver: async (payload, info) => { + if (info.kind !== "final") return; + const text = payload.text?.trim() ?? ""; + if (!text) return; + finalReplyParts.push(text); + }, + }); + + let agentRunStarted = false; + void dispatchInboundMessage({ + ctx, + cfg, + dispatcher, + replyOptions: { + runId: clientRunId, + abortSignal: abortController.signal, + images: parsedImages.length > 0 ? parsedImages : undefined, + disableBlockStreaming: true, + onAgentRunStart: () => { + agentRunStarted = true; + }, + onModelSelected: (ctx) => { + prefixContext.provider = ctx.provider; + prefixContext.model = extractShortModelName(ctx.model); + prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; + prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + }, + }, + }) + .then(() => { + if (!agentRunStarted) { + const combinedReply = finalReplyParts + .map((part) => part.trim()) + .filter(Boolean) + .join("\n\n") + .trim(); + let message: Record | undefined; + if (combinedReply) { + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + p.sessionKey, + ); + const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; + const appended = appendAssistantTranscriptMessage({ + message: combinedReply, + sessionId, + storePath: latestStorePath, + sessionFile: latestEntry?.sessionFile, + createIfMissing: true, + }); + if (appended.ok) { + message = appended.message; + } else { + context.logGateway.warn( + `webchat transcript append failed: ${appended.error ?? "unknown error"}`, + ); + const now = Date.now(); + message = { + role: "assistant", + content: [{ type: "text", text: combinedReply }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + } + } + broadcastChatFinal({ + context, runId: clientRunId, sessionKey: p.sessionKey, - seq: 0, - state: "final" as const, message, - }; - context.broadcast("chat", payload); - context.nodeSendToSession(p.sessionKey, "chat", payload); - context.dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: true, - payload: { runId: clientRunId, status: "ok" as const }, }); - context.chatAbortControllers.delete(clientRunId); - context.removeChatRun(clientRunId, clientRunId, p.sessionKey); - return; } - } catch (err) { - const payload = { - runId: clientRunId, - sessionKey: p.sessionKey, - seq: 0, - state: "error" as const, - errorMessage: formatForLog(err), - }; - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); - context.broadcast("chat", payload); - context.nodeSendToSession(p.sessionKey, "chat", payload); - context.dedupe.set(`chat:${clientRunId}`, { - ts: Date.now(), - ok: false, - payload: { - runId: clientRunId, - status: "error" as const, - summary: String(err), - }, - error, - }); - context.chatAbortControllers.delete(clientRunId); - context.removeChatRun(clientRunId, clientRunId, p.sessionKey); - return; - } - } - - const envelopeOptions = resolveEnvelopeFormatOptions(cfg); - const envelopedMessage = formatInboundEnvelope({ - channel: "WebChat", - from: p.sessionKey, - timestamp: now, - body: parsedMessage, - chatType: "direct", - previousTimestamp: entry?.updatedAt, - envelope: envelopeOptions, - }); - const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined; - void agentCommand( - { - message: envelopedMessage, - images: parsedImages.length > 0 ? parsedImages : undefined, - sessionId, - sessionKey: p.sessionKey, - runId: clientRunId, - thinking: p.thinking, - deliver: p.deliver, - timeout: Math.ceil(timeoutMs / 1000).toString(), - messageChannel: INTERNAL_MESSAGE_CHANNEL, - abortSignal: abortController.signal, - lane, - }, - defaultRuntime, - context.deps, - ) - .then(() => { context.dedupe.set(`chat:${clientRunId}`, { ts: Date.now(), ok: true, @@ -502,6 +556,12 @@ export const chatHandlers: GatewayRequestHandlers = { }, error, }); + broadcastChatError({ + context, + runId: clientRunId, + sessionKey: p.sessionKey, + errorMessage: String(err), + }); }) .finally(() => { context.chatAbortControllers.delete(clientRunId); diff --git a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts index 2b55b0c2e..e5c6c37aa 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts @@ -4,8 +4,8 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import { - agentCommand, connectOk, + getReplyFromConfig, installGatewayTestHooks, onceMessage, rpcReq, @@ -47,7 +47,7 @@ describe("gateway server chat", () => { async () => { const tempDirs: string[] = []; const { server, ws } = await startServerWithClient(); - const spy = vi.mocked(agentCommand); + const spy = vi.mocked(getReplyFromConfig); const resetSpy = () => { spy.mockReset(); spy.mockResolvedValue(undefined); @@ -122,8 +122,9 @@ describe("gateway server chat", () => { let abortInFlight: Promise | undefined; try { const callsBefore = spy.mock.calls.length; - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -155,7 +156,7 @@ describe("gateway server chat", () => { const tick = () => { if (spy.mock.calls.length > callsBefore) return resolve(); if (Date.now() > deadline) - return reject(new Error("timeout waiting for agentCommand")); + return reject(new Error("timeout waiting for getReplyFromConfig")); setTimeout(tick, 5); }; tick(); @@ -177,8 +178,9 @@ describe("gateway server chat", () => { sessionStoreSaveDelayMs.value = 120; resetSpy(); try { - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -215,8 +217,9 @@ describe("gateway server chat", () => { await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } }); resetSpy(); const callsBeforeStop = spy.mock.calls.length; - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -261,7 +264,8 @@ describe("gateway server chat", () => { const runDone = new Promise((resolve) => { resolveRun = resolve; }); - spy.mockImplementationOnce(async () => { + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1"); await runDone; }); const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", { @@ -294,8 +298,9 @@ describe("gateway server chat", () => { } expect(completed).toBe(true); resetSpy(); - spy.mockImplementationOnce(async (opts) => { - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + spy.mockImplementationOnce(async (_ctx, opts) => { + opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1"); + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); @@ -359,9 +364,9 @@ describe("gateway server chat", () => { const agentStartedP = new Promise((resolve) => { agentStartedResolve = resolve; }); - spy.mockImplementationOnce(async (opts) => { + spy.mockImplementationOnce(async (_ctx, opts) => { agentStartedResolve?.(); - const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + const signal = opts?.abortSignal; await new Promise((resolve) => { if (!signal) return resolve(); if (signal.aborted) return resolve(); diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index d4035037b..54f772580 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -6,8 +6,8 @@ import { WebSocket } from "ws"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { - agentCommand, connectOk, + getReplyFromConfig, installGatewayTestHooks, onceMessage, rpcReq, @@ -71,7 +71,7 @@ describe("gateway server chat", () => { webchatWs.close(); webchatWs = undefined; - const spy = vi.mocked(agentCommand); + const spy = vi.mocked(getReplyFromConfig); spy.mockClear(); testState.agentConfig = { timeoutSeconds: 123 }; const callsBeforeTimeout = spy.mock.calls.length; @@ -83,8 +83,8 @@ describe("gateway server chat", () => { expect(timeoutRes.ok).toBe(true); await waitFor(() => spy.mock.calls.length > callsBeforeTimeout); - const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined; - expect(timeoutCall?.timeout).toBe("123"); + const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined; + expect(timeoutCall?.runId).toBe("idem-timeout-1"); testState.agentConfig = undefined; spy.mockClear(); @@ -97,8 +97,8 @@ describe("gateway server chat", () => { expect(sessionRes.ok).toBe(true); await waitFor(() => spy.mock.calls.length > callsBeforeSession); - const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined; - expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc"); + const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined; + expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc"); const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); tempDirs.push(sendPolicyDir); @@ -203,10 +203,10 @@ describe("gateway server chat", () => { expect(imgRes.payload?.runId).toBeDefined(); await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000); - const imgCall = spy.mock.calls.at(-1)?.[0] as + const imgOpts = spy.mock.calls.at(-1)?.[1] as | { images?: Array<{ type: string; data: string; mimeType: string }> } | undefined; - expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); + expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]); const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); tempDirs.push(historyDir); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 46631ba09..5268bd459 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -166,6 +166,7 @@ const hoisted = vi.hoisted(() => ({ waitCalls: [] as string[], waitResults: new Map(), }, + getReplyFromConfig: vi.fn().mockResolvedValue(undefined), sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), })); @@ -197,6 +198,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4; export const piSdkMock = hoisted.piSdkMock; export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand = hoisted.agentCommand; +export const getReplyFromConfig = hoisted.getReplyFromConfig; export const testState = { agentConfig: undefined as Record | undefined, @@ -540,6 +542,9 @@ vi.mock("../channels/web/index.js", async () => { vi.mock("../commands/agent.js", () => ({ agentCommand, })); +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig, +})); vi.mock("../cli/deps.js", async () => { const actual = await vi.importActual("../cli/deps.js"); const base = actual.createDefaultDeps(); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 65185087b..13e106eb9 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -20,7 +20,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { buildPendingHistoryContextFromMap, @@ -565,7 +565,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); - const { queuedFinal } = await dispatchReplyFromConfig({ + const { queuedFinal } = await dispatchInboundMessage({ ctx: ctxPayload, cfg, dispatcher, diff --git a/src/signal/monitor.event-handler.sender-prefix.test.ts b/src/signal/monitor.event-handler.sender-prefix.test.ts index 3c5569940..bf8ee7136 100644 --- a/src/signal/monitor.event-handler.sender-prefix.test.ts +++ b/src/signal/monitor.event-handler.sender-prefix.test.ts @@ -12,21 +12,21 @@ describe("signal event handler sender prefix", () => { beforeEach(() => { dispatchMock.mockReset().mockImplementation(async ({ dispatcher, ctx }) => { dispatcher.sendFinalReply({ text: "ok" }); - return { queuedFinal: true, counts: { final: 1 }, ctx }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, ctx }; }); readAllowFromMock.mockReset().mockResolvedValue([]); }); it("prefixes group bodies with sender label", async () => { let capturedBody = ""; - const dispatchModule = await import("../auto-reply/reply/dispatch-from-config.js"); - vi.spyOn(dispatchModule, "dispatchReplyFromConfig").mockImplementation( + const dispatchModule = await import("../auto-reply/dispatch.js"); + vi.spyOn(dispatchModule, "dispatchInboundMessage").mockImplementation( async (...args: unknown[]) => dispatchMock(...args), ); dispatchMock.mockImplementationOnce(async ({ dispatcher, ctx }) => { capturedBody = ctx.Body ?? ""; dispatcher.sendFinalReply({ text: "ok" }); - return { queuedFinal: true, counts: { final: 1 } }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); const { createSignalEventHandler } = await import("./monitor/event-handler.js"); diff --git a/src/signal/monitor.event-handler.typing-read-receipts.test.ts b/src/signal/monitor.event-handler.typing-read-receipts.test.ts index c4ba6f9ce..7aee1e24d 100644 --- a/src/signal/monitor.event-handler.typing-read-receipts.test.ts +++ b/src/signal/monitor.event-handler.typing-read-receipts.test.ts @@ -9,14 +9,21 @@ vi.mock("./send.js", () => ({ sendReadReceiptSignal: (...args: unknown[]) => sendReadReceiptMock(...args), })); -vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn( +vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn( async (params: { replyOptions?: { onReplyStart?: () => void } }) => { await Promise.resolve(params.replyOptions?.onReplyStart?.()); return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }, - ), -})); + ); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn().mockResolvedValue([]), @@ -25,11 +32,13 @@ vi.mock("../pairing/pairing-store.js", () => ({ describe("signal event handler typing + read receipts", () => { beforeEach(() => { + vi.useRealTimers(); sendTypingMock.mockReset().mockResolvedValue(true); sendReadReceiptMock.mockReset().mockResolvedValue(true); }); it("sends typing + read receipt for allowed DMs", async () => { + vi.resetModules(); const { createSignalEventHandler } = await import("./monitor/event-handler.js"); const handler = createSignalEventHandler({ runtime: { log: () => {}, error: () => {} } as any, diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts index 9277eb990..d073357ff 100644 --- a/src/signal/monitor/event-handler.inbound-contract.test.ts +++ b/src/signal/monitor/event-handler.inbound-contract.test.ts @@ -5,17 +5,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont let capturedCtx: MsgContext | undefined; -vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ - dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { +vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => { capturedCtx = params.ctx; - return { queuedFinal: false, counts: { tool: 0, block: 0 } }; - }), -})); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); + return { + ...actual, + dispatchInboundMessage, + dispatchInboundMessageWithDispatcher: dispatchInboundMessage, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage, + }; +}); import { createSignalEventHandler } from "./event-handler.js"; describe("signal createSignalEventHandler inbound contract", () => { - it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + it("passes a finalized MsgContext to dispatchInboundMessage", async () => { capturedCtx = undefined; const handler = createSignalEventHandler({ diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 30fabedfb..dfa3fe7ab 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -17,7 +17,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntries, @@ -225,7 +225,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onReplyStart, }); - const { queuedFinal } = await dispatchReplyFromConfig({ + const { queuedFinal } = await dispatchInboundMessage({ ctx: ctxPayload, cfg: deps.cfg, dispatcher, diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index de1b1f267..22cd9fcfe 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -7,7 +7,7 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; -import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js"; +import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntries } from "../../../auto-reply/reply/history.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; @@ -104,7 +104,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag onReplyStart, }); - const { queuedFinal, counts } = await dispatchReplyFromConfig({ + const { queuedFinal, counts } = await dispatchInboundMessage({ ctx: prepared.ctxPayload, cfg, dispatcher, diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 7024a2e52..1a6afa519 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -4,6 +4,10 @@ import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelo let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -23,6 +27,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 1a10ca94c..0cda853be 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 7937c1064..0aa431d1b 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 5e8a2dcfa..8ed8e189f 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 05aac6388..ebbd3b092 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -6,6 +6,9 @@ let createTelegramBot: typeof import("./bot.js").createTelegramBot; let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-throttler-${Math.random().toString(16).slice(2)}.json`, +})); const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -25,6 +28,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index 2c4dfa472..805aa34da 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index 2281fb407..ec81283bb 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 829391727..fd9401dac 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -22,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index 164095a9c..80c880b79 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -6,6 +6,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let createTelegramBot: typeof import("./bot.js").createTelegramBot; let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-reply-threading-${Math.random() + .toString(16) + .slice(2)}.json`, +})); + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -25,6 +31,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index da67c2e38..d4cdfaf4b 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -21,6 +21,10 @@ vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); +const { sessionStorePath } = vi.hoisted(() => ({ + sessionStorePath: `/tmp/clawdbot-telegram-bot-${Math.random().toString(16).slice(2)}.json`, +})); + function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); } @@ -44,6 +48,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), + }; +}); + const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({ readTelegramAllowFromStore: vi.fn(async () => [] as string[]), upsertTelegramPairingRequest: vi.fn(async () => ({ From a8054d1e8366313a81f8934e71c152c90b27a12f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:53:11 +0000 Subject: [PATCH 17/45] fix: complete inbound dispatch refactor --- src/auto-reply/dispatch.ts | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/auto-reply/dispatch.ts diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts new file mode 100644 index 000000000..8b1626c3d --- /dev/null +++ b/src/auto-reply/dispatch.ts @@ -0,0 +1,77 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { FinalizedMsgContext, MsgContext } from "./templating.js"; +import type { GetReplyOptions } from "./types.js"; +import { finalizeInboundContext } from "./reply/inbound-context.js"; +import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js"; +import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; +import { + createReplyDispatcher, + createReplyDispatcherWithTyping, + type ReplyDispatcher, + type ReplyDispatcherOptions, + type ReplyDispatcherWithTypingOptions, +} from "./reply/reply-dispatcher.js"; + +export type DispatchInboundResult = DispatchFromConfigResult; + +export async function dispatchInboundMessage(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcher: ReplyDispatcher; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const finalized = finalizeInboundContext(params.ctx); + return await dispatchReplyFromConfig({ + ctx: finalized, + cfg: params.cfg, + dispatcher: params.dispatcher, + replyOptions: params.replyOptions, + replyResolver: params.replyResolver, + }); +} + +export async function dispatchInboundMessageWithBufferedDispatcher(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcherOptions: ReplyDispatcherWithTypingOptions; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping( + params.dispatcherOptions, + ); + + const result = await dispatchInboundMessage({ + ctx: params.ctx, + cfg: params.cfg, + dispatcher, + replyResolver: params.replyResolver, + replyOptions: { + ...params.replyOptions, + ...replyOptions, + }, + }); + + markDispatchIdle(); + return result; +} + +export async function dispatchInboundMessageWithDispatcher(params: { + ctx: MsgContext | FinalizedMsgContext; + cfg: ClawdbotConfig; + dispatcherOptions: ReplyDispatcherOptions; + replyOptions?: Omit; + replyResolver?: typeof import("./reply.js").getReplyFromConfig; +}): Promise { + const dispatcher = createReplyDispatcher(params.dispatcherOptions); + const result = await dispatchInboundMessage({ + ctx: params.ctx, + cfg: params.cfg, + dispatcher, + replyResolver: params.replyResolver, + replyOptions: params.replyOptions, + }); + await dispatcher.waitForIdle(); + return result; +} From ed05152cb1e67f1de6947784f6f5de68407032ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:03:04 +0000 Subject: [PATCH 18/45] fix: align compaction summary message types --- src/agents/compaction.ts | 4 ++-- src/agents/pi-extensions/compaction-safeguard.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index ae134e827..2ab4566fd 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -278,8 +278,8 @@ export async function summarizeInStages(params: { } const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({ - role: "assistant", - content: [{ type: "text", text: summary }], + role: "user", + content: summary, timestamp: Date.now(), })); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 7f82a2757..82ad19f2a 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent"; import { BASE_CHUNK_RATIO, MIN_CHUNK_RATIO, From cb8c8fee9aed42bc3a2091ecdf84a0b9a5e49b14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:29:47 +0000 Subject: [PATCH 19/45] refactor: centralize ack reaction removal --- extensions/bluebubbles/src/monitor.test.ts | 3 +- extensions/bluebubbles/src/monitor.ts | 33 ++++++------- src/channels/ack-reactions.test.ts | 49 ++++++++++++++++++- src/channels/ack-reactions.ts | 16 ++++++ .../monitor/message-handler.process.ts | 30 ++++++------ src/plugin-sdk/index.ts | 6 ++- src/plugins/runtime/index.ts | 3 +- src/plugins/runtime/types.ts | 3 ++ src/slack/monitor/message-handler/dispatch.ts | 36 ++++++++------ src/telegram/bot-message-dispatch.ts | 23 +++++---- 10 files changed, 140 insertions(+), 62 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 1901af351..c960b5c4e 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IncomingMessage, ServerResponse } from "node:http"; import { EventEmitter } from "node:events"; -import { shouldAckReaction } from "clawdbot/plugin-sdk"; +import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import { handleBlueBubblesWebhookRequest, @@ -138,6 +138,7 @@ function createMockRuntime(): PluginRuntime { }, reactions: { shouldAckReaction, + removeAckReactionAfterReply, }, groups: { resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 325599c97..1a7a68058 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1750,29 +1750,26 @@ async function processMessage( }, }); } finally { - if ( - removeAckAfterReply && - sentMessage && - ackReactionPromise && - ackReactionValue && - chatGuidForActions && - ackMessageId - ) { - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - sendBlueBubblesReaction({ - chatGuid: chatGuidForActions, - messageGuid: ackMessageId, - emoji: ackReactionValue, - remove: true, - opts: { cfg: config, accountId: account.accountId }, - }).catch((err) => { + if (sentMessage && chatGuidForActions && ackMessageId) { + core.channel.reactions.removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionValue ?? null, + remove: () => + sendBlueBubblesReaction({ + chatGuid: chatGuidForActions, + messageGuid: ackMessageId, + emoji: ackReactionValue ?? "", + remove: true, + opts: { cfg: config, accountId: account.accountId }, + }), + onError: (err) => { logVerbose( core, runtime, `ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`, ); - }); + }, }); } if (chatGuidForActions && baseUrl && password && !sentMessage) { diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts index 8be3fc323..ed018ba5a 100644 --- a/src/channels/ack-reactions.test.ts +++ b/src/channels/ack-reactions.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { shouldAckReaction, shouldAckReactionForWhatsApp } from "./ack-reactions.js"; +import { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, +} from "./ack-reactions.js"; describe("shouldAckReaction", () => { it("honors direct and group-all scopes", () => { @@ -222,3 +226,44 @@ describe("shouldAckReactionForWhatsApp", () => { ).toBe(false); }); }); + +describe("removeAckReactionAfterReply", () => { + it("removes only when ack succeeded", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + const onError = vi.fn(); + removeAckReactionAfterReply({ + removeAfterReply: true, + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove, + onError, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it("skips removal when ack did not happen", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + removeAckReactionAfterReply({ + removeAfterReply: true, + ackReactionPromise: Promise.resolve(false), + ackReactionValue: "👀", + remove, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).not.toHaveBeenCalled(); + }); + + it("skips when not configured", async () => { + const remove = vi.fn().mockResolvedValue(undefined); + removeAckReactionAfterReply({ + removeAfterReply: false, + ackReactionPromise: Promise.resolve(true), + ackReactionValue: "👀", + remove, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts index beeb34a47..f35ae76d8 100644 --- a/src/channels/ack-reactions.ts +++ b/src/channels/ack-reactions.ts @@ -53,3 +53,19 @@ export function shouldAckReactionForWhatsApp(params: { shouldBypassMention: params.groupActivated, }); } + +export function removeAckReactionAfterReply(params: { + removeAfterReply: boolean; + ackReactionPromise: Promise | null; + ackReactionValue: string | null; + remove: () => Promise; + onError?: (err: unknown) => void; +}) { + if (!params.removeAfterReply) return; + if (!params.ackReactionPromise) return; + if (!params.ackReactionValue) return; + void params.ackReactionPromise.then((didAck) => { + if (!didAck) return; + params.remove().catch((err) => params.onError?.(err)); + }); +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index fe26c79d5..138619e6a 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -8,7 +8,10 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; -import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js"; +import { + removeAckReactionAfterReply, + shouldAckReaction as shouldAckReactionGate, +} from "../../channels/ack-reactions.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, @@ -394,19 +397,18 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); } - if (removeAckAfterReply && ackReactionPromise && ackReaction) { - const ackReactionValue = ackReaction; - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - removeReactionDiscord(message.channelId, message.id, ackReactionValue, { - rest: client.rest, - }).catch((err) => { - logVerbose( - `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`, - ); - }); - }); - } + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReaction, + remove: () => + removeReactionDiscord(message.channelId, message.id, ackReaction, { rest: client.rest }), + onError: (err) => { + logVerbose( + `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`, + ); + }, + }); if (isGuildMessage && historyLimit > 0) { clearHistoryEntries({ historyMap: guildHistories, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 652657b92..7cb61e2d3 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -122,7 +122,11 @@ export type { AckReactionScope, WhatsAppAckReactionMode, } from "../channels/ack-reactions.js"; -export { shouldAckReaction, shouldAckReactionForWhatsApp } from "../channels/ack-reactions.js"; +export { + removeAckReactionAfterReply, + shouldAckReaction, + shouldAckReactionForWhatsApp, +} from "../channels/ack-reactions.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index e0ade62ca..504d5f034 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -25,7 +25,7 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; -import { shouldAckReaction } from "../../channels/ack-reactions.js"; +import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; @@ -201,6 +201,7 @@ export function createPluginRuntime(): PluginRuntime { }, reactions: { shouldAckReaction, + removeAckReactionAfterReply, }, groups: { resolveGroupPolicy: resolveChannelGroupPolicy, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 86c7caa55..7351bc8da 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -20,6 +20,8 @@ type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").b type MatchesMentionPatterns = typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction; +type RemoveAckReactionAfterReply = + typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply; type ResolveChannelGroupPolicy = typeof import("../../config/group-policy.js").resolveChannelGroupPolicy; type ResolveChannelGroupRequireMention = @@ -214,6 +216,7 @@ export type PluginRuntime = { }; reactions: { shouldAckReaction: ShouldAckReaction; + removeAckReactionAfterReply: RemoveAckReactionAfterReply; }; groups: { resolveGroupPolicy: ResolveChannelGroupPolicy; diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 22cd9fcfe..734c7cf49 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -9,6 +9,7 @@ import { } from "../../../auto-reply/reply/response-prefix-template.js"; import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntries } from "../../../auto-reply/reply/history.js"; +import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; @@ -152,21 +153,26 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ); } - if (ctx.removeAckAfterReply && prepared.ackReactionPromise && prepared.ackReactionMessageTs) { - const messageTs = prepared.ackReactionMessageTs; - const ackValue = prepared.ackReactionValue; - void prepared.ackReactionPromise.then((didAck) => { - if (!didAck) return; - removeSlackReaction(message.channel, messageTs, ackValue, { - token: ctx.botToken, - client: ctx.app.client, - }).catch((err) => { - logVerbose( - `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`, - ); - }); - }); - } + removeAckReactionAfterReply({ + removeAfterReply: ctx.removeAckAfterReply, + ackReactionPromise: prepared.ackReactionPromise, + ackReactionValue: prepared.ackReactionValue, + remove: () => + removeSlackReaction( + message.channel, + prepared.ackReactionMessageTs ?? "", + prepared.ackReactionValue, + { + token: ctx.botToken, + client: ctx.app.client, + }, + ), + onError: (err) => { + logVerbose( + `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`, + ); + }, + }); if (prepared.isRoomish && ctx.historyLimit > 0) { clearHistoryEntries({ diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 4afbaa653..bce5cff82 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -7,6 +7,7 @@ import { import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -184,16 +185,18 @@ export const dispatchTelegramMessage = async ({ } return; } - if (removeAckAfterReply && ackReactionPromise && msg.message_id && reactionApi) { - void ackReactionPromise.then((didAck) => { - if (!didAck) return; - reactionApi(chatId, msg.message_id, []).catch((err) => { - logVerbose( - `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`, - ); - }); - }); - } + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionPromise ? "ack" : null, + remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), + onError: (err) => { + if (!msg.message_id) return; + logVerbose( + `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`, + ); + }, + }); if (isGroup && historyKey && historyLimit > 0) { clearHistoryEntries({ historyMap: groupHistories, historyKey }); } From 05e7e0614690d1bbaf8a1dbbc92224a960fe5c29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:36:34 +0000 Subject: [PATCH 20/45] docs: add channel unification checklist --- docs/refactor/channel-unify.md | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/refactor/channel-unify.md diff --git a/docs/refactor/channel-unify.md b/docs/refactor/channel-unify.md new file mode 100644 index 000000000..9f81ffa64 --- /dev/null +++ b/docs/refactor/channel-unify.md @@ -0,0 +1,103 @@ +--- +summary: "Checklist for unifying messaging channel logic" +read_when: + - Planning refactors across channel implementations + - Standardizing shared message handling behavior +--- +# Channel unification checklist + +Purpose: centralize repeated messaging logic so core + extensions stay consistent, testable, and easier to evolve. + +## Ack reactions (already centralized) +- [x] Shared gating helper for core channels. +- [x] Shared gating helper for extensions. +- [x] WhatsApp-specific gating helper (direct/group/mentions) aligned with activation. +- [ ] Optional: centralize “remove after reply” behavior (see below). + +## Ack reaction removal (after reply) +Problem: duplicated logic across Discord, Slack, Telegram, BlueBubbles. +- [ ] Create `channel.reactions.removeAfterReply()` helper that accepts: + - `removeAckAfterReply` flag + - ack promise + result boolean + - channel-specific remove fn + ids +- [ ] Wire in: + - `src/discord/monitor/message-handler.process.ts` + - `src/slack/monitor/message-handler/dispatch.ts` + - `src/telegram/bot-message-dispatch.ts` + - `extensions/bluebubbles/src/monitor.ts` +- [ ] Add unit tests for the helper (success + ack-failed paths). + +## Pending history buffering + flush +Problem: repeated “record pending history”, “prepend pending history”, and “clear history” patterns. +- [ ] Identify shared flow in: + - `src/discord/monitor/message-handler.preflight.ts` + - `src/discord/monitor/message-handler.process.ts` + - `src/slack/monitor/message-handler/prepare.ts` + - `src/telegram/bot-message-context.ts` + - `src/signal/monitor/event-handler.ts` + - `src/imessage/monitor/monitor-provider.ts` + - `extensions/mattermost/src/mattermost/monitor.ts` + - `src/web/auto-reply/monitor/group-gating.ts` +- [ ] Add helper(s) to `src/auto-reply/reply/history.ts`: + - `recordPendingIfBlocked()` (accepts allowlist/mention gating reason) + - `mergePendingIntoBody()` (returns combined body) + - `clearPendingHistory()` (wrapper to standardize historyKey, limits) +- [ ] Ensure per-channel metadata (sender label, timestamps, messageId) preserved. +- [ ] Add tests for helper(s); keep per-channel smoke tests. + +## Typing lifecycle +Problem: inconsistent typing start/stop handling and error logging. +- [ ] Add a shared typing adapter in core (ex: `src/channels/typing.ts`) that accepts: + - `startTyping` / `stopTyping` callbacks + - `onReplyStart` / `onReplyIdle` hooks from dispatcher + - TTL + interval config (reuse `auto-reply/reply/typing` machinery) +- [ ] Wire in: + - Discord (`src/discord/monitor/typing.ts`) + - Slack (`src/slack/monitor/message-handler/dispatch.ts`) + - Telegram (dispatch flow) + - Signal (`src/signal/monitor/event-handler.ts`) + - Matrix (`extensions/matrix/src/matrix/monitor/handler.ts`) + - Mattermost (`extensions/mattermost/src/mattermost/monitor.ts`) + - BlueBubbles (`extensions/bluebubbles/src/monitor.ts`) + - MS Teams (`extensions/msteams/src/reply-dispatcher.ts`) +- [ ] Add helper tests for start/stop and error handling. + +## Reply dispatcher wiring +Problem: channels hand-roll dispatcher glue; varies in error handling and typing. +- [ ] Add a shared wrapper that builds: + - reply dispatcher + - response prefix context + - table mode conversion +- [ ] Adopt in: + - Discord, Slack, Telegram (core) + - BlueBubbles, Matrix, Mattermost (extensions) +- [ ] Keep per-channel delivery adapter (send message / chunking). + +## Session meta + last route updates +Problem: repeated patterns for `recordSessionMetaFromInbound` and `updateLastRoute`. +- [ ] Add helper `channel.session.recordInbound()` that accepts: + - `storePath`, `sessionKey`, `ctx` + - optional `channel/accountId/target` for `updateLastRoute` +- [ ] Wire in: + - Discord, Slack, Telegram, Matrix, BlueBubbles + +## Control command gating patterns +Problem: similar gating flow per channel (allowlists + commands). +- [ ] Add a helper that merges: + - allowlist checks + - command gating decisions + - mention bypass evaluation +- [ ] Keep channel-specific identity/user resolution separate. + +## Error + verbose logging +Problem: inconsistent message formats across channels. +- [ ] Define canonical log helpers: + - `logInboundDrop(reason, meta)` + - `logAckFailure(meta)` + - `logTypingFailure(meta)` +- [ ] Apply to all channel handlers. + +## Docs + SDK +- [ ] Expose new helpers through `src/plugin-sdk/index.ts` + plugin runtime. +- [ ] Update `docs/tools/reactions.md` if ack semantics expand. +- [ ] Add `read_when` hints if new cross-cutting helpers are introduced. From 521ea4ae5bcb7e362d702274fe9240b8418cbe07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:36:43 +0000 Subject: [PATCH 21/45] refactor: unify pending history helpers --- .../mattermost/src/mattermost/monitor.ts | 30 ++++---- .../src/monitor-handler/message-handler.ts | 43 ++++++------ src/auto-reply/reply/history.ts | 25 +++++++ .../monitor/message-handler.preflight.ts | 19 ++--- .../monitor/message-handler.process.ts | 12 ++-- src/imessage/monitor/monitor-provider.ts | 44 ++++++------ src/plugin-sdk/index.ts | 2 + src/signal/monitor/event-handler.ts | 20 ++++-- src/slack/monitor/message-handler/dispatch.ts | 12 ++-- src/slack/monitor/message-handler/prepare.ts | 36 +++++----- src/telegram/bot-message-context.ts | 28 ++++---- src/telegram/bot-message-dispatch.ts | 10 +-- src/web/auto-reply/monitor/group-gating.ts | 70 +++++++++---------- 13 files changed, 196 insertions(+), 155 deletions(-) diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index cce05f0cb..245388d68 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -8,9 +8,9 @@ import type { } from "clawdbot/plugin-sdk"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, resolveChannelMediaMaxBytes, type HistoryEntry, } from "clawdbot/plugin-sdk"; @@ -534,19 +534,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} : ""); const pendingSender = senderName; const recordPendingHistory = () => { - if (!historyKey || historyLimit <= 0) return; const trimmed = pendingBody.trim(); - if (!trimmed) return; - recordPendingHistoryEntry({ + recordPendingHistoryEntryIfEnabled({ historyMap: channelHistories, - historyKey, limit: historyLimit, - entry: { - sender: pendingSender, - body: trimmed, - timestamp: typeof post.create_at === "number" ? post.create_at : undefined, - messageId: post.id ?? undefined, - }, + historyKey: historyKey ?? "", + entry: historyKey && trimmed + ? { + sender: pendingSender, + body: trimmed, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + messageId: post.id ?? undefined, + } + : null, }); }; @@ -623,7 +623,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} sender: { name: senderName, id: senderId }, }); let combinedBody = body; - if (historyKey && historyLimit > 0) { + if (historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: channelHistories, historyKey, @@ -772,8 +772,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, }); markDispatchIdle(); - if (historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: channelHistories, historyKey }); + if (historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit }); } }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 1a0129180..79006ad70 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -1,8 +1,8 @@ import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -371,19 +371,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { requireMention, mentioned, }); - if (historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: conversationHistories, - historyKey: conversationId, - limit: historyLimit, - entry: { - sender: senderName, - body: rawBody, - timestamp: timestamp?.getTime(), - messageId: activity.id ?? undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: conversationHistories, + historyKey: conversationId, + limit: historyLimit, + entry: { + sender: senderName, + body: rawBody, + timestamp: timestamp?.getTime(), + messageId: activity.id ?? undefined, + }, + }); return; } } @@ -426,7 +424,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { let combinedBody = body; const isRoomish = !isDirectMessage; const historyKey = isRoomish ? conversationId : undefined; - if (isRoomish && historyKey && historyLimit > 0) { + if (isRoomish && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: conversationHistories, historyKey, @@ -512,10 +510,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const didSendReply = counts.final + counts.tool + counts.block > 0; if (!queuedFinal) { - if (isRoomish && historyKey && historyLimit > 0) { - clearHistoryEntries({ + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: conversationHistories, historyKey, + limit: historyLimit, }); } return; @@ -524,8 +523,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { logVerboseMessage( `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, ); - if (isRoomish && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: conversationHistories, historyKey }); + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: conversationHistories, + historyKey, + limit: historyLimit, + }); } } catch (err) { log.error("dispatch failed", { error: String(err) }); diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index b51e27da4..bc59b4f2e 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -47,6 +47,22 @@ export function recordPendingHistoryEntry(params: { return appendHistoryEntry(params); } +export function recordPendingHistoryEntryIfEnabled(params: { + historyMap: Map; + historyKey: string; + entry?: T | null; + limit: number; +}): T[] { + if (!params.entry) return []; + if (params.limit <= 0) return []; + return recordPendingHistoryEntry({ + historyMap: params.historyMap, + historyKey: params.historyKey, + entry: params.entry, + limit: params.limit, + }); +} + export function buildPendingHistoryContextFromMap(params: { historyMap: Map; historyKey: string; @@ -101,6 +117,15 @@ export function clearHistoryEntries(params: { params.historyMap.set(params.historyKey, []); } +export function clearHistoryEntriesIfEnabled(params: { + historyMap: Map; + historyKey: string; + limit: number; +}): void { + if (params.limit <= 0) return; + clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey }); +} + export function buildHistoryContextFromEntries(params: { entries: HistoryEntry[]; currentMessage: string; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 607b02cdd..d378ad871 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -2,7 +2,10 @@ import { ChannelType, MessageType, type User } from "@buape/carbon"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; -import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js"; +import { + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { recordChannelActivity } from "../../infra/channel-activity.js"; @@ -410,14 +413,12 @@ export async function preflightDiscordMessage( }, "discord: skipping guild message", ); - if (historyEntry && params.historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: params.guildHistories, - historyKey: message.channelId, - limit: params.historyLimit, - entry: historyEntry, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: params.guildHistories, + historyKey: message.channelId, + limit: params.historyLimit, + entry: historyEntry ?? null, + }); return null; } } diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 138619e6a..ce5212d9c 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -20,7 +20,7 @@ import { import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; @@ -383,10 +383,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }); markDispatchIdle(); if (!queuedFinal) { - if (isGuildMessage && historyLimit > 0) { - clearHistoryEntries({ + if (isGuildMessage) { + clearHistoryEntriesIfEnabled({ historyMap: guildHistories, historyKey: message.channelId, + limit: historyLimit, }); } return; @@ -409,10 +410,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ); }, }); - if (isGuildMessage && historyLimit > 0) { - clearHistoryEntries({ + if (isGuildMessage) { + clearHistoryEntriesIfEnabled({ historyMap: guildHistories, historyKey: message.channelId, + limit: historyLimit, }); } } diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 13e106eb9..ab542ad64 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -24,9 +24,9 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; @@ -405,19 +405,19 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const effectiveWasMentioned = mentioned || shouldBypassMention; if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { logVerbose(`imessage: skipping group message (no mention)`); - if (historyKey && historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: groupHistories, - historyKey, - limit: historyLimit, - entry: { - sender: senderNormalized, - body: bodyText, - timestamp: createdAt, - messageId: message.id ? String(message.id) : undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: groupHistories, + historyKey: historyKey ?? "", + limit: historyLimit, + entry: historyKey + ? { + sender: senderNormalized, + body: bodyText, + timestamp: createdAt, + messageId: message.id ? String(message.id) : undefined, + } + : null, + }); return; } @@ -454,7 +454,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P envelope: envelopeOptions, }); let combinedBody = body; - if (isGroup && historyKey && historyLimit > 0) { + if (isGroup && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: groupHistories, historyKey, @@ -584,13 +584,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); if (!queuedFinal) { - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: groupHistories, + historyKey, + limit: historyLimit, + }); } return; } - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } } diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7cb61e2d3..b349df7c2 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -108,8 +108,10 @@ export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; export { buildPendingHistoryContextFromMap, clearHistoryEntries, + clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, } from "../auto-reply/reply/history.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index dfa3fe7ab..01c1cc727 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -20,7 +20,7 @@ import { import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { buildPendingHistoryContextFromMap, - clearHistoryEntries, + clearHistoryEntriesIfEnabled, } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; @@ -111,7 +111,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); let combinedBody = body; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; - if (entry.isGroup && historyKey && deps.historyLimit > 0) { + if (entry.isGroup && historyKey) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: deps.groupHistories, historyKey, @@ -244,13 +244,21 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); markDispatchIdle(); if (!queuedFinal) { - if (entry.isGroup && historyKey && deps.historyLimit > 0) { - clearHistoryEntries({ historyMap: deps.groupHistories, historyKey }); + if (entry.isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: deps.groupHistories, + historyKey, + limit: deps.historyLimit, + }); } return; } - if (entry.isGroup && historyKey && deps.historyLimit > 0) { - clearHistoryEntries({ historyMap: deps.groupHistories, historyKey }); + if (entry.isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: deps.groupHistories, + historyKey, + limit: deps.historyLimit, + }); } } diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 734c7cf49..e06795f59 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -8,7 +8,7 @@ import { type ResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; -import { clearHistoryEntries } from "../../../auto-reply/reply/history.js"; +import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; @@ -137,10 +137,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } if (!queuedFinal) { - if (prepared.isRoomish && ctx.historyLimit > 0) { - clearHistoryEntries({ + if (prepared.isRoomish) { + clearHistoryEntriesIfEnabled({ historyMap: ctx.channelHistories, historyKey: prepared.historyKey, + limit: ctx.historyLimit, }); } return; @@ -174,10 +175,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, }); - if (prepared.isRoomish && ctx.historyLimit > 0) { - clearHistoryEntries({ + if (prepared.isRoomish) { + clearHistoryEntriesIfEnabled({ historyMap: ctx.channelHistories, historyKey: prepared.historyKey, + limit: ctx.historyLimit, }); } } diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 7bea8a170..06f77f620 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -9,7 +9,7 @@ import { } from "../../../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, } from "../../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js"; @@ -292,28 +292,26 @@ export async function prepareSlackMessage(params: { const effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (isRoom && shouldRequireMention && mentionGate.shouldSkip) { ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message"); - if (ctx.historyLimit > 0) { - const pendingText = (message.text ?? "").trim(); - const fallbackFile = message.files?.[0]?.name - ? `[Slack file: ${message.files[0].name}]` - : message.files?.length - ? "[Slack file]" - : ""; - const pendingBody = pendingText || fallbackFile; - if (pendingBody) { - recordPendingHistoryEntry({ - historyMap: ctx.channelHistories, - historyKey, - limit: ctx.historyLimit, - entry: { + const pendingText = (message.text ?? "").trim(); + const fallbackFile = message.files?.[0]?.name + ? `[Slack file: ${message.files[0].name}]` + : message.files?.length + ? "[Slack file]" + : ""; + const pendingBody = pendingText || fallbackFile; + recordPendingHistoryEntryIfEnabled({ + historyMap: ctx.channelHistories, + historyKey, + limit: ctx.historyLimit, + entry: pendingBody + ? { sender: senderName, body: pendingBody, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, messageId: message.ts, - }, - }); - } - } + } + : null, + }); return null; } diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 5b67e7eb1..e92222e1c 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -6,7 +6,7 @@ import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, - recordPendingHistoryEntry, + recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; @@ -350,19 +350,19 @@ export const buildTelegramMessageContext = async ({ if (isGroup && requireMention && canDetectMention) { if (mentionGate.shouldSkip) { logger.info({ chatId, reason: "no-mention" }, "skipping group message"); - if (historyKey && historyLimit > 0) { - recordPendingHistoryEntry({ - historyMap: groupHistories, - historyKey, - limit: historyLimit, - entry: { - sender: buildSenderLabel(msg, senderId || chatId), - body: rawBody, - timestamp: msg.date ? msg.date * 1000 : undefined, - messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, - }, - }); - } + recordPendingHistoryEntryIfEnabled({ + historyMap: groupHistories, + historyKey: historyKey ?? "", + limit: historyLimit, + entry: historyKey + ? { + sender: buildSenderLabel(msg, senderId || chatId), + body: rawBody, + timestamp: msg.date ? msg.date * 1000 : undefined, + messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, + } + : null, + }); return null; } } diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index bce5cff82..c48513b79 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -5,7 +5,7 @@ import { type ResponsePrefixContext, } from "../auto-reply/reply/response-prefix-template.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; -import { clearHistoryEntries } from "../auto-reply/reply/history.js"; +import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; import { danger, logVerbose } from "../globals.js"; @@ -180,8 +180,8 @@ export const dispatchTelegramMessage = async ({ }); draftStream?.stop(); if (!queuedFinal) { - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } return; } @@ -197,7 +197,7 @@ export const dispatchTelegramMessage = async ({ ); }, }); - if (isGroup && historyKey && historyLimit > 0) { - clearHistoryEntries({ historyMap: groupHistories, historyKey }); + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } }; diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index a4e46d41e..8d1a33645 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -6,7 +6,7 @@ import { resolveMentionGating } from "../../../channels/mention-gating.js"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; -import { recordPendingHistoryEntry } from "../../../auto-reply/reply/history.js"; +import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js"; import { stripMentionsForCommand } from "./commands.js"; import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js"; import { noteGroupMember } from "./group-members.js"; @@ -66,24 +66,22 @@ export function applyGroupGating(params: { if (activationCommand.hasCommand && !owner) { params.logVerbose(`Ignoring /activation from non-owner in group ${params.conversationId}`); - if (params.groupHistoryLimit > 0) { - const sender = - params.msg.senderName && params.msg.senderE164 - ? `${params.msg.senderName} (${params.msg.senderE164})` - : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); - recordPendingHistoryEntry({ - historyMap: params.groupHistories, - historyKey: params.groupHistoryKey, - limit: params.groupHistoryLimit, - entry: { - sender, - body: params.msg.body, - timestamp: params.msg.timestamp, - id: params.msg.id, - senderJid: params.msg.senderJid, - }, - }); - } + const sender = + params.msg.senderName && params.msg.senderE164 + ? `${params.msg.senderName} (${params.msg.senderE164})` + : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: params.groupHistoryKey, + limit: params.groupHistoryLimit, + entry: { + sender, + body: params.msg.body, + timestamp: params.msg.timestamp, + id: params.msg.id, + senderJid: params.msg.senderJid, + }, + }); return { shouldProcess: false }; } @@ -126,24 +124,22 @@ export function applyGroupGating(params: { params.logVerbose( `Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`, ); - if (params.groupHistoryLimit > 0) { - const sender = - params.msg.senderName && params.msg.senderE164 - ? `${params.msg.senderName} (${params.msg.senderE164})` - : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); - recordPendingHistoryEntry({ - historyMap: params.groupHistories, - historyKey: params.groupHistoryKey, - limit: params.groupHistoryLimit, - entry: { - sender, - body: params.msg.body, - timestamp: params.msg.timestamp, - id: params.msg.id, - senderJid: params.msg.senderJid, - }, - }); - } + const sender = + params.msg.senderName && params.msg.senderE164 + ? `${params.msg.senderName} (${params.msg.senderE164})` + : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown"); + recordPendingHistoryEntryIfEnabled({ + historyMap: params.groupHistories, + historyKey: params.groupHistoryKey, + limit: params.groupHistoryLimit, + entry: { + sender, + body: params.msg.body, + timestamp: params.msg.timestamp, + id: params.msg.id, + senderJid: params.msg.senderJid, + }, + }); return { shouldProcess: false }; } From d82ecaf9dc89dabd95258c44732d3d1d22a2d785 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:48:03 +0000 Subject: [PATCH 22/45] refactor: centralize inbound session updates --- extensions/bluebubbles/src/monitor.test.ts | 1 + .../matrix/src/matrix/monitor/handler.ts | 34 ++++++------- .../src/monitor-handler/message-handler.ts | 9 ++-- extensions/nextcloud-talk/src/inbound.ts | 15 +++--- extensions/zalo/src/monitor.ts | 7 +-- extensions/zalouser/src/monitor.ts | 7 +-- src/channels/session.ts | 49 +++++++++++++++++++ .../monitor/message-handler.process.ts | 36 ++++++-------- src/imessage/monitor/monitor-provider.ts | 41 ++++++---------- src/plugin-sdk/index.ts | 1 + src/plugins/runtime/index.ts | 2 + src/plugins/runtime/types.ts | 2 + src/signal/monitor/event-handler.ts | 36 ++++++-------- src/slack/monitor/message-handler/dispatch.ts | 18 ------- src/slack/monitor/message-handler/prepare.ts | 38 ++++++++------ src/telegram/bot-message-context.ts | 36 ++++++-------- 16 files changed, 170 insertions(+), 162 deletions(-) create mode 100644 src/channels/session.ts diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index c960b5c4e..0f9973de9 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -129,6 +129,7 @@ function createMockRuntime(): PluginRuntime { session: { resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], }, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 10db2be20..c1b46ffd3 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -487,29 +487,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam OriginatingTo: `room:${roomId}`, }); - void core.channel.session - .recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }) - .catch((err) => { + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { logger.warn( { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, "failed updating session meta", ); - }); - - if (isDirectMessage) { - await core.channel.session.updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - channel: "matrix", - to: `room:${roomId}`, - accountId: route.accountId, - ctx: ctxPayload, - }); - } + }, + }); const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 79006ad70..715b6adf0 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -465,12 +465,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, + await core.channel.session.recordInboundSession({ + storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); + }, }); logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 1c6984848..bfa18d834 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -287,15 +287,14 @@ export async function handleNextcloudTalkInbound(params: { CommandAuthorized: commandAuthorized, }); - void core.channel.session - .recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }) - .catch((err) => { + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); - }); + }, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 939dcdbde..44a279354 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -570,12 +570,13 @@ async function processMessageWithPipeline(params: { OriginatingTo: `zalo:${chatId}`, }); - void core.channel.session.recordSessionMetaFromInbound({ + await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + }, }); const tableMode = core.channel.text.resolveMarkdownTableMode({ diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 4015fcc8d..97e5a4be3 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -311,12 +311,13 @@ async function processMessage( OriginatingTo: `zalouser:${chatId}`, }); - void core.channel.session.recordSessionMetaFromInbound({ + await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + onRecordError: (err) => { + runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + }, }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ diff --git a/src/channels/session.ts b/src/channels/session.ts new file mode 100644 index 000000000..2d34d7f74 --- /dev/null +++ b/src/channels/session.ts @@ -0,0 +1,49 @@ +import type { MsgContext } from "../auto-reply/templating.js"; +import { + recordSessionMetaFromInbound, + type GroupKeyResolution, + type SessionEntry, + updateLastRoute, +} from "../config/sessions.js"; + +export type InboundLastRouteUpdate = { + sessionKey: string; + channel: SessionEntry["lastChannel"]; + to: string; + accountId?: string; + threadId?: string | number; +}; + +export async function recordInboundSession(params: { + storePath: string; + sessionKey: string; + ctx: MsgContext; + groupResolution?: GroupKeyResolution | null; + createIfMissing?: boolean; + updateLastRoute?: InboundLastRouteUpdate; + onRecordError: (err: unknown) => void; +}): Promise { + const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params; + void recordSessionMetaFromInbound({ + storePath, + sessionKey, + ctx, + groupResolution, + createIfMissing, + }).catch(params.onRecordError); + + const update = params.updateLastRoute; + if (!update) return; + await updateLastRoute({ + storePath, + sessionKey: update.sessionKey, + deliveryContext: { + channel: update.channel, + to: update.to, + accountId: update.accountId, + threadId: update.threadId, + }, + ctx, + groupResolution, + }); +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index ce5212d9c..09593db95 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -25,12 +25,8 @@ import { import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { recordInboundSession } from "../../channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; @@ -293,27 +289,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`discord: failed updating session meta: ${String(err)}`); + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "discord", + to: `user:${author.id}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`discord: failed updating session meta: ${String(err)}`); + }, }); - if (isDirectMessage) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "discord", - to: `user:${author.id}`, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); logVerbose( diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index ab542ad64..576218416 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -31,17 +31,13 @@ import { } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -509,30 +505,25 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P OriginatingTo: imessageTo, }); - void recordSessionMetaFromInbound({ + const updateTarget = (isGroup ? chatTarget : undefined) || sender; + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`imessage: failed updating session meta: ${String(err)}`); + updateLastRoute: + !isGroup && updateTarget + ? { + sessionKey: route.mainSessionKey, + channel: "imessage", + to: updateTarget, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`imessage: failed updating session meta: ${String(err)}`); + }, }); - if (!isGroup) { - const to = (isGroup ? chatTarget : undefined) || sender; - if (to) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "imessage", - to, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - } - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n"); logVerbose( diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index b349df7c2..d02187d90 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -140,6 +140,7 @@ export { resolveTelegramGroupRequireMention, resolveWhatsAppGroupRequireMention, } from "../channels/plugins/group-mentions.js"; +export { recordInboundSession } from "../channels/session.js"; export { buildChannelKeyCandidates, normalizeChannelSlug, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 504d5f034..534d3361b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -27,6 +27,7 @@ import { handleSlackAction } from "../../agents/tools/slack-actions.js"; import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { recordInboundSession } from "../../channels/session.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; @@ -193,6 +194,7 @@ export function createPluginRuntime(): PluginRuntime { resolveStorePath, readSessionUpdatedAt, recordSessionMetaFromInbound, + recordInboundSession, updateLastRoute, }, mentions: { diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 7351bc8da..3cda8ee51 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -54,6 +54,7 @@ type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").forma type ResolveEnvelopeFormatOptions = typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions; type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir; +type RecordInboundSession = typeof import("../../channels/session.js").recordInboundSession; type RecordSessionMetaFromInbound = typeof import("../../config/sessions.js").recordSessionMetaFromInbound; type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; @@ -208,6 +209,7 @@ export type PluginRuntime = { resolveStorePath: ResolveStorePath; readSessionUpdatedAt: ReadSessionUpdatedAt; recordSessionMetaFromInbound: RecordSessionMetaFromInbound; + recordInboundSession: RecordInboundSession; updateLastRoute: UpdateLastRoute; }; mentions: { diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 01c1cc727..c24ed0f90 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -24,12 +24,8 @@ import { } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; +import { recordInboundSession } from "../../channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -159,27 +155,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { OriginatingTo: signalTo, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`signal: failed updating session meta: ${String(err)}`); + updateLastRoute: !entry.isGroup + ? { + sessionKey: route.mainSessionKey, + channel: "signal", + to: entry.senderRecipient, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`signal: failed updating session meta: ${String(err)}`); + }, }); - if (!entry.isGroup) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "signal", - to: entry.senderRecipient, - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n"); logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index e06795f59..80beee623 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -11,7 +11,6 @@ import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; -import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; import { removeSlackReaction } from "../../actions.js"; import { resolveSlackThreadTargets } from "../../threading.js"; @@ -25,23 +24,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const cfg = ctx.cfg; const runtime = ctx.runtime; - if (prepared.isDirectMessage) { - const sessionCfg = cfg.session; - const storePath = resolveStorePath(sessionCfg?.store, { - agentId: route.agentId, - }); - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "slack", - to: `user:${message.user}`, - accountId: route.accountId, - }, - ctx: prepared.ctxPayload, - }); - } - const { statusThreadTs } = resolveSlackThreadTargets({ message, replyToMode: ctx.replyToMode, diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 06f77f620..8014a3ef7 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -27,11 +27,8 @@ import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, -} from "../../../config/sessions.js"; +import { recordInboundSession } from "../../../channels/session.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -511,19 +508,28 @@ export async function prepareSlackMessage(params: { OriginatingTo: slackTo, }) satisfies FinalizedMsgContext; - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, - sessionKey: sessionKey, + sessionKey, ctx: ctxPayload, - }).catch((err) => { - ctx.logger.warn( - { - error: String(err), - storePath, - sessionKey, - }, - "failed updating session meta", - ); + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "slack", + to: `user:${message.user}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + ctx.logger.warn( + { + error: String(err), + storePath, + sessionKey, + }, + "failed updating session meta", + ); + }, }); const replyTarget = ctxPayload.To ?? undefined; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index e92222e1c..c9a2d52ce 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -12,13 +12,9 @@ import { import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; +import { recordInboundSession } from "../channels/session.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../config/sessions.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; @@ -519,12 +515,21 @@ export const buildTelegramMessageContext = async ({ OriginatingTo: `telegram:${chatId}`, }); - void recordSessionMetaFromInbound({ + await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - }).catch((err) => { - logVerbose(`telegram: failed updating session meta: ${String(err)}`); + updateLastRoute: !isGroup + ? { + sessionKey: route.mainSessionKey, + channel: "telegram", + to: String(chatId), + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logVerbose(`telegram: failed updating session meta: ${String(err)}`); + }, }); if (replyTarget && shouldLogVerbose()) { @@ -540,19 +545,6 @@ export const buildTelegramMessageContext = async ({ ); } - if (!isGroup) { - await updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "telegram", - to: String(chatId), - accountId: route.accountId, - }, - ctx: ctxPayload, - }); - } - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; From 8252ae2da10d3c630cf2fdfc06e3895f5d0c17f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 22:55:41 +0000 Subject: [PATCH 23/45] refactor: unify typing callbacks --- .../matrix/src/matrix/monitor/handler.ts | 17 ++++-- .../mattermost/src/mattermost/monitor.ts | 15 +++--- extensions/msteams/src/reply-dispatcher.ts | 15 +++--- src/channels/typing.ts | 27 ++++++++++ .../monitor/message-handler.process.ts | 8 ++- src/discord/monitor/typing.ts | 14 ++--- src/plugin-sdk/index.ts | 1 + src/signal/monitor/event-handler.ts | 14 ++--- src/slack/monitor/message-handler/dispatch.ts | 52 ++++++++++--------- src/telegram/bot-message-context.ts | 6 +-- src/telegram/bot-message-dispatch.ts | 8 ++- 11 files changed, 114 insertions(+), 63 deletions(-) create mode 100644 src/channels/typing.ts diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index c1b46ffd3..878e3e47c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,6 +1,7 @@ import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import { + createTypingCallbacks, formatAllowlistMatchMeta, type RuntimeEnv, } from "clawdbot/plugin-sdk"; @@ -552,6 +553,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingMatrix(roomId, true, undefined, client), + stop: () => sendTypingMatrix(roomId, false, undefined, client), + onStartError: (err) => { + logVerboseMessage(`matrix typing cue failed for room ${roomId}: ${String(err)}`); + }, + onStopError: (err) => { + logVerboseMessage(`matrix typing stop failed for room ${roomId}: ${String(err)}`); + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -574,10 +585,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onError: (err, info) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: () => - sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), - onIdle: () => - sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 245388d68..ba080294f 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,6 +7,7 @@ import type { RuntimeEnv, } from "clawdbot/plugin-sdk"; import { + createTypingCallbacks, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, @@ -307,11 +308,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }; const sendTypingIndicator = async (channelId: string, parentId?: string) => { - try { - await sendMattermostTyping(client, { channelId, parentId }); - } catch (err) { - logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`); - } + await sendMattermostTyping(client, { channelId, parentId }); }; const resolveChannelInfo = async (channelId: string): Promise => { @@ -717,6 +714,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} identityName: resolveIdentityName(cfg, route.agentId), }; + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingIndicator(channelId, threadRootId), + onStartError: (err) => { + logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`); + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -752,7 +755,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} onError: (err, info) => { runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); }, - onReplyStart: () => sendTypingIndicator(channelId, threadRootId), + onReplyStart: typingCallbacks.onReplyStart, }); await core.channel.reply.dispatchReplyFromConfig({ diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index f711c8240..83adffb7b 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,4 +1,5 @@ import { + createTypingCallbacks, resolveChannelMediaMaxBytes, type ClawdbotConfig, type MSTeamsReplyStyle, @@ -39,12 +40,14 @@ export function createMSTeamsReplyDispatcher(params: { }) { const core = getMSTeamsRuntime(); const sendTypingIndicator = async () => { - try { - await params.context.sendActivities([{ type: "typing" }]); - } catch { - // Typing indicator is best-effort. - } + await params.context.sendActivities([{ type: "typing" }]); }; + const typingCallbacks = createTypingCallbacks({ + start: sendTypingIndicator, + onStartError: () => { + // Typing indicator is best-effort. + }, + }); return core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig( @@ -102,6 +105,6 @@ export function createMSTeamsReplyDispatcher(params: { hint, }); }, - onReplyStart: sendTypingIndicator, + onReplyStart: typingCallbacks.onReplyStart, }); } diff --git a/src/channels/typing.ts b/src/channels/typing.ts new file mode 100644 index 000000000..9c47fad89 --- /dev/null +++ b/src/channels/typing.ts @@ -0,0 +1,27 @@ +export type TypingCallbacks = { + onReplyStart: () => Promise; + onIdle?: () => void; +}; + +export function createTypingCallbacks(params: { + start: () => Promise; + stop?: () => Promise; + onStartError: (err: unknown) => void; + onStopError?: (err: unknown) => void; +}): TypingCallbacks { + const onReplyStart = async () => { + try { + await params.start(); + } catch (err) { + params.onStartError(err); + } + }; + + const onIdle = params.stop + ? () => { + void params.stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + } + : undefined; + + return { onReplyStart, onIdle }; +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 09593db95..b94d860be 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -12,6 +12,7 @@ import { removeAckReactionAfterReply, shouldAckReaction as shouldAckReactionGate, } from "../../channels/ack-reactions.js"; +import { createTypingCallbacks } from "../../channels/typing.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, @@ -350,7 +351,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) onError: (err, info) => { runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: () => sendTyping({ client, channelId: typingChannelId }), + onReplyStart: createTypingCallbacks({ + start: () => sendTyping({ client, channelId: typingChannelId }), + onStartError: (err) => { + logVerbose(`discord typing cue failed for channel ${typingChannelId}: ${String(err)}`); + }, + }).onReplyStart, }); const { queuedFinal, counts } = await dispatchInboundMessage({ diff --git a/src/discord/monitor/typing.ts b/src/discord/monitor/typing.ts index f1ae61fdc..e9ce734d4 100644 --- a/src/discord/monitor/typing.ts +++ b/src/discord/monitor/typing.ts @@ -1,15 +1,9 @@ import type { Client } from "@buape/carbon"; -import { logVerbose } from "../../globals.js"; - export async function sendTyping(params: { client: Client; channelId: string }) { - try { - const channel = await params.client.fetchChannel(params.channelId); - if (!channel) return; - if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") { - await channel.triggerTyping(); - } - } catch (err) { - logVerbose(`discord typing cue failed for channel ${params.channelId}: ${String(err)}`); + const channel = await params.client.fetchChannel(params.channelId); + if (!channel) return; + if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") { + await channel.triggerTyping(); } } diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index d02187d90..7b2d2d43f 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -129,6 +129,7 @@ export { shouldAckReaction, shouldAckReactionForWhatsApp, } from "../channels/ack-reactions.js"; +export { createTypingCallbacks } from "../channels/typing.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index c24ed0f90..802252487 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -25,6 +25,7 @@ import { import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import { recordInboundSession } from "../../channels/session.js"; +import { createTypingCallbacks } from "../../channels/typing.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -182,18 +183,19 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { identityName: resolveIdentityName(deps.cfg, route.agentId), }; - const onReplyStart = async () => { - try { + const typingCallbacks = createTypingCallbacks({ + start: async () => { if (!ctxPayload.To) return; await sendTypingSignal(ctxPayload.To, { baseUrl: deps.baseUrl, account: deps.account, accountId: deps.accountId, }); - } catch (err) { + }, + onStartError: (err) => { logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`); - } - }; + }, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix, @@ -214,7 +216,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onError: (err, info) => { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart, + onReplyStart: typingCallbacks.onReplyStart, }); const { queuedFinal } = await dispatchInboundMessage({ diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 80beee623..a846cd128 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -10,6 +10,7 @@ import { import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; +import { createTypingCallbacks } from "../../../channels/typing.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; import { removeSlackReaction } from "../../actions.js"; @@ -43,14 +44,30 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag hasRepliedRef, }); - const onReplyStart = async () => { - didSetStatus = true; - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "is typing...", - }); - }; + const typingCallbacks = createTypingCallbacks({ + start: async () => { + didSetStatus = true; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "is typing...", + }); + }, + stop: async () => { + if (!didSetStatus) return; + await ctx.setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); + }, + onStartError: (err) => { + runtime.error?.(danger(`slack typing cue failed: ${String(err)}`)); + }, + onStopError: (err) => { + runtime.error?.(danger(`slack typing stop failed: ${String(err)}`)); + }, + }); // Create mutable context for response prefix template interpolation let prefixContext: ResponsePrefixContext = { @@ -76,15 +93,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, onError: (err, info) => { runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`)); - if (didSetStatus) { - void ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "", - }); - } + typingCallbacks.onIdle?.(); }, - onReplyStart, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }); const { queuedFinal, counts } = await dispatchInboundMessage({ @@ -110,14 +122,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }); markDispatchIdle(); - if (didSetStatus) { - await ctx.setSlackThreadStatus({ - channelId: message.channel, - threadTs: statusThreadTs, - status: "", - }); - } - if (!queuedFinal) { if (prepared.isRoomish) { clearHistoryEntriesIfEnabled({ diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index c9a2d52ce..597a7cc79 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -156,11 +156,7 @@ export const buildTelegramMessageContext = async ({ } const sendTyping = async () => { - try { - await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)); - } catch (err) { - logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`); - } + await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)); }; const sendRecordVoice = async () => { diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index c48513b79..784bcb10e 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; +import { createTypingCallbacks } from "../channels/typing.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -158,7 +159,12 @@ export const dispatchTelegramMessage = async ({ onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); }, - onReplyStart: sendTyping, + onReplyStart: createTypingCallbacks({ + start: sendTyping, + onStartError: (err) => { + logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`); + }, + }).onReplyStart, }, replyOptions: { skillFilter, From 1113f17d4c75764f2ec86d7e8bcd599933562bac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:04:09 +0000 Subject: [PATCH 24/45] refactor: share reply prefix context --- .../matrix/src/matrix/monitor/handler.ts | 7 +- .../mattermost/src/mattermost/monitor.ts | 20 ++---- extensions/msteams/src/reply-dispatcher.ts | 64 +++++++++++-------- src/channels/reply-prefix.ts | 41 ++++++++++++ .../monitor/message-handler.process.ts | 27 ++------ src/imessage/monitor/monitor-provider.ts | 28 ++------ src/plugin-sdk/index.ts | 1 + src/signal/monitor/event-handler.ts | 26 ++------ src/slack/monitor/message-handler/dispatch.ts | 44 +++++++------ src/telegram/bot-message-dispatch.ts | 21 ++---- src/web/auto-reply/monitor/process-message.ts | 33 +++------- 11 files changed, 145 insertions(+), 167 deletions(-) create mode 100644 src/channels/reply-prefix.ts diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 878e3e47c..fdb3029b1 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,6 +1,7 @@ import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import { + createReplyPrefixContext, createTypingCallbacks, formatAllowlistMatchMeta, type RuntimeEnv, @@ -553,6 +554,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", accountId: route.accountId, }); + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingMatrix(roomId, true, undefined, client), stop: () => sendTypingMatrix(roomId, false, undefined, client), @@ -565,8 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) - .responsePrefix, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverMatrixReplies({ @@ -596,6 +598,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam replyOptions: { ...replyOptions, skillFilter: roomConfig?.skills, + onModelSelected: prefixContext.onModelSelected, }, }); markDispatchIdle(); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index ba080294f..03a591924 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -7,6 +7,7 @@ import type { RuntimeEnv, } from "clawdbot/plugin-sdk"; import { + createReplyPrefixContext, createTypingCallbacks, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, @@ -31,12 +32,9 @@ import { } from "./client.js"; import { createDedupeCache, - extractShortModelName, formatInboundFromLabel, rawDataToString, - resolveIdentityName, resolveThreadSessionKeys, - type ResponsePrefixContext, } from "./monitor-helpers.js"; import { sendMessageMattermost } from "./send.js"; @@ -710,9 +708,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const typingCallbacks = createTypingCallbacks({ start: () => sendTypingIndicator(channelId, threadRootId), @@ -722,9 +718,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) - .responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); @@ -766,12 +761,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected: (ctx) => { - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, }); markDispatchIdle(); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 83adffb7b..31e334de2 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,4 +1,5 @@ import { + createReplyPrefixContext, createTypingCallbacks, resolveChannelMediaMaxBytes, type ClawdbotConfig, @@ -48,17 +49,20 @@ export function createMSTeamsReplyDispatcher(params: { // Typing indicator is best-effort. }, }); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.agentId, + }); - return core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig( - params.cfg, - params.agentId, - ).responsePrefix, - humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), - deliver: async (payload) => { - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg: params.cfg, - channel: "msteams", + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", }); const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, @@ -90,21 +94,27 @@ export function createMSTeamsReplyDispatcher(params: { mediaMaxBytes, }); if (ids.length > 0) params.onSentMessageIds?.(ids); - }, - onError: (err, info) => { - const errMsg = formatUnknownError(err); - const classification = classifyMSTeamsSendError(err); - const hint = formatMSTeamsSendErrorHint(classification); - params.runtime.error?.( - `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, - ); - params.log.error("reply failed", { - kind: info.kind, - error: errMsg, - classification, - hint, - }); - }, - onReplyStart: typingCallbacks.onReplyStart, - }); + }, + onError: (err, info) => { + const errMsg = formatUnknownError(err); + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + params.runtime.error?.( + `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, + ); + params.log.error("reply failed", { + kind: info.kind, + error: errMsg, + classification, + hint, + }); + }, + onReplyStart: typingCallbacks.onReplyStart, + }); + + return { + dispatcher, + replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, + markDispatchIdle, + }; } diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts new file mode 100644 index 000000000..4897426d0 --- /dev/null +++ b/src/channels/reply-prefix.ts @@ -0,0 +1,41 @@ +import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { GetReplyOptions } from "../auto-reply/types.js"; +import { + extractShortModelName, + type ResponsePrefixContext, +} from "../auto-reply/reply/response-prefix-template.js"; + +type ModelSelectionContext = Parameters>[0]; + +export type ReplyPrefixContextBundle = { + prefixContext: ResponsePrefixContext; + responsePrefix?: string; + responsePrefixContextProvider: () => ResponsePrefixContext; + onModelSelected: (ctx: ModelSelectionContext) => void; +}; + +export function createReplyPrefixContext(params: { + cfg: ClawdbotConfig; + agentId: string; +}): ReplyPrefixContextBundle { + const { cfg, agentId } = params; + const prefixContext: ResponsePrefixContext = { + identityName: resolveIdentityName(cfg, agentId), + }; + + const onModelSelected = (ctx: ModelSelectionContext) => { + // Mutate the object directly instead of reassigning to ensure closures see updates. + prefixContext.provider = ctx.provider; + prefixContext.model = extractShortModelName(ctx.model); + prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; + prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + }; + + return { + prefixContext, + responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix, + responsePrefixContextProvider: () => prefixContext, + onModelSelected, + }; +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index b94d860be..172f885ac 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -1,17 +1,9 @@ -import { - resolveAckReaction, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js"; import { removeAckReactionAfterReply, shouldAckReaction as shouldAckReactionGate, } from "../../channels/ack-reactions.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { formatInboundEnvelope, @@ -318,10 +310,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? deliverTarget.slice("channel:".length) : message.channelId; - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "discord", @@ -329,8 +318,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const replyToId = replyReference.use(); @@ -371,11 +360,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? !discordConfig.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 576218416..8db3831ef 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -1,14 +1,6 @@ import fs from "node:fs/promises"; -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { @@ -31,6 +23,7 @@ import { } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; import { @@ -531,14 +524,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const dispatcher = createReplyDispatcher({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { await deliverReplies({ @@ -565,13 +555,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P typeof accountInfo.config.blockStreaming === "boolean" ? !accountInfo.config.blockStreaming : undefined, - onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, }); if (!queuedFinal) { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7b2d2d43f..23f6582cb 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -130,6 +130,7 @@ export { shouldAckReactionForWhatsApp, } from "../channels/ack-reactions.js"; export { createTypingCallbacks } from "../channels/typing.js"; +export { createReplyPrefixContext } from "../channels/reply-prefix.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 802252487..944b66cd0 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -1,12 +1,4 @@ -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../auto-reply/reply/response-prefix-template.js"; +import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { formatInboundEnvelope, @@ -24,6 +16,7 @@ import { } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; +import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; @@ -178,10 +171,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); } - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(deps.cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg: deps.cfg, agentId: route.agentId }); const typingCallbacks = createTypingCallbacks({ start: async () => { @@ -198,8 +188,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), deliver: async (payload) => { await deps.deliverReplies({ @@ -228,11 +218,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { disableBlockStreaming: typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index a846cd128..14f9cfb61 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -1,17 +1,11 @@ -import { - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - resolveIdentityName, -} from "../../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../../auto-reply/reply/response-prefix-template.js"; +import { resolveHumanDelayConfig } from "../../../agents/identity.js"; import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; +import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../../channels/typing.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; +import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; import { removeSlackReaction } from "../../actions.js"; import { resolveSlackThreadTargets } from "../../threading.js"; @@ -25,6 +19,23 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const cfg = ctx.cfg; const runtime = ctx.runtime; + if (prepared.isDirectMessage) { + const sessionCfg = cfg.session; + const storePath = resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); + await updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + deliveryContext: { + channel: "slack", + to: `user:${message.user}`, + accountId: route.accountId, + }, + ctx: prepared.ctxPayload, + }); + } + const { statusThreadTs } = resolveSlackThreadTargets({ message, replyToMode: ctx.replyToMode, @@ -69,14 +80,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, }); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { const replyThreadTs = replyPlan.nextThreadTs(); @@ -112,11 +120,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag ? !account.config.blockStreaming : undefined, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 784bcb10e..55d1e9f87 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -1,13 +1,9 @@ // @ts-nocheck -import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../auto-reply/reply/response-prefix-template.js"; import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; +import { createReplyPrefixContext } from "../channels/reply-prefix.js"; import { createTypingCallbacks } from "../channels/typing.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; @@ -122,10 +118,7 @@ export const dispatchTelegramMessage = async ({ Boolean(draftStream) || (typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(cfg, route.agentId), - }; + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", @@ -136,8 +129,8 @@ export const dispatchTelegramMessage = async ({ ctx: ctxPayload, cfg, dispatcherOptions: { - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, deliver: async (payload, info) => { if (info.kind === "final") { await flushDraft(); @@ -176,11 +169,7 @@ export const dispatchTelegramMessage = async ({ : undefined, disableBlockStreaming, onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; + prefixContext.onModelSelected(ctx); }, }, }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index c1d280a65..57ad5448f 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -1,12 +1,4 @@ -import { - resolveEffectiveMessagesConfig, - resolveIdentityName, - resolveIdentityNamePrefix, -} from "../../../agents/identity.js"; -import { - extractShortModelName, - type ResponsePrefixContext, -} from "../../../auto-reply/reply/response-prefix-template.js"; +import { resolveIdentityNamePrefix } from "../../../agents/identity.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; import { formatInboundEnvelope, @@ -22,6 +14,7 @@ import type { ReplyPayload } from "../../../auto-reply/types.js"; import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-detection.js"; import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { toLocationContext } from "../../../channels/location.js"; +import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; import type { loadConfig } from "../../../config/config.js"; import { readSessionUpdatedAt, @@ -247,22 +240,20 @@ export async function processMessage(params: { ? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg }) : undefined; const configuredResponsePrefix = params.cfg.messages?.responsePrefix; - const resolvedMessages = resolveEffectiveMessagesConfig(params.cfg, params.route.agentId); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.route.agentId, + }); const isSelfChat = params.msg.chatType !== "group" && Boolean(params.msg.selfE164) && normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? ""); const responsePrefix = - resolvedMessages.responsePrefix ?? + prefixContext.responsePrefix ?? (configuredResponsePrefix === undefined && isSelfChat ? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]") : undefined); - // Create mutable context for response prefix template interpolation - let prefixContext: ResponsePrefixContext = { - identityName: resolveIdentityName(params.cfg, params.route.agentId), - }; - const ctxPayload = finalizeInboundContext({ Body: combinedBody, RawBody: params.msg.body, @@ -334,7 +325,7 @@ export async function processMessage(params: { replyResolver: params.replyResolver, dispatcherOptions: { responsePrefix, - responsePrefixContextProvider: () => prefixContext, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, onHeartbeatStrip: () => { if (!didLogHeartbeatStrip) { didLogHeartbeatStrip = true; @@ -393,13 +384,7 @@ export async function processMessage(params: { typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" ? !params.cfg.channels.whatsapp.blockStreaming : undefined, - onModelSelected: (ctx) => { - // Mutate the object directly instead of reassigning to ensure the closure sees updates - prefixContext.provider = ctx.provider; - prefixContext.model = extractShortModelName(ctx.model); - prefixContext.modelFull = `${ctx.provider}/${ctx.model}`; - prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; - }, + onModelSelected: prefixContext.onModelSelected, }, }); From 07ce1d73ff43af90a672b198c6c920ed6577c06a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:10:59 +0000 Subject: [PATCH 25/45] refactor: standardize control command gating --- extensions/bluebubbles/src/monitor.ts | 23 +++++++------- .../matrix/src/matrix/monitor/handler.ts | 16 +++++----- .../mattermost/src/mattermost/monitor.ts | 31 ++++++++++--------- .../src/monitor-handler/message-handler.ts | 9 ++++-- extensions/nextcloud-talk/src/inbound.ts | 22 ++++++------- src/imessage/monitor/monitor-provider.ts | 30 +++++++++--------- src/plugin-sdk/index.ts | 1 + src/signal/monitor/event-handler.ts | 24 +++++++------- 8 files changed, 82 insertions(+), 74 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 1a7a68058..86d7bf63e 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import { resolveAckReaction } from "clawdbot/plugin-sdk"; +import { resolveAckReaction, resolveControlCommandGate } from "clawdbot/plugin-sdk"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { downloadBlueBubblesAttachment } from "./attachments.js"; @@ -1346,18 +1346,19 @@ async function processMessage( }) : false; const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; - const commandAuthorized = isGroup - ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAuthorized; + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCmd, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; // Block control commands from unauthorized senders in groups - if (isGroup && hasControlCmd && !commandAuthorized) { + if (isGroup && commandGate.shouldBlock) { logVerbose( core, runtime, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index fdb3029b1..8bc43879f 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -4,6 +4,7 @@ import { createReplyPrefixContext, createTypingCallbacks, formatAllowlistMatchMeta, + resolveControlCommandGate, type RuntimeEnv, } from "clawdbot/plugin-sdk"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -378,20 +379,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam userName: senderName, }) : false; - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, ], + allowTextCommands, + hasControlCommand: hasControlCommandInMessage, }); - if ( - isRoom && - allowTextCommands && - core.channel.text.hasControlCommand(bodyText, cfg) && - !commandAuthorized - ) { + const commandAuthorized = commandGate.commandAuthorized; + if (isRoom && commandGate.shouldBlock) { logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); return; } @@ -411,7 +411,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam !wasMentioned && !hasExplicitMention && commandAuthorized && - core.channel.text.hasControlCommand(bodyText); + hasControlCommandInMessage; const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { logger.info({ roomId, reason: "no-mention" }, "skipping room message"); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 03a591924..8dd5c6f9b 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -13,6 +13,7 @@ import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, resolveChannelMediaMaxBytes, type HistoryEntry, } from "clawdbot/plugin-sdk"; @@ -398,7 +399,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, surface: "mattermost", }); - const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); + const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); + const isControlCommand = allowTextCommands && hasControlCommand; const useAccessGroups = cfg.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed({ senderId, @@ -410,19 +412,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderName, allowFrom: effectiveGroupAllowFrom, }); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: effectiveGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); const commandAuthorized = - kind === "dm" - ? dmPolicy === "open" || senderAllowedForCommands - : core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { - configured: effectiveGroupAllowFrom.length > 0, - allowed: groupAllowedForCommands, - }, - ], - }); + kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized; if (kind === "dm") { if (dmPolicy === "disabled") { @@ -483,7 +486,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } - if (kind !== "dm" && isControlCommand && !commandAuthorized) { + if (kind !== "dm" && commandGate.shouldBlock) { logVerboseMessage( `mattermost: drop control command from unauthorized sender ${senderId}`, ); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 715b6adf0..9bf113584 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -3,6 +3,7 @@ import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, resolveMentionGating, formatAllowlistMatchMeta, type HistoryEntry, @@ -251,14 +252,18 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { senderId, senderName, }); - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, }); - if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) { + const commandAuthorized = commandGate.commandAuthorized; + if (commandGate.shouldBlock) { logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); return; } diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index bfa18d834..ec99ba8f3 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,4 +1,4 @@ -import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import { resolveControlCommandGate, type ClawdbotConfig, type RuntimeEnv } from "clawdbot/plugin-sdk"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { @@ -118,7 +118,11 @@ export async function handleNextcloudTalkInbound(params: { senderId, senderName, }).allowed; - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + const hasControlCommand = core.channel.text.hasControlCommand( + rawBody, + config as ClawdbotConfig, + ); + const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ { @@ -127,7 +131,10 @@ export async function handleNextcloudTalkInbound(params: { allowed: senderAllowedForCommands, }, ], + allowTextCommands, + hasControlCommand, }); + const commandAuthorized = commandGate.commandAuthorized; if (isGroup) { const groupAllow = resolveNextcloudTalkGroupAllow({ @@ -188,12 +195,7 @@ export async function handleNextcloudTalkInbound(params: { } } - if ( - isGroup && - allowTextCommands && - core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) && - commandAuthorized !== true - ) { + if (isGroup && commandGate.shouldBlock) { runtime.log?.( `nextcloud-talk: drop control command from unauthorized sender ${senderId}`, ); @@ -212,10 +214,6 @@ export async function handleNextcloudTalkInbound(params: { wildcardConfig: roomMatch.wildcardConfig, }) : false; - const hasControlCommand = core.channel.text.hasControlCommand( - rawBody, - config as ClawdbotConfig, - ); const mentionGate = resolveNextcloudTalkMentionGate({ isGroup, requireMention: shouldRequireMention, diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 8db3831ef..f9126346e 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -41,7 +41,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { truncateUtf16Safe } from "../../utils.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { probeIMessage } from "../probe.js"; @@ -372,25 +372,23 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P chatIdentifier, }) : false; - const commandAuthorized = isGroup - ? resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAuthorized; - if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) { + const hasControlCommandInMessage = hasControlCommand(messageText, cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; + if (isGroup && commandGate.shouldBlock) { logVerbose(`imessage: drop control command from unauthorized sender ${sender}`); return; } const shouldBypassMention = - isGroup && - requireMention && - !mentioned && - commandAuthorized && - hasControlCommand(messageText); + isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage; const effectiveWasMentioned = mentioned || shouldBypassMention; if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) { logVerbose(`imessage: skipping group message (no mention)`); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 23f6582cb..9ee7015bf 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -134,6 +134,7 @@ export { createReplyPrefixContext } from "../channels/reply-prefix.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { resolveControlCommandGate } from "../channels/command-gating.js"; export { resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 944b66cd0..2c9105664 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -30,7 +30,7 @@ import { } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { normalizeE164 } from "../../utils.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { formatSignalPairingIdLine, formatSignalSenderDisplay, @@ -439,16 +439,18 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow); const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow); - const commandAuthorized = isGroup - ? resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, - { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, - ], - }) - : dmAllowed; - if (isGroup && hasControlCommand(messageText, deps.cfg) && !commandAuthorized) { + const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAllowed; + if (isGroup && commandGate.shouldBlock) { logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`); return; } From aeb6b2ffad28ff5d5ae6ac3b0d9bee70b37ce90b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:20:07 +0000 Subject: [PATCH 26/45] refactor: standardize channel logging --- extensions/bluebubbles/src/monitor.ts | 38 +++++++++++++------ .../matrix/src/matrix/monitor/handler.ts | 25 ++++++++++-- .../mattermost/src/mattermost/monitor.ts | 18 +++++++-- .../src/monitor-handler/message-handler.ts | 8 +++- extensions/msteams/src/reply-dispatcher.ts | 10 ++++- extensions/nextcloud-talk/src/inbound.ts | 16 ++++++-- src/channels/logging.ts | 33 ++++++++++++++++ .../monitor/message-handler.preflight.ts | 8 +++- .../monitor/message-handler.process.ts | 17 +++++++-- src/imessage/monitor/monitor-provider.ts | 8 +++- src/plugin-sdk/index.ts | 1 + src/signal/monitor/event-handler.ts | 15 +++++++- src/slack/monitor/message-handler/dispatch.ts | 27 ++++++++++--- src/slack/monitor/message-handler/prepare.ts | 8 +++- src/telegram/bot-message-context.ts | 8 +++- src/telegram/bot-message-dispatch.ts | 17 +++++++-- 16 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 src/channels/logging.ts diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 86d7bf63e..7c860d761 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1,7 +1,13 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import { resolveAckReaction, resolveControlCommandGate } from "clawdbot/plugin-sdk"; +import { + logAckFailure, + logInboundDrop, + logTypingFailure, + resolveAckReaction, + resolveControlCommandGate, +} from "clawdbot/plugin-sdk"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { downloadBlueBubblesAttachment } from "./attachments.js"; @@ -1359,11 +1365,12 @@ async function processMessage( // Block control commands from unauthorized senders in groups if (isGroup && commandGate.shouldBlock) { - logVerbose( - core, - runtime, - `bluebubbles: drop control command from unauthorized sender ${message.senderId}`, - ); + logInboundDrop({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + reason: "control command (unauthorized)", + target: message.senderId, + }); return; } @@ -1765,11 +1772,12 @@ async function processMessage( opts: { cfg: config, accountId: account.accountId }, }), onError: (err) => { - logVerbose( - core, - runtime, - `ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`, - ); + logAckFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + target: `${chatGuidForActions}/${ackMessageId}`, + error: err, + }); }, }); } @@ -1779,7 +1787,13 @@ async function processMessage( cfg: config, accountId: account.accountId, }).catch((err) => { - logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`); + logTypingFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + action: "stop", + target: chatGuidForActions, + error: err, + }); }); } } diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 8bc43879f..2ba7cbef0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -4,6 +4,8 @@ import { createReplyPrefixContext, createTypingCallbacks, formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, resolveControlCommandGate, type RuntimeEnv, } from "clawdbot/plugin-sdk"; @@ -392,7 +394,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); const commandAuthorized = commandGate.commandAuthorized; if (isRoom && commandGate.shouldBlock) { - logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); + logInboundDrop({ + log: logVerboseMessage, + channel: "matrix", + reason: "control command (unauthorized)", + target: senderId, + }); return; } const shouldRequireMention = isRoom @@ -559,10 +566,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam start: () => sendTypingMatrix(roomId, true, undefined, client), stop: () => sendTypingMatrix(roomId, false, undefined, client), onStartError: (err) => { - logVerboseMessage(`matrix typing cue failed for room ${roomId}: ${String(err)}`); + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "start", + target: roomId, + error: err, + }); }, onStopError: (err) => { - logVerboseMessage(`matrix typing stop failed for room ${roomId}: ${String(err)}`); + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "stop", + target: roomId, + error: err, + }); }, }); const { dispatcher, replyOptions, markDispatchIdle } = diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 8dd5c6f9b..659ca83aa 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -9,6 +9,8 @@ import type { import { createReplyPrefixContext, createTypingCallbacks, + logInboundDrop, + logTypingFailure, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, @@ -487,9 +489,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } if (kind !== "dm" && commandGate.shouldBlock) { - logVerboseMessage( - `mattermost: drop control command from unauthorized sender ${senderId}`, - ); + logInboundDrop({ + log: logVerboseMessage, + channel: "mattermost", + reason: "control command (unauthorized)", + target: senderId, + }); return; } @@ -716,7 +721,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const typingCallbacks = createTypingCallbacks({ start: () => sendTypingIndicator(channelId, threadRootId), onStartError: (err) => { - logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`); + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: channelId, + error: err, + }); }, }); const { dispatcher, replyOptions, markDispatchIdle } = diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 9bf113584..16eb8fc0a 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -2,6 +2,7 @@ import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, + logInboundDrop, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, resolveMentionGating, @@ -264,7 +265,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const commandAuthorized = commandGate.commandAuthorized; if (commandGate.shouldBlock) { - logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); + logInboundDrop({ + log: logVerboseMessage, + channel: "msteams", + reason: "control command (unauthorized)", + target: senderId, + }); return; } diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 31e334de2..449a14fe2 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,6 +1,7 @@ import { createReplyPrefixContext, createTypingCallbacks, + logTypingFailure, resolveChannelMediaMaxBytes, type ClawdbotConfig, type MSTeamsReplyStyle, @@ -45,8 +46,13 @@ export function createMSTeamsReplyDispatcher(params: { }; const typingCallbacks = createTypingCallbacks({ start: sendTypingIndicator, - onStartError: () => { - // Typing indicator is best-effort. + onStartError: (err) => { + logTypingFailure({ + log: (message) => params.log.debug(message), + channel: "msteams", + action: "start", + error: err, + }); }, }); const prefixContext = createReplyPrefixContext({ diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index ec99ba8f3..77db9f338 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,4 +1,9 @@ -import { resolveControlCommandGate, type ClawdbotConfig, type RuntimeEnv } from "clawdbot/plugin-sdk"; +import { + logInboundDrop, + resolveControlCommandGate, + type ClawdbotConfig, + type RuntimeEnv, +} from "clawdbot/plugin-sdk"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { @@ -196,9 +201,12 @@ export async function handleNextcloudTalkInbound(params: { } if (isGroup && commandGate.shouldBlock) { - runtime.log?.( - `nextcloud-talk: drop control command from unauthorized sender ${senderId}`, - ); + logInboundDrop({ + log: (message) => runtime.log?.(message), + channel: CHANNEL_ID, + reason: "control command (unauthorized)", + target: senderId, + }); return; } diff --git a/src/channels/logging.ts b/src/channels/logging.ts new file mode 100644 index 000000000..0e124a14d --- /dev/null +++ b/src/channels/logging.ts @@ -0,0 +1,33 @@ +export type LogFn = (message: string) => void; + +export function logInboundDrop(params: { + log: LogFn; + channel: string; + reason: string; + target?: string; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + params.log(`${params.channel}: drop ${params.reason}${target}`); +} + +export function logTypingFailure(params: { + log: LogFn; + channel: string; + target?: string; + action?: "start" | "stop"; + error: unknown; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + const action = params.action ? ` action=${params.action}` : ""; + params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`); +} + +export function logAckFailure(params: { + log: LogFn; + channel: string; + target?: string; + error: unknown; +}): void { + const target = params.target ? ` target=${params.target}` : ""; + params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`); +} diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index d378ad871..5245fe253 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -21,6 +21,7 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { sendMessageDiscord } from "../send.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js"; +import { logInboundDrop } from "../../channels/logging.js"; import { allowListMatches, isDiscordGroupAllowedByPolicy, @@ -385,7 +386,12 @@ export async function preflightDiscordMessage( commandAuthorized = commandGate.commandAuthorized; if (commandGate.shouldBlock) { - logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`); + logInboundDrop({ + log: logVerbose, + channel: "discord", + reason: "control command (unauthorized)", + target: author.id, + }); return null; } } diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 172f885ac..5297caf49 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -3,6 +3,7 @@ import { removeAckReactionAfterReply, shouldAckReaction as shouldAckReactionGate, } from "../../channels/ack-reactions.js"; +import { logTypingFailure, logAckFailure } from "../../channels/logging.js"; import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../channels/typing.js"; import { @@ -343,7 +344,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) onReplyStart: createTypingCallbacks({ start: () => sendTyping({ client, channelId: typingChannelId }), onStartError: (err) => { - logVerbose(`discord typing cue failed for channel ${typingChannelId}: ${String(err)}`); + logTypingFailure({ + log: logVerbose, + channel: "discord", + target: typingChannelId, + error: err, + }); }, }).onReplyStart, }); @@ -388,9 +394,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) remove: () => removeReactionDiscord(message.channelId, message.id, ackReaction, { rest: client.rest }), onError: (err) => { - logVerbose( - `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`, - ); + logAckFailure({ + log: logVerbose, + channel: "discord", + target: `${message.channelId}/${message.id}`, + error: err, + }); }, }); if (isGuildMessage) { diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index f9126346e..fa0ce2195 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -23,6 +23,7 @@ import { } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { logInboundDrop } from "../../channels/logging.js"; import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; @@ -384,7 +385,12 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; if (isGroup && commandGate.shouldBlock) { - logVerbose(`imessage: drop control command from unauthorized sender ${sender}`); + logInboundDrop({ + log: logVerbose, + channel: "imessage", + reason: "control command (unauthorized)", + target: sender, + }); return; } const shouldBypassMention = diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 9ee7015bf..167838b52 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -131,6 +131,7 @@ export { } from "../channels/ack-reactions.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createReplyPrefixContext } from "../channels/reply-prefix.js"; +export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 2c9105664..72195ff78 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -16,6 +16,7 @@ import { } from "../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; +import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; import { createReplyPrefixContext } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; @@ -183,7 +184,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); }, onStartError: (err) => { - logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`); + logTypingFailure({ + log: logVerbose, + channel: "signal", + target: ctxPayload.To ?? undefined, + error: err, + }); }, }); @@ -451,7 +457,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }); const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAllowed; if (isGroup && commandGate.shouldBlock) { - logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`); + logInboundDrop({ + log: logVerbose, + channel: "signal", + reason: "control command (unauthorized)", + target: senderDisplay, + }); return; } diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 14f9cfb61..32968aa17 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -2,6 +2,7 @@ import { resolveHumanDelayConfig } from "../../../agents/identity.js"; import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js"; import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js"; import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../../../channels/logging.js"; import { createReplyPrefixContext } from "../../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../../channels/typing.js"; import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js"; @@ -55,6 +56,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag hasRepliedRef, }); + const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel; const typingCallbacks = createTypingCallbacks({ start: async () => { didSetStatus = true; @@ -73,10 +75,22 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }); }, onStartError: (err) => { - runtime.error?.(danger(`slack typing cue failed: ${String(err)}`)); + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "start", + target: typingTarget, + error: err, + }); }, onStopError: (err) => { - runtime.error?.(danger(`slack typing stop failed: ${String(err)}`)); + logTypingFailure({ + log: (message) => runtime.error?.(danger(message)), + channel: "slack", + action: "stop", + target: typingTarget, + error: err, + }); }, }); @@ -159,9 +173,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, ), onError: (err) => { - logVerbose( - `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`, - ); + logAckFailure({ + log: logVerbose, + channel: "slack", + target: `${message.channel}/${message.ts}`, + error: err, + }); }, }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8014a3ef7..24c251d2a 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -26,6 +26,7 @@ import { import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; +import { logInboundDrop } from "../../../channels/logging.js"; import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js"; import { recordInboundSession } from "../../../channels/session.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js"; @@ -265,7 +266,12 @@ export async function prepareSlackMessage(params: { const commandAuthorized = commandGate.commandAuthorized; if (isRoomish && commandGate.shouldBlock) { - logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`); + logInboundDrop({ + log: logVerbose, + channel: "slack", + reason: "control command (unauthorized)", + target: senderId, + }); return null; } diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 597a7cc79..e4a7d6780 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -23,6 +23,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; +import { logInboundDrop } from "../channels/logging.js"; import { buildGroupLabel, buildSenderLabel, @@ -306,7 +307,12 @@ export const buildTelegramMessageContext = async ({ (ent) => ent.type === "mention", ); if (isGroup && commandGate.shouldBlock) { - logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`); + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "control command (unauthorized)", + target: senderId ?? "unknown", + }); return null; } const activationOverride = resolveGroupActivation({ diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 55d1e9f87..98c5a6d40 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -3,6 +3,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { removeAckReactionAfterReply } from "../channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../channels/logging.js"; import { createReplyPrefixContext } from "../channels/reply-prefix.js"; import { createTypingCallbacks } from "../channels/typing.js"; import { danger, logVerbose } from "../globals.js"; @@ -155,7 +156,12 @@ export const dispatchTelegramMessage = async ({ onReplyStart: createTypingCallbacks({ start: sendTyping, onStartError: (err) => { - logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`); + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(chatId), + error: err, + }); }, }).onReplyStart, }, @@ -187,9 +193,12 @@ export const dispatchTelegramMessage = async ({ remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), onError: (err) => { if (!msg.message_id) return; - logVerbose( - `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`, - ); + logAckFailure({ + log: logVerbose, + channel: "telegram", + target: `${chatId}/${msg.message_id}`, + error: err, + }); }, }); if (isGroup && historyKey) { From c9a7c77b24ffd40fe3b208aacb90e38cae7515f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:22:08 +0000 Subject: [PATCH 27/45] test: cover typing and history helpers --- src/auto-reply/reply/history.test.ts | 44 ++++++++++++++++++++++++++++ src/channels/typing.test.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/channels/typing.test.ts diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts index addbf5860..7991731da 100644 --- a/src/auto-reply/reply/history.test.ts +++ b/src/auto-reply/reply/history.test.ts @@ -5,7 +5,9 @@ import { buildHistoryContextFromEntries, buildHistoryContextFromMap, buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, HISTORY_CONTEXT_MARKER, + recordPendingHistoryEntryIfEnabled, } from "./history.js"; import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; @@ -105,4 +107,46 @@ describe("history helpers", () => { expect(result).toContain(CURRENT_MESSAGE_MARKER); expect(result).toContain("current"); }); + + it("records pending entries only when enabled", () => { + const historyMap = new Map(); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 0, + entry: { sender: "A", body: "one" }, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: null, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); + }); + + it("clears history entries only when enabled", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); + expect(historyMap.get("group")).toEqual([]); + }); }); diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts new file mode 100644 index 000000000..42080b3c1 --- /dev/null +++ b/src/channels/typing.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTypingCallbacks } from "./typing.js"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("createTypingCallbacks", () => { + it("invokes start on reply start", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + expect(onStartError).not.toHaveBeenCalled(); + }); + + it("reports start errors", async () => { + const start = vi.fn().mockRejectedValue(new Error("fail")); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, onStartError }); + + await callbacks.onReplyStart(); + + expect(onStartError).toHaveBeenCalledTimes(1); + }); + + it("invokes stop on idle and reports stop errors", async () => { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockRejectedValue(new Error("stop")); + const onStartError = vi.fn(); + const onStopError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError }); + + callbacks.onIdle?.(); + await flush(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onStopError).toHaveBeenCalledTimes(1); + }); +}); From bf4544784a23204933f8e345625954ad17368246 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:30:52 +0000 Subject: [PATCH 28/45] fix: stabilize typing + summary merge --- src/channels/typing.ts | 5 +++-- src/discord/monitor/message-handler.process.ts | 7 +++++-- src/slack/monitor/message-handler/dispatch.ts | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/channels/typing.ts b/src/channels/typing.ts index 9c47fad89..f24c7f188 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -9,6 +9,7 @@ export function createTypingCallbacks(params: { onStartError: (err: unknown) => void; onStopError?: (err: unknown) => void; }): TypingCallbacks { + const stop = params.stop; const onReplyStart = async () => { try { await params.start(); @@ -17,9 +18,9 @@ export function createTypingCallbacks(params: { } }; - const onIdle = params.stop + const onIdle = stop ? () => { - void params.stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); + void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); } : undefined; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 5297caf49..f575e6e55 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -391,8 +391,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) removeAfterReply: removeAckAfterReply, ackReactionPromise, ackReactionValue: ackReaction, - remove: () => - removeReactionDiscord(message.channelId, message.id, ackReaction, { rest: client.rest }), + remove: async () => { + await removeReactionDiscord(message.channelId, message.id, ackReaction, { + rest: client.rest, + }); + }, onError: (err) => { logAckFailure({ log: logVerbose, diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 32968aa17..d31885cfa 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -68,6 +68,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }, stop: async () => { if (!didSetStatus) return; + didSetStatus = false; await ctx.setSlackThreadStatus({ channelId: message.channel, threadTs: statusThreadTs, From efec5fc751e1692002ef90c4d973aebb124ce6b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:37:04 +0000 Subject: [PATCH 29/45] docs: remove channel unify checklist --- docs/refactor/channel-unify.md | 103 --------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 docs/refactor/channel-unify.md diff --git a/docs/refactor/channel-unify.md b/docs/refactor/channel-unify.md deleted file mode 100644 index 9f81ffa64..000000000 --- a/docs/refactor/channel-unify.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -summary: "Checklist for unifying messaging channel logic" -read_when: - - Planning refactors across channel implementations - - Standardizing shared message handling behavior ---- -# Channel unification checklist - -Purpose: centralize repeated messaging logic so core + extensions stay consistent, testable, and easier to evolve. - -## Ack reactions (already centralized) -- [x] Shared gating helper for core channels. -- [x] Shared gating helper for extensions. -- [x] WhatsApp-specific gating helper (direct/group/mentions) aligned with activation. -- [ ] Optional: centralize “remove after reply” behavior (see below). - -## Ack reaction removal (after reply) -Problem: duplicated logic across Discord, Slack, Telegram, BlueBubbles. -- [ ] Create `channel.reactions.removeAfterReply()` helper that accepts: - - `removeAckAfterReply` flag - - ack promise + result boolean - - channel-specific remove fn + ids -- [ ] Wire in: - - `src/discord/monitor/message-handler.process.ts` - - `src/slack/monitor/message-handler/dispatch.ts` - - `src/telegram/bot-message-dispatch.ts` - - `extensions/bluebubbles/src/monitor.ts` -- [ ] Add unit tests for the helper (success + ack-failed paths). - -## Pending history buffering + flush -Problem: repeated “record pending history”, “prepend pending history”, and “clear history” patterns. -- [ ] Identify shared flow in: - - `src/discord/monitor/message-handler.preflight.ts` - - `src/discord/monitor/message-handler.process.ts` - - `src/slack/monitor/message-handler/prepare.ts` - - `src/telegram/bot-message-context.ts` - - `src/signal/monitor/event-handler.ts` - - `src/imessage/monitor/monitor-provider.ts` - - `extensions/mattermost/src/mattermost/monitor.ts` - - `src/web/auto-reply/monitor/group-gating.ts` -- [ ] Add helper(s) to `src/auto-reply/reply/history.ts`: - - `recordPendingIfBlocked()` (accepts allowlist/mention gating reason) - - `mergePendingIntoBody()` (returns combined body) - - `clearPendingHistory()` (wrapper to standardize historyKey, limits) -- [ ] Ensure per-channel metadata (sender label, timestamps, messageId) preserved. -- [ ] Add tests for helper(s); keep per-channel smoke tests. - -## Typing lifecycle -Problem: inconsistent typing start/stop handling and error logging. -- [ ] Add a shared typing adapter in core (ex: `src/channels/typing.ts`) that accepts: - - `startTyping` / `stopTyping` callbacks - - `onReplyStart` / `onReplyIdle` hooks from dispatcher - - TTL + interval config (reuse `auto-reply/reply/typing` machinery) -- [ ] Wire in: - - Discord (`src/discord/monitor/typing.ts`) - - Slack (`src/slack/monitor/message-handler/dispatch.ts`) - - Telegram (dispatch flow) - - Signal (`src/signal/monitor/event-handler.ts`) - - Matrix (`extensions/matrix/src/matrix/monitor/handler.ts`) - - Mattermost (`extensions/mattermost/src/mattermost/monitor.ts`) - - BlueBubbles (`extensions/bluebubbles/src/monitor.ts`) - - MS Teams (`extensions/msteams/src/reply-dispatcher.ts`) -- [ ] Add helper tests for start/stop and error handling. - -## Reply dispatcher wiring -Problem: channels hand-roll dispatcher glue; varies in error handling and typing. -- [ ] Add a shared wrapper that builds: - - reply dispatcher - - response prefix context - - table mode conversion -- [ ] Adopt in: - - Discord, Slack, Telegram (core) - - BlueBubbles, Matrix, Mattermost (extensions) -- [ ] Keep per-channel delivery adapter (send message / chunking). - -## Session meta + last route updates -Problem: repeated patterns for `recordSessionMetaFromInbound` and `updateLastRoute`. -- [ ] Add helper `channel.session.recordInbound()` that accepts: - - `storePath`, `sessionKey`, `ctx` - - optional `channel/accountId/target` for `updateLastRoute` -- [ ] Wire in: - - Discord, Slack, Telegram, Matrix, BlueBubbles - -## Control command gating patterns -Problem: similar gating flow per channel (allowlists + commands). -- [ ] Add a helper that merges: - - allowlist checks - - command gating decisions - - mention bypass evaluation -- [ ] Keep channel-specific identity/user resolution separate. - -## Error + verbose logging -Problem: inconsistent message formats across channels. -- [ ] Define canonical log helpers: - - `logInboundDrop(reason, meta)` - - `logAckFailure(meta)` - - `logTypingFailure(meta)` -- [ ] Apply to all channel handlers. - -## Docs + SDK -- [ ] Expose new helpers through `src/plugin-sdk/index.ts` + plugin runtime. -- [ ] Update `docs/tools/reactions.md` if ack semantics expand. -- [ ] Add `read_when` hints if new cross-cutting helpers are introduced. From 69f645c662c4888845f69f8b00477d7d42bde4f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:57:53 +0000 Subject: [PATCH 30/45] fix: auto-save voice wake words across apps --- CHANGELOG.md | 2 +- .../java/com/clawdbot/android/WakeWords.kt | 6 ++- .../com/clawdbot/android/ui/SettingsSheet.kt | 48 ++++++++++++------- .../com/clawdbot/android/WakeWordsTest.kt | 16 ++++++- .../Settings/VoiceWakeWordsSettingsView.swift | 37 ++++++++++---- .../Sources/Voice/VoiceWakePreferences.swift | 4 ++ .../ios/Tests/VoiceWakePreferencesTests.swift | 12 +++++ apps/macos/Sources/Clawdbot/Constants.swift | 2 + .../Sources/Clawdbot/VoiceWakeHelpers.swift | 2 + .../VoiceWakeHelpersTests.swift | 12 +++++ 10 files changed, 113 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09fcd603..81f667782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Docs: https://docs.clawd.bot - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes -- Gateway/WebChat: route inbound messages through the unified dispatch pipeline so /new works consistently across WebChat/TUI and channels. +- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt index 855a0de7c..d54ed1e08 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt @@ -8,10 +8,14 @@ object WakeWords { return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + fun parseIfChanged(input: String, current: List): List? { + val parsed = parseCommaSeparated(input) + return if (parsed == current) null else parsed + } + fun sanitize(words: List, defaults: List): List { val cleaned = words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } return cleaned.ifEmpty { defaults } } } - diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt index aee1059bd..e3a9b3ecb 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -49,7 +51,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode import com.clawdbot.android.MainViewModel import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.VoiceWakeMode +import com.clawdbot.android.WakeWords @Composable fun SettingsSheet(viewModel: MainViewModel) { @@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = remember { listOfNotNull(Build.MANUFACTURER, Build.MODEL) @@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) { } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val commitWakeWords = { + val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) + if (parsed != null) { + viewModel.setWakeWords(parsed) + } + } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> @@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) { value = wakeWordsText, onValueChange = setWakeWordsText, label = { Text("Wake Words (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier.fillMaxWidth().onFocusChanged { focusState -> + if (focusState.isFocused) { + wakeWordsHadFocus = true + } else if (wakeWordsHadFocus) { + wakeWordsHadFocus = false + commitWakeWords() + } + }, singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + commitWakeWords() + focusManager.clearFocus() + }, + ), ) } - item { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText) - viewModel.setWakeWords(parsed) - }, - enabled = isConnected, - ) { - Text("Save + Sync") - } - - Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } - } - } + item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } item { Text( if (isConnected) { diff --git a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt index 1d61383e8..9363e810c 100644 --- a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt +++ b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt @@ -1,6 +1,7 @@ package com.clawdbot.android import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class WakeWordsTest { @@ -32,5 +33,18 @@ class WakeWordsTest { assertEquals("w1", sanitized.first()) assertEquals("w${WakeWords.maxWords}", sanitized.last()) } -} + @Test + fun parseIfChangedSkipsWhenUnchanged() { + val current = listOf("clawd", "claude") + val parsed = WakeWords.parseIfChanged(" clawd , claude ", current) + assertNull(parsed) + } + + @Test + fun parseIfChangedReturnsUpdatedList() { + val current = listOf("clawd") + val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current) + assertEquals(listOf("clawd", "jarvis"), parsed) + } +} diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift index d13edafe2..5aef87b0c 100644 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -1,8 +1,10 @@ import SwiftUI +import Combine struct VoiceWakeWordsSettingsView: View { @Environment(NodeAppModel.self) private var appModel @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @FocusState private var focusedTriggerIndex: Int? @State private var syncTask: Task? var body: some View { @@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View { TextField("Wake word", text: self.binding(for: index)) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .focused(self.$focusedTriggerIndex, equals: index) + .onSubmit { + self.commitTriggerWords() + } } .onDelete(perform: self.removeWords) @@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View { .onAppear { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords + self.commitTriggerWords() } } - .onChange(of: self.triggerWords) { _, newValue in - // Keep local voice wake responsive even if the gateway isn't connected yet. - VoiceWakePreferences.saveTriggerWords(newValue) - - let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) - self.syncTask?.cancel() - self.syncTask = Task { [snapshot, weak appModel = self.appModel] in - try? await Task.sleep(nanoseconds: 650_000_000) - await appModel?.setGlobalWakeWords(snapshot) + .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in + guard oldValue != nil, oldValue != newValue else { return } + self.commitTriggerWords() + } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + guard self.focusedTriggerIndex == nil else { return } + let updated = VoiceWakePreferences.loadTriggerWords() + if updated != self.triggerWords { + self.triggerWords = updated } } } @@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords } + self.commitTriggerWords() } private func binding(for index: Int) -> Binding { @@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View { self.triggerWords[index] = newValue }) } + + private func commitTriggerWords() { + VoiceWakePreferences.saveTriggerWords(self.triggerWords) + + let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) + self.syncTask?.cancel() + self.syncTask = Task { [snapshot, weak appModel = self.appModel] in + try? await Task.sleep(nanoseconds: 650_000_000) + await appModel?.setGlobalWakeWords(snapshot) + } + } } diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift index 96f46518e..4c75c22a6 100644 --- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift +++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -6,6 +6,8 @@ enum VoiceWakePreferences { // Keep defaults aligned with the mac app. static let defaultTriggerWords: [String] = ["clawd", "claude"] + static let maxWords = 32 + static let maxWordLength = 64 static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { guard let data = payloadJSON.data(using: .utf8) else { return nil } @@ -30,6 +32,8 @@ enum VoiceWakePreferences { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(Self.maxWords) + .map { String($0.prefix(Self.maxWordLength)) } return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned } diff --git a/apps/ios/Tests/VoiceWakePreferencesTests.swift b/apps/ios/Tests/VoiceWakePreferencesTests.swift index acf501654..ec4a63afa 100644 --- a/apps/ios/Tests/VoiceWakePreferencesTests.swift +++ b/apps/ios/Tests/VoiceWakePreferencesTests.swift @@ -11,6 +11,18 @@ import Testing #expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords) } + @Test func sanitizeTriggerWordsLimitsWordLength() { + let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5) + let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long]) + #expect(cleaned[1].count == VoiceWakePreferences.maxWordLength) + } + + @Test func sanitizeTriggerWordsLimitsWordCount() { + let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" } + let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words) + #expect(cleaned.count == VoiceWakePreferences.maxWords) + } + @Test func displayStringUsesSanitizedWords() { #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") } diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift index 25f2589e3..b55bd6d20 100644 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ b/apps/macos/Sources/Clawdbot/Constants.swift @@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let showDockIconKey = "clawdbot.showDockIcon" let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift index a60aa7d7c..98cdc0cb5 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift @@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(voiceWakeMaxWords) + .map { String($0.prefix(voiceWakeMaxWordLength)) } return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned } diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift index e7f7e06fc..49ad5a124 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift @@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests { #expect(cleaned == defaultVoiceWakeTriggers) } + @Test func sanitizeTriggersLimitsWordLength() { + let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5) + let cleaned = sanitizeVoiceWakeTriggers(["ok", long]) + #expect(cleaned[1].count == voiceWakeMaxWordLength) + } + + @Test func sanitizeTriggersLimitsWordCount() { + let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" } + let cleaned = sanitizeVoiceWakeTriggers(words) + #expect(cleaned.count == voiceWakeMaxWords) + } + @Test func normalizeLocaleStripsCollation() { #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") } From b9c35d9fdc3d48c3d3716678688f8b1a255387d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:01:02 +0000 Subject: [PATCH 31/45] docs: add Comcast SSL troubleshooting note --- docs/help/troubleshooting.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 1cef34b11..d87eb5f7f 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -43,6 +43,14 @@ Almost always a Node/npm PATH issue. Start here: - [Gateway troubleshooting](/gateway/troubleshooting) - [Control UI](/web/control-ui#insecure-http) +### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity) + +Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security. +Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry. + +- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security +- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering + ### Service says running, but RPC probe fails - [Gateway troubleshooting](/gateway/troubleshooting) From ef777d6bb64bd156a3b0c12e2512710da2bf0286 Mon Sep 17 00:00:00 2001 From: Christof Date: Sat, 24 Jan 2026 01:07:22 +0100 Subject: [PATCH 32/45] fix(msteams): remove .default suffix from graph scopes (#1507) The @microsoft/agents-hosting SDK's MsalTokenProvider automatically appends `/.default` to all scope strings in its token acquisition methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC, acquireAccessTokenViaWID, acquireTokenWithCertificate in msalTokenProvider.ts). This is consistent SDK behavior, not a recent change. Our code was including `.default` in scope URLs, resulting in invalid double suffixes like `https://graph.microsoft.com/.default/.default`. This was confirmed to cause Graph API authentication errors. Removing the `.default` suffix from our scope strings allows the SDK to append it correctly, resolving the issue. Before: we pass `.default` -> SDK appends -> double `.default` (broken) After: we pass base URL -> SDK appends -> single `.default` (works) Co-authored-by: Christof Salis --- extensions/msteams/src/attachments/download.ts | 6 +++--- extensions/msteams/src/attachments/graph.ts | 2 +- extensions/msteams/src/directory-live.ts | 2 +- extensions/msteams/src/graph-upload.ts | 2 +- extensions/msteams/src/resolve-allowlist.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 0a44c50d6..cadb00dca 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] { host.endsWith("1drv.ms") || host.includes("sharepoint"); return looksLikeGraph - ? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"] - : ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; + ? ["https://graph.microsoft.com", "https://api.botframework.com"] + : ["https://api.botframework.com", "https://graph.microsoft.com"]; } catch { - return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; + return ["https://api.botframework.com", "https://graph.microsoft.com"]; } } diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index bb47d413f..6cad32e46 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: { const messageUrl = params.messageUrl; let accessToken: string; try { - accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com"); } catch { return { media: [], messageUrl, tokenError: true }; } diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 35715acb4..bbc5c79eb 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise { if (!creds) throw new Error("MS Teams credentials missing"); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const tokenProvider = new sdk.MsalTokenProvider(authConfig); - const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const accessToken = readAccessToken(token); if (!accessToken) throw new Error("MS Teams graph token unavailable"); return accessToken; diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index dd4e28683..3bd9ea5a6 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; const GRAPH_BETA = "https://graph.microsoft.com/beta"; -const GRAPH_SCOPE = "https://graph.microsoft.com/.default"; +const GRAPH_SCOPE = "https://graph.microsoft.com"; export interface OneDriveUploadResult { id: string; diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index a74c42f61..a5e7a0c74 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise { if (!creds) throw new Error("MS Teams credentials missing"); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const tokenProvider = new sdk.MsalTokenProvider(authConfig); - const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const accessToken = readAccessToken(token); if (!accessToken) throw new Error("MS Teams graph token unavailable"); return accessToken; From d35403097426ccaa86b0fe094daccf0cee1960d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:07:03 +0000 Subject: [PATCH 33/45] docs: changelog for MS Teams scopes (#1507) (thanks @Evizero) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f667782..56e0fb6e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. +- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero. ## 2026.1.22 From 438e782f81a5932c3f1d0b8c69cc1cd2f9a2e559 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:04:53 +0000 Subject: [PATCH 34/45] fix: silence probe timeouts --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run.ts | 3 ++- src/agents/pi-embedded-runner/run/attempt.ts | 17 +++++++++++------ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e0fb6e6..fc09c651a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - CLI: suppress diagnostic session/run noise during auth probes. +- CLI: hide auth probe timeout warnings from embedded runs. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0e3388b84..da33315f8 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -79,6 +79,7 @@ export async function runEmbeddedPiAgent( ? "markdown" : "plain" : "markdown"); + const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { @@ -455,7 +456,7 @@ export async function runEmbeddedPiAgent( cfg: params.config, agentDir: params.agentDir, }); - if (timedOut) { + if (timedOut && !isProbeSession) { log.warn( `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`, ); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 093588cb3..74c405981 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -595,18 +595,23 @@ export async function runEmbeddedAttempt( setActiveEmbeddedRun(params.sessionId, queueHandle); let abortWarnTimer: NodeJS.Timeout | undefined; + const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; const abortTimer = setTimeout( () => { - log.warn( - `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, - ); + if (!isProbeSession) { + log.warn( + `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, + ); + } abortRun(true); if (!abortWarnTimer) { abortWarnTimer = setTimeout(() => { if (!activeSession.isStreaming) return; - log.warn( - `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, - ); + if (!isProbeSession) { + log.warn( + `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, + ); + } }, 10_000); } }, From da3f2b4898b11e69c04464d6121b095d5175bcae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:11:01 +0000 Subject: [PATCH 35/45] fix: table auth probe output --- CHANGELOG.md | 1 + src/commands/models/list.status-command.ts | 55 ++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc09c651a..17f0da5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - CLI: suppress diagnostic session/run noise during auth probes. - CLI: hide auth probe timeout warnings from embedded runs. +- CLI: render auth probe results as a table in `clawdbot models status`. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 41c126460..6b8c8c36d 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -28,6 +28,7 @@ import { } from "../../infra/provider-usage.js"; import type { RuntimeEnv } from "../../runtime.js"; import { colorize, theme } from "../../terminal/theme.js"; +import { renderTable } from "../../terminal/table.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; @@ -35,7 +36,6 @@ import { isRich } from "./list.format.js"; import { describeProbeSummary, formatProbeLatency, - groupProbeResults, runAuthProbes, sortProbeResults, type AuthProbeSummary, @@ -571,7 +571,8 @@ export async function modelsStatusCommand( if (probeSummary.results.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); } else { - const grouped = groupProbeResults(sortProbeResults(probeSummary.results)); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const sorted = sortProbeResults(probeSummary.results); const statusColor = (status: string) => { if (status === "ok") return theme.success; if (status === "rate_limit") return theme.warn; @@ -580,29 +581,33 @@ export async function modelsStatusCommand( if (status === "no_model") return theme.muted; return theme.muted; }; - for (const [provider, results] of grouped) { - const modelLabel = results.find((r) => r.model)?.model ?? "-"; - runtime.log( - `- ${theme.heading(provider)}${colorize( - rich, - theme.muted, - modelLabel ? ` (model: ${modelLabel})` : "", - )}`, - ); - for (const result of results) { - const status = colorize(rich, statusColor(result.status), result.status); - const latency = formatProbeLatency(result.latencyMs); - const mode = result.mode ? ` (${result.mode})` : ""; - const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : ""; - runtime.log( - ` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize( - rich, - theme.muted, - latency, - )}${detail}`, - ); - } - } + const rows = sorted.map((result) => { + const status = colorize(rich, statusColor(result.status), result.status); + const latency = formatProbeLatency(result.latencyMs); + const detail = result.error ? colorize(rich, theme.muted, result.error) : ""; + const modelLabel = result.model ?? `${result.provider}/-`; + const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : ""; + const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`; + const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`; + return { + Model: colorize(rich, theme.heading, modelLabel), + Profile: profile, + Status: statusLabel, + Detail: detail, + }; + }); + runtime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Model", header: "Model", minWidth: 18 }, + { key: "Profile", header: "Profile", minWidth: 24 }, + { key: "Status", header: "Status", minWidth: 12 }, + { key: "Detail", header: "Detail", minWidth: 16, flex: true }, + ], + rows, + }).trimEnd(), + ); runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary))); } } From 511a0c22b72eb3e67a5b5595dde4b1423b6063f3 Mon Sep 17 00:00:00 2001 From: Robby Date: Fri, 23 Jan 2026 21:41:56 +0000 Subject: [PATCH 36/45] fix(sessions): reset token counts to 0 on /new (#1523) - Set inputTokens, outputTokens, totalTokens to 0 in sessions.reset - Clear TUI sessionInfo tokens immediately before async reset - Prevents stale token display after session reset Fixes #1523 --- src/gateway/server-methods/sessions.ts | 4 ++++ src/tui/tui-command-handlers.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index f31c726bb..df59a3f31 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, + // Reset token counts to 0 on session reset (#1523) + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, }; store[primaryKey] = nextEntry; return nextEntry; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 7bedb4d62..a14172809 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -408,6 +408,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "new": case "reset": try { + // Clear token counts immediately to avoid stale display (#1523) + state.sessionInfo.inputTokens = null; + state.sessionInfo.outputTokens = null; + state.sessionInfo.totalTokens = null; + tui.requestRender(); + await client.resetSession(state.currentSessionKey); chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); From 66f353fe7a01f2adaf4e5ec34ac04641cc1a044b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:59:12 +0000 Subject: [PATCH 37/45] feat: use sudo for tailscale configuration commands To avoid permission denied errors when modifying Tailscale configuration (serve/funnel), we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate sudo privileges (specifically passwordless for these commands or generally), the operation succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first. - Updated `ensureFunnel` to use `sudo -n` for the enabling step. - Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`. - Added tests in `src/infra/tailscale.test.ts` to verify `sudo` usage. --- src/infra/tailscale.test.ts | 82 ++++++++++++++++++++++++++++++++++++- src/infra/tailscale.ts | 10 ++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cb8d0be4e..5fb6ff06d 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; +import { + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, + enableTailscaleServe, + disableTailscaleServe, + enableTailscaleFunnel, + disableTailscaleFunnel, + ensureFunnel +} from "./tailscale.js"; describe("tailscale helpers", () => { it("parses DNS name from tailscale status", async () => { @@ -48,4 +57,75 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); + + it("enableTailscaleServe uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await enableTailscaleServe(3000, exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleServe uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await disableTailscaleServe(exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), + expect.any(Object) + ); + }); + + it("enableTailscaleFunnel uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await enableTailscaleFunnel(4000, exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--bg", "--yes", "4000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleFunnel uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await disableTailscaleFunnel(exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "reset"]), + expect.any(Object) + ); + }); + + it("ensureFunnel uses sudo for enabling", async () => { + // Mock exec: first call is status (not sudo), second call is enable (sudo) + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status + .mockResolvedValueOnce({ stdout: "" }); // enable + + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + const prompt = vi.fn(); + + await ensureFunnel(8080, exec as never, runtime, prompt); + + // First call: check status (no sudo) + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["funnel", "status", "--json"]) + ); + + // Second call: enable (sudo) + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 58d7b3f93..ca2faf8d5 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -237,7 +237,7 @@ export async function ensureFunnel( } logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], { + const { stdout } = await exec("sudo", ["-n", tailscaleBin, "funnel", "--yes", "--bg", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -288,7 +288,7 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { + await exec("sudo", ["-n", tailscaleBin, "serve", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -296,7 +296,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "reset"], { + await exec("sudo", ["-n", tailscaleBin, "serve", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -304,7 +304,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) { export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { + await exec("sudo", ["-n", tailscaleBin, "funnel", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -312,7 +312,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec = export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "reset"], { + await exec("sudo", ["-n", tailscaleBin, "funnel", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); From 29f0463f650a8f0fde5f7d7bc22552d08282c8a8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:02:46 +0000 Subject: [PATCH 38/45] feat: use sudo for tailscale configuration commands To avoid permission denied errors when modifying Tailscale configuration (serve/funnel), we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate sudo privileges (specifically passwordless for these commands or generally), the operation succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first. - Updated `ensureFunnel` to use `sudo -n` for the enabling step. - Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`. --- src/infra/tailscale.test.ts | 82 +------------------------------------ 1 file changed, 1 insertion(+), 81 deletions(-) diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 5fb6ff06d..cb8d0be4e 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,15 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { - ensureGoInstalled, - ensureTailscaledInstalled, - getTailnetHostname, - enableTailscaleServe, - disableTailscaleServe, - enableTailscaleFunnel, - disableTailscaleFunnel, - ensureFunnel -} from "./tailscale.js"; +import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; describe("tailscale helpers", () => { it("parses DNS name from tailscale status", async () => { @@ -57,75 +48,4 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); - - it("enableTailscaleServe uses sudo", async () => { - const exec = vi.fn().mockResolvedValue({ stdout: "" }); - await enableTailscaleServe(3000, exec as never); - expect(exec).toHaveBeenCalledWith( - "sudo", - expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), - expect.any(Object) - ); - }); - - it("disableTailscaleServe uses sudo", async () => { - const exec = vi.fn().mockResolvedValue({ stdout: "" }); - await disableTailscaleServe(exec as never); - expect(exec).toHaveBeenCalledWith( - "sudo", - expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), - expect.any(Object) - ); - }); - - it("enableTailscaleFunnel uses sudo", async () => { - const exec = vi.fn().mockResolvedValue({ stdout: "" }); - await enableTailscaleFunnel(4000, exec as never); - expect(exec).toHaveBeenCalledWith( - "sudo", - expect.arrayContaining(["-n", "tailscale", "funnel", "--bg", "--yes", "4000"]), - expect.any(Object) - ); - }); - - it("disableTailscaleFunnel uses sudo", async () => { - const exec = vi.fn().mockResolvedValue({ stdout: "" }); - await disableTailscaleFunnel(exec as never); - expect(exec).toHaveBeenCalledWith( - "sudo", - expect.arrayContaining(["-n", "tailscale", "funnel", "reset"]), - expect.any(Object) - ); - }); - - it("ensureFunnel uses sudo for enabling", async () => { - // Mock exec: first call is status (not sudo), second call is enable (sudo) - const exec = vi.fn() - .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status - .mockResolvedValueOnce({ stdout: "" }); // enable - - const runtime = { - error: vi.fn(), - log: vi.fn(), - exit: vi.fn() as unknown as (code: number) => never, - }; - const prompt = vi.fn(); - - await ensureFunnel(8080, exec as never, runtime, prompt); - - // First call: check status (no sudo) - expect(exec).toHaveBeenNthCalledWith( - 1, - "tailscale", - expect.arrayContaining(["funnel", "status", "--json"]) - ); - - // Second call: enable (sudo) - expect(exec).toHaveBeenNthCalledWith( - 2, - "sudo", - expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), - expect.any(Object) - ); - }); }); From 908d9331af89fc7e83fcdf01ff23bc4270a201fe Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:16:19 +0000 Subject: [PATCH 39/45] feat: use sudo fallback for tailscale configuration commands To avoid permission denied errors when modifying Tailscale configuration (serve/funnel), we now attempt the command directly first. If it fails, we catch the error and retry with `sudo -n`. This preserves existing behavior for users where it works, but attempts to escalate privileges (non-interactively) if needed. - Added `execWithSudoFallback` helper in `src/infra/tailscale.ts`. - Updated `ensureFunnel`, `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, and `disableTailscaleFunnel` to use the fallback helper. - Added tests in `src/infra/tailscale.test.ts` to verify fallback behavior. --- src/infra/tailscale.test.ts | 107 +++++++++++++++++++++++++++++++++++- src/infra/tailscale.ts | 85 +++++++++++++++++++++------- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cb8d0be4e..39bb9ecc3 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; +import { + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, + enableTailscaleServe, + disableTailscaleServe, + enableTailscaleFunnel, + disableTailscaleFunnel, + ensureFunnel +} from "./tailscale.js"; describe("tailscale helpers", () => { it("parses DNS name from tailscale status", async () => { @@ -48,4 +57,100 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); + + it("enableTailscaleServe attempts normal first, then sudo", async () => { + // 1. First attempt fails + // 2. Second attempt (sudo) succeeds + const exec = vi.fn() + .mockRejectedValueOnce(new Error("permission denied")) + .mockResolvedValueOnce({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + + await enableTailscaleServe(3000, exec as never); + + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith( + "tailscale", + expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleServe uses fallback", async () => { + const exec = vi.fn() + .mockRejectedValueOnce(new Error("failed")) + .mockResolvedValueOnce({ stdout: "" }); + + await disableTailscaleServe(exec as never); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), + expect.any(Object) + ); + }); + + it("ensureFunnel uses fallback for enabling", async () => { + // Mock exec: + // 1. status (success) + // 2. enable (fails) + // 3. enable sudo (success) + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status + .mockRejectedValueOnce(new Error("failed")) // enable normal + .mockResolvedValueOnce({ stdout: "" }); // enable sudo + + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + const prompt = vi.fn(); + + await ensureFunnel(8080, exec as never, runtime, prompt); + + // 1. status + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["funnel", "status", "--json"]) + ); + + // 2. enable normal + expect(exec).toHaveBeenNthCalledWith( + 2, + "tailscale", + expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + + // 3. enable sudo + expect(exec).toHaveBeenNthCalledWith( + 3, + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index ca2faf8d5..f7a0c6d41 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -206,6 +206,25 @@ export async function ensureTailscaledInstalled( await exec("brew", ["install", "tailscale"]); } +// Helper to attempt a command, and retry with sudo if it fails. +async function execWithSudoFallback( + exec: typeof runExec, + bin: string, + args: string[], + opts: { maxBuffer?: number; timeoutMs?: number }, +): Promise<{ stdout: string; stderr: string }> { + try { + return await exec(bin, args, opts); + } catch (err) { + // If the error suggests permission denied or access denied, try with sudo. + // Or honestly, for any error in these specific ops, trying sudo is a reasonable fallback + // given the context of what we're doing (system-level network config). + // We'll log a verbose message that we're falling back. + logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`); + return await exec("sudo", ["-n", bin, ...args], opts); + } +} + export async function ensureFunnel( port: number, exec: typeof runExec = runExec, @@ -237,10 +256,16 @@ export async function ensureFunnel( } logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec("sudo", ["-n", tailscaleBin, "funnel", "--yes", "--bg", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + // Attempt with fallback + const { stdout } = await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "--yes", "--bg", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); if (stdout.trim()) console.log(stdout.trim()); } catch (err) { const errOutput = err as { stdout?: unknown; stderr?: unknown }; @@ -288,32 +313,52 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "serve", "--bg", "--yes", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["serve", "--bg", "--yes", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "serve", "reset"], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["serve", "reset"], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "funnel", "--bg", "--yes", `${port}`], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "--bg", "--yes", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec("sudo", ["-n", tailscaleBin, "funnel", "reset"], { - maxBuffer: 200_000, - timeoutMs: 15_000, - }); + await execWithSudoFallback( + exec, + tailscaleBin, + ["funnel", "reset"], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); } From 05b0b82937afa148bdd9603f93d3345d886ec6d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:16:07 +0000 Subject: [PATCH 40/45] fix: guard tailscale sudo fallback (#1551) (thanks @sweepies) --- CHANGELOG.md | 1 + src/infra/tailscale.test.ts | 71 ++++++++++++++++++------- src/infra/tailscale.ts | 101 +++++++++++++++++++++--------------- 3 files changed, 114 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f0da5ec..557a80ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. +- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 39bb9ecc3..410c7befd 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,17 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { +import * as tailscale from "./tailscale.js"; + +const { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname, enableTailscaleServe, disableTailscaleServe, - enableTailscaleFunnel, - disableTailscaleFunnel, - ensureFunnel -} from "./tailscale.js"; + ensureFunnel, +} = tailscale; describe("tailscale helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("parses DNS name from tailscale status", async () => { const exec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ @@ -61,7 +65,9 @@ describe("tailscale helpers", () => { it("enableTailscaleServe attempts normal first, then sudo", async () => { // 1. First attempt fails // 2. Second attempt (sudo) succeeds - const exec = vi.fn() + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() .mockRejectedValueOnce(new Error("permission denied")) .mockResolvedValueOnce({ stdout: "" }); @@ -71,18 +77,19 @@ describe("tailscale helpers", () => { 1, "tailscale", expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), - expect.any(Object) + expect.any(Object), ); expect(exec).toHaveBeenNthCalledWith( 2, "sudo", expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), - expect.any(Object) + expect.any(Object), ); }); it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); const exec = vi.fn().mockResolvedValue({ stdout: "" }); await enableTailscaleServe(3000, exec as never); @@ -91,13 +98,15 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenCalledWith( "tailscale", expect.arrayContaining(["serve", "--bg", "--yes", "3000"]), - expect.any(Object) + expect.any(Object), ); }); it("disableTailscaleServe uses fallback", async () => { - const exec = vi.fn() - .mockRejectedValueOnce(new Error("failed")) + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() + .mockRejectedValueOnce(new Error("permission denied")) .mockResolvedValueOnce({ stdout: "" }); await disableTailscaleServe(exec as never); @@ -107,7 +116,7 @@ describe("tailscale helpers", () => { 2, "sudo", expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), - expect.any(Object) + expect.any(Object), ); }); @@ -116,9 +125,11 @@ describe("tailscale helpers", () => { // 1. status (success) // 2. enable (fails) // 3. enable sudo (success) - const exec = vi.fn() + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi + .fn() .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status - .mockRejectedValueOnce(new Error("failed")) // enable normal + .mockRejectedValueOnce(new Error("permission denied")) // enable normal .mockResolvedValueOnce({ stdout: "" }); // enable sudo const runtime = { @@ -134,7 +145,7 @@ describe("tailscale helpers", () => { expect(exec).toHaveBeenNthCalledWith( 1, "tailscale", - expect.arrayContaining(["funnel", "status", "--json"]) + expect.arrayContaining(["funnel", "status", "--json"]), ); // 2. enable normal @@ -142,7 +153,7 @@ describe("tailscale helpers", () => { 2, "tailscale", expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]), - expect.any(Object) + expect.any(Object), ); // 3. enable sudo @@ -150,7 +161,31 @@ describe("tailscale helpers", () => { 3, "sudo", expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), - expect.any(Object) + expect.any(Object), ); }); + + it("enableTailscaleServe skips sudo on non-permission errors", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const exec = vi.fn().mockRejectedValueOnce(new Error("boom")); + + await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom"); + + expect(exec).toHaveBeenCalledTimes(1); + }); + + it("enableTailscaleServe rethrows original error if sudo fails", async () => { + vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale"); + const originalError = Object.assign(new Error("permission denied"), { + stderr: "permission denied", + }); + const exec = vi + .fn() + .mockRejectedValueOnce(originalError) + .mockRejectedValueOnce(new Error("sudo: a password is required")); + + await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError); + + expect(exec).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index f7a0c6d41..8ff340184 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -206,6 +206,39 @@ export async function ensureTailscaledInstalled( await exec("brew", ["install", "tailscale"]); } +type ExecErrorDetails = { + stdout?: unknown; + stderr?: unknown; + message?: unknown; + code?: unknown; +}; + +function extractExecErrorText(err: unknown) { + const errOutput = err as ExecErrorDetails; + const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; + const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; + const message = typeof errOutput.message === "string" ? errOutput.message : ""; + const code = typeof errOutput.code === "string" ? errOutput.code : ""; + return { stdout, stderr, message, code }; +} + +function isPermissionDeniedError(err: unknown): boolean { + const { stdout, stderr, message, code } = extractExecErrorText(err); + if (code.toUpperCase() === "EACCES") return true; + const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase(); + return ( + combined.includes("permission denied") || + combined.includes("access denied") || + combined.includes("operation not permitted") || + combined.includes("not permitted") || + combined.includes("requires root") || + combined.includes("must be run as root") || + combined.includes("must be run with sudo") || + combined.includes("requires sudo") || + combined.includes("need sudo") + ); +} + // Helper to attempt a command, and retry with sudo if it fails. async function execWithSudoFallback( exec: typeof runExec, @@ -216,12 +249,18 @@ async function execWithSudoFallback( try { return await exec(bin, args, opts); } catch (err) { - // If the error suggests permission denied or access denied, try with sudo. - // Or honestly, for any error in these specific ops, trying sudo is a reasonable fallback - // given the context of what we're doing (system-level network config). - // We'll log a verbose message that we're falling back. + if (!isPermissionDeniedError(err)) { + throw err; + } logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`); - return await exec("sudo", ["-n", bin, ...args], opts); + try { + return await exec("sudo", ["-n", bin, ...args], opts); + } catch (sudoErr) { + const { stderr, message } = extractExecErrorText(sudoErr); + const detail = (stderr || message).trim(); + if (detail) logVerbose(`Sudo retry failed: ${detail}`); + throw err; + } } } @@ -313,52 +352,32 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await execWithSudoFallback( - exec, - tailscaleBin, - ["serve", "--bg", "--yes", `${port}`], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); + await execWithSudoFallback(exec, tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { + maxBuffer: 200_000, + timeoutMs: 15_000, + }); } export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await execWithSudoFallback( - exec, - tailscaleBin, - ["serve", "reset"], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); + await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], { + maxBuffer: 200_000, + timeoutMs: 15_000, + }); } export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await execWithSudoFallback( - exec, - tailscaleBin, - ["funnel", "--bg", "--yes", `${port}`], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); + await execWithSudoFallback(exec, tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { + maxBuffer: 200_000, + timeoutMs: 15_000, + }); } export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await execWithSudoFallback( - exec, - tailscaleBin, - ["funnel", "reset"], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); + await execWithSudoFallback(exec, tailscaleBin, ["funnel", "reset"], { + maxBuffer: 200_000, + timeoutMs: 15_000, + }); } From a96d37ca69730f56fd8865aaccd73995afe0fee4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:23:13 +0000 Subject: [PATCH 41/45] docs: clarify plugin dependency rules --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index f0b3ab183..0ef57992e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - Tests: colocated `*.test.ts`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). - Core channel docs: `docs/channels/` From d46642319bfbbe1d28df7ca98a2f53549c9d07f9 Mon Sep 17 00:00:00 2001 From: william arzt Date: Fri, 23 Jan 2026 15:17:07 -0500 Subject: [PATCH 42/45] 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 --- extensions/tlon/README.md | 828 ++++++++++++ extensions/tlon/clawdbot.plugin.json | 11 + extensions/tlon/index.ts | 16 + extensions/tlon/package.json | 16 + extensions/tlon/src/channel.js | 360 ++++++ extensions/tlon/src/core-bridge.js | 100 ++ extensions/tlon/src/monitor.js | 1572 +++++++++++++++++++++++ extensions/tlon/src/urbit-sse-client.js | 371 ++++++ 8 files changed, 3274 insertions(+) create mode 100644 extensions/tlon/README.md create mode 100644 extensions/tlon/clawdbot.plugin.json create mode 100644 extensions/tlon/index.ts create mode 100644 extensions/tlon/package.json create mode 100644 extensions/tlon/src/channel.js create mode 100644 extensions/tlon/src/core-bridge.js create mode 100644 extensions/tlon/src/monitor.js create mode 100644 extensions/tlon/src/urbit-sse-client.js diff --git a/extensions/tlon/README.md b/extensions/tlon/README.md new file mode 100644 index 000000000..0fd7fd8da --- /dev/null +++ b/extensions/tlon/README.md @@ -0,0 +1,828 @@ +# Clawdbot Tlon/Urbit Integration + +Complete documentation for integrating Clawdbot with Tlon Messenger (built on Urbit). + +## Overview + +This extension enables Clawdbot to: +- Monitor and respond to direct messages on Tlon Messenger +- Monitor and respond to group channel messages when mentioned +- Auto-discover available group channels +- Use per-conversation subscriptions for reliable message delivery +- **Automatic AI model fallback** - Seamlessly switches from Anthropic to OpenAI when rate limited (see [FALLBACK.md](./FALLBACK.md)) + +**Ship:** ~sitrul-nacwyl +**Test User:** ~malmur-halmex + +## Architecture + +### Files + +- **`index.js`** - Plugin entry point, registers the Tlon channel adapter +- **`monitor.js`** - Core monitoring logic, handles incoming messages and AI dispatch +- **`urbit-sse-client.js`** - Custom SSE client for Urbit HTTP API +- **`core-bridge.js`** - Dynamic loader for clawdbot core modules +- **`package.json`** - Plugin package definition +- **`FALLBACK.md`** - AI model fallback system documentation + +### How It Works + +1. **Authentication**: Uses ship name + code to authenticate via `/~/login` endpoint +2. **Channel Creation**: Creates Tlon Messenger channel via PUT to `/~/channel/{uid}` +3. **Activation**: Sends "helm-hi" poke to activate channel (required!) +4. **Subscriptions**: + - **DMs**: Individual subscriptions to `/dm/{ship}` for each conversation + - **Groups**: Individual subscriptions to `/{channelNest}` for each channel +5. **SSE Stream**: Opens server-sent events stream for real-time updates +6. **Auto-Reconnection**: Automatically reconnects if SSE stream dies + - Exponential backoff (1s to 30s delays) + - Up to 10 reconnection attempts + - Generates new channel ID on each attempt +7. **Auto-Discovery**: Queries `/groups-ui/v6/init.json` to find all available channels +8. **Dynamic Refresh**: Polls every 2 minutes for new conversations/channels +9. **Message Processing**: When bot is mentioned, routes to AI via clawdbot core +10. **AI Fallback**: Automatically switches providers when rate limited + - Primary: Anthropic Claude Sonnet 4.5 + - Fallbacks: OpenAI GPT-4o, GPT-4 Turbo + - Automatic cooldown management + - See [FALLBACK.md](./FALLBACK.md) for details + +## Configuration + +### 1. Install Dependencies + +```bash +cd ~/.clawdbot/extensions/tlon +npm install +``` + +### 2. Configure Credentials + +Edit `~/.clawdbot/clawdbot.json`: + +```json +{ + "channels": { + "tlon": { + "enabled": true, + "ship": "your-ship-name", + "code": "your-ship-code", + "url": "https://your-ship-name.tlon.network", + "showModelSignature": false, + "dmAllowlist": ["~friend-ship-1", "~friend-ship-2"], + "defaultAuthorizedShips": ["~malmur-halmex"], + "authorization": { + "channelRules": { + "chat/~host-ship/channel-name": { + "mode": "open", + "allowedShips": [] + }, + "chat/~another-host/private-channel": { + "mode": "restricted", + "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] + } + } + } + } + } +} +``` + +**Configuration Options:** +- `enabled` - Enable/disable the Tlon channel (default: `false`) +- `ship` - Your Urbit ship name (required) +- `code` - Your ship's login code (required) +- `url` - Your ship's URL (required) +- `showModelSignature` - Append model name to responses (default: `false`) + - When enabled, adds `[Generated by Claude Sonnet 4.5]` to the end of each response + - Useful for transparency about which AI model generated the response +- `dmAllowlist` - Ships allowed to send DMs (optional) + - If omitted or empty, all DMs are accepted (default behavior) + - Ship names can include or omit the `~` prefix + - Example: `["~trusted-friend", "~another-ship"]` + - Blocked DMs are logged for visibility +- `defaultAuthorizedShips` - Ships authorized in new/unconfigured channels (default: `["~malmur-halmex"]`) + - New channels default to `restricted` mode using these ships +- `authorization` - Per-channel access control (optional) + - `channelRules` - Map of channel nest to authorization rules + - `mode`: `"open"` (all ships) or `"restricted"` (allowedShips only) + - `allowedShips`: Array of authorized ships (only for `restricted` mode) + +**For localhost development:** +```json +"url": "http://localhost:8080" +``` + +**For Tlon-hosted ships:** +```json +"url": "https://{ship-name}.tlon.network" +``` + +### 3. Set Environment Variable + +The monitor needs to find clawdbot's core modules. Set the environment variable: + +```bash +export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot +``` + +Or if clawdbot is installed elsewhere: +```bash +export CLAWDBOT_ROOT=$(dirname $(dirname $(readlink -f $(which clawdbot)))) +``` + +**Make it permanent** (add to `~/.zshrc` or `~/.bashrc`): +```bash +echo 'export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot' >> ~/.zshrc +``` + +### 4. Configure AI Authentication + +The bot needs API credentials to generate responses. + +**Option A: Use Claude Code CLI credentials** +```bash +clawdbot agents add main +# Select "Use Claude Code CLI credentials" +``` + +**Option B: Use Anthropic API key** +```bash +clawdbot agents add main +# Enter your API key from console.anthropic.com +``` + +### 5. Start the Gateway + +```bash +CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway +``` + +Or create a launch script: + +```bash +cat > ~/start-clawdbot.sh << 'EOF' +#!/bin/bash +export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot +clawdbot gateway +EOF +chmod +x ~/start-clawdbot.sh +``` + +## Usage + +### Testing + +1. Send a DM from another ship to ~sitrul-nacwyl +2. Mention the bot: `~sitrul-nacwyl hello there!` +3. Bot should respond with AI-generated reply + +### Monitoring Logs + +Check gateway logs: +```bash +tail -f /tmp/clawdbot/clawdbot-$(date +%Y-%m-%d).log +``` + +Look for these indicators: +- `[tlon] Successfully authenticated to https://...` +- `[tlon] Auto-discovered N chat channel(s)` +- `[tlon] Connected! All subscriptions active` +- `[tlon] Received DM from ~ship: "..." (mentioned: true)` +- `[tlon] Dispatching to AI for ~ship (DM)` +- `[tlon] Delivered AI reply to ~ship` + +### Group Channels + +The bot automatically discovers and subscribes to all group channels using **delta-based discovery** for efficiency. + +**How Auto-Discovery Works:** +1. **On startup:** Fetches changes from the last 5 days via `/groups-ui/v5/changes/~YYYY.M.D..20.19.51..9b9d.json` +2. **Periodic refresh:** Checks for new channels every 2 minutes +3. **Smart caching:** Only fetches deltas, not full state each time + +**Benefits:** +- Reduced bandwidth usage +- Faster startup (especially for ships with many groups) +- Automatically picks up new channels you join +- Context of recent group activity + +**Manual Configuration:** + +To disable auto-discovery and use specific channels: + +```json +{ + "channels": { + "tlon": { + "enabled": true, + "ship": "your-ship-name", + "code": "your-ship-code", + "url": "https://your-ship-name.tlon.network", + "autoDiscoverChannels": false, + "groupChannels": [ + "chat/~host-ship/channel-name", + "chat/~another-host/another-channel" + ] + } + } +} +``` + +### Model Signatures + +The bot can append the AI model name to each response for transparency. Enable this feature in your config: + +```json +{ + "channels": { + "tlon": { + "enabled": true, + "ship": "your-ship-name", + "code": "your-ship-code", + "url": "https://your-ship-name.tlon.network", + "showModelSignature": true + } + } +} +``` + +**Example output with signature enabled:** +``` +User: ~sitrul-nacwyl explain quantum computing +Bot: Quantum computing uses quantum mechanics principles like superposition + and entanglement to perform calculations... + + [Generated by Claude Sonnet 4.5] +``` + +**Supported model formats:** +- `Claude Opus 4.5` +- `Claude Sonnet 4.5` +- `GPT-4o` +- `GPT-4 Turbo` +- `Gemini 2.0 Flash` + +When using the [AI fallback system](./FALLBACK.md), signatures automatically reflect which model generated the response (e.g., if Anthropic is rate limited and OpenAI is used, the signature will show `GPT-4o`). + +### Channel History Summarization + +The bot can summarize recent channel activity when asked. This is useful for catching up on conversations you missed. + +**Trigger phrases:** +- `~bot-ship summarize this channel` +- `~bot-ship what did I miss?` +- `~bot-ship catch me up` +- `~bot-ship tldr` +- `~bot-ship channel summary` + +**Example:** +``` +User: ~sitrul-nacwyl what did I miss? +Bot: Here's a summary of the last 50 messages: + +Main topics discussed: +1. Discussion about Urbit networking (Ames protocol) +2. Planning for next week's developer meetup +3. Bug reports for the new UI update + +Key decisions: +- Meetup scheduled for Thursday at 3pm EST +- Priority on fixing the scrolling issue + +Notable participants: ~malmur-halmex, ~bolbex-fogdys +``` + +**How it works:** +- Fetches the last 50 messages from the channel +- Sends them to the AI for summarization +- Returns a concise summary with main topics, decisions, and action items + +### Thread Support + +The bot automatically maintains context in threaded conversations. When you mention the bot in a reply thread, it will respond within that thread instead of posting to the main channel. + +**Example:** +``` +Main channel post: + User A: ~sitrul-nacwyl what's the capital of France? + Bot: Paris is the capital of France. + └─ User B (in thread): ~sitrul-nacwyl and what's its population? + └─ Bot (in thread): Paris has a population of approximately 2.2 million... +``` + +**Benefits:** +- Keeps conversations organized +- Reduces noise in main channel +- Maintains conversation context within threads + +**Technical Details:** +The bot handles both top-level posts and thread replies with different data structures: +- Top-level posts: `response.post.r-post.set.essay` +- Thread replies: `response.post.r-post.reply.r-reply.set.memo` + +When replying in a thread, the bot uses the `parent-id` from the incoming message to ensure the reply stays within the same thread. + +**Note:** Thread support is automatic - no configuration needed. + +### Link Summarization + +The bot can fetch and summarize web content when you share links. + +**Example:** +``` +User: ~sitrul-nacwyl can you summarize this https://example.com/article +Bot: This article discusses... [summary of the content] +``` + +**How it works:** +- Bot extracts URLs from rich text messages (including inline links) +- Fetches the web page content +- Summarizes using the WebFetch tool + +### Channel Authorization + +Control which ships can invoke the bot in specific group channels. **New channels default to `restricted` mode** for security. + +#### Default Behavior + +**DMs:** Always open (no restrictions) +**Group Channels:** Restricted by default, only ships in `defaultAuthorizedShips` can invoke the bot + +#### Configuration + +```json +{ + "channels": { + "tlon": { + "enabled": true, + "ship": "sitrul-nacwyl", + "code": "your-code", + "url": "https://sitrul-nacwyl.tlon.network", + "defaultAuthorizedShips": ["~malmur-halmex"], + "authorization": { + "channelRules": { + "chat/~bitpyx-dildus/core": { + "mode": "open" + }, + "chat/~nocsyx-lassul/bongtable": { + "mode": "restricted", + "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] + } + } + } + } + } +} +``` + +#### Authorization Modes + +**`open`** - Any ship can invoke the bot when mentioned +- Good for public channels +- No `allowedShips` needed + +**`restricted`** (default) - Only specific ships can invoke the bot +- Good for private/work channels +- Requires `allowedShips` list +- New channels use `defaultAuthorizedShips` if no rule exists + +#### Examples + +**Make a channel public:** +```json +"chat/~bitpyx-dildus/core": { + "mode": "open" +} +``` + +**Restrict to specific users:** +```json +"chat/~nocsyx-lassul/bongtable": { + "mode": "restricted", + "allowedShips": ["~malmur-halmex"] +} +``` + +**New channel (no config):** +- Mode: `restricted` (safe default) +- Allowed ships: `defaultAuthorizedShips` (e.g., `["~malmur-halmex"]`) + +#### Behavior + +**Authorized mention:** +``` +~malmur-halmex: ~sitrul-nacwyl tell me about quantum computing +Bot: [Responds with answer] +``` + +**Unauthorized mention (silently ignored):** +``` +~other-ship: ~sitrul-nacwyl tell me about quantum computing +Bot: [No response, logs show access denied] +``` + +**Check logs:** +```bash +tail -f /tmp/tlon-fallback.log | grep "Access" +``` + +You'll see: +``` +[tlon] ✅ Access granted: ~malmur-halmex in chat/~host/channel (authorized user) +[tlon] ⛔ Access denied: ~other-ship in chat/~host/channel (restricted, allowed: ~malmur-halmex) +``` + +## Technical Deep Dive + +### Urbit HTTP API Flow + +1. **Login** (POST `/~/login`) + - Sends `password={code}` + - Returns authentication cookie in `set-cookie` header + +2. **Channel Creation** (PUT `/~/channel/{channelId}`) + - Channel ID format: `{timestamp}-{random}` + - Body: array of subscription objects + - Response: 204 No Content + +3. **Channel Activation** (PUT `/~/channel/{channelId}`) + - **Critical:** Must send helm-hi poke BEFORE opening SSE stream + - Poke structure: + ```json + { + "id": timestamp, + "action": "poke", + "ship": "sitrul-nacwyl", + "app": "hood", + "mark": "helm-hi", + "json": "Opening API channel" + } + ``` + +4. **SSE Stream** (GET `/~/channel/{channelId}`) + - Headers: `Accept: text/event-stream` + - Returns Server-Sent Events + - Format: + ``` + id: {event-id} + data: {json-payload} + + ``` + +### Subscription Paths + +#### DMs (Chat App) +- **Path:** `/dm/{ship}` +- **App:** `chat` +- **Event Format:** + ```json + { + "id": "~ship/timestamp", + "whom": "~other-ship", + "response": { + "add": { + "memo": { + "author": "~sender-ship", + "sent": 1768742460781, + "content": [ + { + "inline": [ + "text", + {"ship": "~mentioned-ship"}, + "more text", + {"break": null} + ] + } + ] + } + } + } + } + ``` + +#### Group Channels (Channels App) +- **Path:** `/{channelNest}` +- **Channel Nest Format:** `chat/~host-ship/channel-name` +- **App:** `channels` +- **Event Format:** + ```json + { + "response": { + "post": { + "id": "message-id", + "r-post": { + "set": { + "essay": { + "author": "~sender-ship", + "sent": 1768742460781, + "kind": "/chat", + "content": [...] + } + } + } + } + } + } + ``` + +### Text Extraction + +Message content uses inline format with mixed types: +- Strings: plain text +- Objects with `ship`: mentions (e.g., `{"ship": "~sitrul-nacwyl"}`) +- Objects with `break`: line breaks (e.g., `{"break": null}`) + +Example: +```json +{ + "inline": [ + "Hey ", + {"ship": "~sitrul-nacwyl"}, + " how are you?", + {"break": null}, + "This is a new line" + ] +} +``` + +Extracts to: `"Hey ~sitrul-nacwyl how are you?\nThis is a new line"` + +### Mention Detection + +Simple includes check (case-insensitive): +```javascript +const normalizedBotShip = botShipName.startsWith("~") + ? botShipName + : `~${botShipName}`; +return messageText.toLowerCase().includes(normalizedBotShip.toLowerCase()); +``` + +Note: Word boundaries (`\b`) don't work with `~` character. + +## Troubleshooting + +### Issue: "Cannot read properties of undefined (reading 'href')" + +**Cause:** Some clawdbot dependencies (axios, Slack SDK) expect browser globals + +**Fix:** Window.location polyfill is already added to monitor.js (lines 1-18) + +### Issue: "Unable to resolve Clawdbot root" + +**Cause:** core-bridge.js can't find clawdbot installation + +**Fix:** Set `CLAWDBOT_ROOT` environment variable: +```bash +export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot +``` + +### Issue: SSE Stream Returns 403 Forbidden + +**Cause:** Trying to open SSE stream without activating channel first + +**Fix:** Send helm-hi poke before opening stream (urbit-sse-client.js handles this) + +### Issue: No Events Received After Subscribing + +**Cause:** Wrong subscription path or app name + +**Fix:** +- DMs: Use `/dm/{ship}` with `app: "chat"` +- Groups: Use `/{channelNest}` with `app: "channels"` + +### Issue: Messages Show "[object Object]" + +**Cause:** Not handling inline content objects properly + +**Fix:** Text extraction handles mentions and breaks (monitor.js `extractMessageText()`) + +### Issue: Bot Not Detecting Mentions + +**Cause:** Message doesn't contain bot's ship name + +**Debug:** +```bash +tail -f /tmp/clawdbot/clawdbot-*.log | grep "mentioned:" +``` + +Should show: +``` +[tlon] Received DM from ~malmur-halmex: "~sitrul-nacwyl hello..." (mentioned: true) +``` + +### Issue: "No API key found for provider 'anthropic'" + +**Cause:** AI authentication not configured + +**Fix:** Run `clawdbot agents add main` and configure credentials + +### Issue: Gateway Port Already in Use + +**Fix:** +```bash +# Stop existing instance +clawdbot daemon stop + +# Or force kill +lsof -ti:18789 | xargs kill -9 +``` + +### Issue: Bot Stops Responding (SSE Disconnection) + +**Cause:** Urbit SSE stream disconnected (sent "quit" event or stream ended) + +**Symptoms:** +- Logs show: `[SSE] Received event: {"id":X,"response":"quit"}` +- No more incoming SSE events +- Bot appears online but doesn't respond to mentions + +**Fix:** The bot now **automatically reconnects**! Look for these log messages: +``` +[SSE] Stream ended, attempting reconnection... +[SSE] Reconnection attempt 1/10 in 1000ms... +[SSE] Reconnecting with new channel ID: xxx-yyy +[SSE] Reconnection successful! +``` + +**Manual restart if needed:** +```bash +kill $(pgrep -f "clawdbot gateway") +CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway +``` + +**Configuration options** (in urbit-sse-client.js constructor): +```javascript +new UrbitSSEClient(url, cookie, { + autoReconnect: true, // Default: true + maxReconnectAttempts: 10, // Default: 10 + reconnectDelay: 1000, // Initial delay: 1s + maxReconnectDelay: 30000, // Max delay: 30s + onReconnect: async (client) => { + // Optional callback for resubscription logic + } +}) +``` + +## Development Notes + +### Testing Without Clawdbot + +You can test the Urbit API directly: + +```javascript +import { UrbitSSEClient } from "./urbit-sse-client.js"; + +const api = new UrbitSSEClient( + "https://sitrul-nacwyl.tlon.network", + "your-cookie-here" +); + +// Subscribe to DMs +await api.subscribe({ + app: "chat", + path: "/dm/malmur-halmex", + event: (data) => console.log("DM:", data), + err: (e) => console.error("Error:", e), + quit: () => console.log("Quit") +}); + +// Connect +await api.connect(); + +// Send a DM +await api.poke({ + app: "chat", + mark: "chat-dm-action", + json: { + ship: "~malmur-halmex", + diff: { + id: `~sitrul-nacwyl/${Date.now()}`, + delta: { + add: { + memo: { + content: [{ inline: ["Hello!"] }], + author: "~sitrul-nacwyl", + sent: Date.now() + }, + kind: null, + time: null + } + } + } + } +}); +``` + +### Debugging SSE Events + +Enable verbose logging in urbit-sse-client.js: + +```javascript +// Line 169-171 +if (parsed.response !== "subscribe" && parsed.response !== "poke") { + console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); +} +``` + +Remove the condition to see all events: +```javascript +console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); +``` + +### Channel Nest Format + +Format: `{type}/{host-ship}/{channel-name}` + +Examples: +- `chat/~bitpyx-dildus/core` +- `chat/~malmur-halmex/v3aedb3s` +- `chat/~sitrul-nacwyl/tm-wayfinding-group-chat` + +Parse with: +```javascript +const match = channelNest.match(/^([^/]+)\/([^/]+)\/(.+)$/); +const [, type, hostShip, channelName] = match; +``` + +### Auto-Discovery Endpoint + +Query: `GET /~/scry/groups-ui/v6/init.json` + +Response structure: +```json +{ + "groups": { + "group-id": { + "channels": { + "chat/~host/name": { ... }, + "diary/~host/name": { ... } + } + } + } +} +``` + +Filter for chat channels only: +```javascript +if (channelNest.startsWith("chat/")) { + channels.push(channelNest); +} +``` + +## Implementation Timeline + +### Major Milestones + +1. ✅ Plugin structure and registration +2. ✅ Authentication and cookie management +3. ✅ Channel creation and activation (helm-hi poke) +4. ✅ SSE stream connection +5. ✅ DM subscription and event parsing +6. ✅ Group channel support +7. ✅ Auto-discovery of channels +8. ✅ Per-conversation subscriptions +9. ✅ Text extraction (mentions and breaks) +10. ✅ Mention detection +11. ✅ Node.js polyfills (window.location) +12. ✅ Core module integration +13. ⏳ API authentication (user needs to configure) + +### Key Discoveries + +- **Helm-hi requirement:** Must send helm-hi poke before opening SSE stream +- **Subscription paths:** Frontend uses `/v3` globally, but individual `/dm/{ship}` and `/{channelNest}` paths work better +- **Event formats:** V3 API uses `essay` and `memo` structures (not older `writs` format) +- **Inline content:** Mixed array of strings and objects (mentions, breaks) +- **Tilde handling:** Ship mentions already include `~` prefix +- **Word boundaries:** `\b` regex doesn't work with `~` character +- **Browser globals:** axios and Slack SDK need window.location polyfill +- **Module resolution:** Need CLAWDBOT_ROOT for dynamic imports + +## Resources + +- **Tlon Apps GitHub:** https://github.com/tloncorp/tlon-apps +- **Urbit HTTP API:** @urbit/http-api package +- **Tlon Frontend Code:** `/tmp/tlon-apps/packages/shared/src/api/chatApi.ts` +- **Clawdbot Docs:** https://docs.clawd.bot/ +- **Anthropic Provider:** https://docs.clawd.bot/providers/anthropic + +## Future Enhancements + +- [ ] Support for message reactions +- [ ] Support for message editing/deletion +- [ ] Support for attachments/images +- [ ] Typing indicators +- [ ] Read receipts +- [ ] Message threading +- [ ] Channel-specific bot personas +- [ ] Rate limiting +- [ ] Message queuing for offline ships +- [ ] Metrics and monitoring + +## Credits + +Built for integrating Clawdbot with Tlon messenger. + +**Developer:** Claude (Sonnet 4.5) +**Platform:** Tlon Messenger built on Urbit diff --git a/extensions/tlon/clawdbot.plugin.json b/extensions/tlon/clawdbot.plugin.json new file mode 100644 index 000000000..85e1aaa8c --- /dev/null +++ b/extensions/tlon/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "tlon", + "channels": [ + "tlon" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts new file mode 100644 index 000000000..52b82e9dd --- /dev/null +++ b/extensions/tlon/index.ts @@ -0,0 +1,16 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { tlonPlugin } from "./src/channel.js"; + +const plugin = { + id: "tlon", + name: "Tlon", + description: "Tlon/Urbit channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + api.registerChannel({ plugin: tlonPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json new file mode 100644 index 000000000..c11d45c97 --- /dev/null +++ b/extensions/tlon/package.json @@ -0,0 +1,16 @@ +{ + "name": "@clawdbot/tlon", + "version": "2026.1.22", + "type": "module", + "description": "Clawdbot Tlon/Urbit channel plugin", + "clawdbot": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@urbit/http-api": "^3.0.0", + "@urbit/aura": "^2.0.0", + "eventsource": "^2.0.2" + } +} diff --git a/extensions/tlon/src/channel.js b/extensions/tlon/src/channel.js new file mode 100644 index 000000000..c1974f91b --- /dev/null +++ b/extensions/tlon/src/channel.js @@ -0,0 +1,360 @@ +import { Urbit } from "@urbit/http-api"; +import { unixToDa, formatUd } from "@urbit/aura"; + +// Polyfill minimal browser globals needed by @urbit/http-api in Node +if (typeof global.window === "undefined") { + global.window = { fetch: global.fetch }; +} +if (typeof global.document === "undefined") { + global.document = { + hidden: true, + addEventListener() {}, + removeEventListener() {}, + }; +} + +// Patch Urbit.prototype.connect for HTTP authentication +const { connect } = Urbit.prototype; +Urbit.prototype.connect = async function patchedConnect() { + const resp = await fetch(`${this.url}/~/login`, { + method: "POST", + body: `password=${this.code}`, + credentials: "include", + }); + + if (resp.status >= 400) { + throw new Error("Login failed with status " + resp.status); + } + + const cookie = resp.headers.get("set-cookie"); + if (cookie) { + const match = /urbauth-~([\w-]+)/.exec(cookie); + if (!this.nodeId && match) { + this.nodeId = match[1]; + } + this.cookie = cookie; + } + await this.getShipName(); + await this.getOurName(); +}; + +/** + * Tlon/Urbit channel plugin for Clawdbot + */ +export const tlonPlugin = { + id: "tlon", + meta: { + id: "tlon", + label: "Tlon", + selectionLabel: "Tlon/Urbit", + docsPath: "/channels/tlon", + docsLabel: "tlon", + blurb: "Decentralized messaging on Urbit", + aliases: ["urbit"], + order: 90, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + }, + reload: { configPrefixes: ["channels.tlon"] }, + config: { + listAccountIds: (cfg) => { + const base = cfg.channels?.tlon; + if (!base) return []; + const accounts = base.accounts || {}; + return [ + ...(base.ship ? ["default"] : []), + ...Object.keys(accounts), + ]; + }, + resolveAccount: (cfg, accountId) => { + const base = cfg.channels?.tlon; + if (!base) { + return { + accountId: accountId || "default", + name: null, + enabled: false, + configured: false, + ship: null, + url: null, + code: null, + }; + } + + const useDefault = !accountId || accountId === "default"; + const account = useDefault ? base : base.accounts?.[accountId]; + + return { + accountId: accountId || "default", + name: account?.name || null, + enabled: account?.enabled !== false, + configured: Boolean(account?.ship && account?.code && account?.url), + ship: account?.ship || null, + url: account?.url || null, + code: account?.code || null, + groupChannels: account?.groupChannels || [], + dmAllowlist: account?.dmAllowlist || [], + notebookChannel: account?.notebookChannel || null, + }; + }, + defaultAccountId: () => "default", + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const useDefault = !accountId || accountId === "default"; + + if (useDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...cfg.channels?.tlon, + enabled, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...cfg.channels?.tlon, + accounts: { + ...cfg.channels?.tlon?.accounts, + [accountId]: { + ...cfg.channels?.tlon?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const useDefault = !accountId || accountId === "default"; + + if (useDefault) { + const { ship, code, url, name, ...rest } = cfg.channels?.tlon || {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: rest, + }, + }; + } + + const { [accountId]: removed, ...remainingAccounts } = + cfg.channels?.tlon?.accounts || {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...cfg.channels?.tlon, + accounts: remainingAccounts, + }, + }, + }; + }, + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + }), + }, + messaging: { + normalizeTarget: (target) => { + // Normalize Urbit ship names + const trimmed = target.trim(); + if (!trimmed.startsWith("~")) { + return `~${trimmed}`; + } + return trimmed; + }, + targetResolver: { + looksLikeId: (target) => { + return /^~?[a-z-]+$/.test(target); + }, + hint: "~sampel-palnet or sampel-palnet", + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => [text], // No chunking for now + textChunkLimit: 10000, + sendText: async ({ cfg, to, text, accountId }) => { + const account = tlonPlugin.config.resolveAccount(cfg, accountId); + + if (!account.configured) { + throw new Error("Tlon account not configured"); + } + + // Authenticate with Urbit + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + + try { + // Normalize ship name for sending + const toShip = to.startsWith("~") ? to : `~${to}`; + const fromShip = account.ship.startsWith("~") + ? account.ship + : `~${account.ship}`; + + // Construct message in Tlon format + 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 }, + }; + + // Send via poke + await api.poke({ + app: "chat", + mark: "chat-dm-action", + json: action, + }); + + return { + channel: "tlon", + success: true, + messageId: id, + }; + } finally { + // Clean up connection + try { + await api.delete(); + } catch (e) { + // Ignore cleanup errors + } + } + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { + // TODO: Tlon/Urbit doesn't support media attachments yet + // For now, send the caption text and include media URL in the message + const messageText = mediaUrl + ? `${text}\n\n[Media: ${mediaUrl}]` + : text; + + // Reuse sendText implementation + return await tlonPlugin.outbound.sendText({ + cfg, + to, + text: messageText, + accountId, + }); + }, + }, + status: { + defaultRuntime: { + accountId: "default", + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + return accounts.flatMap((account) => { + if (!account.configured) { + return [{ + channel: "tlon", + accountId: account.accountId, + kind: "config", + message: "Account not configured (missing ship, code, or url)", + }]; + } + return []; + }); + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + ship: snapshot.ship ?? null, + url: snapshot.url ?? null, + }), + probeAccount: async ({ account }) => { + if (!account.configured) { + return { ok: false, error: "Not configured" }; + } + + try { + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + + try { + await api.getOurName(); + return { ok: true }; + } finally { + await api.delete(); + } + } catch (error) { + return { ok: false, error: error.message }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + ship: account.ship, + url: account.url, + }); + ctx.log?.info( + `[${account.accountId}] starting Tlon provider for ${account.ship}` + ); + + // Lazy import to avoid circular dependencies + const { monitorTlonProvider } = await import("./monitor.js"); + + return monitorTlonProvider({ + account, + accountId: account.accountId, + cfg: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; + +// Export tlonPlugin for use by index.ts +export { tlonPlugin }; diff --git a/extensions/tlon/src/core-bridge.js b/extensions/tlon/src/core-bridge.js new file mode 100644 index 000000000..634ef3dd8 --- /dev/null +++ b/extensions/tlon/src/core-bridge.js @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +let coreRootCache = null; +let coreDepsPromise = null; + +function findPackageRoot(startDir, name) { + let dir = startDir; + for (;;) { + const pkgPath = path.join(dir, "package.json"); + try { + if (fs.existsSync(pkgPath)) { + const raw = fs.readFileSync(pkgPath, "utf8"); + const pkg = JSON.parse(raw); + if (pkg.name === name) return dir; + } + } catch { + // ignore parse errors + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function resolveClawdbotRoot() { + if (coreRootCache) return coreRootCache; + const override = process.env.CLAWDBOT_ROOT?.trim(); + if (override) { + coreRootCache = override; + return override; + } + + const candidates = new Set(); + if (process.argv[1]) { + candidates.add(path.dirname(process.argv[1])); + } + candidates.add(process.cwd()); + try { + const urlPath = fileURLToPath(import.meta.url); + candidates.add(path.dirname(urlPath)); + } catch { + // ignore + } + + for (const start of candidates) { + const found = findPackageRoot(start, "clawdbot"); + if (found) { + coreRootCache = found; + return found; + } + } + + throw new Error( + "Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.", + ); +} + +async function importCoreModule(relativePath) { + const root = resolveClawdbotRoot(); + const distPath = path.join(root, "dist", relativePath); + if (!fs.existsSync(distPath)) { + throw new Error( + `Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`, + ); + } + return await import(pathToFileURL(distPath).href); +} + +export async function loadCoreChannelDeps() { + if (coreDepsPromise) return coreDepsPromise; + + coreDepsPromise = (async () => { + const [ + chunk, + envelope, + dispatcher, + routing, + inboundContext, + ] = await Promise.all([ + importCoreModule("auto-reply/chunk.js"), + importCoreModule("auto-reply/envelope.js"), + importCoreModule("auto-reply/reply/provider-dispatcher.js"), + importCoreModule("routing/resolve-route.js"), + importCoreModule("auto-reply/reply/inbound-context.js"), + ]); + + return { + chunkMarkdownText: chunk.chunkMarkdownText, + formatAgentEnvelope: envelope.formatAgentEnvelope, + dispatchReplyWithBufferedBlockDispatcher: + dispatcher.dispatchReplyWithBufferedBlockDispatcher, + resolveAgentRoute: routing.resolveAgentRoute, + finalizeInboundContext: inboundContext.finalizeInboundContext, + }; + })(); + + return coreDepsPromise; +} diff --git a/extensions/tlon/src/monitor.js b/extensions/tlon/src/monitor.js new file mode 100644 index 000000000..8cfcf54ea --- /dev/null +++ b/extensions/tlon/src/monitor.js @@ -0,0 +1,1572 @@ +// 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 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> + */ +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//posts/newest//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}`); + } + } +} diff --git a/extensions/tlon/src/urbit-sse-client.js b/extensions/tlon/src/urbit-sse-client.js new file mode 100644 index 000000000..eb52c8573 --- /dev/null +++ b/extensions/tlon/src/urbit-sse-client.js @@ -0,0 +1,371 @@ +/** + * Custom SSE client for Urbit that works in Node.js + * Handles authentication cookies and streaming properly + */ + +import { Readable } from "stream"; + +export class UrbitSSEClient { + constructor(url, cookie, options = {}) { + this.url = url; + // Extract just the cookie value (first part before semicolon) + this.cookie = cookie.split(";")[0]; + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() + .toString(36) + .substring(2, 8)}`; + this.channelUrl = `${url}/~/channel/${this.channelId}`; + this.subscriptions = []; + this.eventHandlers = new Map(); + this.aborted = false; + this.streamController = null; + + // Reconnection settings + this.onReconnect = options.onReconnect || null; + this.autoReconnect = options.autoReconnect !== false; // Default true + this.reconnectAttempts = 0; + this.maxReconnectAttempts = options.maxReconnectAttempts || 10; + this.reconnectDelay = options.reconnectDelay || 1000; // Start at 1s + this.maxReconnectDelay = options.maxReconnectDelay || 30000; // Max 30s + this.isConnected = false; + } + + /** + * Subscribe to an Urbit path + */ + async subscribe({ app, path, event, err, quit }) { + const subId = this.subscriptions.length + 1; + + this.subscriptions.push({ + id: subId, + action: "subscribe", + ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), + app, + path, + }); + + // Store event handlers + this.eventHandlers.set(subId, { event, err, quit }); + + return subId; + } + + /** + * Create the channel and start listening for events + */ + async connect() { + // Create channel with all subscriptions + const createResp = await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(this.subscriptions), + }); + + if (!createResp.ok && createResp.status !== 204) { + throw new Error(`Channel creation failed: ${createResp.status}`); + } + + // Send helm-hi poke to activate the channel + // This is required before opening the SSE stream + const pokeResp = await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([ + { + id: Date.now(), + action: "poke", + ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), + app: "hood", + mark: "helm-hi", + json: "Opening API channel", + }, + ]), + }); + + if (!pokeResp.ok && pokeResp.status !== 204) { + throw new Error(`Channel activation failed: ${pokeResp.status}`); + } + + // Open SSE stream + await this.openStream(); + this.isConnected = true; + this.reconnectAttempts = 0; // Reset on successful connection + } + + /** + * Open the SSE stream and process events + */ + async openStream() { + const response = await fetch(this.channelUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + Cookie: this.cookie, + }, + }); + + if (!response.ok) { + throw new Error(`Stream connection failed: ${response.status}`); + } + + // Start processing the stream in the background (don't await) + this.processStream(response.body).catch((error) => { + if (!this.aborted) { + console.error("Stream error:", error); + // Notify all error handlers + for (const { err } of this.eventHandlers.values()) { + if (err) err(error); + } + } + }); + + // Stream is connected and running in background + // Return immediately so connect() can complete + } + + /** + * Process the SSE stream (runs in background) + */ + async processStream(body) { + const reader = body; + let buffer = ""; + + // Convert Web ReadableStream to Node Readable if needed + const stream = + reader instanceof ReadableStream ? Readable.fromWeb(reader) : reader; + + try { + for await (const chunk of stream) { + if (this.aborted) break; + + buffer += chunk.toString(); + + // Process complete SSE events + let eventEnd; + while ((eventEnd = buffer.indexOf("\n\n")) !== -1) { + const eventData = buffer.substring(0, eventEnd); + buffer = buffer.substring(eventEnd + 2); + + this.processEvent(eventData); + } + } + } finally { + // Stream ended (either normally or due to error) + if (!this.aborted && this.autoReconnect) { + this.isConnected = false; + console.log("[SSE] Stream ended, attempting reconnection..."); + await this.attemptReconnect(); + } + } + } + + /** + * Process a single SSE event + */ + processEvent(eventData) { + const lines = eventData.split("\n"); + let id = null; + let data = null; + + for (const line of lines) { + if (line.startsWith("id: ")) { + id = line.substring(4); + } else if (line.startsWith("data: ")) { + data = line.substring(6); + } + } + + if (!data) return; + + try { + const parsed = JSON.parse(data); + + // Handle quit events - subscription ended + if (parsed.response === "quit") { + console.log(`[SSE] Received quit event for subscription ${parsed.id}`); + const handlers = this.eventHandlers.get(parsed.id); + if (handlers && handlers.quit) { + handlers.quit(); + } + return; + } + + // Debug: Log received events (skip subscription confirmations) + if (parsed.response !== "subscribe" && parsed.response !== "poke") { + console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); + } + + // Route to appropriate handler based on subscription + if (parsed.id && this.eventHandlers.has(parsed.id)) { + const { event } = this.eventHandlers.get(parsed.id); + if (event && parsed.json) { + console.log(`[SSE] Calling handler for subscription ${parsed.id}`); + event(parsed.json); + } + } else if (parsed.json) { + // Try to match by response structure for events without specific ID + console.log(`[SSE] Broadcasting event to all handlers`); + for (const { event } of this.eventHandlers.values()) { + if (event) { + event(parsed.json); + } + } + } + } catch (error) { + console.error("Error parsing SSE event:", error); + } + } + + /** + * Send a poke to Urbit + */ + async poke({ app, mark, json }) { + const pokeId = Date.now(); + + const pokeData = { + id: pokeId, + action: "poke", + ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), + app, + mark, + json, + }; + + console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300)); + + const response = await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([pokeData]), + }); + + console.log(`[SSE] Poke response status: ${response.status}`); + + if (!response.ok && response.status !== 204) { + const errorText = await response.text(); + console.log(`[SSE] Poke error body: ${errorText.substring(0, 500)}`); + throw new Error(`Poke failed: ${response.status} - ${errorText}`); + } + + return pokeId; + } + + /** + * Perform a scry (read-only query) to Urbit + */ + async scry(path) { + const scryUrl = `${this.url}/~/scry${path}`; + + const response = await fetch(scryUrl, { + method: "GET", + headers: { + Cookie: this.cookie, + }, + }); + + if (!response.ok) { + throw new Error(`Scry failed: ${response.status} for path ${path}`); + } + + return await response.json(); + } + + /** + * Attempt to reconnect with exponential backoff + */ + async attemptReconnect() { + if (this.aborted || !this.autoReconnect) { + console.log("[SSE] Reconnection aborted or disabled"); + return; + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error( + `[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.` + ); + return; + } + + this.reconnectAttempts++; + + // Calculate delay with exponential backoff + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), + this.maxReconnectDelay + ); + + console.log( + `[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...` + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + try { + // Generate new channel ID for reconnection + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() + .toString(36) + .substring(2, 8)}`; + this.channelUrl = `${this.url}/~/channel/${this.channelId}`; + + console.log(`[SSE] Reconnecting with new channel ID: ${this.channelId}`); + + // Call reconnect callback if provided + if (this.onReconnect) { + await this.onReconnect(this); + } + + // Reconnect + await this.connect(); + + console.log("[SSE] Reconnection successful!"); + } catch (error) { + console.error(`[SSE] Reconnection failed: ${error.message}`); + // Try again + await this.attemptReconnect(); + } + } + + /** + * Close the connection + */ + async close() { + this.aborted = true; + this.isConnected = false; + + try { + // Send unsubscribe for all subscriptions + const unsubscribes = this.subscriptions.map((sub) => ({ + id: sub.id, + action: "unsubscribe", + subscription: sub.id, + })); + + await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(unsubscribes), + }); + + // Delete the channel + await fetch(this.channelUrl, { + method: "DELETE", + headers: { + Cookie: this.cookie, + }, + }); + } catch (error) { + console.error("Error closing channel:", error); + } + } +} From 791b568f782550390936434b6b6d78f11540f906 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:17:58 +0000 Subject: [PATCH 43/45] feat: add tlon channel plugin --- CHANGELOG.md | 3 +- docs/channels/index.md | 1 + docs/channels/tlon.md | 133 ++ docs/plugin.md | 10 + extensions/tlon/README.md | 829 +-------- extensions/tlon/index.ts | 2 + extensions/tlon/node_modules/@urbit/aura | 1 + extensions/tlon/node_modules/@urbit/http-api | 1 + extensions/tlon/package.json | 20 +- extensions/tlon/src/channel.js | 360 ---- extensions/tlon/src/channel.ts | 379 ++++ extensions/tlon/src/config-schema.ts | 43 + extensions/tlon/src/core-bridge.js | 100 -- extensions/tlon/src/monitor.js | 1572 ----------------- extensions/tlon/src/monitor/discovery.ts | 71 + extensions/tlon/src/monitor/history.ts | 87 + extensions/tlon/src/monitor/index.ts | 501 ++++++ .../src/monitor/processed-messages.test.ts | 24 + .../tlon/src/monitor/processed-messages.ts | 38 + extensions/tlon/src/monitor/utils.ts | 83 + extensions/tlon/src/onboarding.ts | 213 +++ extensions/tlon/src/runtime.ts | 14 + extensions/tlon/src/targets.ts | 79 + extensions/tlon/src/types.ts | 85 + extensions/tlon/src/urbit/auth.ts | 18 + extensions/tlon/src/urbit/http-api.ts | 36 + extensions/tlon/src/urbit/send.ts | 114 ++ extensions/tlon/src/urbit/sse-client.test.ts | 41 + .../sse-client.ts} | 292 ++- pnpm-lock.yaml | 36 + src/channels/plugins/catalog.test.ts | 36 + src/channels/plugins/catalog.ts | 87 + src/channels/plugins/types.core.ts | 6 + src/cli/channel-options.ts | 19 +- src/cli/channels-cli.ts | 23 +- src/commands/channels/add-mutators.ts | 12 + src/commands/channels/add.ts | 83 +- src/commands/onboard-channels.ts | 6 +- 38 files changed, 2431 insertions(+), 3027 deletions(-) create mode 100644 docs/channels/tlon.md create mode 120000 extensions/tlon/node_modules/@urbit/aura create mode 120000 extensions/tlon/node_modules/@urbit/http-api delete mode 100644 extensions/tlon/src/channel.js create mode 100644 extensions/tlon/src/channel.ts create mode 100644 extensions/tlon/src/config-schema.ts delete mode 100644 extensions/tlon/src/core-bridge.js delete mode 100644 extensions/tlon/src/monitor.js create mode 100644 extensions/tlon/src/monitor/discovery.ts create mode 100644 extensions/tlon/src/monitor/history.ts create mode 100644 extensions/tlon/src/monitor/index.ts create mode 100644 extensions/tlon/src/monitor/processed-messages.test.ts create mode 100644 extensions/tlon/src/monitor/processed-messages.ts create mode 100644 extensions/tlon/src/monitor/utils.ts create mode 100644 extensions/tlon/src/onboarding.ts create mode 100644 extensions/tlon/src/runtime.ts create mode 100644 extensions/tlon/src/targets.ts create mode 100644 extensions/tlon/src/types.ts create mode 100644 extensions/tlon/src/urbit/auth.ts create mode 100644 extensions/tlon/src/urbit/http-api.ts create mode 100644 extensions/tlon/src/urbit/send.ts create mode 100644 extensions/tlon/src/urbit/sse-client.test.ts rename extensions/tlon/src/{urbit-sse-client.js => urbit/sse-client.ts} (51%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557a80ac5..714a3eb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,13 @@ Docs: https://docs.clawd.bot -## 2026.1.23 +## 2026.1.23 (Unreleased) ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: add live auth probes to `clawdbot models status` for per-profile verification. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. +- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. ### Fixes - Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. diff --git a/docs/channels/index.md b/docs/channels/index.md index 00b33ac07..f8fd860c3 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -23,6 +23,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). +- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md new file mode 100644 index 000000000..a2436d5e7 --- /dev/null +++ b/docs/channels/tlon.md @@ -0,0 +1,133 @@ +--- +summary: "Tlon/Urbit support status, capabilities, and configuration" +read_when: + - Working on Tlon/Urbit channel features +--- +# Tlon (plugin) + +Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can +respond to DMs and group chat messages. Group replies require an @ mention by default and can +be further restricted via allowlists. + +Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback +(URL appended to caption). Reactions, polls, and native media uploads are not supported. + +## Plugin required + +Tlon ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +clawdbot plugins install @clawdbot/tlon +``` + +Local checkout (when running from a git repo): + +```bash +clawdbot plugins install ./extensions/tlon +``` + +Details: [Plugins](/plugin) + +## Setup + +1) Install the Tlon plugin. +2) Gather your ship URL and login code. +3) Configure `channels.tlon`. +4) Restart the gateway. +5) DM the bot or mention it in a group channel. + +Minimal config (single account): + +```json5 +{ + channels: { + tlon: { + enabled: true, + ship: "~sampel-palnet", + url: "https://your-ship-host", + code: "lidlut-tabwed-pillex-ridrup" + } + } +} +``` + +## Group channels + +Auto-discovery is enabled by default. You can also pin channels manually: + +```json5 +{ + channels: { + tlon: { + groupChannels: [ + "chat/~host-ship/general", + "chat/~host-ship/support" + ] + } + } +} +``` + +Disable auto-discovery: + +```json5 +{ + channels: { + tlon: { + autoDiscoverChannels: false + } + } +} +``` + +## Access control + +DM allowlist (empty = allow all): + +```json5 +{ + channels: { + tlon: { + dmAllowlist: ["~zod", "~nec"] + } + } +} +``` + +Group authorization (restricted by default): + +```json5 +{ + channels: { + tlon: { + defaultAuthorizedShips: ["~zod"], + authorization: { + channelRules: { + "chat/~host-ship/general": { + mode: "restricted", + allowedShips: ["~zod", "~nec"] + }, + "chat/~host-ship/announcements": { + mode: "open" + } + } + } + } + } +} +``` + +## Delivery targets (CLI/cron) + +Use these with `clawdbot message send` or cron delivery: + +- DM: `~sampel-palnet` or `dm/~sampel-palnet` +- Group: `chat/~host-ship/channel` or `group:~host-ship/channel` + +## Notes + +- Group replies require a mention (e.g. `~your-bot-ship`) to respond. +- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread. +- Media: `sendMedia` falls back to text + URL (no native upload). diff --git a/docs/plugin.md b/docs/plugin.md index e740591b0..e954b8418 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -145,6 +145,16 @@ Example: } ``` +Clawdbot can also merge **external channel catalogs** (for example, an MPM +registry export). Drop a JSON file at one of: +- `~/.clawdbot/mpm/plugins.json` +- `~/.clawdbot/mpm/catalog.json` +- `~/.clawdbot/plugins/catalog.json` + +Or point `CLAWDBOT_PLUGIN_CATALOG_PATHS` (or `CLAWDBOT_MPM_CATALOG_PATHS`) at +one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should +contain `{ "entries": [ { "name": "@scope/pkg", "clawdbot": { "channel": {...}, "install": {...} } } ] }`. + ## Plugin IDs Default plugin ids: diff --git a/extensions/tlon/README.md b/extensions/tlon/README.md index 0fd7fd8da..aa02cab93 100644 --- a/extensions/tlon/README.md +++ b/extensions/tlon/README.md @@ -1,828 +1,5 @@ -# Clawdbot Tlon/Urbit Integration +# Tlon (Clawdbot plugin) -Complete documentation for integrating Clawdbot with Tlon Messenger (built on Urbit). +Tlon/Urbit channel plugin for Clawdbot. Supports DMs, group mentions, and thread replies. -## Overview - -This extension enables Clawdbot to: -- Monitor and respond to direct messages on Tlon Messenger -- Monitor and respond to group channel messages when mentioned -- Auto-discover available group channels -- Use per-conversation subscriptions for reliable message delivery -- **Automatic AI model fallback** - Seamlessly switches from Anthropic to OpenAI when rate limited (see [FALLBACK.md](./FALLBACK.md)) - -**Ship:** ~sitrul-nacwyl -**Test User:** ~malmur-halmex - -## Architecture - -### Files - -- **`index.js`** - Plugin entry point, registers the Tlon channel adapter -- **`monitor.js`** - Core monitoring logic, handles incoming messages and AI dispatch -- **`urbit-sse-client.js`** - Custom SSE client for Urbit HTTP API -- **`core-bridge.js`** - Dynamic loader for clawdbot core modules -- **`package.json`** - Plugin package definition -- **`FALLBACK.md`** - AI model fallback system documentation - -### How It Works - -1. **Authentication**: Uses ship name + code to authenticate via `/~/login` endpoint -2. **Channel Creation**: Creates Tlon Messenger channel via PUT to `/~/channel/{uid}` -3. **Activation**: Sends "helm-hi" poke to activate channel (required!) -4. **Subscriptions**: - - **DMs**: Individual subscriptions to `/dm/{ship}` for each conversation - - **Groups**: Individual subscriptions to `/{channelNest}` for each channel -5. **SSE Stream**: Opens server-sent events stream for real-time updates -6. **Auto-Reconnection**: Automatically reconnects if SSE stream dies - - Exponential backoff (1s to 30s delays) - - Up to 10 reconnection attempts - - Generates new channel ID on each attempt -7. **Auto-Discovery**: Queries `/groups-ui/v6/init.json` to find all available channels -8. **Dynamic Refresh**: Polls every 2 minutes for new conversations/channels -9. **Message Processing**: When bot is mentioned, routes to AI via clawdbot core -10. **AI Fallback**: Automatically switches providers when rate limited - - Primary: Anthropic Claude Sonnet 4.5 - - Fallbacks: OpenAI GPT-4o, GPT-4 Turbo - - Automatic cooldown management - - See [FALLBACK.md](./FALLBACK.md) for details - -## Configuration - -### 1. Install Dependencies - -```bash -cd ~/.clawdbot/extensions/tlon -npm install -``` - -### 2. Configure Credentials - -Edit `~/.clawdbot/clawdbot.json`: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "showModelSignature": false, - "dmAllowlist": ["~friend-ship-1", "~friend-ship-2"], - "defaultAuthorizedShips": ["~malmur-halmex"], - "authorization": { - "channelRules": { - "chat/~host-ship/channel-name": { - "mode": "open", - "allowedShips": [] - }, - "chat/~another-host/private-channel": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] - } - } - } - } - } -} -``` - -**Configuration Options:** -- `enabled` - Enable/disable the Tlon channel (default: `false`) -- `ship` - Your Urbit ship name (required) -- `code` - Your ship's login code (required) -- `url` - Your ship's URL (required) -- `showModelSignature` - Append model name to responses (default: `false`) - - When enabled, adds `[Generated by Claude Sonnet 4.5]` to the end of each response - - Useful for transparency about which AI model generated the response -- `dmAllowlist` - Ships allowed to send DMs (optional) - - If omitted or empty, all DMs are accepted (default behavior) - - Ship names can include or omit the `~` prefix - - Example: `["~trusted-friend", "~another-ship"]` - - Blocked DMs are logged for visibility -- `defaultAuthorizedShips` - Ships authorized in new/unconfigured channels (default: `["~malmur-halmex"]`) - - New channels default to `restricted` mode using these ships -- `authorization` - Per-channel access control (optional) - - `channelRules` - Map of channel nest to authorization rules - - `mode`: `"open"` (all ships) or `"restricted"` (allowedShips only) - - `allowedShips`: Array of authorized ships (only for `restricted` mode) - -**For localhost development:** -```json -"url": "http://localhost:8080" -``` - -**For Tlon-hosted ships:** -```json -"url": "https://{ship-name}.tlon.network" -``` - -### 3. Set Environment Variable - -The monitor needs to find clawdbot's core modules. Set the environment variable: - -```bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -``` - -Or if clawdbot is installed elsewhere: -```bash -export CLAWDBOT_ROOT=$(dirname $(dirname $(readlink -f $(which clawdbot)))) -``` - -**Make it permanent** (add to `~/.zshrc` or `~/.bashrc`): -```bash -echo 'export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot' >> ~/.zshrc -``` - -### 4. Configure AI Authentication - -The bot needs API credentials to generate responses. - -**Option A: Use Claude Code CLI credentials** -```bash -clawdbot agents add main -# Select "Use Claude Code CLI credentials" -``` - -**Option B: Use Anthropic API key** -```bash -clawdbot agents add main -# Enter your API key from console.anthropic.com -``` - -### 5. Start the Gateway - -```bash -CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway -``` - -Or create a launch script: - -```bash -cat > ~/start-clawdbot.sh << 'EOF' -#!/bin/bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -clawdbot gateway -EOF -chmod +x ~/start-clawdbot.sh -``` - -## Usage - -### Testing - -1. Send a DM from another ship to ~sitrul-nacwyl -2. Mention the bot: `~sitrul-nacwyl hello there!` -3. Bot should respond with AI-generated reply - -### Monitoring Logs - -Check gateway logs: -```bash -tail -f /tmp/clawdbot/clawdbot-$(date +%Y-%m-%d).log -``` - -Look for these indicators: -- `[tlon] Successfully authenticated to https://...` -- `[tlon] Auto-discovered N chat channel(s)` -- `[tlon] Connected! All subscriptions active` -- `[tlon] Received DM from ~ship: "..." (mentioned: true)` -- `[tlon] Dispatching to AI for ~ship (DM)` -- `[tlon] Delivered AI reply to ~ship` - -### Group Channels - -The bot automatically discovers and subscribes to all group channels using **delta-based discovery** for efficiency. - -**How Auto-Discovery Works:** -1. **On startup:** Fetches changes from the last 5 days via `/groups-ui/v5/changes/~YYYY.M.D..20.19.51..9b9d.json` -2. **Periodic refresh:** Checks for new channels every 2 minutes -3. **Smart caching:** Only fetches deltas, not full state each time - -**Benefits:** -- Reduced bandwidth usage -- Faster startup (especially for ships with many groups) -- Automatically picks up new channels you join -- Context of recent group activity - -**Manual Configuration:** - -To disable auto-discovery and use specific channels: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "autoDiscoverChannels": false, - "groupChannels": [ - "chat/~host-ship/channel-name", - "chat/~another-host/another-channel" - ] - } - } -} -``` - -### Model Signatures - -The bot can append the AI model name to each response for transparency. Enable this feature in your config: - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "your-ship-name", - "code": "your-ship-code", - "url": "https://your-ship-name.tlon.network", - "showModelSignature": true - } - } -} -``` - -**Example output with signature enabled:** -``` -User: ~sitrul-nacwyl explain quantum computing -Bot: Quantum computing uses quantum mechanics principles like superposition - and entanglement to perform calculations... - - [Generated by Claude Sonnet 4.5] -``` - -**Supported model formats:** -- `Claude Opus 4.5` -- `Claude Sonnet 4.5` -- `GPT-4o` -- `GPT-4 Turbo` -- `Gemini 2.0 Flash` - -When using the [AI fallback system](./FALLBACK.md), signatures automatically reflect which model generated the response (e.g., if Anthropic is rate limited and OpenAI is used, the signature will show `GPT-4o`). - -### Channel History Summarization - -The bot can summarize recent channel activity when asked. This is useful for catching up on conversations you missed. - -**Trigger phrases:** -- `~bot-ship summarize this channel` -- `~bot-ship what did I miss?` -- `~bot-ship catch me up` -- `~bot-ship tldr` -- `~bot-ship channel summary` - -**Example:** -``` -User: ~sitrul-nacwyl what did I miss? -Bot: Here's a summary of the last 50 messages: - -Main topics discussed: -1. Discussion about Urbit networking (Ames protocol) -2. Planning for next week's developer meetup -3. Bug reports for the new UI update - -Key decisions: -- Meetup scheduled for Thursday at 3pm EST -- Priority on fixing the scrolling issue - -Notable participants: ~malmur-halmex, ~bolbex-fogdys -``` - -**How it works:** -- Fetches the last 50 messages from the channel -- Sends them to the AI for summarization -- Returns a concise summary with main topics, decisions, and action items - -### Thread Support - -The bot automatically maintains context in threaded conversations. When you mention the bot in a reply thread, it will respond within that thread instead of posting to the main channel. - -**Example:** -``` -Main channel post: - User A: ~sitrul-nacwyl what's the capital of France? - Bot: Paris is the capital of France. - └─ User B (in thread): ~sitrul-nacwyl and what's its population? - └─ Bot (in thread): Paris has a population of approximately 2.2 million... -``` - -**Benefits:** -- Keeps conversations organized -- Reduces noise in main channel -- Maintains conversation context within threads - -**Technical Details:** -The bot handles both top-level posts and thread replies with different data structures: -- Top-level posts: `response.post.r-post.set.essay` -- Thread replies: `response.post.r-post.reply.r-reply.set.memo` - -When replying in a thread, the bot uses the `parent-id` from the incoming message to ensure the reply stays within the same thread. - -**Note:** Thread support is automatic - no configuration needed. - -### Link Summarization - -The bot can fetch and summarize web content when you share links. - -**Example:** -``` -User: ~sitrul-nacwyl can you summarize this https://example.com/article -Bot: This article discusses... [summary of the content] -``` - -**How it works:** -- Bot extracts URLs from rich text messages (including inline links) -- Fetches the web page content -- Summarizes using the WebFetch tool - -### Channel Authorization - -Control which ships can invoke the bot in specific group channels. **New channels default to `restricted` mode** for security. - -#### Default Behavior - -**DMs:** Always open (no restrictions) -**Group Channels:** Restricted by default, only ships in `defaultAuthorizedShips` can invoke the bot - -#### Configuration - -```json -{ - "channels": { - "tlon": { - "enabled": true, - "ship": "sitrul-nacwyl", - "code": "your-code", - "url": "https://sitrul-nacwyl.tlon.network", - "defaultAuthorizedShips": ["~malmur-halmex"], - "authorization": { - "channelRules": { - "chat/~bitpyx-dildus/core": { - "mode": "open" - }, - "chat/~nocsyx-lassul/bongtable": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"] - } - } - } - } - } -} -``` - -#### Authorization Modes - -**`open`** - Any ship can invoke the bot when mentioned -- Good for public channels -- No `allowedShips` needed - -**`restricted`** (default) - Only specific ships can invoke the bot -- Good for private/work channels -- Requires `allowedShips` list -- New channels use `defaultAuthorizedShips` if no rule exists - -#### Examples - -**Make a channel public:** -```json -"chat/~bitpyx-dildus/core": { - "mode": "open" -} -``` - -**Restrict to specific users:** -```json -"chat/~nocsyx-lassul/bongtable": { - "mode": "restricted", - "allowedShips": ["~malmur-halmex"] -} -``` - -**New channel (no config):** -- Mode: `restricted` (safe default) -- Allowed ships: `defaultAuthorizedShips` (e.g., `["~malmur-halmex"]`) - -#### Behavior - -**Authorized mention:** -``` -~malmur-halmex: ~sitrul-nacwyl tell me about quantum computing -Bot: [Responds with answer] -``` - -**Unauthorized mention (silently ignored):** -``` -~other-ship: ~sitrul-nacwyl tell me about quantum computing -Bot: [No response, logs show access denied] -``` - -**Check logs:** -```bash -tail -f /tmp/tlon-fallback.log | grep "Access" -``` - -You'll see: -``` -[tlon] ✅ Access granted: ~malmur-halmex in chat/~host/channel (authorized user) -[tlon] ⛔ Access denied: ~other-ship in chat/~host/channel (restricted, allowed: ~malmur-halmex) -``` - -## Technical Deep Dive - -### Urbit HTTP API Flow - -1. **Login** (POST `/~/login`) - - Sends `password={code}` - - Returns authentication cookie in `set-cookie` header - -2. **Channel Creation** (PUT `/~/channel/{channelId}`) - - Channel ID format: `{timestamp}-{random}` - - Body: array of subscription objects - - Response: 204 No Content - -3. **Channel Activation** (PUT `/~/channel/{channelId}`) - - **Critical:** Must send helm-hi poke BEFORE opening SSE stream - - Poke structure: - ```json - { - "id": timestamp, - "action": "poke", - "ship": "sitrul-nacwyl", - "app": "hood", - "mark": "helm-hi", - "json": "Opening API channel" - } - ``` - -4. **SSE Stream** (GET `/~/channel/{channelId}`) - - Headers: `Accept: text/event-stream` - - Returns Server-Sent Events - - Format: - ``` - id: {event-id} - data: {json-payload} - - ``` - -### Subscription Paths - -#### DMs (Chat App) -- **Path:** `/dm/{ship}` -- **App:** `chat` -- **Event Format:** - ```json - { - "id": "~ship/timestamp", - "whom": "~other-ship", - "response": { - "add": { - "memo": { - "author": "~sender-ship", - "sent": 1768742460781, - "content": [ - { - "inline": [ - "text", - {"ship": "~mentioned-ship"}, - "more text", - {"break": null} - ] - } - ] - } - } - } - } - ``` - -#### Group Channels (Channels App) -- **Path:** `/{channelNest}` -- **Channel Nest Format:** `chat/~host-ship/channel-name` -- **App:** `channels` -- **Event Format:** - ```json - { - "response": { - "post": { - "id": "message-id", - "r-post": { - "set": { - "essay": { - "author": "~sender-ship", - "sent": 1768742460781, - "kind": "/chat", - "content": [...] - } - } - } - } - } - } - ``` - -### Text Extraction - -Message content uses inline format with mixed types: -- Strings: plain text -- Objects with `ship`: mentions (e.g., `{"ship": "~sitrul-nacwyl"}`) -- Objects with `break`: line breaks (e.g., `{"break": null}`) - -Example: -```json -{ - "inline": [ - "Hey ", - {"ship": "~sitrul-nacwyl"}, - " how are you?", - {"break": null}, - "This is a new line" - ] -} -``` - -Extracts to: `"Hey ~sitrul-nacwyl how are you?\nThis is a new line"` - -### Mention Detection - -Simple includes check (case-insensitive): -```javascript -const normalizedBotShip = botShipName.startsWith("~") - ? botShipName - : `~${botShipName}`; -return messageText.toLowerCase().includes(normalizedBotShip.toLowerCase()); -``` - -Note: Word boundaries (`\b`) don't work with `~` character. - -## Troubleshooting - -### Issue: "Cannot read properties of undefined (reading 'href')" - -**Cause:** Some clawdbot dependencies (axios, Slack SDK) expect browser globals - -**Fix:** Window.location polyfill is already added to monitor.js (lines 1-18) - -### Issue: "Unable to resolve Clawdbot root" - -**Cause:** core-bridge.js can't find clawdbot installation - -**Fix:** Set `CLAWDBOT_ROOT` environment variable: -```bash -export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot -``` - -### Issue: SSE Stream Returns 403 Forbidden - -**Cause:** Trying to open SSE stream without activating channel first - -**Fix:** Send helm-hi poke before opening stream (urbit-sse-client.js handles this) - -### Issue: No Events Received After Subscribing - -**Cause:** Wrong subscription path or app name - -**Fix:** -- DMs: Use `/dm/{ship}` with `app: "chat"` -- Groups: Use `/{channelNest}` with `app: "channels"` - -### Issue: Messages Show "[object Object]" - -**Cause:** Not handling inline content objects properly - -**Fix:** Text extraction handles mentions and breaks (monitor.js `extractMessageText()`) - -### Issue: Bot Not Detecting Mentions - -**Cause:** Message doesn't contain bot's ship name - -**Debug:** -```bash -tail -f /tmp/clawdbot/clawdbot-*.log | grep "mentioned:" -``` - -Should show: -``` -[tlon] Received DM from ~malmur-halmex: "~sitrul-nacwyl hello..." (mentioned: true) -``` - -### Issue: "No API key found for provider 'anthropic'" - -**Cause:** AI authentication not configured - -**Fix:** Run `clawdbot agents add main` and configure credentials - -### Issue: Gateway Port Already in Use - -**Fix:** -```bash -# Stop existing instance -clawdbot daemon stop - -# Or force kill -lsof -ti:18789 | xargs kill -9 -``` - -### Issue: Bot Stops Responding (SSE Disconnection) - -**Cause:** Urbit SSE stream disconnected (sent "quit" event or stream ended) - -**Symptoms:** -- Logs show: `[SSE] Received event: {"id":X,"response":"quit"}` -- No more incoming SSE events -- Bot appears online but doesn't respond to mentions - -**Fix:** The bot now **automatically reconnects**! Look for these log messages: -``` -[SSE] Stream ended, attempting reconnection... -[SSE] Reconnection attempt 1/10 in 1000ms... -[SSE] Reconnecting with new channel ID: xxx-yyy -[SSE] Reconnection successful! -``` - -**Manual restart if needed:** -```bash -kill $(pgrep -f "clawdbot gateway") -CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway -``` - -**Configuration options** (in urbit-sse-client.js constructor): -```javascript -new UrbitSSEClient(url, cookie, { - autoReconnect: true, // Default: true - maxReconnectAttempts: 10, // Default: 10 - reconnectDelay: 1000, // Initial delay: 1s - maxReconnectDelay: 30000, // Max delay: 30s - onReconnect: async (client) => { - // Optional callback for resubscription logic - } -}) -``` - -## Development Notes - -### Testing Without Clawdbot - -You can test the Urbit API directly: - -```javascript -import { UrbitSSEClient } from "./urbit-sse-client.js"; - -const api = new UrbitSSEClient( - "https://sitrul-nacwyl.tlon.network", - "your-cookie-here" -); - -// Subscribe to DMs -await api.subscribe({ - app: "chat", - path: "/dm/malmur-halmex", - event: (data) => console.log("DM:", data), - err: (e) => console.error("Error:", e), - quit: () => console.log("Quit") -}); - -// Connect -await api.connect(); - -// Send a DM -await api.poke({ - app: "chat", - mark: "chat-dm-action", - json: { - ship: "~malmur-halmex", - diff: { - id: `~sitrul-nacwyl/${Date.now()}`, - delta: { - add: { - memo: { - content: [{ inline: ["Hello!"] }], - author: "~sitrul-nacwyl", - sent: Date.now() - }, - kind: null, - time: null - } - } - } - } -}); -``` - -### Debugging SSE Events - -Enable verbose logging in urbit-sse-client.js: - -```javascript -// Line 169-171 -if (parsed.response !== "subscribe" && parsed.response !== "poke") { - console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); -} -``` - -Remove the condition to see all events: -```javascript -console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); -``` - -### Channel Nest Format - -Format: `{type}/{host-ship}/{channel-name}` - -Examples: -- `chat/~bitpyx-dildus/core` -- `chat/~malmur-halmex/v3aedb3s` -- `chat/~sitrul-nacwyl/tm-wayfinding-group-chat` - -Parse with: -```javascript -const match = channelNest.match(/^([^/]+)\/([^/]+)\/(.+)$/); -const [, type, hostShip, channelName] = match; -``` - -### Auto-Discovery Endpoint - -Query: `GET /~/scry/groups-ui/v6/init.json` - -Response structure: -```json -{ - "groups": { - "group-id": { - "channels": { - "chat/~host/name": { ... }, - "diary/~host/name": { ... } - } - } - } -} -``` - -Filter for chat channels only: -```javascript -if (channelNest.startsWith("chat/")) { - channels.push(channelNest); -} -``` - -## Implementation Timeline - -### Major Milestones - -1. ✅ Plugin structure and registration -2. ✅ Authentication and cookie management -3. ✅ Channel creation and activation (helm-hi poke) -4. ✅ SSE stream connection -5. ✅ DM subscription and event parsing -6. ✅ Group channel support -7. ✅ Auto-discovery of channels -8. ✅ Per-conversation subscriptions -9. ✅ Text extraction (mentions and breaks) -10. ✅ Mention detection -11. ✅ Node.js polyfills (window.location) -12. ✅ Core module integration -13. ⏳ API authentication (user needs to configure) - -### Key Discoveries - -- **Helm-hi requirement:** Must send helm-hi poke before opening SSE stream -- **Subscription paths:** Frontend uses `/v3` globally, but individual `/dm/{ship}` and `/{channelNest}` paths work better -- **Event formats:** V3 API uses `essay` and `memo` structures (not older `writs` format) -- **Inline content:** Mixed array of strings and objects (mentions, breaks) -- **Tilde handling:** Ship mentions already include `~` prefix -- **Word boundaries:** `\b` regex doesn't work with `~` character -- **Browser globals:** axios and Slack SDK need window.location polyfill -- **Module resolution:** Need CLAWDBOT_ROOT for dynamic imports - -## Resources - -- **Tlon Apps GitHub:** https://github.com/tloncorp/tlon-apps -- **Urbit HTTP API:** @urbit/http-api package -- **Tlon Frontend Code:** `/tmp/tlon-apps/packages/shared/src/api/chatApi.ts` -- **Clawdbot Docs:** https://docs.clawd.bot/ -- **Anthropic Provider:** https://docs.clawd.bot/providers/anthropic - -## Future Enhancements - -- [ ] Support for message reactions -- [ ] Support for message editing/deletion -- [ ] Support for attachments/images -- [ ] Typing indicators -- [ ] Read receipts -- [ ] Message threading -- [ ] Channel-specific bot personas -- [ ] Rate limiting -- [ ] Message queuing for offline ships -- [ ] Metrics and monitoring - -## Credits - -Built for integrating Clawdbot with Tlon messenger. - -**Developer:** Claude (Sonnet 4.5) -**Platform:** Tlon Messenger built on Urbit +Docs: https://docs.clawd.bot/channels/tlon diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 52b82e9dd..d5d27056b 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -2,6 +2,7 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { tlonPlugin } from "./src/channel.js"; +import { setTlonRuntime } from "./src/runtime.js"; const plugin = { id: "tlon", @@ -9,6 +10,7 @@ const plugin = { description: "Tlon/Urbit channel plugin", configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { + setTlonRuntime(api.runtime); api.registerChannel({ plugin: tlonPlugin }); }, }; diff --git a/extensions/tlon/node_modules/@urbit/aura b/extensions/tlon/node_modules/@urbit/aura new file mode 120000 index 000000000..8e9400cee --- /dev/null +++ b/extensions/tlon/node_modules/@urbit/aura @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@urbit+aura@2.0.1/node_modules/@urbit/aura \ No newline at end of file diff --git a/extensions/tlon/node_modules/@urbit/http-api b/extensions/tlon/node_modules/@urbit/http-api new file mode 120000 index 000000000..6411dd8e7 --- /dev/null +++ b/extensions/tlon/node_modules/@urbit/http-api @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@urbit+http-api@3.0.0/node_modules/@urbit/http-api \ No newline at end of file diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c11d45c97..03158015c 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -6,11 +6,25 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "tlon", + "label": "Tlon", + "selectionLabel": "Tlon (Urbit)", + "docsPath": "/channels/tlon", + "docsLabel": "tlon", + "blurb": "decentralized messaging on Urbit; install the plugin to enable.", + "order": 90, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/tlon", + "localPath": "extensions/tlon", + "defaultChoice": "npm" + } }, "dependencies": { - "@urbit/http-api": "^3.0.0", "@urbit/aura": "^2.0.0", - "eventsource": "^2.0.2" + "@urbit/http-api": "^3.0.0" } } diff --git a/extensions/tlon/src/channel.js b/extensions/tlon/src/channel.js deleted file mode 100644 index c1974f91b..000000000 --- a/extensions/tlon/src/channel.js +++ /dev/null @@ -1,360 +0,0 @@ -import { Urbit } from "@urbit/http-api"; -import { unixToDa, formatUd } from "@urbit/aura"; - -// Polyfill minimal browser globals needed by @urbit/http-api in Node -if (typeof global.window === "undefined") { - global.window = { fetch: global.fetch }; -} -if (typeof global.document === "undefined") { - global.document = { - hidden: true, - addEventListener() {}, - removeEventListener() {}, - }; -} - -// Patch Urbit.prototype.connect for HTTP authentication -const { connect } = Urbit.prototype; -Urbit.prototype.connect = async function patchedConnect() { - const resp = await fetch(`${this.url}/~/login`, { - method: "POST", - body: `password=${this.code}`, - credentials: "include", - }); - - if (resp.status >= 400) { - throw new Error("Login failed with status " + resp.status); - } - - const cookie = resp.headers.get("set-cookie"); - if (cookie) { - const match = /urbauth-~([\w-]+)/.exec(cookie); - if (!this.nodeId && match) { - this.nodeId = match[1]; - } - this.cookie = cookie; - } - await this.getShipName(); - await this.getOurName(); -}; - -/** - * Tlon/Urbit channel plugin for Clawdbot - */ -export const tlonPlugin = { - id: "tlon", - meta: { - id: "tlon", - label: "Tlon", - selectionLabel: "Tlon/Urbit", - docsPath: "/channels/tlon", - docsLabel: "tlon", - blurb: "Decentralized messaging on Urbit", - aliases: ["urbit"], - order: 90, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: false, - }, - reload: { configPrefixes: ["channels.tlon"] }, - config: { - listAccountIds: (cfg) => { - const base = cfg.channels?.tlon; - if (!base) return []; - const accounts = base.accounts || {}; - return [ - ...(base.ship ? ["default"] : []), - ...Object.keys(accounts), - ]; - }, - resolveAccount: (cfg, accountId) => { - const base = cfg.channels?.tlon; - if (!base) { - return { - accountId: accountId || "default", - name: null, - enabled: false, - configured: false, - ship: null, - url: null, - code: null, - }; - } - - const useDefault = !accountId || accountId === "default"; - const account = useDefault ? base : base.accounts?.[accountId]; - - return { - accountId: accountId || "default", - name: account?.name || null, - enabled: account?.enabled !== false, - configured: Boolean(account?.ship && account?.code && account?.url), - ship: account?.ship || null, - url: account?.url || null, - code: account?.code || null, - groupChannels: account?.groupChannels || [], - dmAllowlist: account?.dmAllowlist || [], - notebookChannel: account?.notebookChannel || null, - }; - }, - defaultAccountId: () => "default", - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const useDefault = !accountId || accountId === "default"; - - if (useDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - enabled, - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: { - ...cfg.channels?.tlon?.accounts, - [accountId]: { - ...cfg.channels?.tlon?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const useDefault = !accountId || accountId === "default"; - - if (useDefault) { - const { ship, code, url, name, ...rest } = cfg.channels?.tlon || {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: rest, - }, - }; - } - - const { [accountId]: removed, ...remainingAccounts } = - cfg.channels?.tlon?.accounts || {}; - return { - ...cfg, - channels: { - ...cfg.channels, - tlon: { - ...cfg.channels?.tlon, - accounts: remainingAccounts, - }, - }, - }; - }, - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - ship: account.ship, - url: account.url, - }), - }, - messaging: { - normalizeTarget: (target) => { - // Normalize Urbit ship names - const trimmed = target.trim(); - if (!trimmed.startsWith("~")) { - return `~${trimmed}`; - } - return trimmed; - }, - targetResolver: { - looksLikeId: (target) => { - return /^~?[a-z-]+$/.test(target); - }, - hint: "~sampel-palnet or sampel-palnet", - }, - }, - outbound: { - deliveryMode: "direct", - chunker: (text, limit) => [text], // No chunking for now - textChunkLimit: 10000, - sendText: async ({ cfg, to, text, accountId }) => { - const account = tlonPlugin.config.resolveAccount(cfg, accountId); - - if (!account.configured) { - throw new Error("Tlon account not configured"); - } - - // Authenticate with Urbit - const api = await Urbit.authenticate({ - ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, - }); - - try { - // Normalize ship name for sending - const toShip = to.startsWith("~") ? to : `~${to}`; - const fromShip = account.ship.startsWith("~") - ? account.ship - : `~${account.ship}`; - - // Construct message in Tlon format - 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 }, - }; - - // Send via poke - await api.poke({ - app: "chat", - mark: "chat-dm-action", - json: action, - }); - - return { - channel: "tlon", - success: true, - messageId: id, - }; - } finally { - // Clean up connection - try { - await api.delete(); - } catch (e) { - // Ignore cleanup errors - } - } - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { - // TODO: Tlon/Urbit doesn't support media attachments yet - // For now, send the caption text and include media URL in the message - const messageText = mediaUrl - ? `${text}\n\n[Media: ${mediaUrl}]` - : text; - - // Reuse sendText implementation - return await tlonPlugin.outbound.sendText({ - cfg, - to, - text: messageText, - accountId, - }); - }, - }, - status: { - defaultRuntime: { - accountId: "default", - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => { - return accounts.flatMap((account) => { - if (!account.configured) { - return [{ - channel: "tlon", - accountId: account.accountId, - kind: "config", - message: "Account not configured (missing ship, code, or url)", - }]; - } - return []; - }); - }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - ship: snapshot.ship ?? null, - url: snapshot.url ?? null, - }), - probeAccount: async ({ account }) => { - if (!account.configured) { - return { ok: false, error: "Not configured" }; - } - - try { - const api = await Urbit.authenticate({ - ship: account.ship.replace(/^~/, ""), - url: account.url, - code: account.code, - verbose: false, - }); - - try { - await api.getOurName(); - return { ok: true }; - } finally { - await api.delete(); - } - } catch (error) { - return { ok: false, error: error.message }; - } - }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - ship: account.ship, - url: account.url, - probe, - }), - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - ship: account.ship, - url: account.url, - }); - ctx.log?.info( - `[${account.accountId}] starting Tlon provider for ${account.ship}` - ); - - // Lazy import to avoid circular dependencies - const { monitorTlonProvider } = await import("./monitor.js"); - - return monitorTlonProvider({ - account, - accountId: account.accountId, - cfg: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - }); - }, - }, -}; - -// Export tlonPlugin for use by index.ts -export { tlonPlugin }; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts new file mode 100644 index 000000000..e4c949452 --- /dev/null +++ b/extensions/tlon/src/channel.ts @@ -0,0 +1,379 @@ +import type { + ChannelOutboundAdapter, + ChannelPlugin, + ChannelSetupInput, + ClawdbotConfig, +} from "clawdbot/plugin-sdk"; +import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "clawdbot/plugin-sdk"; + +import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; +import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; +import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js"; +import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js"; +import { monitorTlonProvider } from "./monitor/index.js"; +import { tlonChannelConfigSchema } from "./config-schema.js"; +import { tlonOnboardingAdapter } from "./onboarding.js"; + +const TLON_CHANNEL_ID = "tlon" as const; + +type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; +}; + +function applyTlonSetupConfig(params: { + cfg: ClawdbotConfig; + accountId: string; + input: TlonSetupInput; +}): ClawdbotConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "tlon", + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + + const payload = { + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }; + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: base.enabled ?? true, + accounts: { + ...(base as { accounts?: Record }).accounts, + [accountId]: { + ...((base as { accounts?: Record> }).accounts?.[ + accountId + ] ?? {}), + enabled: true, + ...payload, + }, + }, + }, + }, + }; +} + +const tlonOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + textChunkLimit: 10000, + resolveTarget: ({ to }) => { + const parsed = parseTlonTarget(to ?? ""); + if (!parsed) { + return { + ok: false, + error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`), + }; + } + if (parsed.kind === "dm") { + return { ok: true, to: parsed.ship }; + } + return { ok: true, to: parsed.nest }; + }, + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined); + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured"); + } + + const parsed = parseTlonTarget(to); + if (!parsed) { + throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); + } + + ensureUrbitConnectPatched(); + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + + try { + const fromShip = normalizeShip(account.ship); + if (parsed.kind === "dm") { + return await sendDm({ + api, + fromShip, + toShip: parsed.ship, + text, + }); + } + const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; + return await sendGroupMessage({ + api, + fromShip, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text, + replyToId: replyId, + }); + } finally { + try { + await api.delete(); + } catch { + // ignore cleanup errors + } + } + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { + const mergedText = buildMediaText(text, mediaUrl); + return await tlonOutbound.sendText({ + cfg, + to, + text: mergedText, + accountId, + replyToId, + threadId, + }); + }, +}; + +export const tlonPlugin: ChannelPlugin = { + id: TLON_CHANNEL_ID, + meta: { + id: TLON_CHANNEL_ID, + label: "Tlon", + selectionLabel: "Tlon (Urbit)", + docsPath: "/channels/tlon", + docsLabel: "tlon", + blurb: "Decentralized messaging on Urbit", + aliases: ["urbit"], + order: 90, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + media: false, + reply: true, + threads: true, + }, + onboarding: tlonOnboardingAdapter, + reload: { configPrefixes: ["channels.tlon"] }, + configSchema: tlonChannelConfigSchema, + config: { + listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig), + resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined), + defaultAccountId: () => "default", + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const useDefault = !accountId || accountId === "default"; + if (useDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + enabled, + }, + }, + } as ClawdbotConfig; + } + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + accounts: { + ...(cfg.channels?.tlon?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}), + enabled, + }, + }, + }, + }, + } as ClawdbotConfig; + }, + deleteAccount: ({ cfg, accountId }) => { + const useDefault = !accountId || accountId === "default"; + if (useDefault) { + const { ship, code, url, name, ...rest } = cfg.channels?.tlon ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: rest, + }, + } as ClawdbotConfig; + } + const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...(cfg.channels?.tlon ?? {}), + accounts: remainingAccounts, + }, + }, + } as ClawdbotConfig; + }, + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + }), + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "tlon", + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) return "Tlon requires --ship."; + if (!url) return "Tlon requires --url."; + if (!code) return "Tlon requires --code."; + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg: cfg as ClawdbotConfig, + accountId, + input: input as TlonSetupInput, + }), + }, + messaging: { + normalizeTarget: (target) => { + const parsed = parseTlonTarget(target); + if (!parsed) return target.trim(); + if (parsed.kind === "dm") return parsed.ship; + return parsed.nest; + }, + targetResolver: { + looksLikeId: (target) => Boolean(parseTlonTarget(target)), + hint: formatTargetHint(), + }, + }, + outbound: tlonOutbound, + status: { + defaultRuntime: { + accountId: "default", + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + return accounts.flatMap((account) => { + if (!account.configured) { + return [ + { + channel: TLON_CHANNEL_ID, + accountId: account.accountId, + kind: "config", + message: "Account not configured (missing ship, code, or url)", + }, + ]; + } + return []; + }); + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + ship: snapshot.ship ?? null, + url: snapshot.url ?? null, + }), + probeAccount: async ({ account }) => { + if (!account.configured || !account.ship || !account.url || !account.code) { + return { ok: false, error: "Not configured" }; + } + try { + ensureUrbitConnectPatched(); + const api = await Urbit.authenticate({ + ship: account.ship.replace(/^~/, ""), + url: account.url, + code: account.code, + verbose: false, + }); + try { + await api.getOurName(); + return { ok: true }; + } finally { + await api.delete(); + } + } catch (error: any) { + return { ok: false, error: error?.message ?? String(error) }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + ship: account.ship, + url: account.url, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + ship: account.ship, + url: account.url, + }); + ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`); + return monitorTlonProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + accountId: account.accountId, + }); + }, + }, +}; diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts new file mode 100644 index 000000000..13c7cd7c0 --- /dev/null +++ b/extensions/tlon/src/config-schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; + +const ShipSchema = z.string().min(1); +const ChannelNestSchema = z.string().min(1); + +export const TlonChannelRuleSchema = z.object({ + mode: z.enum(["restricted", "open"]).optional(), + allowedShips: z.array(ShipSchema).optional(), +}); + +export const TlonAuthorizationSchema = z.object({ + channelRules: z.record(TlonChannelRuleSchema).optional(), +}); + +export const TlonAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + ship: ShipSchema.optional(), + url: z.string().optional(), + code: z.string().optional(), + groupChannels: z.array(ChannelNestSchema).optional(), + dmAllowlist: z.array(ShipSchema).optional(), + autoDiscoverChannels: z.boolean().optional(), + showModelSignature: z.boolean().optional(), +}); + +export const TlonConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + ship: ShipSchema.optional(), + url: z.string().optional(), + code: z.string().optional(), + groupChannels: z.array(ChannelNestSchema).optional(), + dmAllowlist: z.array(ShipSchema).optional(), + autoDiscoverChannels: z.boolean().optional(), + showModelSignature: z.boolean().optional(), + authorization: TlonAuthorizationSchema.optional(), + defaultAuthorizedShips: z.array(ShipSchema).optional(), + accounts: z.record(TlonAccountSchema).optional(), +}); + +export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema); diff --git a/extensions/tlon/src/core-bridge.js b/extensions/tlon/src/core-bridge.js deleted file mode 100644 index 634ef3dd8..000000000 --- a/extensions/tlon/src/core-bridge.js +++ /dev/null @@ -1,100 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -let coreRootCache = null; -let coreDepsPromise = null; - -function findPackageRoot(startDir, name) { - let dir = startDir; - for (;;) { - const pkgPath = path.join(dir, "package.json"); - try { - if (fs.existsSync(pkgPath)) { - const raw = fs.readFileSync(pkgPath, "utf8"); - const pkg = JSON.parse(raw); - if (pkg.name === name) return dir; - } - } catch { - // ignore parse errors - } - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -function resolveClawdbotRoot() { - if (coreRootCache) return coreRootCache; - const override = process.env.CLAWDBOT_ROOT?.trim(); - if (override) { - coreRootCache = override; - return override; - } - - const candidates = new Set(); - if (process.argv[1]) { - candidates.add(path.dirname(process.argv[1])); - } - candidates.add(process.cwd()); - try { - const urlPath = fileURLToPath(import.meta.url); - candidates.add(path.dirname(urlPath)); - } catch { - // ignore - } - - for (const start of candidates) { - const found = findPackageRoot(start, "clawdbot"); - if (found) { - coreRootCache = found; - return found; - } - } - - throw new Error( - "Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.", - ); -} - -async function importCoreModule(relativePath) { - const root = resolveClawdbotRoot(); - const distPath = path.join(root, "dist", relativePath); - if (!fs.existsSync(distPath)) { - throw new Error( - `Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`, - ); - } - return await import(pathToFileURL(distPath).href); -} - -export async function loadCoreChannelDeps() { - if (coreDepsPromise) return coreDepsPromise; - - coreDepsPromise = (async () => { - const [ - chunk, - envelope, - dispatcher, - routing, - inboundContext, - ] = await Promise.all([ - importCoreModule("auto-reply/chunk.js"), - importCoreModule("auto-reply/envelope.js"), - importCoreModule("auto-reply/reply/provider-dispatcher.js"), - importCoreModule("routing/resolve-route.js"), - importCoreModule("auto-reply/reply/inbound-context.js"), - ]); - - return { - chunkMarkdownText: chunk.chunkMarkdownText, - formatAgentEnvelope: envelope.formatAgentEnvelope, - dispatchReplyWithBufferedBlockDispatcher: - dispatcher.dispatchReplyWithBufferedBlockDispatcher, - resolveAgentRoute: routing.resolveAgentRoute, - finalizeInboundContext: inboundContext.finalizeInboundContext, - }; - })(); - - return coreDepsPromise; -} diff --git a/extensions/tlon/src/monitor.js b/extensions/tlon/src/monitor.js deleted file mode 100644 index 8cfcf54ea..000000000 --- a/extensions/tlon/src/monitor.js +++ /dev/null @@ -1,1572 +0,0 @@ -// 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 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> - */ -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//posts/newest//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}`); - } - } -} diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts new file mode 100644 index 000000000..05bab008b --- /dev/null +++ b/extensions/tlon/src/monitor/discovery.ts @@ -0,0 +1,71 @@ +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { formatChangesDate } from "./utils.js"; + +export async function fetchGroupChanges( + api: { scry: (path: string) => Promise }, + runtime: RuntimeEnv, + 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: any) { + runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`); + return null; + } +} + +export async function fetchAllChannels( + api: { scry: (path: string) => Promise }, + runtime: RuntimeEnv, +): Promise { + try { + runtime.log?.("[tlon] Attempting auto-discovery of group channels..."); + const changes = await fetchGroupChanges(api, runtime, 5); + + let initData: any; + if (changes) { + runtime.log?.("[tlon] Changes data received, using full init for channel extraction"); + initData = await api.scry("/groups-ui/v6/init.json"); + } else { + initData = await api.scry("/groups-ui/v6/init.json"); + } + + const channels: string[] = []; + if (initData && initData.groups) { + for (const groupData of Object.values(initData.groups as Record)) { + if (groupData && typeof groupData === "object" && groupData.channels) { + for (const channelNest of Object.keys(groupData.channels)) { + 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: any) { + runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`); + runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels"); + runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]"); + return []; + } +} diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts new file mode 100644 index 000000000..137d46d6c --- /dev/null +++ b/extensions/tlon/src/monitor/history.ts @@ -0,0 +1,87 @@ +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { extractMessageText } from "./utils.js"; + +export type TlonHistoryEntry = { + author: string; + content: string; + timestamp: number; + id?: string; +}; + +const messageCache = new Map(); +const MAX_CACHED_MESSAGES = 100; + +export function cacheMessage(channelNest: string, message: TlonHistoryEntry) { + if (!messageCache.has(channelNest)) { + messageCache.set(channelNest, []); + } + const cache = messageCache.get(channelNest); + if (!cache) return; + cache.unshift(message); + if (cache.length > MAX_CACHED_MESSAGES) { + cache.pop(); + } +} + +export async function fetchChannelHistory( + api: { scry: (path: string) => Promise }, + channelNest: string, + count = 50, + runtime?: RuntimeEnv, +): Promise { + try { + const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`; + runtime?.log?.(`[tlon] Fetching history: ${scryPath}`); + + const data: any = await api.scry(scryPath); + if (!data) return []; + + let posts: any[] = []; + if (Array.isArray(data)) { + posts = data; + } else if (data.posts && typeof data.posts === "object") { + posts = Object.values(data.posts); + } else if (typeof data === "object") { + posts = Object.values(data); + } + + const messages = posts + .map((item) => { + 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, + } as TlonHistoryEntry; + }) + .filter((msg) => msg.content); + + runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`); + return messages; + } catch (error: any) { + runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`); + return []; + } +} + +export async function getChannelHistory( + api: { scry: (path: string) => Promise }, + channelNest: string, + count = 50, + runtime?: RuntimeEnv, +): Promise { + 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...`, + ); + return await fetchChannelHistory(api, channelNest, count, runtime); +} diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts new file mode 100644 index 000000000..26ea1407d --- /dev/null +++ b/extensions/tlon/src/monitor/index.ts @@ -0,0 +1,501 @@ +import { format } from "node:util"; + +import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk"; + +import { getTlonRuntime } from "../runtime.js"; +import { resolveTlonAccount } from "../types.js"; +import { normalizeShip, parseChannelNest } from "../targets.js"; +import { authenticate } from "../urbit/auth.js"; +import { UrbitSSEClient } from "../urbit/sse-client.js"; +import { sendDm, sendGroupMessage } from "../urbit/send.js"; +import { cacheMessage, getChannelHistory } from "./history.js"; +import { createProcessedMessageTracker } from "./processed-messages.js"; +import { + extractMessageText, + formatModelName, + isBotMentioned, + isDmAllowed, + isSummarizationRequest, +} from "./utils.js"; +import { fetchAllChannels } from "./discovery.js"; + +export type MonitorTlonOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + accountId?: string | null; +}; + +type ChannelAuthorization = { + mode?: "restricted" | "open"; + allowedShips?: string[]; +}; + +function resolveChannelAuthorization( + cfg: ClawdbotConfig, + channelNest: string, +): { mode: "restricted" | "open"; allowedShips: string[] } { + const tlonConfig = cfg.channels?.tlon as + | { + authorization?: { channelRules?: Record }; + defaultAuthorizedShips?: string[]; + } + | undefined; + const rules = tlonConfig?.authorization?.channelRules ?? {}; + const rule = rules[channelNest]; + const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? []; + const mode = rule?.mode ?? "restricted"; + return { mode, allowedShips }; +} + +export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + const core = getTlonRuntime(); + const cfg = core.config.loadConfig() as ClawdbotConfig; + if (cfg.channels?.tlon?.enabled === false) return; + + const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => { + logger.info(formatRuntimeMessage(...args)); + }, + error: (...args) => { + logger.error(formatRuntimeMessage(...args)); + }, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); + if (!account.enabled) return; + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured (ship/url/code required)"); + } + + const botShipName = normalizeShip(account.ship); + runtime.log?.(`[tlon] Starting monitor for ${botShipName}`); + + let api: UrbitSSEClient | null = null; + try { + runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`); + const cookie = await authenticate(account.url, account.code); + api = new UrbitSSEClient(account.url, cookie, { + ship: botShipName, + logger: { + log: (message) => runtime.log?.(message), + error: (message) => runtime.error?.(message), + }, + }); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`); + throw error; + } + + const processedTracker = createProcessedMessageTracker(2000); + let groupChannels: string[] = []; + + if (account.autoDiscoverChannels !== false) { + try { + const discoveredChannels = await fetchAllChannels(api, runtime); + if (discoveredChannels.length > 0) { + groupChannels = discoveredChannels; + } + } catch (error: any) { + runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`); + } + } + + if (groupChannels.length === 0 && 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)"); + } + + const handleIncomingDM = async (update: any) => { + try { + const memo = update?.response?.add?.memo; + if (!memo) return; + + const messageId = update.id as string | undefined; + if (!processedTracker.mark(messageId)) return; + + const senderShip = normalizeShip(memo.author ?? ""); + if (!senderShip || senderShip === botShipName) return; + + const messageText = extractMessageText(memo.content); + if (!messageText) return; + + if (!isDmAllowed(senderShip, account.dmAllowlist)) { + runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`); + return; + } + + await processMessage({ + messageId: messageId ?? "", + senderShip, + messageText, + isGroup: false, + timestamp: memo.sent || Date.now(), + }); + } catch (error: any) { + runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`); + } + }; + + const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => { + try { + const parsed = parseChannelNest(channelNest); + if (!parsed) return; + + const essay = update?.response?.post?.["r-post"]?.set?.essay; + const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo; + if (!essay && !memo) return; + + const content = memo || essay; + const isThreadReply = Boolean(memo); + const messageId = isThreadReply + ? update?.response?.post?.["r-post"]?.reply?.id + : update?.response?.post?.id; + + if (!processedTracker.mark(messageId)) return; + + const senderShip = normalizeShip(content.author ?? ""); + if (!senderShip || senderShip === botShipName) return; + + const messageText = extractMessageText(content.content); + if (!messageText) return; + + cacheMessage(channelNest, { + author: senderShip, + content: messageText, + timestamp: content.sent || Date.now(), + id: messageId, + }); + + const mentioned = isBotMentioned(messageText, botShipName); + if (!mentioned) return; + + const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest); + if (mode === "restricted") { + if (allowedShips.length === 0) { + runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`); + return; + } + const normalizedAllowed = allowedShips.map(normalizeShip); + if (!normalizedAllowed.includes(senderShip)) { + runtime.log?.( + `[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`, + ); + return; + } + } + + const seal = isThreadReply + ? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal + : update?.response?.post?.["r-post"]?.set?.seal; + + const parentId = seal?.["parent-id"] || seal?.parent || null; + + await processMessage({ + messageId: messageId ?? "", + senderShip, + messageText, + isGroup: true, + groupChannel: channelNest, + groupName: `${parsed.hostShip}/${parsed.channelName}`, + timestamp: content.sent || Date.now(), + parentId, + }); + } catch (error: any) { + runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`); + } + }; + + const processMessage = async (params: { + messageId: string; + senderShip: string; + messageText: string; + isGroup: boolean; + groupChannel?: string; + groupName?: string; + timestamp: number; + parentId?: string | null; + }) => { + const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params; + let messageText = params.messageText; + + if (isGroup && groupChannel && isSummarizationRequest(messageText)) { + 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: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: noHistoryMsg, + }); + } + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg }); + } + return; + } + + const historyText = history + .map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`) + .join("\n"); + + messageText = + `Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` + + "Provide a concise summary highlighting:\n" + + "1. Main topics discussed\n" + + "2. Key decisions or conclusions\n" + + "3. Action items if any\n" + + "4. Notable participants"; + } catch (error: any) { + const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`; + if (isGroup && groupChannel) { + const parsed = parseChannelNest(groupChannel); + if (parsed) { + await sendGroupMessage({ + api: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: errorMsg, + }); + } + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg }); + } + return; + } + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "tlon", + accountId: opts.accountId ?? undefined, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? groupChannel ?? senderShip : senderShip, + }, + }); + + const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip; + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Tlon", + from: fromLabel, + timestamp, + body: messageText, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: messageText, + CommandBody: messageText, + From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`, + To: `tlon:${botShipName}`, + SessionKey: route.sessionKey, + 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}`, + }); + + const dispatchStartTime = Date.now(); + + const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix; + const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix, + humanDelay, + deliver: async (payload: ReplyPayload) => { + let replyText = payload.text; + if (!replyText) return; + + const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false; + if (showSignature) { + const modelInfo = + payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary; + replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`; + } + + if (isGroup && groupChannel) { + const parsed = parseChannelNest(groupChannel); + if (!parsed) return; + await sendGroupMessage({ + api: api!, + fromShip: botShipName, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + text: replyText, + replyToId: parentId ?? undefined, + }); + } else { + await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText }); + } + }, + onError: (err, info) => { + const dispatchDuration = Date.now() - dispatchStartTime; + runtime.error?.( + `[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`, + ); + }, + }, + }); + }; + + const subscribedChannels = new Set(); + const subscribedDMs = new Set(); + + async function subscribeToChannel(channelNest: string) { + if (subscribedChannels.has(channelNest)) return; + const parsed = parseChannelNest(channelNest); + if (!parsed) { + runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`); + return; + } + + try { + await api!.subscribe({ + app: "channels", + path: `/${channelNest}`, + event: handleIncomingGroupMessage(channelNest), + err: (error) => { + runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(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: any) { + runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`); + } + } + + async function subscribeToDM(dmShip: string) { + if (subscribedDMs.has(dmShip)) return; + try { + await api!.subscribe({ + app: "chat", + path: `/dm/${dmShip}`, + event: handleIncomingDM, + err: (error) => { + runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(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: any) { + runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`); + } + } + + async function refreshChannelSubscriptions() { + try { + const dmShips = await api!.scry("/chat/dm.json"); + if (Array.isArray(dmShips)) { + for (const dmShip of dmShips) { + await subscribeToDM(dmShip); + } + } + + if (account.autoDiscoverChannels !== false) { + const discoveredChannels = await fetchAllChannels(api!, runtime); + for (const channelNest of discoveredChannels) { + await subscribeToChannel(channelNest); + } + } + } catch (error: any) { + runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`); + } + } + + try { + runtime.log?.("[tlon] Subscribing to updates..."); + + let dmShips: string[] = []; + try { + const dmList = await api!.scry("/chat/dm.json"); + if (Array.isArray(dmList)) { + dmShips = dmList; + runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`); + } + } catch (error: any) { + runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`); + } + + for (const dmShip of dmShips) { + await subscribeToDM(dmShip); + } + + for (const channelNest of groupChannels) { + await subscribeToChannel(channelNest); + } + + runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream..."); + await api!.connect(); + runtime.log?.("[tlon] Connected! All subscriptions active"); + + const pollInterval = setInterval(() => { + if (!opts.abortSignal?.aborted) { + refreshChannelSubscriptions().catch((error) => { + runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`); + }); + } + }, 2 * 60 * 1000); + + if (opts.abortSignal) { + await new Promise((resolve) => { + opts.abortSignal.addEventListener( + "abort", + () => { + clearInterval(pollInterval); + resolve(null); + }, + { once: true }, + ); + }); + } else { + await new Promise(() => {}); + } + } finally { + try { + await api?.close(); + } catch (error: any) { + runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`); + } + } +} diff --git a/extensions/tlon/src/monitor/processed-messages.test.ts b/extensions/tlon/src/monitor/processed-messages.test.ts new file mode 100644 index 000000000..2dd99fff9 --- /dev/null +++ b/extensions/tlon/src/monitor/processed-messages.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { createProcessedMessageTracker } from "./processed-messages.js"; + +describe("createProcessedMessageTracker", () => { + it("dedupes and evicts oldest entries", () => { + const tracker = createProcessedMessageTracker(3); + + expect(tracker.mark("a")).toBe(true); + expect(tracker.mark("a")).toBe(false); + expect(tracker.has("a")).toBe(true); + + tracker.mark("b"); + tracker.mark("c"); + expect(tracker.size()).toBe(3); + + tracker.mark("d"); + expect(tracker.size()).toBe(3); + expect(tracker.has("a")).toBe(false); + expect(tracker.has("b")).toBe(true); + expect(tracker.has("c")).toBe(true); + expect(tracker.has("d")).toBe(true); + }); +}); diff --git a/extensions/tlon/src/monitor/processed-messages.ts b/extensions/tlon/src/monitor/processed-messages.ts new file mode 100644 index 000000000..83050008c --- /dev/null +++ b/extensions/tlon/src/monitor/processed-messages.ts @@ -0,0 +1,38 @@ +export type ProcessedMessageTracker = { + mark: (id?: string | null) => boolean; + has: (id?: string | null) => boolean; + size: () => number; +}; + +export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker { + const seen = new Set(); + const order: string[] = []; + + const mark = (id?: string | null) => { + const trimmed = id?.trim(); + if (!trimmed) return true; + if (seen.has(trimmed)) return false; + seen.add(trimmed); + order.push(trimmed); + if (order.length > limit) { + const overflow = order.length - limit; + for (let i = 0; i < overflow; i += 1) { + const oldest = order.shift(); + if (oldest) seen.delete(oldest); + } + } + return true; + }; + + const has = (id?: string | null) => { + const trimmed = id?.trim(); + if (!trimmed) return false; + return seen.has(trimmed); + }; + + return { + mark, + has, + size: () => seen.size, + }; +} diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts new file mode 100644 index 000000000..df3ade439 --- /dev/null +++ b/extensions/tlon/src/monitor/utils.ts @@ -0,0 +1,83 @@ +import { normalizeShip } from "../targets.js"; + +export function formatModelName(modelString?: string | null): string { + if (!modelString) return "AI"; + const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString; + const modelMappings: Record = { + "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", + }; + + if (modelMappings[modelName]) return modelMappings[modelName]; + return modelName + .replace(/-/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function isBotMentioned(messageText: string, botShipName: string): boolean { + if (!messageText || !botShipName) return false; + const normalizedBotShip = normalizeShip(botShipName); + const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i"); + return mentionPattern.test(messageText); +} + +export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean { + if (!allowlist || allowlist.length === 0) return true; + const normalizedSender = normalizeShip(senderShip); + return allowlist + .map((ship) => normalizeShip(ship)) + .some((ship) => ship === normalizedSender); +} + +export function extractMessageText(content: unknown): string { + if (!content || !Array.isArray(content)) return ""; + + return content + .map((block: any) => { + if (block.inline && Array.isArray(block.inline)) { + return block.inline + .map((item: any) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") { + if (item.ship) return item.ship; + if (item.break !== undefined) return "\n"; + if (item.link && item.link.href) return item.link.href; + } + return ""; + }) + .join(""); + } + return ""; + }) + .join("\n") + .trim(); +} + +export function isSummarizationRequest(messageText: string): boolean { + 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)); +} + +export function formatChangesDate(daysAgo = 5): string { + const now = new Date(); + const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); + const year = targetDate.getFullYear(); + const month = targetDate.getMonth() + 1; + const day = targetDate.getDate(); + return `~${year}.${month}.${day}..20.19.51..9b9d`; +} diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts new file mode 100644 index 000000000..803cd5bd3 --- /dev/null +++ b/extensions/tlon/src/onboarding.ts @@ -0,0 +1,213 @@ +import { + formatDocsLink, + promptAccountId, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ChannelOnboardingAdapter, + type WizardPrompter, +} from "clawdbot/plugin-sdk"; + +import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; +import type { TlonResolvedAccount } from "./types.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +const channel = "tlon" as const; + +function isConfigured(account: TlonResolvedAccount): boolean { + return Boolean(account.ship && account.url && account.code); +} + +function applyAccountConfig(params: { + cfg: ClawdbotConfig; + accountId: string; + input: { + name?: string; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + }; +}): ClawdbotConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const base = cfg.channels?.tlon ?? {}; + + if (useDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...base, + enabled: true, + ...(input.name ? { name: input.name } : {}), + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + tlon: { + ...base, + enabled: base.enabled ?? true, + accounts: { + ...(base as { accounts?: Record }).accounts, + [accountId]: { + ...((base as { accounts?: Record> }).accounts?.[accountId] ?? {}), + enabled: true, + ...(input.name ? { name: input.name } : {}), + ...(input.ship ? { ship: input.ship } : {}), + ...(input.url ? { url: input.url } : {}), + ...(input.code ? { code: input.code } : {}), + ...(input.groupChannels ? { groupChannels: input.groupChannels } : {}), + ...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}), + ...(typeof input.autoDiscoverChannels === "boolean" + ? { autoDiscoverChannels: input.autoDiscoverChannels } + : {}), + }, + }, + }, + }, + }; +} + +async function noteTlonHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "You need your Urbit ship URL and login code.", + "Example URL: https://your-ship-host", + "Example ship: ~sampel-palnet", + `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, + ].join("\n"), + "Tlon setup", + ); +} + +function parseList(value: string): string[] { + return value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + const configured = + accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + + return { + channel, + configured, + statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`], + selectionHint: configured ? "configured" : "urbit messenger", + quickstartScore: configured ? 1 : 4, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const override = accountOverrides[channel]?.trim(); + const defaultAccountId = DEFAULT_ACCOUNT_ID; + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "Tlon", + currentId: accountId, + listAccountIds: listTlonAccountIds, + defaultAccountId, + }); + } + + const resolved = resolveTlonAccount(cfg, accountId); + await noteTlonHelp(prompter); + + const ship = await prompter.text({ + message: "Ship name", + placeholder: "~sampel-palnet", + initialValue: resolved.ship ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const url = await prompter.text({ + message: "Ship URL", + placeholder: "https://your-ship-host", + initialValue: resolved.url ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const code = await prompter.text({ + message: "Login code", + placeholder: "lidlut-tabwed-pillex-ridrup", + initialValue: resolved.code ?? undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const wantsGroupChannels = await prompter.confirm({ + message: "Add group channels manually? (optional)", + initialValue: false, + }); + + let groupChannels: string[] | undefined; + if (wantsGroupChannels) { + const entry = await prompter.text({ + message: "Group channels (comma-separated)", + placeholder: "chat/~host-ship/general, chat/~host-ship/support", + }); + const parsed = parseList(String(entry ?? "")); + groupChannels = parsed.length > 0 ? parsed : undefined; + } + + const wantsAllowlist = await prompter.confirm({ + message: "Restrict DMs with an allowlist?", + initialValue: false, + }); + + let dmAllowlist: string[] | undefined; + if (wantsAllowlist) { + const entry = await prompter.text({ + message: "DM allowlist (comma-separated ship names)", + placeholder: "~zod, ~nec", + }); + const parsed = parseList(String(entry ?? "")); + dmAllowlist = parsed.length > 0 ? parsed : undefined; + } + + const autoDiscoverChannels = await prompter.confirm({ + message: "Enable auto-discovery of group channels?", + initialValue: resolved.autoDiscoverChannels ?? true, + }); + + const next = applyAccountConfig({ + cfg, + accountId, + input: { + ship: String(ship).trim(), + url: String(url).trim(), + code: String(code).trim(), + groupChannels, + dmAllowlist, + autoDiscoverChannels, + }, + }); + + return { cfg: next, accountId }; + }, +}; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts new file mode 100644 index 000000000..bdcaeae4d --- /dev/null +++ b/extensions/tlon/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setTlonRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getTlonRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Tlon runtime not initialized"); + } + return runtime; +} diff --git a/extensions/tlon/src/targets.ts b/extensions/tlon/src/targets.ts new file mode 100644 index 000000000..7f1d9f28c --- /dev/null +++ b/extensions/tlon/src/targets.ts @@ -0,0 +1,79 @@ +export type TlonTarget = + | { kind: "dm"; ship: string } + | { kind: "group"; nest: string; hostShip: string; channelName: string }; + +const SHIP_RE = /^~?[a-z-]+$/i; +const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i; + +export function normalizeShip(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return trimmed.startsWith("~") ? trimmed : `~${trimmed}`; +} + +export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null { + const match = NEST_RE.exec(raw.trim()); + if (!match) return null; + const hostShip = normalizeShip(match[1]); + const channelName = match[2]; + return { hostShip, channelName }; +} + +export function parseTlonTarget(raw?: string | null): TlonTarget | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + const withoutPrefix = trimmed.replace(/^tlon:/i, ""); + + const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i); + if (dmPrefix) { + return { kind: "dm", ship: normalizeShip(dmPrefix[1]) }; + } + + const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i); + if (groupPrefix) { + const groupTarget = groupPrefix[2].trim(); + if (groupTarget.startsWith("chat/")) { + const parsed = parseChannelNest(groupTarget); + if (!parsed) return null; + return { + kind: "group", + nest: `chat/${parsed.hostShip}/${parsed.channelName}`, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + }; + } + const parts = groupTarget.split("/"); + if (parts.length === 2) { + const hostShip = normalizeShip(parts[0]); + const channelName = parts[1]; + return { + kind: "group", + nest: `chat/${hostShip}/${channelName}`, + hostShip, + channelName, + }; + } + return null; + } + + if (withoutPrefix.startsWith("chat/")) { + const parsed = parseChannelNest(withoutPrefix); + if (!parsed) return null; + return { + kind: "group", + nest: `chat/${parsed.hostShip}/${parsed.channelName}`, + hostShip: parsed.hostShip, + channelName: parsed.channelName, + }; + } + + if (SHIP_RE.test(withoutPrefix)) { + return { kind: "dm", ship: normalizeShip(withoutPrefix) }; + } + + return null; +} + +export function formatTargetHint(): string { + return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel"; +} diff --git a/extensions/tlon/src/types.ts b/extensions/tlon/src/types.ts new file mode 100644 index 000000000..47595df7b --- /dev/null +++ b/extensions/tlon/src/types.ts @@ -0,0 +1,85 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +export type TlonResolvedAccount = { + accountId: string; + name: string | null; + enabled: boolean; + configured: boolean; + ship: string | null; + url: string | null; + code: string | null; + groupChannels: string[]; + dmAllowlist: string[]; + autoDiscoverChannels: boolean | null; + showModelSignature: boolean | null; +}; + +export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount { + const base = cfg.channels?.tlon as + | { + name?: string; + enabled?: boolean; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + showModelSignature?: boolean; + accounts?: Record>; + } + | undefined; + + if (!base) { + return { + accountId: accountId || "default", + name: null, + enabled: false, + configured: false, + ship: null, + url: null, + code: null, + groupChannels: [], + dmAllowlist: [], + autoDiscoverChannels: null, + showModelSignature: null, + }; + } + + const useDefault = !accountId || accountId === "default"; + const account = useDefault ? base : (base.accounts?.[accountId] as Record | undefined); + + const ship = (account?.ship ?? base.ship ?? null) as string | null; + const url = (account?.url ?? base.url ?? null) as string | null; + const code = (account?.code ?? base.code ?? null) as string | null; + const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[]; + const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[]; + const autoDiscoverChannels = + (account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null; + const showModelSignature = + (account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null; + const configured = Boolean(ship && url && code); + + return { + accountId: accountId || "default", + name: (account?.name ?? base.name ?? null) as string | null, + enabled: (account?.enabled ?? base.enabled ?? true) !== false, + configured, + ship, + url, + code, + groupChannels, + dmAllowlist, + autoDiscoverChannels, + showModelSignature, + }; +} + +export function listTlonAccountIds(cfg: ClawdbotConfig): string[] { + const base = cfg.channels?.tlon as + | { ship?: string; accounts?: Record> } + | undefined; + if (!base) return []; + const accounts = base.accounts ?? {}; + return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)]; +} diff --git a/extensions/tlon/src/urbit/auth.ts b/extensions/tlon/src/urbit/auth.ts new file mode 100644 index 000000000..ae5fb5339 --- /dev/null +++ b/extensions/tlon/src/urbit/auth.ts @@ -0,0 +1,18 @@ +export async function authenticate(url: string, code: string): Promise { + 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}`); + } + + await resp.text(); + const cookie = resp.headers.get("set-cookie"); + if (!cookie) { + throw new Error("No authentication cookie received"); + } + return cookie; +} diff --git a/extensions/tlon/src/urbit/http-api.ts b/extensions/tlon/src/urbit/http-api.ts new file mode 100644 index 000000000..61ff72371 --- /dev/null +++ b/extensions/tlon/src/urbit/http-api.ts @@ -0,0 +1,36 @@ +import { Urbit } from "@urbit/http-api"; + +let patched = false; + +export function ensureUrbitConnectPatched() { + if (patched) return; + patched = true; + Urbit.prototype.connect = async function patchedConnect() { + const resp = await fetch(`${this.url}/~/login`, { + method: "POST", + body: `password=${this.code}`, + credentials: "include", + }); + + if (resp.status >= 400) { + throw new Error(`Login failed with status ${resp.status}`); + } + + const cookie = resp.headers.get("set-cookie"); + if (cookie) { + const match = /urbauth-~([\w-]+)/.exec(cookie); + if (match) { + if (!(this as unknown as { ship?: string | null }).ship) { + (this as unknown as { ship?: string | null }).ship = match[1]; + } + (this as unknown as { nodeId?: string }).nodeId = match[1]; + } + (this as unknown as { cookie?: string }).cookie = cookie; + } + + await (this as typeof Urbit.prototype).getShipName(); + await (this as typeof Urbit.prototype).getOurName(); + }; +} + +export { Urbit }; diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts new file mode 100644 index 000000000..6a90fcbf9 --- /dev/null +++ b/extensions/tlon/src/urbit/send.ts @@ -0,0 +1,114 @@ +import { unixToDa, formatUd } from "@urbit/aura"; + +export type TlonPokeApi = { + poke: (params: { app: string; mark: string; json: unknown }) => Promise; +}; + +type SendTextParams = { + api: TlonPokeApi; + fromShip: string; + toShip: string; + text: string; +}; + +export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) { + const story = [{ inline: [text] }]; + const sentAt = Date.now(); + const idUd = formatUd(unixToDa(sentAt)); + 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", messageId: id }; +} + +type SendGroupParams = { + api: TlonPokeApi; + fromShip: string; + hostShip: string; + channelName: string; + text: string; + replyToId?: string | null; +}; + +export async function sendGroupMessage({ + api, + fromShip, + hostShip, + channelName, + text, + replyToId, +}: SendGroupParams) { + const story = [{ inline: [text] }]; + const sentAt = Date.now(); + + const action = { + channel: { + nest: `chat/${hostShip}/${channelName}`, + action: replyToId + ? { + reply: { + id: replyToId, + delta: { + add: { + memo: { + content: story, + author: fromShip, + sent: sentAt, + }, + }, + }, + }, + } + : { + post: { + add: { + content: story, + author: fromShip, + sent: sentAt, + kind: "/chat", + blob: null, + meta: null, + }, + }, + }, + }, + }; + + await api.poke({ + app: "channels", + mark: "channel-action-1", + json: action, + }); + + return { channel: "tlon", messageId: `${fromShip}/${sentAt}` }; +} + +export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string { + const cleanText = text?.trim() ?? ""; + const cleanUrl = mediaUrl?.trim() ?? ""; + if (cleanText && cleanUrl) return `${cleanText}\n${cleanUrl}`; + if (cleanUrl) return cleanUrl; + return cleanText; +} diff --git a/extensions/tlon/src/urbit/sse-client.test.ts b/extensions/tlon/src/urbit/sse-client.test.ts new file mode 100644 index 000000000..9b67f6bfb --- /dev/null +++ b/extensions/tlon/src/urbit/sse-client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { UrbitSSEClient } from "./sse-client.js"; + +const mockFetch = vi.fn(); + +describe("UrbitSSEClient", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends subscriptions added after connect", async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" }); + + const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123"); + (client as { isConnected: boolean }).isConnected = true; + + await client.subscribe({ + app: "chat", + path: "/dm/~zod", + event: () => {}, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(client.channelUrl); + expect(init.method).toBe("PUT"); + const body = JSON.parse(init.body as string); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + action: "subscribe", + app: "chat", + path: "/dm/~zod", + }); + }); +}); diff --git a/extensions/tlon/src/urbit-sse-client.js b/extensions/tlon/src/urbit/sse-client.ts similarity index 51% rename from extensions/tlon/src/urbit-sse-client.js rename to extensions/tlon/src/urbit/sse-client.ts index eb52c8573..19878e679 100644 --- a/extensions/tlon/src/urbit-sse-client.js +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -1,59 +1,128 @@ -/** - * Custom SSE client for Urbit that works in Node.js - * Handles authentication cookies and streaming properly - */ +import { Readable } from "node:stream"; -import { Readable } from "stream"; +export type UrbitSseLogger = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +type UrbitSseOptions = { + ship?: string; + onReconnect?: (client: UrbitSSEClient) => Promise | void; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + reconnectDelay?: number; + maxReconnectDelay?: number; + logger?: UrbitSseLogger; +}; export class UrbitSSEClient { - constructor(url, cookie, options = {}) { - this.url = url; - // Extract just the cookie value (first part before semicolon) - this.cookie = cookie.split(";")[0]; - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() - .toString(36) - .substring(2, 8)}`; - this.channelUrl = `${url}/~/channel/${this.channelId}`; - this.subscriptions = []; - this.eventHandlers = new Map(); - this.aborted = false; - this.streamController = null; + url: string; + cookie: string; + ship: string; + channelId: string; + channelUrl: string; + subscriptions: Array<{ + id: number; + action: "subscribe"; + ship: string; + app: string; + path: string; + }> = []; + eventHandlers = new Map< + number, + { event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void } + >(); + aborted = false; + streamController: AbortController | null = null; + onReconnect: UrbitSseOptions["onReconnect"] | null; + autoReconnect: boolean; + reconnectAttempts = 0; + maxReconnectAttempts: number; + reconnectDelay: number; + maxReconnectDelay: number; + isConnected = false; + logger: UrbitSseLogger; - // Reconnection settings - this.onReconnect = options.onReconnect || null; - this.autoReconnect = options.autoReconnect !== false; // Default true - this.reconnectAttempts = 0; - this.maxReconnectAttempts = options.maxReconnectAttempts || 10; - this.reconnectDelay = options.reconnectDelay || 1000; // Start at 1s - this.maxReconnectDelay = options.maxReconnectDelay || 30000; // Max 30s - this.isConnected = false; + constructor(url: string, cookie: string, options: UrbitSseOptions = {}) { + this.url = url; + this.cookie = cookie.split(";")[0]; + this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url); + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; + this.channelUrl = `${url}/~/channel/${this.channelId}`; + this.onReconnect = options.onReconnect ?? null; + this.autoReconnect = options.autoReconnect !== false; + this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10; + this.reconnectDelay = options.reconnectDelay ?? 1000; + this.maxReconnectDelay = options.maxReconnectDelay ?? 30000; + this.logger = options.logger ?? {}; } - /** - * Subscribe to an Urbit path - */ - async subscribe({ app, path, event, err, quit }) { - const subId = this.subscriptions.length + 1; + private resolveShipFromUrl(url: string): string { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (host.includes(".")) { + return host.split(".")[0] ?? host; + } + return host; + } catch { + return ""; + } + } - this.subscriptions.push({ + async subscribe(params: { + app: string; + path: string; + event?: (data: unknown) => void; + err?: (error: unknown) => void; + quit?: () => void; + }) { + const subId = this.subscriptions.length + 1; + const subscription = { id: subId, action: "subscribe", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), - app, - path, - }); + ship: this.ship, + app: params.app, + path: params.path, + } as const; - // Store event handlers - this.eventHandlers.set(subId, { event, err, quit }); + this.subscriptions.push(subscription); + this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit }); + if (this.isConnected) { + try { + await this.sendSubscription(subscription); + } catch (error) { + const handler = this.eventHandlers.get(subId); + handler?.err?.(error); + } + } return subId; } - /** - * Create the channel and start listening for events - */ + private async sendSubscription(subscription: { + id: number; + action: "subscribe"; + ship: string; + app: string; + path: string; + }) { + const response = await fetch(this.channelUrl, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify([subscription]), + }); + + if (!response.ok && response.status !== 204) { + const errorText = await response.text(); + throw new Error(`Subscribe failed: ${response.status} - ${errorText}`); + } + } + async connect() { - // Create channel with all subscriptions const createResp = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -67,8 +136,6 @@ export class UrbitSSEClient { throw new Error(`Channel creation failed: ${createResp.status}`); } - // Send helm-hi poke to activate the channel - // This is required before opening the SSE stream const pokeResp = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -79,7 +146,7 @@ export class UrbitSSEClient { { id: Date.now(), action: "poke", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), + ship: this.ship, app: "hood", mark: "helm-hi", json: "Opening API channel", @@ -91,15 +158,11 @@ export class UrbitSSEClient { throw new Error(`Channel activation failed: ${pokeResp.status}`); } - // Open SSE stream await this.openStream(); this.isConnected = true; - this.reconnectAttempts = 0; // Reset on successful connection + this.reconnectAttempts = 0; } - /** - * Open the SSE stream and process events - */ async openStream() { const response = await fetch(this.channelUrl, { method: "GET", @@ -113,69 +176,47 @@ export class UrbitSSEClient { throw new Error(`Stream connection failed: ${response.status}`); } - // Start processing the stream in the background (don't await) this.processStream(response.body).catch((error) => { if (!this.aborted) { - console.error("Stream error:", error); - // Notify all error handlers + this.logger.error?.(`Stream error: ${String(error)}`); for (const { err } of this.eventHandlers.values()) { if (err) err(error); } } }); - - // Stream is connected and running in background - // Return immediately so connect() can complete } - /** - * Process the SSE stream (runs in background) - */ - async processStream(body) { - const reader = body; + async processStream(body: ReadableStream | Readable | null) { + if (!body) return; + const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body; let buffer = ""; - // Convert Web ReadableStream to Node Readable if needed - const stream = - reader instanceof ReadableStream ? Readable.fromWeb(reader) : reader; - try { for await (const chunk of stream) { if (this.aborted) break; - buffer += chunk.toString(); - - // Process complete SSE events let eventEnd; while ((eventEnd = buffer.indexOf("\n\n")) !== -1) { const eventData = buffer.substring(0, eventEnd); buffer = buffer.substring(eventEnd + 2); - this.processEvent(eventData); } } } finally { - // Stream ended (either normally or due to error) if (!this.aborted && this.autoReconnect) { this.isConnected = false; - console.log("[SSE] Stream ended, attempting reconnection..."); + this.logger.log?.("[SSE] Stream ended, attempting reconnection..."); await this.attemptReconnect(); } } } - /** - * Process a single SSE event - */ - processEvent(eventData) { + processEvent(eventData: string) { const lines = eventData.split("\n"); - let id = null; - let data = null; + let data: string | null = null; for (const line of lines) { - if (line.startsWith("id: ")) { - id = line.substring(4); - } else if (line.startsWith("data: ")) { + if (line.startsWith("data: ")) { data = line.substring(6); } } @@ -183,61 +224,42 @@ export class UrbitSSEClient { if (!data) return; try { - const parsed = JSON.parse(data); + const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string }; - // Handle quit events - subscription ended if (parsed.response === "quit") { - console.log(`[SSE] Received quit event for subscription ${parsed.id}`); - const handlers = this.eventHandlers.get(parsed.id); - if (handlers && handlers.quit) { - handlers.quit(); + if (parsed.id) { + const handlers = this.eventHandlers.get(parsed.id); + if (handlers?.quit) handlers.quit(); } return; } - // Debug: Log received events (skip subscription confirmations) - if (parsed.response !== "subscribe" && parsed.response !== "poke") { - console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500)); - } - - // Route to appropriate handler based on subscription if (parsed.id && this.eventHandlers.has(parsed.id)) { - const { event } = this.eventHandlers.get(parsed.id); + const { event } = this.eventHandlers.get(parsed.id) ?? {}; if (event && parsed.json) { - console.log(`[SSE] Calling handler for subscription ${parsed.id}`); event(parsed.json); } } else if (parsed.json) { - // Try to match by response structure for events without specific ID - console.log(`[SSE] Broadcasting event to all handlers`); for (const { event } of this.eventHandlers.values()) { - if (event) { - event(parsed.json); - } + if (event) event(parsed.json); } } } catch (error) { - console.error("Error parsing SSE event:", error); + this.logger.error?.(`Error parsing SSE event: ${String(error)}`); } } - /** - * Send a poke to Urbit - */ - async poke({ app, mark, json }) { + async poke(params: { app: string; mark: string; json: unknown }) { const pokeId = Date.now(); - const pokeData = { id: pokeId, action: "poke", - ship: this.url.match(/\/\/([^.]+)/)[1].replace("~", ""), - app, - mark, - json, + ship: this.ship, + app: params.app, + mark: params.mark, + json: params.json, }; - console.log(`[SSE] Sending poke to ${app}:`, JSON.stringify(pokeData).substring(0, 300)); - const response = await fetch(this.channelUrl, { method: "PUT", headers: { @@ -247,23 +269,16 @@ export class UrbitSSEClient { body: JSON.stringify([pokeData]), }); - console.log(`[SSE] Poke response status: ${response.status}`); - if (!response.ok && response.status !== 204) { const errorText = await response.text(); - console.log(`[SSE] Poke error body: ${errorText.substring(0, 500)}`); throw new Error(`Poke failed: ${response.status} - ${errorText}`); } return pokeId; } - /** - * Perform a scry (read-only query) to Urbit - */ - async scry(path) { + async scry(path: string) { const scryUrl = `${this.url}/~/scry${path}`; - const response = await fetch(scryUrl, { method: "GET", headers: { @@ -278,70 +293,52 @@ export class UrbitSSEClient { return await response.json(); } - /** - * Attempt to reconnect with exponential backoff - */ async attemptReconnect() { if (this.aborted || !this.autoReconnect) { - console.log("[SSE] Reconnection aborted or disabled"); + this.logger.log?.("[SSE] Reconnection aborted or disabled"); return; } if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error( - `[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.` + this.logger.error?.( + `[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`, ); return; } - this.reconnectAttempts++; - - // Calculate delay with exponential backoff + this.reconnectAttempts += 1; const delay = Math.min( this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), - this.maxReconnectDelay + this.maxReconnectDelay, ); - console.log( - `[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...` + this.logger.log?.( + `[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); try { - // Generate new channel ID for reconnection - this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random() - .toString(36) - .substring(2, 8)}`; + this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`; this.channelUrl = `${this.url}/~/channel/${this.channelId}`; - console.log(`[SSE] Reconnecting with new channel ID: ${this.channelId}`); - - // Call reconnect callback if provided if (this.onReconnect) { await this.onReconnect(this); } - // Reconnect await this.connect(); - - console.log("[SSE] Reconnection successful!"); + this.logger.log?.("[SSE] Reconnection successful!"); } catch (error) { - console.error(`[SSE] Reconnection failed: ${error.message}`); - // Try again + this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`); await this.attemptReconnect(); } } - /** - * Close the connection - */ async close() { this.aborted = true; this.isConnected = false; try { - // Send unsubscribe for all subscriptions const unsubscribes = this.subscriptions.map((sub) => ({ id: sub.id, action: "unsubscribe", @@ -357,7 +354,6 @@ export class UrbitSSEClient { body: JSON.stringify(unsubscribes), }); - // Delete the channel await fetch(this.channelUrl, { method: "DELETE", headers: { @@ -365,7 +361,7 @@ export class UrbitSSEClient { }, }); } catch (error) { - console.error("Error closing channel:", error); + this.logger.error?.(`Error closing channel: ${String(error)}`); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 529005132..f2ac16e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,12 +376,23 @@ importers: specifier: ^4.3.5 version: 4.3.5 + extensions/open-prose: {} + extensions/signal: {} extensions/slack: {} extensions/telegram: {} + extensions/tlon: + dependencies: + '@urbit/aura': + specifier: ^2.0.0 + version: 2.0.1 + '@urbit/http-api': + specifier: ^3.0.0 + version: 3.0.0 + extensions/voice-call: dependencies: '@sinclair/typebox': @@ -2572,6 +2583,13 @@ packages: resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} engines: {node: '>=20.0.0'} + '@urbit/aura@2.0.1': + resolution: {integrity: sha512-B1ZTwsEVqi/iybxjHlY3gBz7r4Xd7n9pwi9NY6V+7r4DksqBYBpfzdqWGUXgZ0x67IW8AOGjC73tkTOclNMhUg==} + engines: {node: '>=16', npm: '>=8'} + + '@urbit/http-api@3.0.0': + resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} + '@vitest/browser-playwright@4.0.17': resolution: {integrity: sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==} peerDependencies: @@ -2862,6 +2880,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-or-node@1.3.0: + resolution: {integrity: sha512-0F2z/VSnLbmEeBcUrSuDH5l0HxTXdQQzLjkmBR4cYfvg1zJrKSlmIZFqyFR8oX0NrwPhy3c3HQ6i3OxMbew4Tg==} + browser-or-node@3.0.0: resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} @@ -3037,6 +3058,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -7756,6 +7780,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@urbit/aura@2.0.1': {} + + '@urbit/http-api@3.0.0': + dependencies: + '@babel/runtime': 7.28.6 + browser-or-node: 1.3.0 + core-js: 3.48.0 + '@vitest/browser-playwright@4.0.17(playwright@1.57.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17)': dependencies: '@vitest/browser': 4.0.17(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17) @@ -8117,6 +8149,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-or-node@1.3.0: {} + browser-or-node@3.0.0: {} buffer-equal-constant-time@1.0.1: {} @@ -8309,6 +8343,8 @@ snapshots: cookie@0.7.2: {} + core-js@3.48.0: {} + core-util-is@1.0.2: {} core-util-is@1.0.3: {} diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index 2df29a95c..2470dbd33 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; @@ -13,4 +16,37 @@ describe("channel plugin catalog", () => { const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); expect(ids).toContain("msteams"); }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@clawdbot/demo-channel", + clawdbot: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@clawdbot/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); }); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 5729276d1..d98ee1aa9 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,8 +1,10 @@ +import fs from "node:fs"; import path from "node:path"; import { discoverClawdbotPlugins } from "../../plugins/discovery.js"; import type { PluginOrigin } from "../../plugins/types.js"; import type { ClawdbotPackageManifest } from "../../plugins/manifest.js"; +import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import type { ChannelMeta } from "./types.js"; export type ChannelUiMetaEntry = { @@ -33,6 +35,7 @@ export type ChannelPluginCatalogEntry = { type CatalogOptions = { workspaceDir?: string; + catalogPaths?: string[]; }; const ORIGIN_PRIORITY: Record = { @@ -42,6 +45,74 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; +type ExternalCatalogEntry = { + name?: string; + version?: string; + description?: string; + clawdbot?: ClawdbotPackageManifest; +}; + +const DEFAULT_CATALOG_PATHS = [ + path.join(CONFIG_DIR, "mpm", "plugins.json"), + path.join(CONFIG_DIR, "mpm", "catalog.json"), + path.join(CONFIG_DIR, "plugins", "catalog.json"), +]; + +const ENV_CATALOG_PATHS = ["CLAWDBOT_PLUGIN_CATALOG_PATHS", "CLAWDBOT_MPM_CATALOG_PATHS"]; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { + if (Array.isArray(raw)) { + return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); + } + if (!isRecord(raw)) return []; + const list = raw.entries ?? raw.packages ?? raw.plugins; + if (!Array.isArray(list)) return []; + return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); +} + +function splitEnvPaths(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) return []; + return trimmed + .split(/[;,]/g) + .flatMap((chunk) => chunk.split(path.delimiter)) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function resolveExternalCatalogPaths(options: CatalogOptions): string[] { + if (options.catalogPaths && options.catalogPaths.length > 0) { + return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean); + } + for (const key of ENV_CATALOG_PATHS) { + const raw = process.env[key]; + if (raw && raw.trim()) { + return splitEnvPaths(raw); + } + } + return DEFAULT_CATALOG_PATHS; +} + +function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] { + const paths = resolveExternalCatalogPaths(options); + const entries: ExternalCatalogEntry[] = []; + for (const rawPath of paths) { + const resolved = resolveUserPath(rawPath); + if (!fs.existsSync(resolved)) continue; + try { + const payload = JSON.parse(fs.readFileSync(resolved, "utf-8")) as unknown; + entries.push(...parseCatalogEntries(payload)); + } catch { + // Ignore invalid catalog files. + } + } + return entries; +} + function toChannelMeta(params: { channel: NonNullable; id: string; @@ -132,6 +203,13 @@ function buildCatalogEntry(candidate: { return { id, meta, install }; } +function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null { + return buildCatalogEntry({ + packageName: entry.name, + packageClawdbot: entry.clawdbot, + }); +} + export function buildChannelUiCatalog( plugins: Array<{ id: string; meta: ChannelMeta }>, ): ChannelUiCatalog { @@ -176,6 +254,15 @@ export function listChannelPluginCatalogEntries( } } + const externalEntries = loadExternalCatalogEntries(options) + .map((entry) => buildExternalCatalogEntry(entry)) + .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); + for (const entry of externalEntries) { + if (!resolved.has(entry.id)) { + resolved.set(entry.id, { entry, priority: 99 }); + } + } + return Array.from(resolved.values()) .map(({ entry }) => entry) .sort((a, b) => { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 5b0dbd1fc..058745824 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -39,6 +39,12 @@ export type ChannelSetupInput = { password?: string; deviceName?: string; initialSyncLimit?: number; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; }; export type ChannelStatusIssue = { diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index c7b25cfb3..fac7fbd9e 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -1,14 +1,29 @@ +import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; +function dedupe(values: string[]): string[] { + const seen = new Set(); + const resolved: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + resolved.push(value); + } + return resolved; +} + export function resolveCliChannelOptions(): string[] { + const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); + const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) { ensurePluginRegistryLoaded(); - return listChannelPlugins().map((plugin) => plugin.id); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + return dedupe([...base, ...pluginIds]); } - return [...CHAT_CHANNEL_ORDER]; + return base; } export function formatCliChannelOptions(extra: string[] = []): string { diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 586e7c5c2..8b2e2d8f0 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import { formatCliChannelOptions } from "./channel-options.js"; import { channelsAddCommand, channelsCapabilitiesCommand, @@ -42,6 +42,12 @@ const optionNamesAdd = [ "password", "deviceName", "initialSyncLimit", + "ship", + "url", + "code", + "groupChannels", + "dmAllowlist", + "autoDiscoverChannels", ] as const; const optionNamesRemove = ["channel", "account", "delete"] as const; @@ -58,9 +64,7 @@ function runChannelsCommandWithDanger(action: () => Promise, label: string } export function registerChannelsCli(program: Command) { - const channelNames = listChannelPlugins() - .map((plugin) => plugin.id) - .join("|"); + const channelNames = formatCliChannelOptions(); const channels = program .command("channels") .description("Manage chat channel accounts") @@ -99,7 +103,7 @@ export function registerChannelsCli(program: Command) { channels .command("capabilities") .description("Show provider capabilities (intents/scopes + supported features)") - .option("--channel ", `Channel (${channelNames}|all)`) + .option("--channel ", `Channel (${formatCliChannelOptions(["all"])})`) .option("--account ", "Account id (only with --channel)") .option("--target ", "Channel target for permission audit (Discord channel:)") .option("--timeout ", "Timeout in ms", "10000") @@ -136,7 +140,7 @@ export function registerChannelsCli(program: Command) { channels .command("logs") .description("Show recent channel logs from the gateway log file") - .option("--channel ", `Channel (${channelNames}|all)`, "all") + .option("--channel ", `Channel (${formatCliChannelOptions(["all"])})`, "all") .option("--lines ", "Number of lines (default: 200)", "200") .option("--json", "Output JSON", false) .action(async (opts) => { @@ -171,6 +175,13 @@ export function registerChannelsCli(program: Command) { .option("--password ", "Matrix password") .option("--device-name ", "Matrix device name") .option("--initial-sync-limit ", "Matrix initial sync limit") + .option("--ship ", "Tlon ship name (~sampel-palnet)") + .option("--url ", "Tlon ship URL") + .option("--code ", "Tlon login code") + .option("--group-channels ", "Tlon group channels (comma-separated)") + .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") + .option("--auto-discover-channels", "Tlon auto-discover group channels") + .option("--no-auto-discover-channels", "Disable Tlon auto-discovery") .option("--use-env", "Use env token (default account only)", false) .action(async (opts, command) => { await runChannelsCommand(async () => { diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 01d83b90b..10fb93b30 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -43,6 +43,12 @@ export function applyChannelAccountConfig(params: { password?: string; deviceName?: string; initialSyncLimit?: number; + ship?: string; + url?: string; + code?: string; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; }): ClawdbotConfig { const accountId = normalizeAccountId(params.accountId); const plugin = getChannelPlugin(params.channel); @@ -71,6 +77,12 @@ export function applyChannelAccountConfig(params: { password: params.password, deviceName: params.deviceName, initialSyncLimit: params.initialSyncLimit, + ship: params.ship, + url: params.url, + code: params.code, + groupChannels: params.groupChannels, + dmAllowlist: params.dmAllowlist, + autoDiscoverChannels: params.autoDiscoverChannels, }; return apply({ cfg: params.cfg, accountId, input }); } diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 46bedd50b..8a5b72185 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,11 +1,17 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { ChannelId } from "../../channels/plugins/types.js"; -import { writeConfigFile } from "../../config/config.js"; +import { writeConfigFile, type ClawdbotConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { setupChannels } from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; +import { + ensureOnboardingPluginInstalled, + reloadOnboardingPluginRegistry, +} from "../onboarding/plugin-install.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -34,8 +40,33 @@ export type ChannelsAddOptions = { password?: string; deviceName?: string; initialSyncLimit?: number | string; + ship?: string; + url?: string; + code?: string; + groupChannels?: string; + dmAllowlist?: string; + autoDiscoverChannels?: boolean; }; +function parseList(value: string | undefined): string[] | undefined { + if (!value?.trim()) return undefined; + const parsed = value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + return parsed.length > 0 ? parsed : undefined; +} + +function resolveCatalogChannelEntry(raw: string, cfg: ClawdbotConfig | null) { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) return undefined; + const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; + return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + if (entry.id.toLowerCase() === trimmed) return true; + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); + }); +} + export async function channelsAddCommand( opts: ChannelsAddOptions, runtime: RuntimeEnv = defaultRuntime, @@ -43,6 +74,7 @@ export async function channelsAddCommand( ) { const cfg = await requireValidConfig(runtime); if (!cfg) return; + let nextConfig = cfg; const useWizard = shouldUseWizard(params); if (useWizard) { @@ -99,9 +131,31 @@ export async function channelsAddCommand( return; } - const channel = normalizeChannelId(opts.channel); + const rawChannel = String(opts.channel ?? ""); + let channel = normalizeChannelId(rawChannel); + let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); + + if (!channel && catalogEntry) { + const prompter = createClackPrompter(); + const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + const result = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) return; + reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir }); + channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); + } + if (!channel) { - runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`); + const hint = catalogEntry + ? `Plugin ${catalogEntry.meta.label} could not be loaded after install.` + : `Unknown channel: ${String(opts.channel ?? "")}`; + runtime.error(hint); runtime.exit(1); return; } @@ -113,7 +167,7 @@ export async function channelsAddCommand( return; } const accountId = - plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ?? + plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ?? normalizeAccountId(opts.account); const useEnv = opts.useEnv === true; const initialSyncLimit = @@ -122,8 +176,11 @@ export async function channelsAddCommand( : typeof opts.initialSyncLimit === "string" && opts.initialSyncLimit.trim() ? Number.parseInt(opts.initialSyncLimit, 10) : undefined; + const groupChannels = parseList(opts.groupChannels); + const dmAllowlist = parseList(opts.dmAllowlist); + const validationError = plugin.setup.validateInput?.({ - cfg, + cfg: nextConfig, accountId, input: { name: opts.name, @@ -148,6 +205,12 @@ export async function channelsAddCommand( deviceName: opts.deviceName, initialSyncLimit, useEnv, + ship: opts.ship, + url: opts.url, + code: opts.code, + groupChannels, + dmAllowlist, + autoDiscoverChannels: opts.autoDiscoverChannels, }, }); if (validationError) { @@ -156,8 +219,8 @@ export async function channelsAddCommand( return; } - const nextConfig = applyChannelAccountConfig({ - cfg, + nextConfig = applyChannelAccountConfig({ + cfg: nextConfig, channel, accountId, name: opts.name, @@ -182,6 +245,12 @@ export async function channelsAddCommand( deviceName: opts.deviceName, initialSyncLimit, useEnv, + ship: opts.ship, + url: opts.url, + code: opts.code, + groupChannels, + dmAllowlist, + autoDiscoverChannels: opts.autoDiscoverChannels, }); await writeConfigFile(nextConfig); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 945ecf3e2..bcff1c171 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -111,7 +111,8 @@ async function collectChannelStatus(params: { }): Promise { const installedPlugins = listChannelPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); - const catalogEntries = listChannelPluginCatalogEntries().filter( + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( (entry) => !installedIds.has(entry.id), ); const statusEntries = await Promise.all( @@ -388,7 +389,8 @@ export async function setupChannels( const core = listChatChannels(); const installed = listChannelPlugins(); const installedIds = new Set(installed.map((plugin) => plugin.id)); - const catalog = listChannelPluginCatalogEntries().filter( + const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( (entry) => !installedIds.has(entry.id), ); const metaById = new Map(); From 75cb78a5b1e1dc05f5fe702d2009d2645a6fe6f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:18:58 +0000 Subject: [PATCH 44/45] chore: drop tlon node_modules --- extensions/tlon/node_modules/@urbit/aura | 1 - extensions/tlon/node_modules/@urbit/http-api | 1 - 2 files changed, 2 deletions(-) delete mode 120000 extensions/tlon/node_modules/@urbit/aura delete mode 120000 extensions/tlon/node_modules/@urbit/http-api diff --git a/extensions/tlon/node_modules/@urbit/aura b/extensions/tlon/node_modules/@urbit/aura deleted file mode 120000 index 8e9400cee..000000000 --- a/extensions/tlon/node_modules/@urbit/aura +++ /dev/null @@ -1 +0,0 @@ -../../../../node_modules/.pnpm/@urbit+aura@2.0.1/node_modules/@urbit/aura \ No newline at end of file diff --git a/extensions/tlon/node_modules/@urbit/http-api b/extensions/tlon/node_modules/@urbit/http-api deleted file mode 120000 index 6411dd8e7..000000000 --- a/extensions/tlon/node_modules/@urbit/http-api +++ /dev/null @@ -1 +0,0 @@ -../../../../node_modules/.pnpm/@urbit+http-api@3.0.0/node_modules/@urbit/http-api \ No newline at end of file From 12d22e1c8979b54d42d3c6d7e900bf690b538a3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 00:20:44 +0000 Subject: [PATCH 45/45] chore: update clawtributors --- README.md | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a08760ae6..eb34f3e41 100644 --- a/README.md +++ b/README.md @@ -478,28 +478,29 @@ Thanks to all clawtributors:

steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien MaudeBot rahthakor vrknetha radek-paclt Tobias Bischoff - joshp123 mukhtharcm maxsumrall xadenryan juanpablodlc hsrvc magimetal meaningfool NicholasSpisak sebslight - abhisekbasu1 jamesgroat zerone0x claude SocialNerd42069 Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto - Mariano Belinky dbhurley TSavo julianengel benithors bradleypriest timolins nachx639 sreekaransrinath gupsammy - cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer lc0rp scald gumadeiras andranik-sahakyan davidguttman sleontenko - sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams - rdev osolmaz joshrad-dev kiranjd adityashaw2 sheeek artuskg onutc tyler6204 manuelhettich - minghinmatthewlam myfunc vignesh07 buddyh connorshea mcinteerj dependabot[bot] John-Rood timkrase gerardward2007 - obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips Whoaa512 YuriNachos chriseidhof - ysqander dlauer superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr - HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 ngutman petter-b pkrmf RandyVentures - robbyczgw-cla Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot - mkbehr neist chrisrodz czekaj Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 - manmal ogulcancelik pasogott petradonka rubyrunsstuff sibbl siddhantjain suminhthanh VACInc wes-davis - zats 24601 ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa oswalpalash + joshp123 mukhtharcm maxsumrall xadenryan juanpablodlc hsrvc magimetal meaningfool patelhiren NicholasSpisak + sebslight abhisekbasu1 zerone0x jamesgroat claude SocialNerd42069 Hyaxia dantelex daveonkels mteam88 + Eng. Juan Combetto Mariano Belinky dbhurley TSavo julianengel benithors bradleypriest timolins nachx639 pvoo + sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat iHildy cpojer lc0rp scald + gumadeiras andranik-sahakyan davidguttman sleontenko rodrigouroz sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna + lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev kiranjd adityashaw2 + sheeek artuskg onutc pauloportella tyler6204 neooriginal manuelhettich minghinmatthewlam myfunc travisirby + vignesh07 buddyh connorshea mcinteerj dependabot[bot] John-Rood timkrase gerardward2007 obviyus tosh-hamburg + azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips Whoaa512 YuriNachos chriseidhof dlauer ysqander + robbyczgw-cla superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy + imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 ngutman petter-b pkrmf RandyVentures Ryan Lisse + dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist + sibbl chrisrodz czekaj Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer aaronveklabs adam91holt cash-echo-bot Clawd ClawdFx erik-agens fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML VAC zknicker aj47 alejandro maza andrewting19 Andrii anpoirier - Asleep123 bolismauro conhecendoia Dimitrios Ploutarchos Drake Thomsen Felix Krause gtsifrikas HazAT hrdwdmrbl hugobarauna - Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal martinpucik Matt mini - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 odysseus0 prathamdby ptn1411 reeltimeapps - RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke testingabc321 The Admiral thesash - Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai Zach Knickerbocker Alphonse-arianee Azade carlulsoe - ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pauloportella pcty-nextgen-ios-builder Quentin Randy Torres + Asleep123 bolismauro conhecendoia Dimitrios Ploutarchos Drake Thomsen Felix Krause ganghyun kim gtsifrikas HazAT hrdwdmrbl + hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal martinpucik + Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 odnxe prathamdby ptn1411 + reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha shiv19 siraht snopoke testingabc321 The Admiral + thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai Zach Knickerbocker Alphonse-arianee Azade + carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock