feat: gate slash commands and add compact
This commit is contained in:
@@ -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 user’s 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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.
|
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 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
|
## 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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
65
src/auto-reply/command-auth.ts
Normal file
65
src/auto-reply/command-auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user