Agents: add pluggable CLIs
Co-authored-by: RealSid08 <RealSid08@users.noreply.github.com>
This commit is contained in:
77
docs/agent.md
Normal file
77
docs/agent.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Agent Abstraction Refactor Plan
|
||||||
|
|
||||||
|
Goal: support multiple agent CLIs (Claude, Codex, Pi, Opencode) cleanly, without legacy flags, and make parsing/injection per-agent. Keep WhatsApp/Twilio plumbing intact.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- Introduce a pluggable agent layer (`src/agents/*`), selected by config.
|
||||||
|
- Normalize config (`agent` block) and remove `claudeOutputFormat` legacy knobs.
|
||||||
|
- Provide per-agent argv builders and output parsers (including NDJSON streams).
|
||||||
|
- Preserve MEDIA-token handling and shared queue/heartbeat behavior.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- New shape (no backward compat):
|
||||||
|
```json5
|
||||||
|
inbound: {
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
agent: {
|
||||||
|
kind: "claude" | "opencode" | "pi" | "codex",
|
||||||
|
format?: "text" | "json",
|
||||||
|
identityPrefix?: string
|
||||||
|
},
|
||||||
|
command: ["claude", "{{Body}}"],
|
||||||
|
cwd?: string,
|
||||||
|
session?: { ... },
|
||||||
|
timeoutSeconds?: number,
|
||||||
|
bodyPrefix?: string,
|
||||||
|
mediaUrl?: string,
|
||||||
|
mediaMaxMb?: number,
|
||||||
|
typingIntervalSeconds?: number,
|
||||||
|
heartbeatMinutes?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Validation moves to `config.ts` (new `AgentKind`/`AgentConfig` types).
|
||||||
|
- If `agent` is missing → config error.
|
||||||
|
|
||||||
|
## Agent modules
|
||||||
|
- `src/agents/types.ts` – `AgentKind`, `AgentSpec`:
|
||||||
|
- `buildArgs(argv: string[], body: string, ctx: { sessionId?, isNewSession?, sendSystemOnce?, systemSent?, identityPrefix? }): string[]`
|
||||||
|
- `parse(stdout: string): { text?: string; mediaUrls?: string[]; meta?: AgentMeta }`
|
||||||
|
- `src/agents/claude.ts` – current flag injection (`--output-format`, `-p`), identity prepend.
|
||||||
|
- `src/agents/opencode.ts` – reuse `parseOpencodeJson` (from PR #5), inject `--format json`, session flag `--session` defaults, identity prefix.
|
||||||
|
- `src/agents/pi.ts` – parse NDJSON `AssistantMessageEvent` (final `message_end.message.content[text]`), inject `--mode json`/`-p` defaults, session flags.
|
||||||
|
- `src/agents/codex.ts` – parse Codex JSONL (last `item` with `type:"agent_message"`; usage from `turn.completed`), inject `codex exec --json --skip-git-repo-check`, sandbox default read-only.
|
||||||
|
- Shared MEDIA extraction stays in `media/parse.ts`.
|
||||||
|
|
||||||
|
## Command runner changes
|
||||||
|
- `runCommandReply`:
|
||||||
|
- Resolve agent spec from config.
|
||||||
|
- Apply `buildArgs` (handles identity prepend and session args per agent).
|
||||||
|
- Run command; send stdout to `spec.parse` → `text`, `mediaUrls`, `meta` (stored as `agentMeta`).
|
||||||
|
- Remove `claudeMeta` naming; tests updated to `agentMeta`.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
- Session arg defaults become agent-specific (Claude: `--resume/--session-id`; Opencode/Pi/Codex: `--session`).
|
||||||
|
- Still overridable via `sessionArgNew/sessionArgResume` in config.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Update existing tests to new config (no `claudeOutputFormat`).
|
||||||
|
- Add fixtures:
|
||||||
|
- Opencode NDJSON sample (from PR #5) → parsed text + meta.
|
||||||
|
- Codex NDJSON sample (captured: thread/turn/item/usage) → parsed text.
|
||||||
|
- Pi NDJSON sample (AssistantMessageEvent) → parsed text.
|
||||||
|
- Ensure MEDIA token parsing works on agent text output.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
- README: rename “Claude-aware” → “Multi-agent (Claude, Codex, Pi, Opencode)”.
|
||||||
|
- New short guide per agent (Opencode doc from PR #5; add Codex/Pi snippets).
|
||||||
|
- Mention identityPrefix override and session arg differences.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
- Breaking change: configs must specify `agent`. Remove old `claudeOutputFormat` keys.
|
||||||
|
- Provide migration note in CHANGELOG 1.3.x.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- No media binary support; still relies on MEDIA tokens in text.
|
||||||
|
- No UI changes; WhatsApp/Twilio plumbing unchanged.
|
||||||
67
src/agents/claude.ts
Normal file
67
src/agents/claude.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLAUDE_BIN,
|
||||||
|
CLAUDE_IDENTITY_PREFIX,
|
||||||
|
parseClaudeJson,
|
||||||
|
summarizeClaudeMetadata,
|
||||||
|
type ClaudeJsonParseResult,
|
||||||
|
} from "../auto-reply/claude.js";
|
||||||
|
import type {
|
||||||
|
AgentMeta,
|
||||||
|
AgentParseResult,
|
||||||
|
AgentSpec,
|
||||||
|
BuildArgsContext,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function toMeta(parsed?: ClaudeJsonParseResult): AgentMeta | undefined {
|
||||||
|
if (!parsed?.parsed) return undefined;
|
||||||
|
const summary = summarizeClaudeMetadata(parsed.parsed);
|
||||||
|
return summary ? { extra: { summary } } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claudeSpec: AgentSpec = {
|
||||||
|
kind: "claude",
|
||||||
|
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === CLAUDE_BIN,
|
||||||
|
buildArgs: (ctx) => {
|
||||||
|
// Work off a split of "before body" and "after body" so we don't lose the
|
||||||
|
// body index when inserting flags.
|
||||||
|
const argv = [...ctx.argv];
|
||||||
|
const body = argv[ctx.bodyIndex] ?? "";
|
||||||
|
const beforeBody = argv.slice(0, ctx.bodyIndex);
|
||||||
|
const afterBody = argv.slice(ctx.bodyIndex + 1);
|
||||||
|
|
||||||
|
const wantsOutputFormat = typeof ctx.format === "string";
|
||||||
|
if (wantsOutputFormat) {
|
||||||
|
const hasOutputFormat = argv.some(
|
||||||
|
(part) => part === "--output-format" || part.startsWith("--output-format="),
|
||||||
|
);
|
||||||
|
if (!hasOutputFormat) {
|
||||||
|
beforeBody.push("--output-format", ctx.format!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPrintFlag = argv.some((part) => part === "-p" || part === "--print");
|
||||||
|
if (!hasPrintFlag) {
|
||||||
|
beforeBody.push("-p");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
|
const bodyWithIdentity =
|
||||||
|
shouldPrependIdentity && body
|
||||||
|
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n")
|
||||||
|
: body;
|
||||||
|
|
||||||
|
return [...beforeBody, bodyWithIdentity, ...afterBody];
|
||||||
|
},
|
||||||
|
parseOutput: (rawStdout) => {
|
||||||
|
const parsed = parseClaudeJson(rawStdout);
|
||||||
|
const text = parsed?.text ?? rawStdout.trim();
|
||||||
|
return {
|
||||||
|
text: text?.trim(),
|
||||||
|
meta: toMeta(parsed),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
66
src/agents/codex.ts
Normal file
66
src/agents/codex.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
||||||
|
|
||||||
|
function parseCodexJson(raw: string): AgentParseResult {
|
||||||
|
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
|
||||||
|
let text: string | undefined;
|
||||||
|
let meta: AgentMeta | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(line) as { type?: string; item?: { type?: string; text?: string }; usage?: unknown };
|
||||||
|
if (ev.type === "item.completed" && ev.item?.type === "agent_message" && typeof ev.item.text === "string") {
|
||||||
|
text = ev.item.text;
|
||||||
|
}
|
||||||
|
if (ev.type === "turn.completed" && ev.usage && typeof ev.usage === "object") {
|
||||||
|
const u = ev.usage as {
|
||||||
|
input_tokens?: number;
|
||||||
|
cached_input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
};
|
||||||
|
meta = {
|
||||||
|
usage: {
|
||||||
|
input: u.input_tokens,
|
||||||
|
output: u.output_tokens,
|
||||||
|
cacheRead: u.cached_input_tokens,
|
||||||
|
total:
|
||||||
|
(u.input_tokens ?? 0) +
|
||||||
|
(u.output_tokens ?? 0) +
|
||||||
|
(u.cached_input_tokens ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: text?.trim(), meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codexSpec: AgentSpec = {
|
||||||
|
kind: "codex",
|
||||||
|
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === "codex",
|
||||||
|
buildArgs: (ctx) => {
|
||||||
|
const argv = [...ctx.argv];
|
||||||
|
const hasExec = argv.length > 0 && argv[1] === "exec";
|
||||||
|
if (!hasExec) {
|
||||||
|
argv.splice(1, 0, "exec");
|
||||||
|
}
|
||||||
|
// Ensure JSON output
|
||||||
|
if (!argv.includes("--json")) {
|
||||||
|
argv.splice(argv.length - 1, 0, "--json");
|
||||||
|
}
|
||||||
|
// Safety defaults
|
||||||
|
if (!argv.includes("--skip-git-repo-check")) {
|
||||||
|
argv.splice(argv.length - 1, 0, "--skip-git-repo-check");
|
||||||
|
}
|
||||||
|
if (!argv.some((p) => p === "--sandbox" || p.startsWith("--sandbox="))) {
|
||||||
|
argv.splice(argv.length - 1, 0, "--sandbox", "read-only");
|
||||||
|
}
|
||||||
|
return argv;
|
||||||
|
},
|
||||||
|
parseOutput: parseCodexJson,
|
||||||
|
};
|
||||||
|
|
||||||
19
src/agents/index.ts
Normal file
19
src/agents/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { claudeSpec } from "./claude.js";
|
||||||
|
import { codexSpec } from "./codex.js";
|
||||||
|
import { opencodeSpec } from "./opencode.js";
|
||||||
|
import { piSpec } from "./pi.js";
|
||||||
|
import type { AgentKind, AgentSpec } from "./types.js";
|
||||||
|
|
||||||
|
const specs: Record<AgentKind, AgentSpec> = {
|
||||||
|
claude: claudeSpec,
|
||||||
|
codex: codexSpec,
|
||||||
|
opencode: opencodeSpec,
|
||||||
|
pi: piSpec,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAgentSpec(kind: AgentKind): AgentSpec {
|
||||||
|
return specs[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
||||||
|
|
||||||
54
src/agents/opencode.ts
Normal file
54
src/agents/opencode.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OPENCODE_BIN,
|
||||||
|
OPENCODE_IDENTITY_PREFIX,
|
||||||
|
parseOpencodeJson,
|
||||||
|
summarizeOpencodeMetadata,
|
||||||
|
} from "../auto-reply/opencode.js";
|
||||||
|
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
||||||
|
|
||||||
|
function toMeta(parsed: ReturnType<typeof parseOpencodeJson>): AgentMeta | undefined {
|
||||||
|
const summary = summarizeOpencodeMetadata(parsed.meta);
|
||||||
|
return summary ? { extra: { summary } } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opencodeSpec: AgentSpec = {
|
||||||
|
kind: "opencode",
|
||||||
|
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === OPENCODE_BIN,
|
||||||
|
buildArgs: (ctx) => {
|
||||||
|
const argv = [...ctx.argv];
|
||||||
|
const wantsJson = ctx.format === "json";
|
||||||
|
|
||||||
|
// Ensure format json for parsing
|
||||||
|
if (wantsJson) {
|
||||||
|
const hasFormat = argv.some(
|
||||||
|
(part) => part === "--format" || part.startsWith("--format="),
|
||||||
|
);
|
||||||
|
if (!hasFormat) {
|
||||||
|
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||||
|
argv.splice(insertBeforeBody, 0, "--format", "json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session args default to --session
|
||||||
|
// Identity prefix
|
||||||
|
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
|
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
|
||||||
|
const existingBody = argv[ctx.bodyIndex];
|
||||||
|
argv[ctx.bodyIndex] = [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, existingBody]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return argv;
|
||||||
|
},
|
||||||
|
parseOutput: (rawStdout) => {
|
||||||
|
const parsed = parseOpencodeJson(rawStdout);
|
||||||
|
const text = parsed.text ?? rawStdout.trim();
|
||||||
|
return {
|
||||||
|
text: text?.trim(),
|
||||||
|
meta: toMeta(parsed),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
65
src/agents/pi.ts
Normal file
65
src/agents/pi.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { AgentMeta, AgentParseResult, AgentSpec, BuildArgsContext } from "./types.js";
|
||||||
|
|
||||||
|
type PiAssistantMessage = {
|
||||||
|
role?: string;
|
||||||
|
content?: Array<{ type?: string; text?: string }>;
|
||||||
|
usage?: { input?: number; output?: number };
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
stopReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePiJson(raw: string): AgentParseResult {
|
||||||
|
const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{"));
|
||||||
|
let lastMessage: PiAssistantMessage | undefined;
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(line) as { type?: string; message?: PiAssistantMessage };
|
||||||
|
if (ev.type === "message_end" && ev.message?.role === "assistant") {
|
||||||
|
lastMessage = ev.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
lastMessage?.content
|
||||||
|
?.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n")
|
||||||
|
?.trim() ?? undefined;
|
||||||
|
const meta: AgentMeta | undefined = lastMessage
|
||||||
|
? {
|
||||||
|
model: lastMessage.model,
|
||||||
|
provider: lastMessage.provider,
|
||||||
|
stopReason: lastMessage.stopReason,
|
||||||
|
usage: lastMessage.usage,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return { text, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const piSpec: AgentSpec = {
|
||||||
|
kind: "pi",
|
||||||
|
isInvocation: (argv) => argv.length > 0 && path.basename(argv[0]) === "pi",
|
||||||
|
buildArgs: (ctx) => {
|
||||||
|
const argv = [...ctx.argv];
|
||||||
|
// Non-interactive print + JSON
|
||||||
|
if (!argv.includes("-p") && !argv.includes("--print")) {
|
||||||
|
argv.splice(argv.length - 1, 0, "-p");
|
||||||
|
}
|
||||||
|
if (ctx.format === "json" && !argv.includes("--mode") && !argv.some((a) => a === "--mode")) {
|
||||||
|
argv.splice(argv.length - 1, 0, "--mode", "json");
|
||||||
|
}
|
||||||
|
// Session defaults
|
||||||
|
// Identity prefix optional; Pi usually doesn't need, but allow
|
||||||
|
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) {
|
||||||
|
const existingBody = argv[ctx.bodyIndex];
|
||||||
|
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
return argv;
|
||||||
|
},
|
||||||
|
parseOutput: parsePiJson,
|
||||||
|
};
|
||||||
42
src/agents/types.ts
Normal file
42
src/agents/types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type AgentKind = "claude" | "opencode" | "pi" | "codex";
|
||||||
|
|
||||||
|
export type AgentMeta = {
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
stopReason?: string;
|
||||||
|
usage?: {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
cacheWrite?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentParseResult = {
|
||||||
|
text?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
meta?: AgentMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildArgsContext = {
|
||||||
|
argv: string[];
|
||||||
|
bodyIndex: number; // index of prompt/body argument in argv
|
||||||
|
isNewSession: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
sendSystemOnce: boolean;
|
||||||
|
systemSent: boolean;
|
||||||
|
identityPrefix?: string;
|
||||||
|
format?: "text" | "json";
|
||||||
|
sessionArgNew?: string[];
|
||||||
|
sessionArgResume?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentSpec {
|
||||||
|
kind: AgentKind;
|
||||||
|
isInvocation: (argv: string[]) => boolean;
|
||||||
|
buildArgs: (ctx: BuildArgsContext) => string[];
|
||||||
|
parseOutput: (rawStdout: string) => AgentParseResult;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -160,3 +160,6 @@ export function parseClaudeJsonText(raw: string): string | undefined {
|
|||||||
const parsed = parseClaudeJson(raw);
|
const parsed = parseClaudeJson(raw);
|
||||||
return parsed?.text;
|
return parsed?.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export from command-reply for backwards compatibility
|
||||||
|
export { summarizeClaudeMetadata } from "./command-reply.js";
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
@@ -98,7 +98,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: true,
|
sendSystemOnce: true,
|
||||||
@@ -121,7 +121,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: true,
|
sendSystemOnce: true,
|
||||||
@@ -144,7 +144,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: true,
|
sendSystemOnce: true,
|
||||||
@@ -167,6 +167,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["cli", "{{Body}}"],
|
command: ["cli", "{{Body}}"],
|
||||||
|
agent: { kind: "claude" },
|
||||||
session: {
|
session: {
|
||||||
sessionArgNew: ["--new", "{{SessionId}}"],
|
sessionArgNew: ["--new", "{{SessionId}}"],
|
||||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||||
@@ -192,7 +193,7 @@ describe("runCommandReply", () => {
|
|||||||
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||||
});
|
});
|
||||||
const { payload, meta } = await runCommandReply({
|
const { payload, meta } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"] },
|
reply: { mode: "command", command: ["echo", "hi"], agent: { kind: "claude" } },
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
@@ -213,7 +214,7 @@ describe("runCommandReply", () => {
|
|||||||
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
||||||
});
|
});
|
||||||
const { payload } = await runCommandReply({
|
const { payload } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work" },
|
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work", agent: { kind: "claude" } },
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
@@ -235,7 +236,7 @@ describe("runCommandReply", () => {
|
|||||||
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||||
});
|
});
|
||||||
const { payload } = await runCommandReply({
|
const { payload } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1 },
|
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1, agent: { kind: "claude" } },
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
@@ -259,7 +260,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
@@ -271,14 +272,14 @@ describe("runCommandReply", () => {
|
|||||||
commandRunner: runner,
|
commandRunner: runner,
|
||||||
enqueue: enqueueImmediate,
|
enqueue: enqueueImmediate,
|
||||||
});
|
});
|
||||||
expect(meta.claudeMeta).toContain("duration=50ms");
|
expect(meta.agentMeta?.extra?.summary).toContain("duration=50ms");
|
||||||
expect(meta.claudeMeta).toContain("tool_calls=1");
|
expect(meta.agentMeta?.extra?.summary).toContain("tool_calls=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures queue wait metrics in meta", async () => {
|
it("captures queue wait metrics in meta", async () => {
|
||||||
const runner = makeRunner({ stdout: "ok" });
|
const runner = makeRunner({ stdout: "ok" });
|
||||||
const { meta } = await runCommandReply({
|
const { meta } = await runCommandReply({
|
||||||
reply: { mode: "command", command: ["echo", "{{Body}}"] },
|
reply: { mode: "command", command: ["echo", "{{Body}}"], agent: { kind: "claude" } },
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
isNewSession: true,
|
isNewSession: true,
|
||||||
@@ -303,7 +304,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
@@ -328,7 +329,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
@@ -353,7 +354,7 @@ describe("runCommandReply", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json",
|
agent: { kind: "claude", format: "json" },
|
||||||
},
|
},
|
||||||
templatingCtx: noopTemplateCtx,
|
templatingCtx: noopTemplateCtx,
|
||||||
sendSystemOnce: false,
|
sendSystemOnce: false,
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getAgentSpec } from "../agents/index.js";
|
||||||
|
import type { AgentMeta } from "../agents/types.js";
|
||||||
import type { WarelayConfig } from "../config/config.js";
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
import { isVerbose, logVerbose } from "../globals.js";
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
import { logError } from "../logger.js";
|
import { logError } from "../logger.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
import { splitMediaFromOutput } from "../media/parse.js";
|
||||||
import { enqueueCommand } from "../process/command-queue.js";
|
import { enqueueCommand } from "../process/command-queue.js";
|
||||||
import type { runCommandWithTimeout } from "../process/exec.js";
|
import type { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import {
|
|
||||||
CLAUDE_BIN,
|
|
||||||
CLAUDE_IDENTITY_PREFIX,
|
|
||||||
type ClaudeJsonParseResult,
|
|
||||||
parseClaudeJson,
|
|
||||||
} from "./claude.js";
|
|
||||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||||
import type { ReplyPayload } from "./types.js";
|
import type { ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
@@ -42,7 +38,7 @@ export type CommandReplyMeta = {
|
|||||||
exitCode?: number | null;
|
exitCode?: number | null;
|
||||||
signal?: string | null;
|
signal?: string | null;
|
||||||
killed?: boolean;
|
killed?: boolean;
|
||||||
claudeMeta?: string;
|
agentMeta?: AgentMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandReplyResult = {
|
export type CommandReplyResult = {
|
||||||
@@ -119,6 +115,8 @@ export async function runCommandReply(
|
|||||||
if (!reply.command?.length) {
|
if (!reply.command?.length) {
|
||||||
throw new Error("reply.command is required for mode=command");
|
throw new Error("reply.command is required for mode=command");
|
||||||
}
|
}
|
||||||
|
const agentCfg = reply.agent ?? { kind: "claude" };
|
||||||
|
const agent = getAgentSpec(agentCfg.kind as any);
|
||||||
|
|
||||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||||
const templatePrefix =
|
const templatePrefix =
|
||||||
@@ -129,66 +127,47 @@ export async function runCommandReply(
|
|||||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
// Default body index is last arg
|
||||||
if (
|
let bodyIndex = Math.max(argv.length - 1, 0);
|
||||||
reply.claudeOutputFormat &&
|
|
||||||
argv.length > 0 &&
|
|
||||||
path.basename(argv[0]) === CLAUDE_BIN
|
|
||||||
) {
|
|
||||||
const hasOutputFormat = argv.some(
|
|
||||||
(part) =>
|
|
||||||
part === "--output-format" || part.startsWith("--output-format="),
|
|
||||||
);
|
|
||||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
|
||||||
if (!hasOutputFormat) {
|
|
||||||
argv = [
|
|
||||||
...argv.slice(0, insertBeforeBody),
|
|
||||||
"--output-format",
|
|
||||||
reply.claudeOutputFormat,
|
|
||||||
...argv.slice(insertBeforeBody),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
const hasPrintFlag = argv.some(
|
|
||||||
(part) => part === "-p" || part === "--print",
|
|
||||||
);
|
|
||||||
if (!hasPrintFlag) {
|
|
||||||
const insertIdx = Math.max(argv.length - 1, 0);
|
|
||||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject session args if configured (use resume for existing, session-id for new)
|
// Session args prepared (templated) and injected generically
|
||||||
if (reply.session) {
|
if (reply.session) {
|
||||||
|
const defaultNew =
|
||||||
|
agentCfg.kind === "claude"
|
||||||
|
? ["--session-id", "{{SessionId}}"]
|
||||||
|
: ["--session", "{{SessionId}}"];
|
||||||
|
const defaultResume =
|
||||||
|
agentCfg.kind === "claude"
|
||||||
|
? ["--resume", "{{SessionId}}"]
|
||||||
|
: ["--session", "{{SessionId}}"];
|
||||||
const sessionArgList = (
|
const sessionArgList = (
|
||||||
isNewSession
|
isNewSession
|
||||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
? reply.session.sessionArgNew ?? defaultNew
|
||||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
: reply.session.sessionArgResume ?? defaultResume
|
||||||
).map((part) => applyTemplate(part, templatingCtx));
|
).map((p) => applyTemplate(p, templatingCtx));
|
||||||
if (sessionArgList.length) {
|
if (sessionArgList.length) {
|
||||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||||
const insertAt =
|
const insertAt =
|
||||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||||
argv = [
|
argv = [...argv.slice(0, insertAt), ...sessionArgList, ...argv.slice(insertAt)];
|
||||||
...argv.slice(0, insertAt),
|
bodyIndex = Math.max(argv.length - 1, 0);
|
||||||
...sessionArgList,
|
|
||||||
...argv.slice(insertAt),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalArgv = argv;
|
const shouldApplyAgent = agent.isInvocation(argv);
|
||||||
const isClaudeInvocation =
|
const finalArgv = shouldApplyAgent
|
||||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
? agent.buildArgs({
|
||||||
const shouldPrependIdentity =
|
argv,
|
||||||
isClaudeInvocation && !(sendSystemOnce && systemSent);
|
bodyIndex,
|
||||||
if (shouldPrependIdentity && finalArgv.length > 0) {
|
isNewSession,
|
||||||
const bodyIdx = finalArgv.length - 1;
|
sessionId: templatingCtx.SessionId,
|
||||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
sendSystemOnce,
|
||||||
finalArgv = [
|
systemSent,
|
||||||
...finalArgv.slice(0, bodyIdx),
|
identityPrefix: agentCfg.identityPrefix,
|
||||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
format: agentCfg.format,
|
||||||
];
|
})
|
||||||
}
|
: argv;
|
||||||
|
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||||
);
|
);
|
||||||
@@ -217,28 +196,12 @@ export async function runCommandReply(
|
|||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||||
}
|
}
|
||||||
let parsed: ClaudeJsonParseResult | undefined;
|
|
||||||
if (
|
const parsed = trimmed ? agent.parseOutput(trimmed) : undefined;
|
||||||
trimmed &&
|
if (parsed && parsed.text !== undefined) {
|
||||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
|
||||||
) {
|
|
||||||
parsed = parseClaudeJson(trimmed);
|
|
||||||
if (parsed?.parsed && isVerbose()) {
|
|
||||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
|
||||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
|
||||||
logVerbose(
|
|
||||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof parsed?.text === "string") {
|
|
||||||
logVerbose(
|
|
||||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
|
||||||
);
|
|
||||||
trimmed = parsed.text.trim();
|
trimmed = parsed.text.trim();
|
||||||
} else {
|
|
||||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text: cleanedText, mediaUrls: mediaFound } =
|
const { text: cleanedText, mediaUrls: mediaFound } =
|
||||||
splitMediaFromOutput(trimmed);
|
splitMediaFromOutput(trimmed);
|
||||||
trimmed = cleanedText;
|
trimmed = cleanedText;
|
||||||
@@ -249,7 +212,7 @@ export async function runCommandReply(
|
|||||||
logVerbose("No MEDIA token extracted from final text");
|
logVerbose("No MEDIA token extracted from final text");
|
||||||
}
|
}
|
||||||
if (!trimmed && !mediaFromCommand) {
|
if (!trimmed && !mediaFromCommand) {
|
||||||
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined;
|
const meta = parsed?.meta?.extra?.summary ?? undefined;
|
||||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
logVerbose("No text/media produced; injecting fallback notice to user");
|
||||||
}
|
}
|
||||||
@@ -271,9 +234,7 @@ export async function runCommandReply(
|
|||||||
exitCode: code,
|
exitCode: code,
|
||||||
signal,
|
signal,
|
||||||
killed,
|
killed,
|
||||||
claudeMeta: parsed
|
agentMeta: parsed?.meta,
|
||||||
? summarizeClaudeMetadata(parsed.parsed)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -291,9 +252,7 @@ export async function runCommandReply(
|
|||||||
exitCode: code,
|
exitCode: code,
|
||||||
signal,
|
signal,
|
||||||
killed,
|
killed,
|
||||||
claudeMeta: parsed
|
agentMeta: parsed?.meta,
|
||||||
? summarizeClaudeMetadata(parsed.parsed)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -341,7 +300,7 @@ export async function runCommandReply(
|
|||||||
exitCode: code,
|
exitCode: code,
|
||||||
signal,
|
signal,
|
||||||
killed,
|
killed,
|
||||||
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
agentMeta: parsed?.meta,
|
||||||
};
|
};
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
||||||
|
|||||||
105
src/auto-reply/opencode.ts
Normal file
105
src/auto-reply/opencode.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Helpers specific to Opencode CLI output/argv handling.
|
||||||
|
|
||||||
|
// Preferred binary name for Opencode CLI invocations.
|
||||||
|
export const OPENCODE_BIN = "opencode";
|
||||||
|
|
||||||
|
export const OPENCODE_IDENTITY_PREFIX =
|
||||||
|
"You are Openclawd running on the user's Mac via warelay. Your scratchpad is /Users/steipete/openclawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
||||||
|
|
||||||
|
export type OpencodeJsonParseResult = {
|
||||||
|
text?: string;
|
||||||
|
parsed: unknown[];
|
||||||
|
valid: boolean;
|
||||||
|
meta?: {
|
||||||
|
durationMs?: number;
|
||||||
|
cost?: number;
|
||||||
|
tokens?: {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseOpencodeJson(raw: string): OpencodeJsonParseResult {
|
||||||
|
const lines = raw.split(/\n+/).filter((s) => s.trim());
|
||||||
|
const parsed: unknown[] = [];
|
||||||
|
let text = "";
|
||||||
|
let valid = false;
|
||||||
|
let startTime: number | undefined;
|
||||||
|
let endTime: number | undefined;
|
||||||
|
let cost = 0;
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line);
|
||||||
|
parsed.push(event);
|
||||||
|
if (event && typeof event === "object") {
|
||||||
|
// Opencode emits a stream of events.
|
||||||
|
if (event.type === "step_start") {
|
||||||
|
valid = true;
|
||||||
|
if (typeof event.timestamp === "number") {
|
||||||
|
if (startTime === undefined || event.timestamp < startTime) {
|
||||||
|
startTime = event.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "text" && event.part?.text) {
|
||||||
|
text += event.part.text;
|
||||||
|
valid = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "step_finish") {
|
||||||
|
valid = true;
|
||||||
|
if (typeof event.timestamp === "number") {
|
||||||
|
endTime = event.timestamp;
|
||||||
|
}
|
||||||
|
if (event.part) {
|
||||||
|
if (typeof event.part.cost === "number") {
|
||||||
|
cost += event.part.cost;
|
||||||
|
}
|
||||||
|
if (event.part.tokens) {
|
||||||
|
inputTokens += event.part.tokens.input || 0;
|
||||||
|
outputTokens += event.part.tokens.output || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore non-JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: OpencodeJsonParseResult["meta"] = {};
|
||||||
|
if (startTime !== undefined && endTime !== undefined) {
|
||||||
|
meta.durationMs = endTime - startTime;
|
||||||
|
}
|
||||||
|
if (cost > 0) meta.cost = cost;
|
||||||
|
if (inputTokens > 0 || outputTokens > 0) {
|
||||||
|
meta.tokens = { input: inputTokens, output: outputTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: text || undefined,
|
||||||
|
parsed,
|
||||||
|
valid: valid && parsed.length > 0,
|
||||||
|
meta: Object.keys(meta).length > 0 ? meta : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeOpencodeMetadata(
|
||||||
|
meta: OpencodeJsonParseResult["meta"],
|
||||||
|
): string | undefined {
|
||||||
|
if (!meta) return undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (meta.durationMs !== undefined)
|
||||||
|
parts.push(`duration=${meta.durationMs}ms`);
|
||||||
|
if (meta.cost !== undefined) parts.push(`cost=$${meta.cost.toFixed(4)}`);
|
||||||
|
if (meta.tokens) {
|
||||||
|
parts.push(`tokens=${meta.tokens.input}+${meta.tokens.output}`);
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(", ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -265,8 +265,8 @@ export async function getReplyFromConfig(
|
|||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
});
|
});
|
||||||
if (meta.claudeMeta && isVerbose()) {
|
if (meta.agentMeta && isVerbose()) {
|
||||||
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import path from "node:path";
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { AgentKind } from "../agents/index.js";
|
||||||
|
|
||||||
export type ReplyMode = "text" | "command";
|
export type ReplyMode = "text" | "command";
|
||||||
export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
@@ -56,18 +57,22 @@ export type WarelayConfig = {
|
|||||||
};
|
};
|
||||||
reply?: {
|
reply?: {
|
||||||
mode: ReplyMode;
|
mode: ReplyMode;
|
||||||
text?: string; // for mode=text, can contain {{Body}}
|
text?: string;
|
||||||
command?: string[]; // for mode=command, argv with templates
|
command?: string[];
|
||||||
cwd?: string; // working directory for command execution
|
cwd?: string;
|
||||||
template?: string; // prepend template string when building command/prompt
|
template?: string;
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
timeoutSeconds?: number;
|
||||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
bodyPrefix?: string;
|
||||||
mediaUrl?: string; // optional media attachment (path or URL)
|
mediaUrl?: string;
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
mediaMaxMb?: number;
|
||||||
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
typingIntervalSeconds?: number;
|
||||||
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
heartbeatMinutes?: number;
|
||||||
heartbeatMinutes?: number; // auto-ping cadence for command mode
|
agent?: {
|
||||||
|
kind: AgentKind;
|
||||||
|
format?: "text" | "json";
|
||||||
|
identityPrefix?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
@@ -105,13 +110,17 @@ const ReplySchema = z
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||||
claudeOutputFormat: z
|
agent: z
|
||||||
.union([
|
.object({
|
||||||
z.literal("text"),
|
kind: z.union([
|
||||||
z.literal("json"),
|
z.literal("claude"),
|
||||||
z.literal("stream-json"),
|
z.literal("opencode"),
|
||||||
z.undefined(),
|
z.literal("pi"),
|
||||||
])
|
z.literal("codex"),
|
||||||
|
]),
|
||||||
|
format: z.union([z.literal("text"), z.literal("json")]).optional(),
|
||||||
|
identityPrefix: z.string().optional(),
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ describe("config and templating", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command" as const,
|
mode: "command" as const,
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "text" as const,
|
agent: { kind: "claude", format: "text" as const },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -802,7 +802,7 @@ describe("config and templating", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command" as const,
|
mode: "command" as const,
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
claudeOutputFormat: "json" as const,
|
agent: { kind: "claude", format: "json" as const },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -830,7 +830,7 @@ describe("config and templating", () => {
|
|||||||
reply: {
|
reply: {
|
||||||
mode: "command" as const,
|
mode: "command" as const,
|
||||||
command: ["claude", "{{Body}}"],
|
command: ["claude", "{{Body}}"],
|
||||||
// No claudeOutputFormat set on purpose
|
agent: { kind: "claude" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { monitorWebInbox } from "./inbound.js";
|
|||||||
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
import { sendMessageWeb } from "./outbound.js";
|
import { sendMessageWeb } from "./outbound.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { enqueueCommand, getQueueSize } from "../process/command-queue.js";
|
||||||
import {
|
import {
|
||||||
computeBackoff,
|
computeBackoff,
|
||||||
newConnectionId,
|
newConnectionId,
|
||||||
@@ -621,7 +621,8 @@ export async function monitorWebProvider(
|
|||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
console.log(`\n[${tsDisplay}] ${from} -> ${latest.to}: ${combinedBody}`);
|
console.log(`\n[${tsDisplay}] ${from} -> ${latest.to}: ${combinedBody}`);
|
||||||
|
|
||||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
const replyResult = await enqueueCommand(() =>
|
||||||
|
(replyResolver ?? getReplyFromConfig)(
|
||||||
{
|
{
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
From: latest.from,
|
From: latest.from,
|
||||||
@@ -634,6 +635,7 @@ export async function monitorWebProvider(
|
|||||||
{
|
{
|
||||||
onReplyStart: latest.sendComposing,
|
onReplyStart: latest.sendComposing,
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -917,19 +919,24 @@ export async function monitorWebProvider(
|
|||||||
"reply heartbeat start",
|
"reply heartbeat start",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
const hbFrom = lastInboundMsg.from;
|
||||||
|
const hbTo = lastInboundMsg.to;
|
||||||
|
const hbComposing = lastInboundMsg.sendComposing;
|
||||||
|
const replyResult = await enqueueCommand(() =>
|
||||||
|
(replyResolver ?? getReplyFromConfig)(
|
||||||
{
|
{
|
||||||
Body: HEARTBEAT_PROMPT,
|
Body: HEARTBEAT_PROMPT,
|
||||||
From: lastInboundMsg.from,
|
From: hbFrom,
|
||||||
To: lastInboundMsg.to,
|
To: hbTo,
|
||||||
MessageSid: snapshot.entry?.sessionId,
|
MessageSid: snapshot.entry?.sessionId,
|
||||||
MediaPath: undefined,
|
MediaPath: undefined,
|
||||||
MediaUrl: undefined,
|
MediaUrl: undefined,
|
||||||
MediaType: undefined,
|
MediaType: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: lastInboundMsg.sendComposing,
|
onReplyStart: hbComposing,
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user