feat: embed pi agent runtime

This commit is contained in:
Peter Steinberger
2025-12-17 11:29:04 +01:00
parent c5867b2876
commit fece42ce0a
42 changed files with 2076 additions and 4009 deletions

View File

@@ -5,6 +5,12 @@ import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn(),
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ClawdisConfig } from "../config/config.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
@@ -26,6 +32,23 @@ import {
setLoadConfigMock,
} from "./test-helpers.js";
let previousHome: string | undefined;
let tempHome: string | undefined;
beforeEach(async () => {
previousHome = process.env.HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-web-home-"));
process.env.HOME = tempHome;
});
afterEach(async () => {
process.env.HOME = previousHome;
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true });
tempHome = undefined;
}
});
const makeSessionStore = async (
entries: Record<string, unknown> = {},
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
@@ -89,27 +112,20 @@ describe("heartbeat helpers", () => {
it("resolves heartbeat minutes with default and overrides", () => {
const cfgBase: ClawdisConfig = {
inbound: {
reply: { mode: "command" as const },
},
inbound: {},
};
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "command", heartbeatMinutes: 5 } },
inbound: { agent: { heartbeatMinutes: 5 } },
}),
).toBe(5);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "command", heartbeatMinutes: 0 } },
inbound: { agent: { heartbeatMinutes: 0 } },
}),
).toBeNull();
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "text" } },
}),
).toBeNull();
});
});
@@ -122,7 +138,7 @@ describe("resolveHeartbeatRecipients", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
};
const result = resolveHeartbeatRecipients(cfg);
@@ -140,7 +156,7 @@ describe("resolveHeartbeatRecipients", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
};
const result = resolveHeartbeatRecipients(cfg);
@@ -154,7 +170,7 @@ describe("resolveHeartbeatRecipients", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["*"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
};
const result = resolveHeartbeatRecipients(cfg);
@@ -171,7 +187,7 @@ describe("resolveHeartbeatRecipients", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
};
const result = resolveHeartbeatRecipients(cfg, { all: true });
@@ -191,7 +207,6 @@ describe("partial reply gating", () => {
const mockConfig: ClawdisConfig = {
inbound: {
reply: { mode: "command" },
allowFrom: ["*"],
},
};
@@ -240,10 +255,7 @@ describe("partial reply gating", () => {
const mockConfig: ClawdisConfig = {
inbound: {
allowFrom: ["*"],
reply: {
mode: "command",
session: { store: store.storePath, mainKey: "main" },
},
session: { store: store.storePath, mainKey: "main" },
},
};
@@ -289,12 +301,13 @@ describe("partial reply gating", () => {
});
it("defaults to self-only when no config is present", async () => {
const cfg: ClawdisConfig = {
inbound: {
// No allowFrom provided; this simulates zero config file while keeping reply simple
reply: { mode: "text", text: "ok" },
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
});
// Not self: should be blocked
const blocked = await getReplyFromConfig(
@@ -304,9 +317,10 @@ describe("partial reply gating", () => {
To: "whatsapp:+123",
},
undefined,
cfg,
{},
);
expect(blocked).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
// Self: should be allowed
const allowed = await getReplyFromConfig(
@@ -316,9 +330,10 @@ describe("partial reply gating", () => {
To: "whatsapp:+123",
},
undefined,
cfg,
{},
);
expect(allowed).toEqual({ text: "ok" });
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
@@ -331,7 +346,7 @@ describe("runWebHeartbeatOnce", () => {
cfg: {
inbound: {
allowFrom: ["+1555"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
},
to: "+1555",
@@ -354,7 +369,7 @@ describe("runWebHeartbeatOnce", () => {
cfg: {
inbound: {
allowFrom: ["+1555"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
},
to: "+1555",
@@ -383,7 +398,7 @@ describe("runWebHeartbeatOnce", () => {
cfg: {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: storePath } },
session: { store: storePath },
},
},
to: "+1999",
@@ -412,13 +427,10 @@ describe("runWebHeartbeatOnce", () => {
setLoadConfigMock({
inbound: {
allowFrom: ["+1555"],
reply: {
mode: "command",
session: {
store: storePath,
idleMinutes: 60,
heartbeatIdleMinutes: 10,
},
session: {
store: storePath,
idleMinutes: 60,
heartbeatIdleMinutes: 10,
},
},
});
@@ -451,11 +463,8 @@ describe("runWebHeartbeatOnce", () => {
setLoadConfigMock(() => ({
inbound: {
allowFrom: ["+4367"],
reply: {
mode: "command",
heartbeatMinutes: 0.001,
session: { store: storePath, idleMinutes: 60 },
},
agent: { heartbeatMinutes: 0.001 },
session: { store: storePath, idleMinutes: 60 },
},
}));
@@ -464,10 +473,7 @@ describe("runWebHeartbeatOnce", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+4367"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
session: { store: storePath, idleMinutes: 60 },
},
};
@@ -496,10 +502,7 @@ describe("runWebHeartbeatOnce", () => {
setLoadConfigMock(() => ({
inbound: {
allowFrom: ["+1999"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
session: { store: storePath, idleMinutes: 60 },
},
}));
@@ -507,10 +510,7 @@ describe("runWebHeartbeatOnce", () => {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
session: { store: storePath, idleMinutes: 60 },
},
};
await runWebHeartbeatOnce({
@@ -541,7 +541,7 @@ describe("runWebHeartbeatOnce", () => {
cfg: {
inbound: {
allowFrom: ["+1555"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
},
to: "+1555",
@@ -565,7 +565,7 @@ describe("runWebHeartbeatOnce", () => {
cfg: {
inbound: {
allowFrom: ["+1555"],
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
},
to: "+1555",
@@ -717,7 +717,7 @@ describe("web auto-reply", () => {
setLoadConfigMock(() => ({
inbound: {
allowFrom: ["+1555"],
reply: { mode: "command", session: { store: storePath } },
session: { store: storePath },
},
}));
@@ -776,7 +776,7 @@ describe("web auto-reply", () => {
inbound: {
allowFrom: ["+1555"],
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] },
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
}));
@@ -879,7 +879,7 @@ describe("web auto-reply", () => {
setLoadConfigMock(() => ({
inbound: {
timestampPrefix: "UTC",
reply: { mode: "command", session: { store: store.storePath } },
session: { store: store.storePath },
},
}));
@@ -1155,7 +1155,7 @@ describe("web auto-reply", () => {
for (const fmt of formats) {
// Force a small cap to ensure compression is exercised for every format.
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } }));
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
@@ -1220,7 +1220,7 @@ describe("web auto-reply", () => {
);
it("honors mediaMaxMb from config", async () => {
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } }));
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();

View File

@@ -74,7 +74,7 @@ const formatDuration = (ms: number) =>
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const HEARTBEAT_PROMPT = "HEARTBEAT /think:high";
export const HEARTBEAT_PROMPT = "HEARTBEAT";
function elide(text?: string, limit = 400) {
if (!text) return text;
@@ -164,12 +164,10 @@ export function resolveReplyHeartbeatMinutes(
cfg: ReturnType<typeof loadConfig>,
overrideMinutes?: number,
) {
const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes;
const raw = overrideMinutes ?? cfg.inbound?.agent?.heartbeatMinutes;
if (raw === 0) return null;
if (typeof raw === "number" && raw > 0) return raw;
return cfg.inbound?.reply?.mode === "command"
? DEFAULT_REPLY_HEARTBEAT_MINUTES
: null;
return DEFAULT_REPLY_HEARTBEAT_MINUTES;
}
export function stripHeartbeatToken(raw?: string) {
@@ -214,12 +212,12 @@ export async function runWebHeartbeatOnce(opts: {
});
const cfg = cfgOverride ?? loadConfig();
const sessionCfg = cfg.inbound?.reply?.session;
const sessionCfg = cfg.inbound?.session;
const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = sessionCfg?.mainKey;
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
if (sessionId) {
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
store[sessionKey] = {
...(store[sessionKey] ?? {}),
@@ -319,7 +317,7 @@ export async function runWebHeartbeatOnce(opts: {
const stripped = stripHeartbeatToken(replyPayload.text);
if (stripped.shouldSkip && !hasMedia) {
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
@@ -381,7 +379,7 @@ export async function runWebHeartbeatOnce(opts: {
}
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
const sessionCfg = cfg.inbound?.reply?.session;
const sessionCfg = cfg.inbound?.session;
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
@@ -402,10 +400,10 @@ function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
}
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
const sessionCfg = cfg.inbound?.reply?.session;
const sessionCfg = cfg.inbound?.session;
const scope = sessionCfg?.scope ?? "per-sender";
if (scope === "global") return [];
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
const isGroupKey = (key: string) =>
key.startsWith("group:") || key.includes("@g.us");
@@ -470,7 +468,7 @@ function getSessionSnapshot(
from: string,
isHeartbeat = false,
) {
const sessionCfg = cfg.inbound?.reply?.session;
const sessionCfg = cfg.inbound?.session;
const scope = sessionCfg?.scope ?? "per-sender";
const key = resolveSessionKey(
scope,
@@ -700,7 +698,7 @@ export async function monitorWebProvider(
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const cfg = loadConfig();
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
const configuredMaxMb = cfg.inbound?.agent?.mediaMaxMb;
const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0
? configuredMaxMb * 1024 * 1024
@@ -873,7 +871,7 @@ export async function monitorWebProvider(
);
if (latest.chatType !== "group") {
const sessionCfg = cfg.inbound?.reply?.session;
const sessionCfg = cfg.inbound?.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
const to = (() => {

View File

@@ -11,7 +11,7 @@ import {
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
import { resolveDefaultSessionStorePath } from "../config/sessions.js";
import { danger, info, success } from "../globals.js";
import { getChildLogger, toPinoLikeLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@@ -153,7 +153,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
}
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
// Also drop session store to clear lingering per-sender state after logout.
await fs.rm(SESSION_STORE_DEFAULT, { force: true });
await fs.rm(resolveDefaultSessionStorePath(), { force: true });
runtime.log(success("Cleared WhatsApp Web credentials."));
return true;
}