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

@@ -27,89 +27,7 @@ describe("config identity defaults", () => {
process.env.HOME = previousHome;
});
it("derives responsePrefix, mentionPatterns, and sessionIntro when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
inbound: {
reply: {
mode: "command",
command: ["pi", "--mode", "rpc", "x"],
session: {},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.inbound?.responsePrefix).toBe("🦥");
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.inbound?.reply?.session?.sessionIntro).toContain(
"You are Samantha.",
);
expect(cfg.inbound?.reply?.session?.sessionIntro).toContain(
"Theme: helpful sloth.",
);
expect(cfg.inbound?.reply?.session?.sessionIntro).toContain(
"Your emoji is 🦥.",
);
});
});
it("does not override explicit values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify(
{
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
inbound: {
responsePrefix: "✅",
groupChat: { mentionPatterns: ["@clawd"] },
reply: {
mode: "command",
command: ["pi", "--mode", "rpc", "x"],
session: { sessionIntro: "Explicit intro" },
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.inbound?.responsePrefix).toBe("✅");
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
expect(cfg.inbound?.reply?.session?.sessionIntro).toBe("Explicit intro");
});
});
it("does not synthesize inbound.reply when it is absent", async () => {
it("derives responsePrefix and mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
@@ -134,7 +52,69 @@ describe("config identity defaults", () => {
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.inbound?.reply).toBeUndefined();
});
});
it("does not override explicit values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify(
{
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
inbound: {
responsePrefix: "✅",
groupChat: { mentionPatterns: ["@clawd"] },
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.inbound?.responsePrefix).toBe("✅");
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
});
});
it("does not synthesize inbound.agent/session when absent", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
inbound: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.inbound?.responsePrefix).toBe("🦥");
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.inbound?.agent).toBeUndefined();
expect(cfg.inbound?.session).toBeUndefined();
});
});
});

View File

@@ -5,9 +5,6 @@ import path from "node:path";
import JSON5 from "json5";
import { z } from "zod";
import type { AgentKind } from "../agents/index.js";
export type ReplyMode = "text" | "command";
export type SessionScope = "per-sender" | "global";
export type SessionConfig = {
@@ -16,13 +13,7 @@ export type SessionConfig = {
idleMinutes?: number;
heartbeatIdleMinutes?: number;
store?: string;
sessionArgNew?: string[];
sessionArgResume?: string[];
sessionArgBeforeBody?: boolean;
sendSystemOnce?: boolean;
sessionIntro?: string;
typingIntervalSeconds?: number;
heartbeatMinutes?: number;
mainKey?: string;
};
@@ -105,31 +96,25 @@ export type ClawdisConfig = {
timeoutSeconds?: number;
};
groupChat?: GroupChatConfig;
reply?: {
mode: ReplyMode;
text?: string;
command?: string[];
heartbeatCommand?: string[];
agent?: {
/** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */
provider?: string;
/** Model id within provider, e.g. "claude-opus-4-5". */
model?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
cwd?: string;
template?: string;
timeoutSeconds?: number;
bodyPrefix?: string;
mediaUrl?: string;
session?: SessionConfig;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Periodic background heartbeat runs (minutes). 0 disables. */
heartbeatMinutes?: number;
agent?: {
kind: AgentKind;
format?: "text" | "json";
identityPrefix?: string;
provider?: string;
model?: string;
contextTokens?: number;
};
};
session?: SessionConfig;
};
web?: WebConfig;
telegram?: TelegramConfig;
@@ -144,70 +129,6 @@ export const CONFIG_PATH_CLAWDIS = path.join(
"clawdis.json",
);
const ReplySchema = z
.object({
mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(),
command: z.array(z.string()).optional(),
heartbeatCommand: z.array(z.string()).optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
cwd: z.string().optional(),
template: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
bodyPrefix: z.string().optional(),
mediaUrl: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
session: z
.object({
scope: z
.union([z.literal("per-sender"), z.literal("global")])
.optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
sendSystemOnce: z.boolean().optional(),
sessionIntro: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
mainKey: z.string().optional(),
})
.optional(),
heartbeatMinutes: z.number().int().nonnegative().optional(),
agent: z
.object({
kind: z.literal("pi"),
format: z.union([z.literal("text"), z.literal("json")]).optional(),
identityPrefix: z.string().optional(),
provider: z.string().optional(),
model: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
})
.optional(),
})
.refine(
(val) =>
val.mode === "text"
? Boolean(val.text)
: Boolean(val.command || val.heartbeatCommand),
{
message:
"reply.text is required for mode=text; reply.command or reply.heartbeatCommand is required for mode=command",
},
);
const ClawdisSchema = z.object({
identity: z
.object({
@@ -261,7 +182,42 @@ const ClawdisSchema = z.object({
timeoutSeconds: z.number().int().positive().optional(),
})
.optional(),
reply: ReplySchema.optional(),
agent: z
.object({
provider: z.string().optional(),
model: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
heartbeatMinutes: z.number().nonnegative().optional(),
})
.optional(),
session: z
.object({
scope: z
.union([z.literal("per-sender"), z.literal("global")])
.optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
mainKey: z.string().optional(),
})
.optional(),
})
.optional(),
cron: z
@@ -315,12 +271,9 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
const emoji = identity.emoji?.trim();
const name = identity.name?.trim();
const theme = identity.theme?.trim();
const inbound = cfg.inbound ?? {};
const groupChat = inbound.groupChat ?? {};
const reply = inbound.reply ?? undefined;
const session = reply?.session ?? undefined;
let mutated = false;
const next: ClawdisConfig = { ...cfg };
@@ -341,22 +294,6 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
mutated = true;
}
if (name && reply && !session?.sessionIntro) {
const introParts = [
`You are ${name}.`,
theme ? `Theme: ${theme}.` : undefined,
emoji ? `Your emoji is ${emoji}.` : undefined,
].filter(Boolean);
next.inbound = {
...(next.inbound ?? inbound),
reply: {
...reply,
session: { ...(session ?? {}), sessionIntro: introParts.join(" ") },
},
};
mutated = true;
}
return mutated ? next : cfg;
}

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import JSON5 from "json5";
import type { MsgContext } from "../auto-reply/templating.js";
import { CONFIG_DIR, normalizeE164 } from "../utils.js";
import { normalizeE164 } from "../utils.js";
export type SessionScope = "per-sender" | "global";
@@ -27,16 +27,22 @@ export type SessionEntry = {
syncing?: boolean | string;
};
export const SESSION_STORE_DEFAULT = path.join(
CONFIG_DIR,
"sessions",
"sessions.json",
);
export function resolveSessionTranscriptsDir(): string {
return path.join(os.homedir(), ".clawdis", "sessions");
}
export function resolveDefaultSessionStorePath(): string {
return path.join(resolveSessionTranscriptsDir(), "sessions.json");
}
export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_IDLE_MINUTES = 60;
export function resolveSessionTranscriptPath(sessionId: string): string {
return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`);
}
export function resolveStorePath(store?: string) {
if (!store) return SESSION_STORE_DEFAULT;
if (!store) return resolveDefaultSessionStorePath();
if (store.startsWith("~"))
return path.resolve(store.replace("~", os.homedir()));
return path.resolve(store);