feat: gate slash commands and add compact

This commit is contained in:
Peter Steinberger
2026-01-06 02:06:06 +01:00
parent 085c70a87b
commit b56338171b
16 changed files with 566 additions and 59 deletions

View File

@@ -7,6 +7,7 @@
### Breaking ### Breaking
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - 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 ### Fixes
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - 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: 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: 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. - 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 shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`. - 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. - Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.

View File

@@ -209,6 +209,7 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
- `/status` — health + session info (group shows activation mode) - `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session - `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high - `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off` - `/verbose on|off`
- `/restart` — restart the gateway (owner-only in groups) - `/restart` — restart the gateway (owner-only in groups)

View File

@@ -147,6 +147,7 @@ Example:
- Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl` - Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl`
- Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`) - 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. - `/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) ## Heartbeats (proactive mode)

View File

@@ -302,7 +302,7 @@ Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction*
Practical tips: Practical tips:
- Keep `AGENTS.md` focused, not bloated. - 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. - For large memory/notes collections, use search tools like `qmd` rather than loading everything.
### Where are my memory files? ### Where are my memory files?
@@ -551,6 +551,9 @@ Quick reference (send these in chat):
|---------|--------| |---------|--------|
| `/status` | Health + session info | | `/status` | Health + session info |
| `/new` or `/reset` | Reset the session | | `/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 <level>` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode | | `/verbose on\|off` | Toggle verbose mode |
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | | `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |

View File

@@ -58,7 +58,7 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bots own
1) Add Clawd UK (`+447700900123`) to the group. 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. 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. 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 groups session; your personal DM session remains independent. 4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that groups session; your personal DM session remains independent.
## Testing / verification ## Testing / verification
- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). - Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix).

View File

@@ -77,6 +77,7 @@ Runtime override (owner only):
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`). - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
- `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - `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 `/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. - JSONL transcripts can be opened directly to review full turns.
## Tips ## Tips

View File

@@ -55,6 +55,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/activation <mention|always>` - `/activation <mention|always>`
- `/deliver <on|off>` - `/deliver <on|off>`
- `/new` or `/reset` - `/new` or `/reset`
- `/compact [instructions]`
- `/abort` - `/abort`
- `/settings` - `/settings`
- `/exit` - `/exit`

View File

@@ -98,6 +98,18 @@ export type EmbeddedPiRunResult = {
meta: EmbeddedPiRunMeta; meta: EmbeddedPiRunMeta;
}; };
export type EmbeddedPiCompactResult = {
ok: boolean;
compacted: boolean;
reason?: string;
result?: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
details?: unknown;
};
};
type EmbeddedPiQueueHandle = { type EmbeddedPiQueueHandle = {
queueMessage: (text: string) => Promise<void>; queueMessage: (text: string) => Promise<void>;
isStreaming: () => boolean; isStreaming: () => boolean;
@@ -314,6 +326,212 @@ function resolvePromptSkills(
.filter((skill): skill is Skill => Boolean(skill)); .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<EmbeddedPiCompactResult> {
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: { export async function runEmbeddedPiAgent(params: {
sessionId: string; sessionId: string;
sessionKey?: string; sessionKey?: string;

View File

@@ -1,10 +1,12 @@
export type { export type {
EmbeddedPiAgentMeta, EmbeddedPiAgentMeta,
EmbeddedPiCompactResult,
EmbeddedPiRunMeta, EmbeddedPiRunMeta,
EmbeddedPiRunResult, EmbeddedPiRunResult,
} from "./pi-embedded-runner.js"; } from "./pi-embedded-runner.js";
export { export {
abortEmbeddedPiRun, abortEmbeddedPiRun,
compactEmbeddedPiSession,
isEmbeddedPiRunActive, isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming, isEmbeddedPiRunStreaming,
queueEmbeddedPiMessage, queueEmbeddedPiMessage,

View File

@@ -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,
};
}

View File

@@ -1,5 +1,5 @@
const CONTROL_COMMAND_RE = 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([ const CONTROL_COMMAND_EXACT = new Set([
"help", "help",
@@ -16,6 +16,8 @@ const CONTROL_COMMAND_EXACT = new Set([
"/reset", "/reset",
"new", "new",
"/new", "/new",
"compact",
"/compact",
]); ]);
export function hasControlCommand(text?: string): boolean { export function hasControlCommand(text?: string): boolean {

View File

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(), runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => resolveEmbeddedSessionLane: (key: string) =>
@@ -13,7 +14,10 @@ vi.mock("../agents/pi-embedded.js", () => ({
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), 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 { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { resolveSessionKey } from "../config/sessions.js"; import { resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.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 () => { it("ignores think directives that only appear in the context wrapper", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -24,6 +24,7 @@ import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand } from "./command-detection.js"; import { hasControlCommand } from "./command-detection.js";
import { getAbortMemory } from "./reply/abort.js"; import { getAbortMemory } from "./reply/abort.js";
import { runReplyAgent } from "./reply/agent-runner.js"; import { runReplyAgent } from "./reply/agent-runner.js";
@@ -42,6 +43,7 @@ import {
defaultGroupActivation, defaultGroupActivation,
resolveGroupRequireMention, resolveGroupRequireMention,
} from "./reply/groups.js"; } from "./reply/groups.js";
import { stripMentions } from "./reply/mentions.js";
import { import {
createModelSelectionState, createModelSelectionState,
resolveContextTokens, resolveContextTokens,
@@ -76,6 +78,9 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
const BARE_SESSION_RESET_PROMPT = 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."; "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) { function normalizeAllowToken(value?: string) {
if (!value) return ""; if (!value) return "";
return value.trim().toLowerCase(); 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 { let {
sessionCtx, sessionCtx,
sessionEntry, sessionEntry,
@@ -258,7 +273,6 @@ export async function getReplyFromConfig(
} = sessionState; } = sessionState;
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const commandAuthorized = ctx.CommandAuthorized ?? true;
const parsedDirectives = parseInlineDirectives(rawBody); const parsedDirectives = parseInlineDirectives(rawBody);
const directives = commandAuthorized const directives = commandAuthorized
? parsedDirectives ? parsedDirectives
@@ -516,6 +530,16 @@ export async function getReplyFromConfig(
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
const rawBodyTrimmed = (ctx.Body ?? "").trim(); const rawBodyTrimmed = (ctx.Body ?? "").trim();
const baseBodyTrimmedRaw = baseBody.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)) { if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
typing.cleanup(); typing.cleanup();
return undefined; return undefined;

View File

@@ -6,30 +6,44 @@ import {
getCustomProviderApiKey, getCustomProviderApiKey,
resolveEnvApiKey, resolveEnvApiKey,
} from "../../agents/model-auth.js"; } from "../../agents/model-auth.js";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveSessionTranscriptPath,
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
saveSessionStore, saveSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { triggerClawdbotRestart } from "../../infra/restart.js"; import { triggerClawdbotRestart } from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js"; import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { import {
normalizeGroupActivation, normalizeGroupActivation,
parseActivationCommand, parseActivationCommand,
} from "../group-activation.js"; } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.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 { MsgContext } from "../templating.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js"; import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions } from "./mentions.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
export type CommandContext = { export type CommandContext = {
surface: string; surface: string;
@@ -74,6 +88,30 @@ function resolveModelAuthLabel(
return "unknown"; 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: { export function buildCommandContext(params: {
ctx: MsgContext; ctx: MsgContext;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@@ -82,66 +120,31 @@ export function buildCommandContext(params: {
triggerBodyNormalized: string; triggerBodyNormalized: string;
commandAuthorized: boolean; commandAuthorized: boolean;
}): CommandContext { }): CommandContext {
const { const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const auth = resolveCommandAuthorization({
ctx, ctx,
cfg, cfg,
sessionKey, commandAuthorized: params.commandAuthorized,
isGroup, });
triggerBodyNormalized,
commandAuthorized,
} = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase(); const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface = const abortKey =
surface === "whatsapp" || sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
(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 rawBodyNormalized = triggerBodyNormalized; const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = isGroup const commandBodyNormalized = isGroup
? stripMentions(rawBodyNormalized, ctx, cfg) ? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized; : 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 { return {
surface, surface,
isWhatsAppSurface, isWhatsAppSurface: auth.isWhatsAppSurface,
ownerList, ownerList: auth.ownerList,
isAuthorizedSender, isAuthorizedSender: auth.isAuthorizedSender,
senderE164: senderE164 || undefined, senderE164: auth.senderE164,
abortKey, abortKey,
rawBodyNormalized, rawBodyNormalized,
commandBodyNormalized, commandBodyNormalized,
from: from || undefined, from: auth.from,
to: to || undefined, to: auth.to,
}; };
} }
@@ -364,6 +367,78 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: statusText } }; 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 || "<unknown>"}`,
);
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); const abortRequested = isAbortTrigger(command.rawBodyNormalized);
if (abortRequested) { if (abortRequested) {
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {

View File

@@ -14,6 +14,7 @@ import {
type SessionScope, type SessionScope,
saveSessionStore, saveSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
@@ -37,8 +38,9 @@ export type SessionInitResult = {
export async function initSessionState(params: { export async function initSessionState(params: {
ctx: MsgContext; ctx: MsgContext;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
commandAuthorized: boolean;
}): Promise<SessionInitResult> { }): Promise<SessionInitResult> {
const { ctx, cfg } = params; const { ctx, cfg, commandAuthorized } = params;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const mainKey = sessionCfg?.mainKey ?? "main"; const mainKey = sessionCfg?.mainKey ?? "main";
const resetTriggers = sessionCfg?.resetTriggers?.length const resetTriggers = sessionCfg?.resetTriggers?.length
@@ -76,6 +78,11 @@ export async function initSessionState(params: {
const rawBody = ctx.Body ?? ""; const rawBody = ctx.Body ?? "";
const trimmedBody = rawBody.trim(); const trimmedBody = rawBody.trim();
const resetAuthorized = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized,
}).isAuthorizedSender;
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the // 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" // web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets. // from matching, so strip structural wrappers when checking for resets.
@@ -84,6 +91,7 @@ export async function initSessionState(params: {
: triggerBodyNormalized; : triggerBodyNormalized;
for (const trigger of resetTriggers) { for (const trigger of resetTriggers) {
if (!trigger) continue; if (!trigger) continue;
if (!resetAuthorized) break;
if (trimmedBody === trigger || strippedForReset === trigger) { if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true; isNewSession = true;
bodyStripped = ""; bodyStripped = "";

View File

@@ -56,6 +56,8 @@ const formatAge = (ms?: number | null) => {
const formatKTokens = (value: number) => const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
export const formatTokenCount = (value: number) => formatKTokens(value);
const formatTokens = ( const formatTokens = (
total: number | null | undefined, total: number | null | undefined,
contextTokens: number | null, contextTokens: number | null,
@@ -71,6 +73,11 @@ const formatTokens = (
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
}; };
export const formatContextUsageShort = (
total: number | null | undefined,
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const readUsageFromSessionLog = ( const readUsageFromSessionLog = (
sessionId?: string, sessionId?: string,
): ):
@@ -262,7 +269,7 @@ export function buildStatusMessage(args: StatusArgs): string {
export function buildHelpMessage(): string { export function buildHelpMessage(): string {
return [ return [
" Help", " Help",
"Shortcuts: /new reset | /restart relink", "Shortcuts: /new reset | /compact [instructions] | /restart relink",
"Options: /think <level> | /verbose on|off | /elevated on|off | /model <id>", "Options: /think <level> | /verbose on|off | /elevated on|off | /model <id>",
].join("\n"); ].join("\n");
} }