diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a141ae8d..bf4bbaf24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Breaking - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. +- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. ### Fixes - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. @@ -18,10 +19,10 @@ - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. - macOS: drop deprecated `afterMs` from agent wait params to match gateway schema. -- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth-profiles.json. +- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json. - Model: `/model` list shows auth source (masked key or OAuth email) per provider. - Model: `/model list` is an alias for `/model`. -- Model: `/model` output now includes auth source location (env/auth-profiles.json/models.json). +- Model: `/model` output now includes auth source location (env/auth.json/models.json). - Model: avoid duplicate `missing (missing)` auth labels in `/model` list output. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. diff --git a/README.md b/README.md index e68a5be0f..d91c7d47b 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): - `/status` — health + session info (group shows activation mode) - `/new` or `/reset` — reset the session +- `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high - `/verbose on|off` - `/restart` — restart the gateway (owner-only in groups) diff --git a/docs/clawd.md b/docs/clawd.md index 21eb2c2af..044c89536 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -147,6 +147,7 @@ Example: - Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl` - Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`) - `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset. +- `/compact [instructions]` compacts the session context and reports the remaining context budget. ## Heartbeats (proactive mode) diff --git a/docs/faq.md b/docs/faq.md index d969916bc..ea12dcf30 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -302,7 +302,7 @@ Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction* Practical tips: - Keep `AGENTS.md` focused, not bloated. -- Use `/new` to reset the session when context gets stale. +- Use `/compact` to shrink older context or `/new` to reset when it gets stale. - For large memory/notes collections, use search tools like `qmd` rather than loading everything. ### Where are my memory files? @@ -551,6 +551,9 @@ Quick reference (send these in chat): |---------|--------| | `/status` | Health + session info | | `/new` or `/reset` | Reset the session | +| `/compact` | Compact session context | + +Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces). | `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/verbose on\|off` | Toggle verbose mode | | `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | diff --git a/docs/group-messages.md b/docs/group-messages.md index 0c6701cd1..07be1e4f6 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -58,7 +58,7 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own 1) Add Clawd UK (`+447700900123`) to the group. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. 3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. -4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`) apply only to that group’s session; your personal DM session remains independent. +4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent. ## Testing / verification - Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). diff --git a/docs/session.md b/docs/session.md index d038aab37..6cc7a3396 100644 --- a/docs/session.md +++ b/docs/session.md @@ -77,6 +77,7 @@ Runtime override (owner only): - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active `). - `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/compact` (optional instructions) to summarize older context and free up window space. - JSONL transcripts can be opened directly to review full turns. ## Tips diff --git a/docs/tui.md b/docs/tui.md index 1585b75ec..de0479788 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -55,6 +55,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/activation ` - `/deliver ` - `/new` or `/reset` +- `/compact [instructions]` - `/abort` - `/settings` - `/exit` diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 473f89175..d04314e71 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -98,6 +98,18 @@ export type EmbeddedPiRunResult = { meta: EmbeddedPiRunMeta; }; +export type EmbeddedPiCompactResult = { + ok: boolean; + compacted: boolean; + reason?: string; + result?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; + }; +}; + type EmbeddedPiQueueHandle = { queueMessage: (text: string) => Promise; isStreaming: () => boolean; @@ -314,6 +326,212 @@ function resolvePromptSkills( .filter((skill): skill is Skill => Boolean(skill)); } +export async function compactEmbeddedPiSession(params: { + sessionId: string; + sessionKey?: string; + surface?: string; + sessionFile: string; + workspaceDir: string; + config?: ClawdbotConfig; + skillsSnapshot?: SkillSnapshot; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + bashElevated?: BashElevatedDefaults; + customInstructions?: string; + lane?: string; + enqueue?: typeof enqueueCommand; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}): Promise { + const sessionLane = resolveSessionLane( + params.sessionKey?.trim() || params.sessionId, + ); + const globalLane = resolveGlobalLane(params.lane); + const enqueueGlobal = + params.enqueue ?? + ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); + return enqueueCommandInLane(sessionLane, () => + enqueueGlobal(async () => { + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const prevCwd = process.cwd(); + + const provider = + (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + await ensureClawdbotModelsJson(params.config); + const agentDir = resolveClawdbotAgentDir(); + const { model, error, authStorage, modelRegistry } = resolveModel( + provider, + modelId, + agentDir, + ); + if (!model) { + return { + ok: false, + compacted: false, + reason: error ?? `Unknown model: ${provider}/${modelId}`, + }; + } + try { + const apiKey = await getApiKeyForModel(model, authStorage); + authStorage.setRuntimeApiKey(model.provider, apiKey); + } catch (err) { + return { + ok: false, + compacted: false, + reason: describeUnknownError(err), + }; + } + + await fs.mkdir(resolvedWorkspace, { recursive: true }); + await ensureSessionHeader({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + cwd: resolvedWorkspace, + }); + + let restoreSkillEnv: (() => void) | undefined; + process.chdir(resolvedWorkspace); + try { + const shouldLoadSkillEntries = + !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; + const skillEntries = shouldLoadSkillEntries + ? loadWorkspaceSkillEntries(resolvedWorkspace) + : []; + const skillsSnapshot = + params.skillsSnapshot ?? + buildWorkspaceSkillSnapshot(resolvedWorkspace, { + config: params.config, + entries: skillEntries, + }); + const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + restoreSkillEnv = params.skillsSnapshot + ? applySkillEnvOverridesFromSnapshot({ + snapshot: params.skillsSnapshot, + config: params.config, + }) + : applySkillEnvOverrides({ + skills: skillEntries ?? [], + config: params.config, + }); + + const bootstrapFiles = + await loadWorkspaceBootstrapFiles(resolvedWorkspace); + const contextFiles = buildBootstrapContextFiles(bootstrapFiles); + const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); + const tools = createClawdbotCodingTools({ + bash: { + ...params.config?.agent?.bash, + elevated: params.bashElevated, + }, + sandbox, + surface: params.surface, + sessionKey: params.sessionKey ?? params.sessionId, + config: params.config, + }); + const machineName = await getMachineDisplayName(); + const runtimeInfo = { + host: machineName, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + node: process.version, + model: `${provider}/${modelId}`, + }; + const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); + const reasoningTagHint = provider === "ollama"; + const userTimezone = resolveUserTimezone( + params.config?.agent?.userTimezone, + ); + const userTime = formatUserTime(new Date(), userTimezone); + const systemPrompt = buildSystemPrompt({ + appendPrompt: buildAgentSystemPromptAppend({ + workspaceDir: resolvedWorkspace, + defaultThinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + reasoningTagHint, + runtimeInfo, + sandboxInfo, + toolNames: tools.map((tool) => tool.name), + userTimezone, + userTime, + }), + contextFiles, + skills: promptSkills, + cwd: resolvedWorkspace, + tools, + }); + + const sessionManager = SessionManager.open(params.sessionFile); + const settingsManager = SettingsManager.create( + resolvedWorkspace, + agentDir, + ); + + const builtInToolNames = new Set(["read", "bash", "edit", "write"]); + const builtInTools = tools.filter((t) => builtInToolNames.has(t.name)); + const customTools = toToolDefinitions( + tools.filter((t) => !builtInToolNames.has(t.name)), + ); + + const { session } = await createAgentSession({ + cwd: resolvedWorkspace, + agentDir, + authStorage, + modelRegistry, + model, + thinkingLevel: mapThinkingLevel(params.thinkLevel), + systemPrompt, + tools: builtInTools, + customTools, + sessionManager, + settingsManager, + skills: promptSkills, + contextFiles, + }); + + try { + const prior = await sanitizeSessionMessagesImages( + session.messages, + "session:history", + ); + if (prior.length > 0) { + session.agent.replaceMessages(prior); + } + const result = await session.compact(params.customInstructions); + return { + ok: true, + compacted: true, + result: { + summary: result.summary, + firstKeptEntryId: result.firstKeptEntryId, + tokensBefore: result.tokensBefore, + details: result.details, + }, + }; + } finally { + session.dispose(); + } + } catch (err) { + return { + ok: false, + compacted: false, + reason: describeUnknownError(err), + }; + } finally { + restoreSkillEnv?.(); + process.chdir(prevCwd); + } + }), + ); +} + export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 022a4898f..81e99feec 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -1,10 +1,12 @@ export type { EmbeddedPiAgentMeta, + EmbeddedPiCompactResult, EmbeddedPiRunMeta, EmbeddedPiRunResult, } from "./pi-embedded-runner.js"; export { abortEmbeddedPiRun, + compactEmbeddedPiSession, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, queueEmbeddedPiMessage, diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts new file mode 100644 index 000000000..d48141802 --- /dev/null +++ b/src/auto-reply/command-auth.ts @@ -0,0 +1,65 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { normalizeE164 } from "../utils.js"; +import type { MsgContext } from "./templating.js"; + +export type CommandAuthorization = { + isWhatsAppSurface: boolean; + ownerList: string[]; + senderE164?: string; + isAuthorizedSender: boolean; + from?: string; + to?: string; +}; + +export function resolveCommandAuthorization(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + commandAuthorized: boolean; +}): CommandAuthorization { + const { ctx, cfg, commandAuthorized } = params; + const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const isWhatsAppSurface = + surface === "whatsapp" || + (ctx.From ?? "").startsWith("whatsapp:") || + (ctx.To ?? "").startsWith("whatsapp:"); + + const configuredAllowFrom = isWhatsAppSurface + ? cfg.whatsapp?.allowFrom + : undefined; + const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); + const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); + const allowFromList = + configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; + const allowAll = + !isWhatsAppSurface || + allowFromList.length === 0 || + allowFromList.some((entry) => entry.trim() === "*"); + + const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); + const ownerCandidates = + isWhatsAppSurface && !allowAll + ? allowFromList.filter((entry) => entry !== "*") + : []; + if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) { + ownerCandidates.push(to); + } + const ownerList = ownerCandidates + .map((entry) => normalizeE164(entry)) + .filter((entry): entry is string => Boolean(entry)); + + const isOwner = + !isWhatsAppSurface || + allowAll || + ownerList.length === 0 || + (senderE164 ? ownerList.includes(senderE164) : false); + const isAuthorizedSender = commandAuthorized && isOwner; + + return { + isWhatsAppSurface, + ownerList, + senderE164: senderE164 || undefined, + isAuthorizedSender, + from: from || undefined, + to: to || undefined, + }; +} diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 1148732af..1782f66f9 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -1,5 +1,5 @@ const CONTROL_COMMAND_RE = - /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i; + /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i; const CONTROL_COMMAND_EXACT = new Set([ "help", @@ -16,6 +16,8 @@ const CONTROL_COMMAND_EXACT = new Set([ "/reset", "new", "/new", + "compact", + "/compact", ]); export function hasControlCommand(text?: string): boolean { diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 19d6f0ff7..24c53f662 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + compactEmbeddedPiSession: vi.fn(), runEmbeddedPiAgent: vi.fn(), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => @@ -13,7 +14,10 @@ vi.mock("../agents/pi-embedded.js", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { + compactEmbeddedPiSession, + runEmbeddedPiAgent, +} from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; import { resolveSessionKey } from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; @@ -670,6 +674,100 @@ describe("trigger handling", () => { }); }); + it("does not reset for unauthorized /reset", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + CommandAuthorized: false, + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["+1999"], + }, + session: { + store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`), + }, + }, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("blocks /reset for non-owner senders", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["+1999"], + }, + session: { + store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`), + }, + }, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("runs /compact as a gated command", async () => { + await withTempHome(async (home) => { + vi.mocked(compactEmbeddedPiSession).mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["*"], + }, + session: { + store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`), + }, + }, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(compactEmbeddedPiSession).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("ignores think directives that only appear in the context wrapper", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 26144ef64..085563d6c 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -24,6 +24,7 @@ import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { logVerbose } from "../globals.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; @@ -42,6 +43,7 @@ import { defaultGroupActivation, resolveGroupRequireMention, } from "./reply/groups.js"; +import { stripMentions } from "./reply/mentions.js"; import { createModelSelectionState, resolveContextTokens, @@ -76,6 +78,9 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; +const CONTROL_COMMAND_PREFIX_RE = + /^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i; + function normalizeAllowToken(value?: string) { if (!value) return ""; return value.trim().toLowerCase(); @@ -240,7 +245,17 @@ export async function getReplyFromConfig( } } - const sessionState = await initSessionState({ ctx, cfg }); + const commandAuthorized = ctx.CommandAuthorized ?? true; + const commandAuth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized, + }); + const sessionState = await initSessionState({ + ctx, + cfg, + commandAuthorized, + }); let { sessionCtx, sessionEntry, @@ -258,7 +273,6 @@ export async function getReplyFromConfig( } = sessionState; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const commandAuthorized = ctx.CommandAuthorized ?? true; const parsedDirectives = parseInlineDirectives(rawBody); const directives = commandAuthorized ? parsedDirectives @@ -516,6 +530,16 @@ export async function getReplyFromConfig( const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); + const strippedCommandBody = isGroup + ? stripMentions(triggerBodyNormalized, ctx, cfg) + : triggerBodyNormalized; + if ( + !commandAuth.isAuthorizedSender && + CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim()) + ) { + typing.cleanup(); + return undefined; + } if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) { typing.cleanup(); return undefined; diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index ad2778b42..dcfe6d769 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -6,30 +6,44 @@ import { getCustomProviderApiKey, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { + abortEmbeddedPiRun, + compactEmbeddedPiSession, + isEmbeddedPiRunActive, + waitForEmbeddedPiRunEnd, +} from "../../agents/pi-embedded.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { + resolveSessionTranscriptPath, type SessionEntry, type SessionScope, saveSessionStore, } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { triggerClawdbotRestart } from "../../infra/restart.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeE164 } from "../../utils.js"; import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import { normalizeGroupActivation, parseActivationCommand, } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; -import { buildHelpMessage, buildStatusMessage } from "../status.js"; +import { + buildHelpMessage, + buildStatusMessage, + formatContextUsageShort, + formatTokenCount, +} from "../status.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; -import { stripMentions } from "./mentions.js"; +import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; export type CommandContext = { surface: string; @@ -74,6 +88,30 @@ function resolveModelAuthLabel( return "unknown"; } +function extractCompactInstructions(params: { + rawBody?: string; + ctx: MsgContext; + cfg: ClawdbotConfig; + isGroup: boolean; +}): string | undefined { + const raw = stripStructuralPrefixes(params.rawBody ?? ""); + const stripped = params.isGroup + ? stripMentions(raw, params.ctx, params.cfg) + : raw; + const trimmed = stripped.trim(); + if (!trimmed) return undefined; + const lowered = trimmed.toLowerCase(); + const prefix = lowered.startsWith("/compact") + ? "/compact" + : lowered.startsWith("compact") + ? "compact" + : null; + if (!prefix) return undefined; + let rest = trimmed.slice(prefix.length).trimStart(); + if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); + return rest.length ? rest : undefined; +} + export function buildCommandContext(params: { ctx: MsgContext; cfg: ClawdbotConfig; @@ -82,66 +120,31 @@ export function buildCommandContext(params: { triggerBodyNormalized: string; commandAuthorized: boolean; }): CommandContext { - const { + const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params; + const auth = resolveCommandAuthorization({ ctx, cfg, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - } = params; + commandAuthorized: params.commandAuthorized, + }); const surface = (ctx.Surface ?? "").trim().toLowerCase(); - const isWhatsAppSurface = - surface === "whatsapp" || - (ctx.From ?? "").startsWith("whatsapp:") || - (ctx.To ?? "").startsWith("whatsapp:"); - - const configuredAllowFrom = isWhatsAppSurface - ? cfg.whatsapp?.allowFrom - : undefined; - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); - const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); - const allowFromList = - configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; - const allowAll = - !isWhatsAppSurface || - allowFromList.length === 0 || - allowFromList.some((entry) => entry.trim() === "*"); - - const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined); + const abortKey = + sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; const commandBodyNormalized = isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized; - const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); - const ownerCandidates = - isWhatsAppSurface && !allowAll - ? allowFromList.filter((entry) => entry !== "*") - : []; - if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) { - ownerCandidates.push(to); - } - const ownerList = ownerCandidates - .map((entry) => normalizeE164(entry)) - .filter((entry): entry is string => Boolean(entry)); - const isOwner = - !isWhatsAppSurface || - allowAll || - ownerList.length === 0 || - (senderE164 ? ownerList.includes(senderE164) : false); - const isAuthorizedSender = commandAuthorized && isOwner; return { surface, - isWhatsAppSurface, - ownerList, - isAuthorizedSender, - senderE164: senderE164 || undefined, + isWhatsAppSurface: auth.isWhatsAppSurface, + ownerList: auth.ownerList, + isAuthorizedSender: auth.isAuthorizedSender, + senderE164: auth.senderE164, abortKey, rawBodyNormalized, commandBodyNormalized, - from: from || undefined, - to: to || undefined, + from: auth.from, + to: auth.to, }; } @@ -364,6 +367,78 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: statusText } }; } + const compactRequested = + command.commandBodyNormalized === "/compact" || + command.commandBodyNormalized === "compact" || + command.commandBodyNormalized.startsWith("/compact ") || + command.commandBodyNormalized.startsWith("compact "); + if (compactRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /compact from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + if (!sessionEntry?.sessionId) { + return { + shouldContinue: false, + reply: { text: "⚙️ Compaction unavailable (missing session id)." }, + }; + } + const sessionId = sessionEntry.sessionId; + if (isEmbeddedPiRunActive(sessionId)) { + abortEmbeddedPiRun(sessionId); + await waitForEmbeddedPiRunEnd(sessionId, 15_000); + } + const customInstructions = extractCompactInstructions({ + rawBody: ctx.Body, + ctx, + cfg, + isGroup, + }); + const result = await compactEmbeddedPiSession({ + sessionId, + sessionKey, + surface: command.surface, + sessionFile: resolveSessionTranscriptPath(sessionId), + workspaceDir, + config: cfg, + skillsSnapshot: sessionEntry.skillsSnapshot, + provider, + model, + thinkLevel: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + customInstructions, + ownerNumbers: + command.ownerList.length > 0 ? command.ownerList : undefined, + }); + + const totalTokens = + sessionEntry.totalTokens ?? + (sessionEntry.inputTokens ?? 0) + (sessionEntry.outputTokens ?? 0); + const contextSummary = formatContextUsageShort( + totalTokens > 0 ? totalTokens : null, + contextTokens ?? sessionEntry.contextTokens ?? null, + ); + const compactLabel = result.ok + ? result.compacted + ? result.result?.tokensBefore + ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` + : "Compacted" + : "Compaction skipped" + : "Compaction failed"; + const reason = result.reason?.trim(); + const line = reason + ? `${compactLabel}: ${reason} • ${contextSummary}` + : `${compactLabel} • ${contextSummary}`; + enqueueSystemEvent(line); + return { shouldContinue: false, reply: { text: `⚙️ ${line}` } }; + } + const abortRequested = isAbortTrigger(command.rawBodyNormalized); if (abortRequested) { if (sessionEntry && sessionStore && sessionKey) { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index eeb2edc2f..a6d4f0357 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -14,6 +14,7 @@ import { type SessionScope, saveSessionStore, } from "../../config/sessions.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; @@ -37,8 +38,9 @@ export type SessionInitResult = { export async function initSessionState(params: { ctx: MsgContext; cfg: ClawdbotConfig; + commandAuthorized: boolean; }): Promise { - const { ctx, cfg } = params; + const { ctx, cfg, commandAuthorized } = params; const sessionCfg = cfg.session; const mainKey = sessionCfg?.mainKey ?? "main"; const resetTriggers = sessionCfg?.resetTriggers?.length @@ -76,6 +78,11 @@ export async function initSessionState(params: { const rawBody = ctx.Body ?? ""; const trimmedBody = rawBody.trim(); + const resetAuthorized = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized, + }).isAuthorizedSender; // Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the // web inbox before we get here. They prevented reset triggers like "/new" // from matching, so strip structural wrappers when checking for resets. @@ -84,6 +91,7 @@ export async function initSessionState(params: { : triggerBodyNormalized; for (const trigger of resetTriggers) { if (!trigger) continue; + if (!resetAuthorized) break; if (trimmedBody === trigger || strippedForReset === trigger) { isNewSession = true; bodyStripped = ""; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 281f25824..9c4e2ca01 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -56,6 +56,8 @@ const formatAge = (ms?: number | null) => { const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; +export const formatTokenCount = (value: number) => formatKTokens(value); + const formatTokens = ( total: number | null | undefined, contextTokens: number | null, @@ -71,6 +73,11 @@ const formatTokens = ( return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; }; +export const formatContextUsageShort = ( + total: number | null | undefined, + contextTokens: number | null | undefined, +) => `Context ${formatTokens(total, contextTokens ?? null)}`; + const readUsageFromSessionLog = ( sessionId?: string, ): @@ -262,7 +269,7 @@ export function buildStatusMessage(args: StatusArgs): string { export function buildHelpMessage(): string { return [ "ℹ️ Help", - "Shortcuts: /new reset | /restart relink", + "Shortcuts: /new reset | /compact [instructions] | /restart relink", "Options: /think | /verbose on|off | /elevated on|off | /model ", ].join("\n"); }