diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b1f6235bf..c2dbaf53c 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -36,32 +36,34 @@ const ABORT_MEMORY = new Map(); type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { - if (!raw) return undefined; - const key = raw.toLowerCase(); - if (["off"].includes(key)) return "off"; - if (["min", "minimal"].includes(key)) return "minimal"; - if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) - return "low"; - if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) - return "medium"; - if ( - ["high", "ultra", "ultrathink", "think-hard", "thinkhardest"].includes(key) - ) - return "high"; - if (["think"].includes(key)) return "minimal"; - return undefined; + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off"].includes(key)) return "off"; + if (["min", "minimal"].includes(key)) return "minimal"; + if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) + return "low"; + if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) + return "medium"; + if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key)) + return "high"; + if (["think"].includes(key)) return "minimal"; + return undefined; } function extractThinkDirective(body?: string): { - cleaned: string; - thinkLevel?: ThinkLevel; + cleaned: string; + thinkLevel?: ThinkLevel; } { - if (!body) return { cleaned: "" }; - const re = /\/think:([a-zA-Z-]+)/i; - const match = body.match(re); - const thinkLevel = normalizeThinkLevel(match?.[1]); - const cleaned = match ? body.replace(match[0], "").trim() : body; - return { cleaned, thinkLevel }; + if (!body) return { cleaned: "" }; + // Match the longest keyword first to avoid partial captures (e.g. "/think:high") + const match = body.match( + /\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i, + ); + const thinkLevel = normalizeThinkLevel(match?.[1]); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { cleaned, thinkLevel }; } function isAbortTrigger(text?: string): boolean { @@ -146,6 +148,8 @@ export async function getReplyFromConfig( let systemSent = false; let abortedLastRun = false; + let persistedThinking: string | undefined; + if (sessionCfg) { const trimmedBody = (ctx.Body ?? "").trim(); for (const trigger of resetTriggers) { @@ -173,6 +177,7 @@ export async function getReplyFromConfig( sessionId = entry.sessionId; systemSent = entry.systemSent ?? false; abortedLastRun = entry.abortedLastRun ?? false; + persistedThinking = entry.thinkingLevel; } else { sessionId = crypto.randomUUID(); isNewSession = true; @@ -185,6 +190,7 @@ export async function getReplyFromConfig( updatedAt: Date.now(), systemSent, abortedLastRun, + thinkingLevel: persistedThinking, }; sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); @@ -197,11 +203,32 @@ export async function getReplyFromConfig( IsNewSession: isNewSession ? "true" : "false", }; - const { cleaned: thinkCleaned, thinkLevel } = extractThinkDirective( - sessionCtx.BodyStripped ?? sessionCtx.Body ?? "", - ); - sessionCtx.Body = thinkCleaned; - sessionCtx.BodyStripped = thinkCleaned; + const { cleaned: thinkCleaned, thinkLevel: inlineThink } = extractThinkDirective( + sessionCtx.BodyStripped ?? sessionCtx.Body ?? "", + ); + sessionCtx.Body = thinkCleaned; + sessionCtx.BodyStripped = thinkCleaned; + + let resolvedThinkLevel = + inlineThink ?? + (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? + (reply?.thinkingDefault as ThinkLevel | undefined); + + // Directive-only message => persist session thinking level and return ack + if (inlineThink && !thinkCleaned) { + if (sessionEntry && sessionStore && sessionKey) { + if (inlineThink === "off") { + delete sessionEntry.thinkingLevel; + } else { + sessionEntry.thinkingLevel = inlineThink; + } + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + cleanupTyping(); + return { text: `Thinking level set to ${inlineThink}` }; + } // Optional allowlist by origin number (E.164 without whatsapp: prefix) const allowFrom = cfg.inbound?.allowFrom; @@ -319,12 +346,22 @@ export async function getReplyFromConfig( mediaNote && reply?.mode === "command" ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." : undefined; - const commandBody = mediaNote + let commandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] .filter(Boolean) .join("\n") .trim() : prefixedBody; + + // Fallback: if a stray leading level token remains, consume it + if (!resolvedThinkLevel && commandBody) { + const parts = commandBody.split(/\s+/); + const maybeLevel = normalizeThinkLevel(parts[0]); + if (maybeLevel) { + resolvedThinkLevel = maybeLevel; + commandBody = parts.slice(1).join(" ").trim(); + } + } const templatingCtx: TemplateContext = { ...sessionCtx, Body: commandBody, @@ -379,7 +416,7 @@ export async function getReplyFromConfig( timeoutMs, timeoutSeconds, commandRunner, - thinkLevel, + thinkLevel: resolvedThinkLevel, }); const payloadArray = runResult.payloads ?? []; const meta = runResult.meta; diff --git a/src/config/config.ts b/src/config/config.ts index 74c6efa6d..cd23696b0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -59,6 +59,8 @@ export type WarelayConfig = { mode: ReplyMode; text?: string; command?: string[]; + heartbeatCommand?: string[]; + thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; cwd?: string; template?: string; timeoutSeconds?: number; @@ -85,6 +87,16 @@ const ReplySchema = z 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(), cwd: z.string().optional(), template: z.string().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -125,10 +137,13 @@ const ReplySchema = z .optional(), }) .refine( - (val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)), + (val) => + val.mode === "text" + ? Boolean(val.text) + : Boolean(val.command || val.heartbeatCommand), { message: - "reply.text is required for mode=text; reply.command is required for mode=command", + "reply.text is required for mode=text; reply.command or reply.heartbeatCommand is required for mode=command", }, ); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index eba690e69..ccd7ea540 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -9,10 +9,11 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js"; export type SessionScope = "per-sender" | "global"; export type SessionEntry = { - sessionId: string; - updatedAt: number; - systemSent?: boolean; - abortedLastRun?: boolean; + sessionId: string; + updatedAt: number; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; }; export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); diff --git a/src/index.core.test.ts b/src/index.core.test.ts index ffe4c748a..c4e355f08 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -7,6 +7,7 @@ import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockTwilio } from "../test/mocks/twilio.js"; import * as exec from "./process/exec.js"; +import * as tauRpc from "./process/tau-rpc.js"; import { withWhatsAppPrefix } from "./utils.js"; // Mock config to avoid loading real user config @@ -346,6 +347,66 @@ describe("config and templating", () => { expect(result?.text).toBe("caption before caption after"); }); + it("uses heartbeatCommand only for heartbeat polls", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "normal {{Body}}"], + heartbeatCommand: ["echo", "heartbeat {{Body}}"], + }, + }, + }; + + await index.getReplyFromConfig( + { Body: "PING", From: "+1", To: "+2" }, + { isHeartbeat: true }, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalledWith( + ["echo", "heartbeat PING"], + expect.any(Object), + ); + }); + + it("falls back to default command for non-heartbeat calls", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "normal {{Body}}"], + heartbeatCommand: ["echo", "heartbeat {{Body}}"], + }, + }, + }; + + await index.getReplyFromConfig( + { Body: "PING", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalledWith( + ["echo", "normal PING"], + expect.any(Object), + ); + }); + it("captures MEDIA wrapped in backticks", async () => { const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ stdout: "MEDIA:`/tmp/pic.png` cool", @@ -396,6 +457,331 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBe("/tmp/pic.png"); }); + it("injects --thinking for pi when /think directive is present", async () => { + const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["pi", "--mode", "json", "{{Body}}"], + agent: { kind: "pi" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/think:high hello", From: "+1", To: "+2" }, + undefined, + cfg, + ); + expect(rpcSpy).toHaveBeenCalled(); + const args = rpcSpy.mock.calls[0][0].argv; + expect(args).toContain("--thinking"); + expect(args).toContain("high"); + expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); + }); + + it("rewrites /think directive to textual cue for non-pi agents", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/think:medium hi there", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalled(); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args[1]).toBe("hi there think harder"); + }); + + it("treats /think:off as no-op for non-pi agents", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/think:off hi there", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalled(); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args[1]).toBe("hi there"); + }); + + it("treats /think:off as no-op for pi (no --thinking injected)", async () => { + const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["pi", "--mode", "json", "{{Body}}"], + agent: { kind: "pi" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/think:off hello", From: "+1", To: "+2" }, + undefined, + cfg, + ); + expect(rpcSpy).toHaveBeenCalled(); + const args = rpcSpy.mock.calls[0][0].argv; + expect(args).not.toContain("--thinking"); + expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); + }); + + it("persists session thinking level when directive-only message is sent", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const storeDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "warelay-session-"), + ); + const storePath = path.join(storeDir, "sessions.json"); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + session: { store: storePath }, + }, + }, + }; + + await index.getReplyFromConfig( + { Body: "/think:medium", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + + await index.getReplyFromConfig( + { Body: "hi there", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + + expect(runSpy).toHaveBeenCalledTimes(1); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args.join(" ")).toContain("hi there think harder"); + }); + + it("uses global thinkingDefault when no directive or session override", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + thinkingDefault: "low" as const, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "hello", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalled(); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args[1]).toBe("hello think hard"); + }); + + it("accepts spaced directive form '/think high' and applies cue", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/think high hello world", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalled(); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args[1]).toBe("hello world ultrathink"); + }); + + it("accepts shorthand '/t:medium' and applies cue", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + agent: { kind: "claude" }, + }, + }, + }; + await index.getReplyFromConfig( + { Body: "/t:medium greetings", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(runSpy).toHaveBeenCalled(); + const args = runSpy.mock.calls[0][0] as string[]; + expect(args[1]).toBe("greetings think harder"); + }); + + it("stores session thinking for pi and injects on next message", async () => { + const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const storeDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "warelay-session-"), + ); + const storePath = path.join(storeDir, "sessions.json"); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["pi", "--mode", "json", "{{Body}}"], + agent: { kind: "pi" }, + session: { store: storePath }, + }, + }, + }; + + await index.getReplyFromConfig( + { Body: "/thinking max", From: "+1", To: "+2" }, + undefined, + cfg, + ); + + await index.getReplyFromConfig( + { Body: "next run", From: "+1", To: "+2" }, + undefined, + cfg, + ); + + expect(rpcSpy).toHaveBeenCalled(); + const args = rpcSpy.mock.calls[0][0].argv; + expect(args).toContain("--thinking"); + expect(args).toContain("high"); + }); + + it("clears stored thinking when directive-only /think:off is sent", async () => { + const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const storeDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "warelay-session-"), + ); + const storePath = path.join(storeDir, "sessions.json"); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["pi", "--mode", "json", "{{Body}}"], + agent: { kind: "pi" }, + session: { store: storePath }, + }, + }, + }; + + await index.getReplyFromConfig( + { Body: "/think:medium", From: "+1", To: "+2" }, + undefined, + cfg, + ); + await index.getReplyFromConfig( + { Body: "/think:off", From: "+1", To: "+2" }, + undefined, + cfg, + ); + rpcSpy.mockClear(); + await index.getReplyFromConfig( + { Body: "plain text", From: "+1", To: "+2" }, + undefined, + cfg, + ); + expect(rpcSpy).toHaveBeenCalled(); + const args = rpcSpy.mock.calls[0][0].argv; + expect(args).not.toContain("--thinking"); + }); + it("ignores invalid MEDIA lines with whitespace", async () => { const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ stdout: "hello\nMEDIA: not a url with spaces\nrest\n",